# 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 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 `blocks.agents.permissions.read`, `.create`,
`.publish` and `.moderate`. The `maxInstructions` option (100,000
characters by default) limits the instructions.

## Server [#server]

Server code calls the `agents` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAgents } = defineDataHooks(
  "agents",
  (db: AppDb) => db.agents,
);
```

```ts
const rows = await db.agents.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/agents/list.tsx"
"use client";

import { useAgents } from "@/lib/supabase/hooks";

const rows = useAgents({ limit: 50 });
```

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

## Your own fields [#your-own-fields]

`blocks.agents.options.extraColumns` adds columns to the `agents` table,
keyed by column name with their SQL type. Pass `createAgents` a Standard Schema
for them as `fields`: `create`, `update` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and every agent read return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAgents({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.agents` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

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

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

# 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 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; `upsert` fails with `invalid_input` above it |

## Server [#server]

Server code calls the `ai-cache` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAiCache } = defineDataHooks(
  "aiCache",
  (db: AppDb) => db.aiCache,
);
```

```ts
const rows = await db.aiCache.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/ai-cache/list.tsx"
"use client";

import { useAiCache } from "@/lib/supabase/hooks";

const rows = useAiCache({ limit: 50 });
```

```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
    .upsert(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/queue):

```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 |

## Without the client [#without-the-client]

`db.aiCache` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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(bsb.admin().$client, { schema: "api" }),
  schema: "api",
});
```

# 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 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]

Server code calls the `ai-chat` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAiChat } = defineDataHooks(
  "aiChat",
  (db: AppDb) => db.aiChat,
);
```

```ts
const rows = await db.aiChat.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/ai-chat/list.tsx"
"use client";

import { useAiChat } from "@/lib/supabase/hooks";

const rows = useAiChat({ limit: 50 });
```

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

With the [usage block](/docs/blocks/usage) installed, `runs.release`
records the run's tokens on two meters: `ai.input_tokens` and
`ai.output_tokens` (the `inputMeter` and `outputMeter` options), once per
run, keyed by the run so a retried release counts nothing twice. With a
`meters` catalog on usage, only the meters it names are recorded. If the app
already meters tokens itself (`meterTelemetry` or `usageEntries` from
`better-supabase/ai-sdk`), set `meterUsage: false` so they are not counted
twice.

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.

## Tool approvals [#tool-approvals]

`approvals.request` stores a tool call that waits for a decision in
`ai_tool_approvals`, and `approvals.decide` records it through
`decide_ai_tool_approval`. The service role may always decide. Otherwise
the approval's owner, the user whose chat made the call, decides it.

Under the `provider` [access model](/docs/repository/permissions), the provider's
[`isApprover`](/docs/extending/authorization-providers#approvals) template
decides instead, with `{kind}` `'ai-tool'`, `{subject}` the tool's name and
`{tenant}` the chat's tenant. The approval's owner is then always refused
(`AI_APPROVAL_NOT_FOUND`), so a second person decides every call. A provider
that still sets the deprecated `canApprove` is read when `isApprover` is
unset, and there `approvals.distinctApprover: true` refuses the owner. The server side of the same check is the `approverOf`
[runtime hook](/docs/extending/authorization-providers#runtime-hooks).

With the [approvals](/docs/blocks/approvals#tool-calls-and-support-sessions)
module installed, every waiting tool call also files an approval request
(`subjectType` `ai_tool_approval`, action `run`, the call's input in
`payload`), so the approvals inbox lists it. Approving or rejecting it there
decides the tool call, and `decide_ai_tool_approval` closes the request. The
approvers stay the ones above.

## 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]

The React hooks are a registry item. Install them with
`npx shadcn add @better-supabase/use-ai-chat`, which writes
`hooks/use-ai-chat.ts` into the app.

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

import { useAiChats } from "@/hooks/use-ai-chat";

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
(`blocks["ai-chat"].options.listTopic` and `topic`). In Server
Components the hooks throw; call the block on the server instead.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs any of the block's
controllers (`createAiChatsController`, `createAiChatTreeController`,
`createAiModelsController` and `createAiShareController`) with its options;
`auth` and `ports` come from `app.use(betterSupabase(bsb))` unless you pass them. `state` is a
shallow ref of the snapshot and `actions` are the controller's actions. The
work starts on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createAiChat,
  createAiChatsController,
  rpcTransport,
} from "better-supabase/blocks/ai-chat";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createAiChatsController, {
  client: createAiChat({ transport: rpcTransport(supabase) }),
  query: { organizationId },
});
</script>

<template>
  <ul>
    <li v-for="chat in state.items ?? []" :key="chat.id">{{ chat.title }}</li>
  </ul>
  <button @click="actions.loadMore()">Load more</button>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs any of the block's
controllers (`createAiChatsController` and the others) with its options;
`auth` and `ports` come from `setBetterSupabase(bsb)` unless you pass them.
It returns a readable store of the snapshot, read with `$`, with the
actions on `.actions`. The work starts on mount, and the controller is
disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createAiChat,
    createAiChatsController,
    rpcTransport,
  } from "better-supabase/blocks/ai-chat";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const chats = useBlockController(createAiChatsController, {
    client: createAiChat({ transport: rpcTransport(supabase) }),
    query: { organizationId },
  });
</script>

<ul>
  {#each $chats.items ?? [] as chat (chat.id)}
    <li>{chat.title}</li>
  {/each}
</ul>
<button onclick={() => chats.actions.loadMore()}>Load more</button>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs any of the block's
controllers (`createAiChatsController` and the others) with its options;
`auth` and `ports` come from `<BetterSupabaseProvider client={bsb}>` unless
you pass them. `state()` reads the snapshot through `from`, and `actions`
are the controller's actions. The work starts on mount, and the controller
is disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createAiChat,
  createAiChatsController,
  rpcTransport,
} from "better-supabase/blocks/ai-chat";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state, actions } = useBlockController(createAiChatsController, {
    client: createAiChat({ transport: rpcTransport(supabase) }),
    query: { organizationId: props.organizationId },
  });
  return (
    <>
      <ul>
        <For each={state().items ?? []}>{(chat) => <li>{chat.title}</li>}</For>
      </ul>
      <button onClick={() => actions.loadMore()}>Load more</button>
    </>
  );
}
```

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

## Your own fields [#your-own-fields]

`blocks.ai-chat.options.extraColumns` adds columns to the `ai_chats` table,
keyed by column name with their SQL type. Pass `createAiChat` a Standard Schema
for them as `fields`: `chats.create`, `chats.update` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and `chats.get`, `chats.list` return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAiChat({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.aiChat` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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),
  });
```

# 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 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
`blocks["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]

Server code calls the `ai-files` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAiFiles } = defineDataHooks(
  "aiFiles",
  (db: AppDb) => db.aiFiles,
);
```

```ts
const rows = await db.aiFiles.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/ai-files/list.tsx"
"use client";

import { useAiFiles } from "@/lib/supabase/hooks";

const rows = useAiFiles({ limit: 50 });
```

`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`, `upsert`, `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. In a
writable [support session](/docs/auth/impersonation#storage-in-a-support-session)
it first claims the upload for the user the admin acts as; a refused claim
returns `forbidden` (`AI_FILE_FORBIDDEN`) or `not_found`
(`AI_FILE_NOT_UPLOADED`) and leaves the file pending. 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.

## Your own fields [#your-own-fields]

`blocks.ai-files.options.extraColumns` adds columns to the `ai_files` table,
keyed by column name with their SQL type. Pass `createAiFiles` a Standard Schema
for them as `fields`: `files.upload`, `files.store` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and the file reads return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAiFiles({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.aiFiles` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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,
  });
```

# 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-keys

The `ai-keys` 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 add ai-keys   # 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]

Server code calls the `ai-keys` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAiKeys } = defineDataHooks(
  "aiKeys",
  (db: AppDb) => db.aiKeys,
);
```

```ts
const rows = await db.aiKeys.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/ai-keys/list.tsx"
"use client";

import { useAiKeys } from "@/lib/supabase/hooks";

const rows = useAiKeys({ limit: 50 });
```

## 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
  .upsert(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`. `upsert` 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.

## Without the client [#without-the-client]

`db.aiKeys` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

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

import { createAiKeys, rpcTransport } from "better-supabase/blocks/ai-keys";
import { vaultCredentials } from "better-supabase/credentials";

const service = rpcTransport(bsb.admin().$client, { schema: "api" });

export const providersFor = (supabase: SupabaseClient) =>
  createAiKeys({
    transport: rpcTransport(supabase, { schema: "api" }),
    service,
    credentials: vaultCredentials({ transport: service, schema: "api" }),
    schema: "api",
  });
```

# 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-schedules

The `ai-schedules` 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 add ai-schedules   # 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]

Server code calls the `ai-schedules` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAiSchedules } = defineDataHooks(
  "aiSchedules",
  (db: AppDb) => db.aiSchedules,
);
```

```ts
const rows = await db.aiSchedules.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/ai-schedules/list.tsx"
"use client";

import { useAiSchedules } from "@/lib/supabase/hooks";

const rows = useAiSchedules({ limit: 50 });
```

```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/queue#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/queue) 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. Without it, a `notification`
type sends each outcome through the `notifier` port, the notifications block
that [`createBlocks`](/docs/blocks/create-blocks#ports) passes:
`aiSchedulesBlock.with({ notification: "ai_task.finished" })`.

## Your own fields [#your-own-fields]

`blocks["ai-schedules"].options.extraColumns` adds columns to the `ai_scheduled_tasks` table,
keyed by column name with their SQL type. Pass `createAiSchedules` a Standard Schema
for them as `fields`: `create`, `update` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and `create`, `update`, `pause`, `resume`, `list` return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAiSchedules({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.aiSchedules` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

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

export const tasksFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createAiSchedules({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
    run: async (task, run, signal) => {
      const chatId = await runAssistant(task, signal);
      return { chatId };
    },
  });
```

# AI

> Chat, knowledge, agents and the blocks an assistant needs.

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

- [Vector search](/docs/blocks/vector-search)
- [AI chat](/docs/blocks/ai-chat)
- [AI files](/docs/blocks/ai-files)
- [Knowledge](/docs/blocks/knowledge)
- [Memory](/docs/blocks/memory)
- [Agents](/docs/blocks/agents)
- [AI tasks](/docs/blocks/ai-schedules)
- [AI cache](/docs/blocks/ai-cache)
- [AI providers](/docs/blocks/ai-keys)

# 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 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 `blocks.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/queue) queue a new document is enqueued on       |

## Server [#server]

Server code calls the `knowledge` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useKnowledge } = defineDataHooks(
  "knowledge",
  (db: AppDb) => db.knowledge,
);
```

```ts
const rows = await db.knowledge.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/knowledge/list.tsx"
"use client";

import { useKnowledge } from "@/lib/supabase/hooks";

const rows = useKnowledge({ limit: 50 });
```

`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/queue) 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.

With the jobs block installed, `ingest.queue(organizationId, fileId)` moves
that work off the request: it creates a pending document for the file and
enqueues it on `knowledge_ingest` (the `ingestQueue` option). Register
`knowledge.ingestJob()` as that queue's handler on the server; it reads the
file, extracts its text and writes the chunks, which enqueues their
embedding. The handler runs as the service role, so give `createKnowledge`
an AI files client with a service transport. The last failed attempt marks
the document `failed`.

```ts
const handlers = {
  knowledge_ingest: knowledge.ingestJob(),
  knowledge_embed: knowledge.embedJob(),
};
```

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

## Hooks [#hooks]

`createKnowledge` takes `hooks`, keyed by method name, that run your code
before and after a call: refuse it with a `DbError`, rewrite its arguments
or observe the result (see [extending blocks](/docs/extending/blocks#hooks)). Methods in a group take their hooks under the group's name,
such as `hooks: { documents: { create: { before } } }`.

## Without the client [#without-the-client]

`db.knowledge` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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"),
  });
```

# 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 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 `blocks.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]

Server code calls the `memory` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useMemory } = defineDataHooks(
  "memory",
  (db: AppDb) => db.memory,
);
```

```ts
const rows = await db.memory.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/memory/list.tsx"
"use client";

import { useMemory } from "@/lib/supabase/hooks";

const rows = useMemory({ limit: 50 });
```

| Group       | Methods                                                             |
| ----------- | ------------------------------------------------------------------- |
| `core`      | `view`, `write`, `strReplace`, `insert`, `remove`, `rename`, `list` |
| `archival`  | `upsert`, `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
  .upsert(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.

## Hooks [#hooks]

`createMemory` takes `hooks`, keyed by method name, that run your code
before and after a call: refuse it with a `DbError`, rewrite its arguments
or observe the result (see [extending blocks](/docs/extending/blocks#hooks)). Methods in a group take their hooks under the group's name,
such as `hooks: { core: { write: { before } } }`.

## Without the client [#without-the-client]

`db.memory` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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"),
  });
```

# 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({
  database: {
    vectorSearch: {
      chunks: "embedding", // cosine distance
      "docs.pages": { column: "embedding", distance: "inner_product" }, // or 'l2'
    },
  },
  blocks: ["vector-search"],
});
```

```bash
better-supabase upgrade
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. `upgrade` reads it
from the `create extension ... vector ... schema` statement in your schema
files or migrations, and falls back to `extensions`, the Supabase default.
Set `blocks.vector-search.options.schema` when pgvector is installed
another way:

```ts title="better-supabase.config.ts"
blocks: { "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 `list`.
- `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).

# Rules

> Tenant-editable rules that test a subject against a filter tree when a trigger event happens and name the actions your app runs, evaluated the same way in SQL and TypeScript.

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

`rules` lets an organization write its own "when this, then that" rules. A rule belongs to a subject type (`expense`), is tested when a trigger event happens (`expense.submitted`), holds its conditions as a [filter tree](/docs/platform/list#filter-trees) over fields you declare, and names actions as `{ kind, config }` references: `approvals.request` with `{ action: "finance_review" }`, for example. The database stores, checks and evaluates rules; your app decides what each action kind does.

Reading rules needs `rules.read`, changing a disabled rule needs `rules.edit`, enabling a rule or changing an enabled one needs `rules.publish`, and removing one needs `rules.admin`. Admins get every permission and members get `rules.read`.

```bash
better-supabase add automations
```

The module needs `memberships` and `access`.

## Fields [#fields]

`defineRuleFields` declares what rules on a subject type may test: a field id, its [filter type](/docs/platform/list#filter-trees) and the key of the subject record it reads, dotted for a nested value. Sync the catalog once per deploy with a service-role client, so the database checks trees against the same fields.

Server code calls the `automations` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAutomations } = defineDataHooks(
  "automations",
  (db: AppDb) => db.automations,
);
```

```ts
const rows = await db.automations.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/automations/list.tsx"
"use client";

import { useAutomations } from "@/lib/supabase/hooks";

const rows = useAutomations({ limit: 50 });
```

A field's `columnType` decides how dates compare: `timestamptz` (the default) compares instants in the evaluation's time zone, and `date` compares days. `nullable: false` tells the evaluator a value is always there; on a nullable field, "is not" and the other negative operators also match a record without a value, except "is not empty". `operators` narrows the type's operators, and `options` lists the values a select offers.

`ruleFilterFields(fields)` returns the `fields` prop of ReUI's Filters, and `checkRuleConditions(tree, fields)` returns the problems `upsert` would report, so an editor can show them before it saves.

## Rules [#rules]

```ts
import { createAutomations } from "better-supabase/blocks/automations";

const rules = createAutomations({ transport });

const rule = await rules
  .upsert(organizationId, {
    subjectType: "expense",
    trigger: "expense.submitted",
    name: "Large travel expenses",
    conditions: {
      id: "root",
      type: "group",
      combinator: "and",
      rules: [
        {
          id: "a",
          type: "rule",
          path: ["category"],
          operator: "is",
          value: "travel",
        },
        {
          id: "b",
          type: "rule",
          path: ["amount"],
          operator: "gt",
          value: 1000,
        },
      ],
    },
    actions: [
      { kind: "approvals.request", config: { action: "finance_review" } },
    ],
  })
  .orThrow();

await rules.setEnabled(rule.id, true).orThrow();
```

`upsert` creates a rule, or changes the one with `id`. Keys you leave out keep their value, and the subject type can't change. Pass the `version` the editor read and a newer stored version fails with `RULE_CONFLICT`, so two editors don't overwrite each other. Every change bumps `version` and keeps the previous tree in `versions(id)`. A new rule is disabled and goes last; `move(id, rank)` takes a key from `rankBetween` over its neighbours ([rank keys](/docs/blocks/rank)).

The database checks every tree: fields must be in the catalog, operators must suit the field's type, values must fit the operator (two for "between", a list for "is any of"), and the tree stays within `maxDepth` and `maxConditions`. Conditions without an operator take no part, so an editor can save a half-finished row.

`list(organizationId, { subjectType, trigger, enabled })` returns rules in the order they are tested. `get(id)` returns `undefined` when the caller can't read the rule, and `remove(id)` returns `false` when there was none they may remove.

## Evaluate [#evaluate]

A rule matches when its tree is true for the subject. The answer is three-valued: a condition on a missing value is unknown, an "and" with an unknown part is unknown unless another part is false, and only a true root matches. An empty tree matches everything, so a rule without conditions runs on every trigger event.

`matching(organizationId, { subjectType, trigger, subject, timeZone })` evaluates the enabled rules for the trigger in the database and returns each match with `results`, the truth of every condition and group by node id. A matched rule with `stopProcessing` ends the list. From a job or a webhook, the service-role `run` does the same and also logs each evaluation:

```ts
const matches = await createAutomationsAdmin({ transport: serviceTransport })
  .run(organizationId, {
    subjectType: "expense",
    trigger: "expense.submitted",
    subjectId: expense.id,
    subject: expense,
    timeZone: "Europe/Amsterdam",
  })
  .orThrow();
```

`evaluateRule(rule, subject, fields, clock)` runs the same evaluation in memory, and in SQL `rule_evaluate(conditions, subject, rule_field_map('expense'), time_zone, now)` returns `{ matched, results }`; `rule_matches` returns only the boolean, for policies and triggers. Both evaluators run against the same cases in the integration tests.

`test(rule, sample)` evaluates a rule on a sample record without running anything and logs it as a test, for a "Try it" button. `evaluations(id)` lists the log, newest first.

## Actions [#actions]

Actions are references, never code: the database stores `{ kind, config }` and your app runs them. One action registry serves automations, workflow action steps and sequence action steps. `defineAction` declares an action with a kind, a title for the editor, an optional Standard Schema for its config and a `run` that returns a `Result`. `createActionRegistry` collects the actions of every installed block and throws when two of them handle the same kind.

`createRuleDispatcher({ actions })` runs the actions of every match in order. A failed action doesn't stop the others, an action that throws becomes an `unexpected` outcome, a kind the registry lacks is an `invalid_request` outcome with the hint `ACTION_UNKNOWN`, and a config that fails the action's schema is a `validation` outcome with the hint `ACTION_CONFIG_INVALID`.

```ts
import { createApprovals } from "better-supabase/blocks/approvals";
import {
  approvalsAction,
  createActionRegistry,
  createRuleDispatcher,
  defineAction,
} from "better-supabase/blocks/automations";
import { ok } from "better-supabase";

const actions = createActionRegistry([
  approvalsAction(createApprovals({ transport })),
  defineAction({
    kind: "crm.tag",
    title: "Tag the customer",
    run: async ({ context, action }) => {
      await tagCustomer(context.subjectId, String(action.config?.tag));
      return ok(null);
    },
  }),
]);

const dispatcher = createRuleDispatcher({ actions });

const outcomes = await dispatcher
  .dispatch(matches, {
    organizationId,
    subjectType: "expense",
    subjectId: expense.id,
    payload: { amount: expense.amount },
  })
  .orThrow();
```

`run` receives the action, its origin (the automation, workflow run or sequence enrollment that started it) and the context, plus a `key` that stays the same for every retry of this action for this event. Pass the key on as an idempotency key.

`approvalsAction` starts an [approval request](/docs/blocks/approvals) for the action in `config.action`, which must be a policy action your approvals options name for the subject type.

Four more actions cover the common cases. Each takes the part of a client it calls, so you pass the block you already built:

| Action                      | Config                                                | What it does                                                                         |
| --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `notifyAction(notifier)`    | `type`, `recipients` and or `recipientField`, `data`  | sends the notification to the listed user ids and the one in the payload column      |
| `setFieldAction(setField)`  | `field`, `value`                                      | calls your setter with the subject and the value; the setter decides what it accepts |
| `webhookAction(webhooks)`   | `endpointId`, `type` (defaults to the rule's trigger) | queues an outgoing webhook delivery with the subject and the payload                 |
| `workflowAction(workflows)` | `definition`                                          | starts the workflow definition on the subject, with the payload as its input         |

`notify` and `workflow` key each send by rule, action and event, so a retried event sends once.

```ts
const actions = createActionRegistry([
  notifyAction(notifications),
  setFieldAction(({ subjectId, field, value }) =>
    db.tasks.update(subjectId, { [field]: value }),
  ),
  webhookAction(webhooks),
  workflowAction(workflowBuilder),
]);
```

### Conversion actions [#conversion-actions]

A conversion action creates a record in its block from a record in another and links it `created_from` the source, once: a retried event finds the link and creates nothing. The target blocks ship them:

| Action                          | Block       | Creates                                                |
| ------------------------------- | ----------- | ------------------------------------------------------ |
| `taskFromConversationAction`    | `tasks`     | a task from an inbox conversation                      |
| `employeeFromApplicationAction` | `employees` | an employee from a hired candidate                     |
| `orderFromQuoteAction`          | `orders`    | an order from an accepted quote, through your `create` |

Each takes `clients`, which returns the block clients for one organization, such as `createBlocks` over a service connection with `tenant: () => tenant`. `defineConversionAction` builds your own from a `load`, a `map` into the target's input schema and a `create`.

## Default automations [#default-automations]

A block that feeds another ships default automations: rows its SQL module writes to `rule_defaults`, which every organization gets its own copy of the first time its rules run or are listed. The organization turns them off or edits them like any rule. `resetDefault(id)` puts the shipped version back, and removing one fails with the hint `RULE_DEFAULT`.

| Default                                | Block    | Trigger                     | Action                       | Ships when               |
| -------------------------------------- | -------- | --------------------------- | ---------------------------- | ------------------------ |
| Open a task for each new conversation  | `inbox`  | `inbox_conversation.opened` | `tasks.from_conversation`    | `tasks` is installed     |
| Preboard each hired candidate          | `hiring` | `application.hired`         | `employees.from_application` | `employees` is installed |
| Order each accepted quote (turned off) | `quotes` | `quote.accepted`            | `orders.from_quote`          | `orders` is installed    |

A rule copied from a default carries its `defaultKey`. Register the matching conversion action in the registry, and the sink's `eventTarget` fallback runs the default without a `load` for its event.

## Run rules from events [#run-rules-from-events]

`createRuleSink` turns the rules into an [outbox](/docs/blocks/outbox) consumer. For each event it calls your `load`, tests the rules whose trigger is the event's type and dispatches the actions of the ones that match. App tables with [`events`](/docs/repository/tables#events) emit `task.created`, `task.updated` and `task.deleted`, so a rule with the trigger `task.updated` runs on every edit.

```ts title="app/api/outbox/rules/route.ts"
import { createRuleSink } from "better-supabase/blocks/automations";

const sink = createRuleSink({
  rules: rulesAdmin,
  dispatcher,
  load: async (event) => {
    const { id, organizationId } = event.payload as AppTableEventData;
    const task = await db.tasks.get(String(id)).orThrow();

    return {
      organizationId: String(organizationId),
      subjectType: "task",
      subjectId: String(id),
      subject: task,
      context: {
        organizationId: String(organizationId),
        subjectType: "task",
        subjectId: String(id),
        eventId: event.id,
        payload: task,
      },
    };
  },
});

export const POST = async () =>
  Response.json(await outbox.consume("rules", sink));
```

`load` returning `undefined`, or no `load`, falls back to `eventTarget(event)`: the organization from the outbox tenant or `payload.organizationId`, the id from the CloudEvents subject or `payload.id`, and the payload as the subject. An event without an organization is skipped. A failed evaluation throws, so the outbox retries the batch; a failed action goes to `onOutcomes` instead, because a retry would run the other actions again.

## Editor [#editor]

`useRuleBuilder` holds the editor state of one rule: the subject type's fields, the condition tree with your edits, the problems `checkRuleConditions` finds, whether anything changed, and `upsert`. Pass `rule: null` to start a new rule from `initial` (an empty `and` group by default). The hook is a registry item: install it with `npx shadcn add @better-supabase/use-automations`, which writes `hooks/use-automations.ts` into the app.

```tsx title="components/rule-editor.tsx"
"use client";

import { useRuleBuilder } from "@/hooks/use-automations";

export function RuleEditor({ organizationId }: { organizationId: string }) {
  const builder = useRuleBuilder({
    organizationId,
    rule: null,
    subjectType: "expense",
    trigger: "expense.submitted",
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void builder.save({ enabled: true });
      }}
    >
      <button
        type="button"
        onClick={() => builder.addRule(undefined, "amount")}
      >
        Add condition
      </button>
      <button
        type="submit"
        disabled={!builder.dirty || builder.issues.length > 0}
      >
        Save
      </button>
    </form>
  );
}
```

`addRule(group, field)` and `addGroup(group, combinator)` add to the root group when `group` is left out and return the new node's id. `setField` keeps the operator and value when the new field has the same type, and `setOperator` keeps the value when the new operator takes the same kind of value. `move(id, group, index)` takes the index the node ends up at, as `arrayMove` from dnd-kit counts it. `locked` names nodes the editor can't change, move or remove, such as a fixed first condition; a group that holds a locked node can't be removed either. The lock lives in the editor only, so enforce it on the server if it matters.

The [Rule builder recipe](/docs/registry/ui-recipes/rule-builder) renders this editor with shadcn components: field, operator and value rows, nested And and Or groups, drag handles and a locked first condition. The `use-rule-editor` [registry item](/docs/registry) installs a `useRuleEditor` hook that reads the organization from the session and the schema from `blocks.generated.ts`.

The tree helpers the controller uses (`findNode`, `insertNode`, `moveNode`, `removeNode`, `setRuleField`, `setRuleOperator` and the rest) are exported from `better-supabase/blocks/automations` for your own editor. `createRuleBuilderController` is the same state without React. In Server Components, call `rules.get(id)` instead.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs `createRuleBuilderController` with its options and a `client` from `createAutomations`; `auth` and `ports` come from `app.use(betterSupabase(bsb))` unless you pass them. `state` is a shallow ref of the snapshot and `actions` are the controller's actions. The work starts on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createAutomations,
  createRuleBuilderController,
  rpcTransport,
} from "better-supabase/blocks/automations";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createRuleBuilderController, {
  client: createAutomations({ transport: rpcTransport(supabase) }),
  organizationId,
  rule: null,
  subjectType: "expense",
});
</script>

<template>
  <button type="button" @click="actions.addRule(undefined, 'amount')">
    Add condition
  </button>
  <p>{{ state.issues.length }} problems</p>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs `createRuleBuilderController` with the same options; `auth` and `ports` come from `setBetterSupabase(bsb)` unless you pass them. It returns a readable store of the snapshot, read with `$`, with the actions on `.actions`. The work starts on mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createAutomations,
    createRuleBuilderController,
    rpcTransport,
  } from "better-supabase/blocks/automations";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const builder = useBlockController(createRuleBuilderController, {
    client: createAutomations({ transport: rpcTransport(supabase) }),
    organizationId,
    rule: null,
    subjectType: "expense",
  });
</script>

<button type="button" onclick={() => builder.actions.addRule(undefined, "amount")}>
  Add condition
</button>
<p>{$builder.issues.length} problems</p>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs `createRuleBuilderController` with the same options; `auth` and `ports` come from `<BetterSupabaseProvider client={bsb}>` unless you pass them. `state()` reads the snapshot and `actions` are the controller's actions. The work starts on mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createAutomations,
  createRuleBuilderController,
  rpcTransport,
} from "better-supabase/blocks/automations";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state, actions } = useBlockController(createRuleBuilderController, {
    client: createAutomations({ transport: rpcTransport(supabase) }),
    organizationId: props.organizationId,
    rule: null,
    subjectType: "expense",
  });
  return (
    <>
      <button
        type="button"
        onClick={() => actions.addRule(undefined, "amount")}
      >
        Add condition
      </button>
      <p>{state().issues.length} problems</p>
    </>
  );
}
```

## Configure [#configure]

```ts title="better-supabase.config.ts"
blocks: {
  automations: {
    options: { maxConditions: 30, maxDepth: 3, maxActions: 5 },
  },
},
```

| Option          | Default | Meaning                                         |
| --------------- | ------- | ----------------------------------------------- |
| `maxConditions` | `50`    | the most conditions one rule may hold           |
| `maxDepth`      | `4`     | how deep groups nest, the root group counting 1 |
| `maxActions`    | `20`    | the most actions one rule may run               |
| `enable`        | `[]`    | default automation keys to ship turned on       |
| `disable`       | `[]`    | default automation keys to ship turned off      |

`enable` and `disable` take the keys of the [default automations](#default-automations), such as `quotes.quote_to_order`, and change whether a new organization's copy starts on. `disable` wins when a key is in both. The `erp` preset enables `quotes.quote_to_order`.

## Errors [#errors]

| Hint                    | Cause                                                                    |
| ----------------------- | ------------------------------------------------------------------------ |
| `RULE_FORBIDDEN`        | the caller lacks the permission for this                                 |
| `RULE_NOT_FOUND`        | no rule the caller can see has this id                                   |
| `RULE_CONFLICT`         | the rule changed since the editor read `version`                         |
| `RULE_SUBJECT_UNKNOWN`  | the subject type has no synced fields                                    |
| `RULE_INVALID`          | a malformed tree, a missing name or trigger, or a tree over the limits   |
| `RULE_FIELD_UNKNOWN`    | a condition names a field the catalog doesn't have                       |
| `RULE_OPERATOR_INVALID` | the operator doesn't suit the field's type                               |
| `RULE_VALUE_INVALID`    | the value doesn't fit the operator                                       |
| `RULE_ACTIONS_INVALID`  | actions aren't a list of `{ kind, config }` with a dotted lowercase kind |
| `RULE_RANK_INVALID`     | the rank isn't a rank key                                                |
| `RULE_FIELDS_INVALID`   | `syncFields` got a field with a bad id, type or column type              |
| `RULE_DEFAULT`          | the rule is a block's default automation; turn it off instead            |
| `RULE_NOT_DEFAULT`      | `resetDefault` got a rule no block ships                                 |
| `ACTION_UNKNOWN`        | no action in the registry handles the kind                               |
| `ACTION_CONFIG_INVALID` | the action's config fails its schema                                     |

## Events [#events]

The block records `rule.saved`, `rule.enabled`, `rule.disabled` and `rule.removed` with `organizationId`, `ruleId`, `name`, `subjectType`, `trigger` and `version`, which the audit log keeps as configuration changes. A live match records `rule.matched` with the `subjectId`. See [events](/docs/extending/events).

The block takes `hooks` around its methods; see [extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.automations` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createAutomationsAdmin,
  defineRuleFields,
} from "better-supabase/blocks/automations";

export const expenseFields = defineRuleFields("expense", {
  status: { type: "select", options: ["draft", "submitted", "paid"] },
  amount: { type: "number", nullable: false },
  category: { type: "select", options: ["travel", "meals", "software"] },
  submittedAt: { type: "date" },
  "submitter.seniority": { type: "select", label: "Submitter seniority" },
});

await createAutomationsAdmin({ transport: serviceTransport })
  .syncFields(expenseFields)
  .orThrow();
```

# Automation

> Automations, workflows and the blocks that run them.

Source: https://bettersupabase.com/docs/blocks/catalog-automation

- [Jobs, idempotency and webhook inbox](/docs/blocks/queue)
- [Outbox](/docs/blocks/outbox)
- [Workflows](/docs/blocks/workflows)
- [Workflow SDK](/docs/blocks/workflow-sdk)
- [Workflow builder](/docs/blocks/workflow-editor)
- [Durable streams](/docs/blocks/streams)
- [Connectors](/docs/blocks/connectors)
- [Rules](/docs/blocks/automations)

# 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 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 `blocks.connectors.permissions.read`, `.use` and
`.manage`.

## Server [#server]

Server code calls the `connectors` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useConnectors } = defineDataHooks(
  "connectors",
  (db: AppDb) => db.connectors,
);
```

```ts
const rows = await db.connectors.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/connectors/list.tsx"
"use client";

import { useConnectors } from "@/lib/supabase/hooks";

const rows = useConnectors({ limit: 50 });
```

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

## Your own columns [#your-own-columns]

Add columns to `connector_servers` with
`blocks.connectors.options.extraColumns` (column name to SQL type), then
pass a Standard Schema for them as `fields`. `servers.create` and
`servers.update` validate the `fields` you pass and write them in the same
`save_connector_server` call as the server's own columns; a column you
leave out keeps its value. `servers.list`, `servers.get`, `create` and
`update` return the columns parsed under `fields`, keyed by column name.

```ts title="better-supabase.config.ts"
blocks: {
  connectors: {
    options: { extraColumns: { region: "text not null default 'eu'" } },
  },
},
```

```ts title="lib/connectors.ts"
import {
  createConnectors,
  rpcTransport,
} from "better-supabase/blocks/connectors";
import { z } from "zod";

const connectors = createConnectors({
  transport: rpcTransport(supabase),
  fields: z.object({ region: z.enum(["eu", "us"]) }),
});

const server = await connectors.servers
  .create(organizationId, {
    name: "GitHub",
    url: "https://mcp.example.com/mcp",
    fields: { region: "us" },
  })
  .orThrow();
server.fields.region; // "eu" | "us"
```

The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

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

## Without the client [#without-the-client]

`db.connectors` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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) }),
  });
```

# 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 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 `impersonation` emits `support.started` and
`support.ended`. Set
`blocks.<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
`blocks.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 over a server connection, register each consumer
once, then relay it from a cron route or a worker. `transport` emits and
reads the history; `service`, which defaults to `transport`, runs the
consumer calls, which need the service role:

Server code calls the `outbox` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useOutbox } = defineDataHooks(
  "outbox",
  (db: AppDb) => db.outbox,
);
```

```ts
const rows = await db.outbox.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/outbox/list.tsx"
"use client";

import { useOutbox } from "@/lib/supabase/hooks";

const rows = useOutbox({ limit: 50 });
```

```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 or returns a failed `Result` 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) }`.

### Which events a consumer gets [#which-events-a-consumer-gets]

Pass `subscribers`, a `SubscriberPolicy`, to keep events from consumers that
may not read them. Without it, `createOutbox` uses
`authorization.delivery.subscribers`. `relay` and `consume` call the policy
once per claimed event with the consumer as a service subscriber
(`{ principal: "search", kind: "service" }`). An event it leaves out is
acknowledged without reaching the sink or handler, and the result's `refused`
counts it. A policy that throws fails the batch like a sink that throws.
Consumers are your own server code, not principals, so the block has no
default policy for them under an authorization provider; the sinks they
feed (webhooks, notifications, realtime) check their own subscribers.

```ts
const outbox = createOutbox({
  transport: sqlTransport(postgres.admin),
  source: "https://crm.example.com",
  subscribers: (event, subscribers) =>
    event.type.startsWith("billing.")
      ? subscribers.filter((s) => s.principal === "ledger")
      : subscribers,
});
```

The outbox SQL doesn't filter: every registered consumer claims every event,
and the policy runs in TypeScript.

`hooks` run around `emit`, `register`, `unregister`, `history`,
`deadLetters` and `purge`, the methods that return a result. With
[`defineBlocks`](/docs/blocks/create-blocks), set the CloudEvents source with
`outboxBlock.with({ source: "https://crm.example.com" })`. The outbox tables
take no app columns: the app's data goes in each event's payload.

### 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/queue)
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
`blocks.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.

### Partitioned events [#partitioned-events]

`blocks.outbox.options.partitioned` creates a new `outbox_events` table
partitioned by month on `created_at`, with the primary key
`(id, created_at)` and a sequence for the position. `purge_outbox` then drops
every month past the retention cutoff that no active consumer still waits
for, before it deletes the remaining rows. A key stays unique per tenant
through a lock in `emit_event` instead of a unique index. Schedule
`better_supabase.maintain_partitions()` once a month to add the months ahead.

The option applies when the module creates the table. An existing table
stays as it is, and `better-supabase doctor` reports it as BS335.

## Existing tables [#existing-tables]

Map `tables.events` to an events table you already have and rename its
columns. The block adds the `xid` column (and its index) when the table lacks
it, so `upgrade` doesn'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({
  blocks: {
    outbox: {
      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 the outbox runs
over your 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 only when the outbox runs over a table your app already has, 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 |

## Without the client [#without-the-client]

`db.outbox` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/outbox.ts"
import { createOutbox, sqlTransport } from "better-supabase/blocks/outbox";

export const outbox = createOutbox({
  transport: sqlTransport(postgres.admin),
  source: "https://crm.example.com",
  typePrefix: "com.example.crm",
});

await outbox.register("billing", {
  types: ["invoice.*", "organization.created"],
});
```

# 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/queue

`better-supabase/blocks/queue` sits on top of the `queue`, `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 add queue 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                                                                                                                                                |
| -------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `blocks.queue.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                              |
| `blocks.queue.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({
  blocks: { queue: { 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 add --adopt` 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 upgrade` 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.
`jobs.purge({ olderThan, deadOlderThan, batch, queues })` calls it for every
queue `createQueue` declared (or the `queues` given) and returns how many it
deleted; `jobs.purgeJob(options)` is the same as a job handler. See
[Retention](/docs/blocks/sql#retention) for the other purges.

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 { createQueue } from "better-supabase/blocks/queue";
import * as v from "valibot";

export const jobs = createQueue({
  sql: postgres.admin,
  queues: {
    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 a whole number of seconds; a fraction or a negative number is a
`validation` error. 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. When an extension
  fails or finds the lease gone, the handler's `signal` aborts and the
  worker neither completes nor fails that job; `onError` gets a `conflict`
  with code `JOB_LEASE_LOST` and the job counts as failed.
- 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)`.
- `enqueueOnce(queue, payload, { dedupeKey })` enqueues at most one job per
  key, ever: a completed or dead job still holds its key, so a retried
  webhook or a repeated import adds nothing. It returns `{ id, duplicate }`,
  with the first job's id on a duplicate. In SQL it is
  `enqueue_job_once(queue, payload, dedupe_key)`.
- `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
`createQueue` 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 or the API transport described in
[Without a database connection](#without-a-database-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
the memberships extension resolved for the connection, or the `tenant_id` claim (then
`app_metadata.tenant_id`). The handler gets them back as `job.context`, and
[`bsb.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 bsb.forContext(job.context).orThrow();
  const invoice = await db.invoices.get(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.

A job an OAuth or MCP client queues stays limited to what the user granted
that client. The envelope records the token's `client_id`, `scope`,
`authorization_details` (RFC 9396) and the full RFC 8693 `act` chain, and
`forContext` puts them back into the claims the job runs with.
`ServerOptions.claimsFor` can't set or replace them. The access functions
(`can`, `member_can`, `tenant_ids_with` and `is_platform`) narrow a delegated
token on their own: a key counts only when one of the token's scopes maps to
it (see [Delegated tokens](/docs/repository/permissions#delegated-tokens)), so a
policy that asks them needs nothing else.

```sql
create policy "posts: writers" on public.posts
  for update to authenticated
  using (organization_id in (select better_supabase.tenant_ids_with('posts.write')));
```

For a policy that checks the literal OAuth scope instead of a permission,
`better_supabase.token_scope_allows(scope)` from the `created-by` module is true
for a token no client acts with, and for a delegated token only when its
`scope` claim holds the scope.

`forContext(context, { permissions })` caps the work further. The cap is the
`bsb_permission_cap` claim, a job queued under it keeps it, and a later
`permissions` only narrows it:

```ts
await bsb.forContext(job.context, { permissions: ["posts.write"] });
```

A context whose `act` chain or delegation claims are malformed is refused:
`enqueue` fails with `forbidden`, and `forContext` refuses a stored one.

#### Tickets [#tickets]

An authorization provider can hand out an opaque ticket that names who a
job runs as, for example a grant that a user can revoke before the job
runs. Pass it as `ticket` and the envelope records it:

```ts
await jobs.enqueue(
  "export_report",
  { reportId },
  { context: db.$context, ticket },
);

await jobs.work("export_report", async (payload, job) => {
  const db = await bsb.forContext(job.context).orThrow();
});
```

`forContext` resolves a recorded ticket through its `ticket` option, or
else the provider's `tickets.subject` hook from
`createServer(bsb, { authorization })`, and runs as the context it returns.
A ticket without a resolver, a `null` answer and a throw all fail with
`forbidden`; `forContext` never falls back to the recorded actor or the
service role.

`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 `bsb.admin(job.context)` on purpose: it
stamps `createdBy` and applies the memberships 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
the memberships extension applies its `onMissing` (by default the query is left to RLS). 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`, `memberships` and `context` as `schedule`;
one without a tenant gets the `memberships` option.

#### Block timers [#block-timers]

Blocks that act on time declare their timers in the manifest, and they all run
from this scheduler. `blockSchedules(blocks)` turns them into job schedules
named `block:<block>.<name>` on the `block_ticks` queue, and
`blockTickHandler` runs the handler named `<block>.<name>` for each tick. A
tick without a handler, or a handler that returns a failed `Result`, throws,
so the queue retries it and the dead letter shows it.

| Timer               | Cron               | Handler                                                          |
| ------------------- | ------------------ | ---------------------------------------------------------------- |
| `sla.check`         | every minute       | `sla.check()`: warnings and breaches of running clocks           |
| `sequences.run-due` | every minute       | `sequences.runDue()`: sends the steps whose wait ended           |
| `ai-schedules.tick` | every minute       | `aiSchedules.tick()`: runs the due AI tasks                      |
| `workflows.tick`    | every minute       | `schedules.tick()` and `admission.tick()` of the workflows block |
| `content.publish`   | every five minutes | `content.publishDue()`: publishes the scheduled entries now due  |

```ts title="lib/jobs.ts"
import {
  BLOCK_SCHEDULE_PREFIX,
  BLOCK_TICKS_QUEUE,
  blockSchedules,
  blockTickHandler,
  blockTickSchema,
  createQueue,
} from "better-supabase/blocks/queue";
import { sequencesBlock } from "better-supabase/blocks/sequences";
import { slaBlock } from "better-supabase/blocks/sla";

export const jobs = createQueue({
  sql: postgres.admin,
  queues: { [BLOCK_TICKS_QUEUE]: blockTickSchema },
});

// On deploy: one schedule per timer of the installed blocks.
await jobs
  .ensureSchedules(blockSchedules([slaBlock, sequencesBlock]), {
    prefix: BLOCK_SCHEDULE_PREFIX,
  })
  .orThrow();

export const ticks = blockTickHandler({
  "sla.check": () => blocks.sla.check(),
  "sequences.run-due": () => blocks.sequences.runDue(),
});
```

Pass `ticks` as the `block_ticks` handler of the [drain route](#drain-route).
Reminders don't need a timer: each reminder is a job on its own queue, due at
its time.

#### 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-schedules)                  | 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/queue";

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]

`createQueue` accepts a SQL connection, a Supabase client, or any
`QueueProvider` (API version 1). `sqlQueueProvider(sql)`,
`pgmqPublicBackend(client)` and `vercelQueueProvider(client)` are built in; implement the interface to
put jobs somewhere else, and prove it with `testQueueProvider` from
[conformance kits](/docs/extending/conformance).

```ts
import { createQueue, type QueueProvider } from "better-supabase/blocks/queue";

const backend: QueueProvider = myQueue;
const jobs = createQueue({ backend, queues: { send_email: schema } });
```

### Vercel Queues [#vercel-queues]

`vercelQueueProvider(client)` stores jobs on [Vercel Queues](https://vercel.com/docs/queues)
instead of Postgres. Pass a `PollingQueueClient` from `@vercel/queue` (an
optional peer, range `>=0.8 <0.9`). Queue names are topic names, so they match
`[A-Za-z0-9_-]+`.

```ts
import { PollingQueueClient } from "@vercel/queue";
import { createQueue, vercelQueueProvider } from "better-supabase/blocks/queue";

const jobs = createQueue({
  backend: vercelQueueProvider(new PollingQueueClient({ region: "iad1" })),
  queues: { send_email: schema },
});
```

`drain` reads with the client's `receive`, which keeps the visibility timeout
alive while a handler runs, and `fail` redelivers the message after the retry
delay. Vercel Queues keeps no dead letters and has no schedules, so a message
past `maxAttempts` is acknowledged and dropped, `stats` and `listDead` are
absent, and `schedule` needs the SQL backend. Job ids are numbers local to the
process. The backend passes `testQueueProvider`.

### 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. Without `schema`, jobs go
through the `pgmq_public` RPCs, which you turn on in the dashboard under Integrations, Queues, "Expose
Queues via PostgREST".

```ts
const jobs = createQueue({
  client: createClient(url, secretKey),
  queues: { 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`, `enqueueOnce`, `schedule`, `stats`, `listDead` and
  `retryDead` return `invalid_request`.

To get the whole API without a direct connection, write wrappers for the
module into an exposed schema with `blocks.queue.api` and pass an
`rpcTransport` over a service-role client. `createQueue` detects a transport
and calls every module function by name, so leases, retries with backoff,
dedupe keys, schedules, `stats`, `listDead` and `retryDead` all work:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { queue: { api: "api" }, idempotency: { api: "api" } },
});
```

```ts
import { createClient } from "@supabase/supabase-js";
import {
  createIdempotency,
  createQueue,
  rpcTransport,
  withLease,
} from "better-supabase/blocks/queue";

const transport = rpcTransport(createClient(url, secretKey), { schema: "api" });
const jobs = createQueue({ transport, queues: { send_email: schema } });
const idempotency = createIdempotency({ transport, schema: "api" });
await withLease(transport, "nightly-report", run, { schema: "api" });
```

For jobs alone, `client` with `schema` does the same: `createQueue` builds the
`rpcTransport` itself and calls the wrappers in that schema. A `client`
without `schema` stays on `pgmq_public`.

```ts
const jobs = createQueue({
  client: createClient(url, secretKey),
  schema: "api",
  queues: { send_email: schema },
});
```

`createIdempotency` and `withLease` call the functions in `better_supabase`
unless you pass `schema`, which an `rpcTransport` with its own `schema`
option ignores. Keep the wrappers service-only: the module functions are
granted to `service_role`, and the wrappers inherit those grants. Over
`sqlTransport`, a set-returning function such as `claim_jobs` returns only
its first row, so pass the SQL client itself there.

## 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/queue";

const idempotency = createIdempotency({ sql: 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             |
| Another request took the key over before the store | `409` with code `IDEMPOTENCY_KEY_LOST` and `Retry-After` |

Pass a service SQL connection as `sql`, or a service-role `transport` such
as the `rpcTransport` above. `hooks` runs your code around `begin`,
`extend`, `complete`, `release` and `purge` (see
[extending blocks](/docs/extending/blocks#hooks)); `handle` calls them
through the hooks.

The fingerprint is a SHA-256 of the method, path, query string and body.
The lock (`lock`, one minute by default) is renewed at half its length while
the handler runs, so a slow handler keeps its key. The lock only runs out
when the process stops renewing it, for example when it crashes; another
request with the same key then takes it over. 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 `extend(key, holder, scope)`,
`complete(key, holder, status, body, scope)` and `release(key, holder, scope)`
need it. Call `extend` before the lock runs out to keep the key for work that
takes longer; it returns `false` once the lock has run out. 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/queue";

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, sqlTransport } from "better-supabase/blocks/queue";

const inbox = createWebhookInbox({
  transport: sqlTransport(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.

To keep messages in a table your app already has, map it in
`blocks.webhook-inbox.tables`; see
[Over your own tables](/docs/blocks/webhooks-in#over-your-own-tables).

`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({
  transport: sqlTransport(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. The
handler gets an `AbortSignal` as its second argument, which aborts when a
renewal fails; the worker then leaves the message for the next claim.

```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
`bsb.actingAs(userId, { tenant_id })` so RLS applies:

```ts
await inbox.process(async (message) => {
  const owner = await ownerOfCustomer(message.payload.customer);
  const db = bsb.actingAs(owner.userId, { tenant_id: owner.organizationId });
  await db.invoices
    .update(message.payload.invoice, { status: "paid" })
    .orThrow();
});
```

Use `bsb.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.
`purgeWebhookInbox(source, options)` runs the same purge without an inbox,
over a service SQL connection or a service-role transport, and
`inbox.purgeJob(options)` returns it as a job handler.

```ts
const inbox = createWebhookInbox({
  transport: sqlTransport(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({
  transport: sqlTransport(postgres.admin),
  source: "chat",
});

await chat.store({ id: event.id, type: event.type, payload: event });
```

## Your own columns [#your-own-columns]

Add columns to the webhook inbox message table with `blocks.webhook-inbox.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`store` validates and writes them, and `list` and the messages `process` hands the handler return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createWebhookInbox } from "better-supabase/blocks/queue";
import { z } from "zod";

const client = createWebhookInbox({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

# 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 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, `blocks.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` (`bsb: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/queue)
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).

# 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-editor

The `workflow-editor` 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 add workflow-editor   # adds workflows, tenant and access as well
```

| Table                        | Holds                                                                                                                                          |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `workflow_definitions`       | One row per workflow: `tenant_id`, `slug`, `name`, `description`, `subject_types`, `scope`, `forked_from`, `forked_from_version`, `restricted` |
| `workflow_opt_outs`          | The organizations that opted out of a definition, usually a system one                                                                         |
| `workflow_definition_grants` | Who may run or edit a restricted definition: a user, a team or an organization role                                                            |
| `workflow_versions`          | Numbered versions of a definition: `graph`, `compiled`, `status` (`draft`, `published`, `archived`), publisher                                 |
| `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, and the system definitions   | none by default |
| `workflow.run`     | members start a published version                                                           | none by default |
| `workflow.edit`    | members create definitions, save drafts, fork and start test runs                           | none by default |
| `workflow.publish` | members publish a version                                                                   | none by default |
| `workflow.admin`   | members manage triggers, credentials, alerts, opt-outs and grants, and restrict definitions | none by default |

Grant the keys to your roles in `blocks.permissions.roles`.

## Graphs [#graphs]

A graph is a list of nodes and the edges between them. It has at least one
`trigger` node. A run starts at one of them, and the other nodes run in
dependency order once that trigger reaches them; the triggers it didn't
start at never run. A trigger picks its node with `config.node`, and a
graph with one trigger node starts there.

```ts
import type { WorkflowGraph } from "better-supabase/blocks/workflow-editor";

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`  | Files an approvals request (with the engine's `requestApproval`) and waits on its id; `{ approved: false }` takes the `false` branch                                                     |
| `condition` | Tests `config.path` (under `input`, `results` or `subject`) with `equals`, `notEquals`, `exists`, `truthy`, `greaterThan`, `lessThan` or `contains`, or a filter tree in `config.filter` |

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: no 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.

### Filter conditions [#filter-conditions]

A `condition` node can test a whole tree instead of one path: the
`FilterQuery` format of [list filters](/docs/platform/list) and the
[rules](/docs/blocks/automations) block, with And and Or groups and every
operator of its field types. `config.fields` declares each field the rules
name; its `id` is the path it reads under `input`, `results` or `subject`.
A date field compares instants unless its `columnType` is `date`.

```ts
const node = {
  id: "large",
  kind: "condition",
  config: {
    fields: [
      { id: "input.amount", type: "number" },
      { id: "subject.type", type: "select" },
    ],
    filter: {
      id: "root",
      type: "group",
      combinator: "and",
      rules: [
        {
          id: "amount",
          type: "rule",
          path: ["input", "amount"],
          operator: "gt",
          value: 500,
        },
        {
          id: "type",
          type: "rule",
          path: ["subject", "type"],
          operator: "is",
          value: "expense",
        },
      ],
    },
  },
};
```

`conditionOf` checks the tree against the fields with the
[list limits](/docs/platform/list) (depth 4, 50 rules), and
`validateGraph` reports a tree without a finished rule as an invalid
condition. The single-path form keeps working. Relative dates such as
"7 days ago" resolve against `Date.now()`, which the workflow runtime
replays deterministically.

## Editing and publishing [#editing-and-publishing]

Server code calls the `workflow-editor` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useWorkflowEditor } = defineDataHooks(
  "workflowEditor",
  (db: AppDb) => db.workflowEditor,
);
```

```ts
const rows = await db.workflowEditor.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/workflow-editor/list.tsx"
"use client";

import { useWorkflowEditor } from "@/lib/supabase/hooks";

const rows = useWorkflowEditor({ limit: 50 });
```

```ts
const builder = builderFor(await bsb.sql());

const definition = await builder.definitions
  .upsert({ tenant: organizationId, slug: "onboarding", name: "Onboarding" })
  .orThrow();
const draft = await builder.versions.upsert(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.upsert` 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.

`versions.upsert(definition, graph, { inputSchema })` stores the JSON Schema
of the input a run takes on the version (`input_schema`). A later save
without it keeps the schema, and a fork copies it. The builder doesn't
check `run` inputs against it; a [template](/docs/blocks/templates) checks
the inputs it attaches against the published version's schema.

## Runs on a record [#runs-on-a-record]

A definition lists the record types its runs can belong to in
`subjectTypes`. Pass a `subject` to `run`, and the run is stored with it,
so the record's page can list its runs (`workflows.runs.list({ subject })`)
and conditions can read `subject.type` and `subject.id`:

```ts
await builder.definitions
  .upsert({
    tenant: organizationId,
    slug: "deal-review",
    name: "Deal review",
    subjectTypes: ["deal"],
  })
  .orThrow();

const runId = await builder
  .run({ definition: id, subject: { type: "deal", id: dealId } })
  .orThrow();
const forDeals = await builder.definitions
  .list(organizationId, { subjectType: "deal" })
  .orThrow();
```

`workflow_subject_start_target` refuses a type the definition doesn't list
(`WORKFLOW_SUBJECT_TYPE`). With the [subjects](/docs/blocks/sql#subject-registry)
module installed, the caller must also be able to read the record
(`can_subject`); it runs as the caller, so the record's RLS applies. An
event trigger attaches the event's record (`subjectType` and `subjectId` on
the event or in its payload) when the definition lists its type. Each step
gets the subject in `call.subject`.

## Test runs [#test-runs]

`test` starts a run of the open draft (or the version you pass) without
running any step: each step node returns its stub, sleeps are skipped, and
an approval node takes its stub as the decision (approved without one). It
needs `workflow.edit`. A step without a stub returns the example in its
library `outputSchema` (its first `examples` entry, else `default`), else
`{ stubbed: true }`.

```ts
const runId = await builder
  .test({
    definition: id,
    triggerNode: "start",
    input: { email: "a@example.com" },
    stubs: { lookup: { plan: "pro" }, review: { approved: false } },
  })
  .orThrow();
```

The run records `mode: "test"`: it writes no outbox events, fires no
alerts, and run lists leave it out by default. With dynamic workflows the
compiled source handles test runs itself. A static executor must spread
`graphRunFields(meta)` into its runtime and you pass
`executorRunsTests: true` to `graphStarter`; otherwise `graphStarter`
refuses test runs, because an executor that ignores the stubs would run
real steps.

## System workflows, forks and opt-outs [#system-workflows-forks-and-opt-outs]

A definition with `scope: "system"` belongs to the app, not to one
organization. Only the service role creates one, with no tenant, and the
scope can't change afterwards. Every organization whose members hold
`workflow.read` sees it in `definitions.list(tenant)`, and its runs belong
to the organization they run in: pass `tenant` to `run`. A system
version is organization-owned, so its steps run as that organization's
service principal (`versions.runAs(call.version, call.tenant)`).

```ts
const mine = await builder.definitions
  .fork({ source: systemId, tenant: organizationId, slug: "onboarding" })
  .orThrow();
await builder.definitions
  .optOut({ tenant: organizationId, definition: systemId })
  .orThrow();
```

`fork` copies the source's published graph into a new definition in the
tenant as its draft, and records `forkedFrom` and `forkedFromVersion`.
Lists then return `upstreamVersion` and `behind`, so the canvas can say
when the source published something newer. A fork doesn't opt the
organization out of the source; call `optOut` for that. An opted-out
organization's starts fail with `WORKFLOW_OPTED_OUT`, its event triggers
skip the definition, and a schedule tick moves the schedule on without a
run. `optOut` with `optedOut: false` opts back in.

## Restricting a definition [#restricting-a-definition]

A definition with `restricted: true` can be run, edited and published only
by members with `workflow.admin` and by holders of a grant; every member
with `workflow.read` still sees it. An `edit` grant includes `run`.

```ts
await builder.definitions.upsert({ ...input, restricted: true }).orThrow();
await builder.definitions.grants
  .add({
    definition: id,
    principalKind: "team",
    principalId: teamId,
    action: "edit",
  })
  .orThrow();
```

A grant names a user id, a team id (a member of the team, with the
[teams](/docs/blocks/teams) module installed) or an organization role.
Changing `restricted` and managing grants need `workflow.admin`.

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

## Publisher rights [#publisher-rights]

A published version runs with its publisher's rights, never more.
`publish_workflow_version` records three columns on the version:

| Column         | Holds                                                                         |
| -------------- | ----------------------------------------------------------------------------- |
| `published_by` | The user who published it; `null` when the service role published it          |
| `owner_kind`   | `user`, or `organization` for a version the service role published            |
| `permissions`  | The permissions its step nodes need, from `workflow_graph_permissions(graph)` |

Each `step` node needs a permission. The provider's operations table answers
first: an entry `workflow.node.<step>` (such as `workflow.node.crm.lookup`)
names the permission. Next comes the step's own `operation`, set on the
step in `steps` (such as `invoices.void`): the provider's entry for it, or
the operation name itself as the key. Without either, the step needs the module's `run` key,
after the rename in `blocks["workflow-editor"].permissions`. Trigger,
sleep, approval and condition nodes need none. A user who doesn't hold every
one of them in the tenant can't publish the version: the `forbidden` error
has `hint: "WORKFLOW_PUBLISHER_FORBIDDEN"` and lists the keys they lack in
`permissions`. The operations table reaches SQL only under
the provider access model; see
[authorization providers](/docs/extending/authorization-providers).

Pass the server's authorization hooks to the builder. `automation.mayPublish`
then decides each publish as well, with the publisher's claims:

```ts title="lib/workflow-builder.ts"
export const builderFor = (sql: Sql) =>
  createWorkflowEditor({
    transport: sqlTransport(sql),
    service: sqlTransport(postgres.admin),
    steps: stepLibrary,
    authorization: server.authorization,
  });

await builder.versions.publish(draft.id, { claims: session.claims }).orThrow();
```

`mayPublish` gets `{ publisher, tenant, permissions, claims }`. Anything
but `true` refuses the publish, a throw included. With the hook set, a
publish without `claims` is refused too.

### Running a step as the publisher [#running-a-step-as-the-publisher]

Call `builder.versions.runAs(call.version)` at the start of every step, from
a builder on the `service` transport. It reads the version's publisher,
checks that they still hold `workflow.publish` and every stored permission,
then asks `automation.runAs` for the claims the step runs with:

```ts title="workflows/steps.ts"
export async function crmLookup(call: GraphStepCall) {
  "use step";
  const claims = await serviceBuilder.versions.runAs(call.version).orThrow();
  const db = bsb.actingAs(String(claims["sub"]), {
    tenant_id: claims["tenant_id"],
  });
  return db.contacts.list({ where: { email: call.input.email } }).orThrow();
}
```

The step fails with `WORKFLOW_PUBLISHER_REVOKED` when:

- the publisher was demoted, lost their membership or was deleted;
- `runAs` returns `null` or throws;
- the version is organization-owned and the organization has no default
  [service principal](/docs/blocks/service-accounts), or its role no
  longer holds every stored permission;
- the version was published before publishers were recorded (`owner_kind`
  is `null`). Publish it again.

The check runs per step, so a run stops at the first step after the
publisher loses a permission. The trigger that started the run is recorded
for audit only and never changes whom a step runs as. Nothing falls back to
the service role or `admin()`; [doctor](/docs/cli/doctor#bs333) reports
`admin()` in a file that runs builder steps (BS333).

Without a `runAs` hook, `versions.runAs` returns the publisher's claims
capped to the stored permissions: `{ role: "authenticated", sub, tenant_id,
bsb_permission_cap }`. An organization-owned version runs as the
organization's default [service principal](/docs/blocks/service-accounts)
when that module is installed: the SQL check resolves the principal and
returns it as the publisher. Without the module, `runAs` receives
`publisher: null` and must return claims for the tenant, or every step
fails. A hook always overrides the default.

## Triggers [#triggers]

```ts
await builder.triggers.upsert({ 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` through the `workflows` option |
| `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.

### Start from outbox events [#start-from-outbox-events]

A definition doesn't listen for outbox events itself. An
[automation](/docs/blocks/automations) does: its trigger is the event type,
its conditions are a filter tree over the subject, and its `workflow` action
starts the definition on the event's record, keyed by automation, action and
event so a redelivered event starts one run. Register `workflowAction` with
the builder in the [action registry](/docs/blocks/automations#actions).

`syncSchedule` writes through the `workflows` option, the workflows block
built with a transport that may create schedules (the service role);
`createBlocks` passes it as the `workflows` port when the set has
`workflowsBlock`. Without it, `syncSchedule` fails with the hint
`WORKFLOW_BUILDER_WORKFLOWS_CLIENT`.

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 = createWorkflowEditor({
  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 {
  useWorkflowEditor,
  useWorkflowCanvasRun,
} from "@/hooks/use-workflow-editor";

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>
  );
}
```

`useWorkflowEditor({ 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. Both hooks are a registry item:
`npx shadcn add @better-supabase/use-workflow-editor` writes
`hooks/use-workflow-editor.ts` into the app.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createWorkflowEditorController` for the definitions and
`createWorkflowCanvasRunController` for a run's status, each with its
options; `auth` and `ports` come from `app.use(betterSupabase(bsb))` unless you pass
them. `state` is a shallow ref of the snapshot and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createWorkflowEditor,
  createWorkflowEditorController,
  rpcTransport,
} from "better-supabase/blocks/workflow-editor";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(createWorkflowEditorController, {
  client: createWorkflowEditor({ transport: rpcTransport(supabase) }),
  tenant: organizationId,
});
</script>

<template>
  <ul>
    <li v-for="definition in state.definitions ?? []" :key="definition.id">
      {{ definition.name }}
    </li>
  </ul>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createWorkflowEditorController` or `createWorkflowCanvasRunController` with
its options; `auth` and `ports` come from `setBetterSupabase(bsb)` unless you
pass them. It returns a readable
store of the snapshot, read with `$`, with the actions on `.actions`. The
work starts on mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createWorkflowEditor,
    createWorkflowEditorController,
    rpcTransport,
  } from "better-supabase/blocks/workflow-editor";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const builder = useBlockController(createWorkflowEditorController, {
    client: createWorkflowEditor({ transport: rpcTransport(supabase) }),
    tenant: organizationId,
  });
</script>

<ul>
  {#each $builder.definitions ?? [] as definition (definition.id)}
    <li>{definition.name}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createWorkflowEditorController` or `createWorkflowCanvasRunController` with
its options; `auth` and `ports` come from `<BetterSupabaseProvider client={bsb}>` unless
you pass them. `state()` reads the snapshot through `from`, and `actions`
are the controller's actions. The work starts on mount, and the controller
is disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createWorkflowEditor,
  createWorkflowEditorController,
  rpcTransport,
} from "better-supabase/blocks/workflow-editor";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createWorkflowEditorController, {
    client: createWorkflowEditor({ transport: rpcTransport(supabase) }),
    tenant: props.organizationId,
  });
  return (
    <ul>
      <For each={state().definitions ?? []}>
        {(definition) => <li>{definition.name}</li>}
      </For>
    </ul>
  );
}
```

## Alerts [#alerts]

```ts
await builder.alerts.upsert({
  definition: id,
  onEvent: "failed",
  channel: { type: "email", to },
});
await builder.alerts.upsert({
  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 `bsb.definition` attribute, which
`graphStarter` sets.

## Functions [#functions]

| Function                                                                                                                                               | Granted to                      | Does                                             |
| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | ------------------------------------------------ |
| `save_workflow_definition(tenant, slug, name, description, subject_types, scope, restricted)`, `workflow_definition_get`, `remove_workflow_definition` | `authenticated`, `service_role` | Definitions                                      |
| `workflow_definitions_list(tenant, subject_type)`                                                                                                      | `authenticated`, `service_role` | The tenant's definitions and the system ones     |
| `fork_workflow_definition(source, tenant, slug)`, `set_workflow_opt_out(tenant, definition, opted_out)`                                                | `authenticated`, `service_role` | Forks and opt-outs                               |
| `set_workflow_definition_grant(definition, principal_kind, principal_id, action, granted)`, `workflow_definition_grants_list(definition)`              | `authenticated`, `service_role` | Grants on restricted 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, tenant)`                                                                                                            | `authenticated`, `service_role` | The published version a `workflow.run` may start |
| `workflow_subject_start_target(definition, subject_type, subject_id, tenant)`                                                                          | `authenticated`, `service_role` | The same, for a run on a record                  |
| `start_test_run(definition, version, trigger_node, input, stubs)`                                                                                      | `authenticated`, `service_role` | What a test run starts from                      |
| `workflow_graph_permissions(graph)`                                                                                                                    | `authenticated`, `service_role` | The permissions a graph's steps need             |
| `workflow_run_principal(version, tenant)`                                                                                                              | `service_role`                  | Whom a published version's steps run as          |
| `save_workflow_trigger`, `workflow_triggers_list`, `remove_workflow_trigger`, `rotate_workflow_webhook_token`                                          | `authenticated`, `service_role` | Triggers                                         |
| `workflow_webhook_target(token)`                                                                                                                       | `service_role`                  | What a webhook 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               |

## Your own columns [#your-own-columns]

Add columns to the workflow definition table with `blocks["workflow-editor"].options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`definitions.upsert` validates and writes them, and `definitions.upsert`, `list`, `get` and `fork` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createWorkflowEditor } from "better-supabase/blocks/workflow-editor";
import { z } from "zod";

const client = createWorkflowEditor({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.workflowEditor` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

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

import {
  createWorkflowEditor,
  sqlTransport,
} from "better-supabase/blocks/workflow-editor";
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) =>
  createWorkflowEditor({
    transport: sqlTransport(sql),
    service: sqlTransport(postgres.admin),
    steps: stepLibrary,
    compile: compileGraph,
    start: graphStarter({ steps, executor: graphExecutor }),
  });
```

# 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/queue) 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 add workflow-sdk   # 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                                                                                         |
| `WORKFLOW_POSTGRES_MAX_CONNECTIONS`                        | `maxConnections`           | 10                                                                                          |
| `WORKFLOW_POSTGRES_CONNECTION_TIMEOUT_MS`                  | `connectionTimeout`        | 30000                                                                                       |
| `WORKFLOW_POSTGRES_IDLE_TIMEOUT_MS`                        | `idleTimeout`              | 10000                                                                                       |
| `WORKFLOW_POSTGRES_STATEMENT_TIMEOUT_MS`                   | `statementTimeout`         | 30000                                                                                       |
| `WORKFLOW_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS`         | `idleInTransactionTimeout` | 15000                                                                                       |

The pool holds at most `maxConnections` connections. A delivery that finds
them all busy waits up to `connectionTimeout` for one, so a burst queues
instead of failing. `statementTimeout` ends a query that runs longer, and
`idleInTransactionTimeout` ends a transaction left open with no query, so a
stuck worker can't hold row locks. Pass `events` (`betterSupabase.events`)
or a `logger` to see errors of idle pooled connections; without either, the
World logs them to the console. They never throw, so a dropped connection
can't crash the process.

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 `blocks["workflow-sdk"].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-bsb-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 `bsb.tenant` and `bsb.actor` attributes
of the caller, which the World copies into `workflow_runs.tenant_id` and
`actor_id`. Pass `subject: { type: "deal", id }` to store the record the
run belongs to (the `bsb.subject` attribute, copied into `subject_type` and
`subject_id`; see [runs on a record](/docs/blocks/workflows#runs-on-a-record)). 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 and call `bsb.forContext(context)` in
the step. It keeps the actor and tenant and drops the claims, so no token
ends up in the run's stored state. A delegated token's `client_id`,
`scope`, `authorization_details` and `act` chain are the exception: they
stay, so a run an OAuth or MCP client started keeps that client's scopes
(see [Actor and tenant](/docs/blocks/queue#actor-and-tenant)).

### Tickets [#tickets]

`startFor(ctx, workflow, args, { ticket })` stores an opaque ticket from
your authorization provider as the `bsb.ticket` attribute. Pass
`workflowContext(ctx, { ticket })` as an argument too: inside a step,
`bsb.forContext` resolves the ticket through the provider's
`tickets.subject` hook and runs as the context it returns, or fails with
`forbidden` when the ticket is refused or nothing resolves it.
`startOnEvent` takes `ticket: (event) => string | undefined`, and its
`args` function gets the event's context with the ticket as a second
argument. The ticket is stored in plain text with the run, so make it an
id the provider looks up, never a bearer secret.

### Event and schedule starts run as a publisher [#event-and-schedule-starts-run-as-a-publisher]

Nobody is signed in when an event or a schedule starts a run, so
`startOnEvent` and the schedules of `workflowStarter` need `runAs`: the
publisher, a user who holds the permissions the run needs, and the
permission keys its steps may use. The context the run starts with is the
publisher's, capped to those keys, so `bsb.forContext(context)` in a step
runs as the publisher and refuses every other key. A publisher who loses a
permission fails the step that needs it. The event's actor or the
schedule's creator is recorded as `bsb.trigger` for audit and grants
nothing; the publisher is recorded as `bsb.publisher`. The run's `actor_id`
is the user who triggered it (none for an event without an actor), so they
see the run they caused, and `principal_kind` and `principal_id` record the
publisher. Set `principalKind: "service_principal"` in `runAs` when the
publisher is a [service principal](/docs/blocks/service-accounts). The
World records the principal only from these trusted starts: `startFor`
drops `bsb.principal_kind` and `bsb.principal` from caller attributes.

```ts title="workflows/start.ts"
import { startOnEvent, workflowStarter } from "better-supabase/workflow-sdk";

const runAs = {
  publisher: automationUserId,
  permissions: ["invoices.read", "invoices.approve"],
};

outbox.consume(
  "invoice-approval",
  startOnEvent(approveInvoice, {
    types: ["invoice.paid"],
    runAs,
    args: (event, context) => [context, event.payload],
  }),
);

await workflows.schedules
  .tick({
    start: workflowStarter(
      { weeklyDigest },
      { runAs, args: (call, context) => [context, call.input] },
    ),
  })
  .orThrow();
```

`runAs` can also be a function of the event or the start call, for a
publisher per tenant; `null` starts nothing for that event and fails that
schedule start, which runs again once its lease ends. A schedule start
without `runAs` throws. Admission requests run as the actor that queued
them unless you set `runAs`. A
[service principal](/docs/blocks/service-accounts) is the usual
publisher for work an organization owns.

| Helper                                      | Does                                                                                                              |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `workflowStarter({ name: workflow }, opts)` | The `start` callback for `schedules.tick` and `admission.tick`; schedules run as `runAs`                          |
| `startOnEvent(workflow, { types, runAs })`  | An outbox handler that starts the workflow as `runAs` 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                     |
| `approvalWaiter.wait` and `.wake`           | Pauses a run on an approval token until a deadline, and resumes it by token                                       |

## 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)`.

### Approvals that expire [#approvals-that-expire]

`approvalWaiter` waits on an approval by a token you choose, such as the
approval request's id, until a deadline. `wait` runs inside the workflow
and races a hook against `sleep`; `expiresAt` is a `Temporal.Instant` or
an RFC 3339 timestamp, since workflow arguments are serialized.

```ts title="workflows/approve-invoice.ts"
import { approvalWaiter } from "better-supabase/workflow-sdk";

export async function approveInvoice(approvalId: string, expiresAt: string) {
  "use workflow";
  const outcome = await approvalWaiter.wait<{ approved: boolean }>(approvalId, {
    expiresAt,
  });
  if (outcome.status === "expired") return "expired";
  // outcome.payload.approved
}
```

The code that records the decision wakes the run once it has checked the
approver:

```ts
await approvalWaiter.wake(approvalId, { approved: true });
```

With the [approvals](/docs/blocks/approvals) block, use the request's id as
the token and let `wakeOnApproval(event)` do the waking from your outbox
consumer: it resumes the run for `approval.approved`, `approval.rejected`,
`approval.expired` and `approval.cancelled` with `{ approved, status,
requestId }`, and ignores a request no run waits on.

`wait` resolves `{ status: "resumed", payload }` or `{ status: "expired" }`,
and returns `expired` at once for a deadline that has passed. `wake`
sends `null` when you pass no payload.

## Graph workflows [#graph-workflows]

`better-supabase/workflow-sdk/builder` runs the graphs of the
[workflow builder](/docs/blocks/workflow-editor) 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`. Pass it as `compile`, and `graphStarter` as `start`, to
`createWorkflowEditor`.

Pass `approval` to `graphStarter` (and `requestApproval` to the static
executor's runtime): a step that files an approvals request for the node
and returns its id. The node then waits on that id, and `wakeOnApproval`
resumes it once an eligible approver decides. Without it the token is
`<runKey>:<node>`, which anyone who knows the run key can guess.

```ts title="workflows/approval.ts"
import { createApprovals } from "better-supabase/blocks/approvals";
import type { GraphApprovalCall } from "better-supabase/blocks/workflow-editor";

export async function requestApproval(call: GraphApprovalCall) {
  "use step";
  const claims = await serviceBuilder.versions.runAs(call.version).orThrow();
  const approvals = createApprovals({
    transport: serviceTransport,
    tenant: String(claims.tenant_id),
  });
  const request = await approvals
    .request(
      { type: "workflow_run", id: call.runKey },
      {
        action:
          typeof call.config.action === "string"
            ? call.config.action
            : "approve",
        payload: { node: call.node },
      },
    )
    .orThrow();
  return request.id;
}
```

```ts title="workflows/graph.ts"
import {
  executeGraph,
  graphRunFields,
  type GraphStepCall,
  type WorkflowGraph,
} from "better-supabase/blocks/workflow-editor";
import { createHook, sleep } from "workflow";

import { requestApproval } from "./approval";
import { steps } from "./steps";

export async function graphExecutor(
  graph: WorkflowGraph,
  input: unknown,
  meta: unknown,
) {
  "use workflow";
  return executeGraph(graph, input, {
    ...graphRunFields(meta),
    step: (call: GraphStepCall) => steps[call.step](call),
    sleep: (ms) => sleep(ms),
    requestApproval: (call) => requestApproval(call),
    approval: (token) => createHook({ token }),
  });
}
```

`graphRunFields(meta)` passes the run key, version, tenant, subject, start
trigger node and, for a test run, the stubs. Pass `executorRunsTests: true`
to `graphStarter` once your executor spreads it; test runs on the static
executor are refused without it.

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 `bsb.definition`,
`bsb.version`, `bsb.tenant`, `bsb.actor` and `bsb.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. Each page
continues after the last row of the previous one (`created_at`, then the
id), so runs started while you page don't shift it. Pass back the `cursor`
the previous page returned. `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 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`, the times, and the `subject_type`, `subject_id`, `principal_kind`, `principal_id`, `mode` and `trigger_node` below |
| `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 `blocks.permissions.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`.

## Runs on a record [#runs-on-a-record]

A run can belong to a record, such as a deal or a ticket: its subject,
stored as `subject_type` and `subject_id`. List a record's runs with
`runs.list({ subject: { type: "deal", id: dealId } })`, and the run's
outbox events carry `subjectType` and `subjectId`, so a record's activity
feed can show them. The Workflow SDK adapter records the subject from the
`subject` option of `startFor` (stored on the run as the `bsb.subject`
attribute), and queued starts take one in `admission.request({ subject })`.

A run also records who its steps act as: `principal_kind` is `user`,
`service_principal`, `system` or `external`, and `principal_id` names the
user or service principal. `actor_id` stays the user who triggered the run,
so a schedule's creator or an event's actor sees the run they caused, while
the steps run as the publisher. `run.principal` returns both.

`mode` is `live` or `test`. Test runs come from the
[workflow builder's](/docs/blocks/workflow-editor#test-runs) test button:
they emit no outbox events and fire no alerts, and `runs.list` leaves them
out unless you pass `mode: "test"` or `mode: "all"`. `trigger_node` names
the trigger node a run of a graph with several started at.

## Reading runs [#reading-runs]

Server code calls the `workflows` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useWorkflows } = defineDataHooks(
  "workflows",
  (db: AppDb) => db.workflows,
);
```

```ts
const rows = await db.workflows.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/workflows/list.tsx"
"use client";

import { useWorkflows } from "@/lib/supabase/hooks";

const rows = useWorkflows({ limit: 50 });
```

`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]

The React hooks are a registry item: `npx shadcn add
@better-supabase/use-workflows` writes `hooks/use-workflows.ts` into the app.

```tsx title="components/run-list.tsx"
"use client";

import { useWorkflowRuns } from "@/hooks/use-workflows";

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.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createWorkflowRunsController` for a list of runs and
`createWorkflowRunController` for one run, each with its options; `auth` and
`ports` come from `app.use(betterSupabase(bsb))` unless you pass them.
`state` is a shallow ref of the snapshot and `actions` are the controller's
actions. The work starts on mount, and the controller is disposed with the
component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createWorkflowRunsController,
  createWorkflows,
  rpcTransport,
} from "better-supabase/blocks/workflows";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(createWorkflowRunsController, {
  client: createWorkflows({ transport: rpcTransport(supabase) }),
  tenant: organizationId,
});
</script>

<template>
  <ul>
    <li v-for="run in state.runs ?? []" :key="run.id">
      {{ `${run.definition}: ${run.status}` }}
    </li>
  </ul>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createWorkflowRunsController` or `createWorkflowRunController` with its
options; `auth` and `ports` come from `setBetterSupabase(bsb)` unless you pass
them. It returns a readable store of the snapshot, read with `$`, with the actions on
`.actions`. The work starts on mount, and the controller is disposed with
the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createWorkflowRunsController,
    createWorkflows,
    rpcTransport,
  } from "better-supabase/blocks/workflows";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const runs = useBlockController(createWorkflowRunsController, {
    client: createWorkflows({ transport: rpcTransport(supabase) }),
    tenant: organizationId,
  });
</script>

<ul>
  {#each $runs.runs ?? [] as run (run.id)}
    <li>{`${run.definition}: ${run.status}`}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createWorkflowRunsController` or `createWorkflowRunController` with its
options; `auth` and `ports` come from `<BetterSupabaseProvider client={bsb}>` unless you pass
them. `state()` reads the snapshot through `from`, and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createWorkflowRunsController,
  createWorkflows,
  rpcTransport,
} from "better-supabase/blocks/workflows";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createWorkflowRunsController, {
    client: createWorkflows({ transport: rpcTransport(supabase) }),
    tenant: props.organizationId,
  });
  return (
    <ul>
      <For each={state().runs ?? []}>
        {(run) => <li>{`${run.definition}: ${run.status}`}</li>}
      </For>
    </ul>
  );
}
```

## Schedules [#schedules]

Workflow schedules sit between job schedules and per-user AI tasks; see
[three scheduling layers](/docs/blocks/queue#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 },
        {
          runAs: { publisher: automationUserId, permissions: ["digest.send"] },
        },
      ),
    })
    .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.

A schedule runs as the `runAs` publisher of `workflowStarter`, capped to
its permissions, never as the member who created it (see
[Event and schedule starts run as a publisher](/docs/blocks/workflow-sdk#event-and-schedule-starts-run-as-a-publisher)).
The start call says which it is in `call.source` (`schedule` or
`admission`), and names the schedule's creator in `call.actor` for audit.

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, subject_type, subject_id, mode)` | `authenticated`, `service_role` | The runs the caller may read, newest first; `mode` defaults to `live`, null lists both |
| `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                                                                      |

## Hooks [#hooks]

`createWorkflows` takes `hooks` keyed by group, then method, such as
`{ schedules: { create: { before } } }` for a rule that returns a
`DbError` to refuse a schedule; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.workflows` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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);
```

# 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 add attachments   # adds tenant and access as well
```

The block's entity is `attachment` (the `attachments` table). It isn't registered as a subject, so files can't be attached to files. Rename it with `blocks.attachments.names`; the storage paths (`.../attachments/...`) and the `bsb_attachments_*` constraint and storage policy names keep the block name, so existing files stay where they are. `attachmentInputSchema` validates what `upload` takes.

| 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({
  blocks: {
    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). The client reads
`requireScan` from `blocks.generated.ts`, which `better-supabase gen` writes
from this config, so `defineBlocks` passes it to `createAttachments` and you
never repeat it (see [the generated config](/docs/blocks/create-blocks#the-generated-config)).

`publicBuckets` lists buckets that Storage serves without a signed URL, for
media on a public page such as a product photo or an article image. The
module creates those buckets as public and keeps every other bucket private,
so removing a bucket from the list makes it private again on the next
`better-supabase add`. A public bucket needs `requireScan: false`, because
Storage serves its files before any scan could run. `attachments.publicUrl(attachment)`
and `images.publicUrl(attachment, { transform })` return the URL; a file in
a private bucket returns `ATTACHMENT_PRIVATE`, and an infected one
`ATTACHMENT_NOT_SCANNED`.

```ts title="better-supabase.config.ts"
attachments: {
  options: { publicBuckets: ["attachments"], requireScan: false },
},
```

### Subjects and buckets [#subjects-and-buckets]

`subjects` maps each subject type to its table, as in the
[subject registry](/docs/blocks/sql#subject-registry): 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.

### Permissions per subject [#permissions-per-subject]

`permissions` gives a subject type its own keys, in place of
`attachments.read`, `attachments.upload` and `attachments.manage`, and
`scope` names a provider scope whose ids are the subject ids, so a grant on
the record itself counts next to one on the tenant. Both work as they do in
the [subject registry](/docs/blocks/sql#subject-registry), and apply to the table's
policies and to the storage checks in `attachment_object_allowed`:

```ts
subjects: {
  contract: {
    table: "contracts",
    permissions: { read: "contracts.read", upload: "contracts.edit", manage: "contracts.manage" },
  },
  project: { table: "projects", idType: "uuid", scope: "project" },
},
```

From 0.11, a subject the block takes from the registry (`config.records`)
also takes its `upload` and `manage` keys from
`records.<type>.permissions`. The registry's `read` stays the key for
reading the subject itself, so set the files' `read` key in
`blocks.attachments.on.<type>.permissions`, which also
overrides the registry's keys one by one.

Without its own keys, a subject's files follow the block-wide keys, so any
member of the tenant with `attachments.read` reads them, even when the
subject table lets only some members see the row. Doctor reports that case as
[BS345](/docs/cli/doctor#bs345) when the table's policies check more than
tenant membership.

A subject without a tenant, such as a user's own notes, takes `tenant: false`:
its files have no `organization_id`, 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. List those types in the
client's `tenantless` option, so `upload`, `put` and `list` on such a subject
send no tenant and need none:

```ts
subjects: {
  note: { table: "notes", tenant: false },
},
```

Server code calls the `attachments` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAttachments } = defineDataHooks(
  "attachments",
  (db: AppDb) => db.attachments,
);
```

```ts
await db.attachments
  .upload(
    { type: "note", id: noteId },
    { name: file.name, mimeType: file.type, size: file.size },
  )
  .orThrow();
```

```tsx title="src/features/attachments/list.tsx"
"use client";

import { useAttachments } from "@/lib/supabase/hooks";

const rows = useAttachments({ limit: 50 });
```

`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,
    tenant: organizationId,
  });
  return attachments
    .upload(
      { type: "project", id: projectId },
      { name: file.name, mimeType: file.type, size: file.size },
    )
    .orThrow();
}
```

The first argument is the record as a `RecordRef` (`{ type, id }`), or
`null` for a file without one. The tenant comes from the block's `tenant`
option, an organization id or a function that returns the current one;
`createBlocks` and `createBlockClient` pass the request's tenant (see
[createBlocks](/docs/blocks/create-blocks#one-block-per-request)). A call
that needs a tenant and has none fails with `invalid_request` and the hint
`TENANT_REQUIRED`. Each attachment carries `subject` (a `RecordRef`, or
`undefined` without one) and `organizationId`.

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), and every
attachment returns it as `metadata` (`{}` without any). It also takes
`width` and `height` in pixels for an image and `pages` for a document, so a
list can lay out previews and show a page count before any file loads.

`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({ type, id })` lists a record's files oldest first (`list()` lists
every file of the tenant), and `remove(id)` deletes the object and then the
row.

With the [profiles](/docs/blocks/profiles) module installed, each file that
`list` returns carries `author` (type `Author`): the uploader's `id`, `name`
and `avatarUrl`. `list_attachments` reads them through
`attachment_authors(uuid[])`, a security definer function that returns only
the uploaders of files the caller may read. `author` is `undefined` without
the profiles module and once the account is deleted, and the other methods
leave it out; `uploadedBy` is always there.

Files the server makes or fetches (an export, a generated PDF, an import
from a URL) go through `put(subject, attachment, file)`, which creates the record,
uploads the bytes with the storage client and confirms it in one call,
without a signed upload URL. Both work in a writable
[support session](/docs/auth/impersonation#storage-in-a-support-session):
there `confirm` first claims the browser's upload for the user the admin
acts as and records the session on the object. A refused claim returns
`forbidden` with the hint `ATTACHMENT_FORBIDDEN`, or `not_found` with
`ATTACHMENT_NOT_UPLOADED`, and the record stays pending. When Storage
refuses the signed upload URL or the upload (a bucket policy or a read-only
support session), `upload` and `put` delete the record again and return the
refusal, so no pending record is left behind. `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(
    { type: "project", id: projectId },
    { 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.

### Upload from the browser [#upload-from-the-browser]

`useAttachmentUpload` runs the three steps for each file, side by side, and
reports progress. It watches each file until the scan answers, so an upload
ends as `done` or `blocked`. The hook is a registry item: install it with
`npx shadcn add @better-supabase/use-attachments`, which writes
`hooks/use-attachments.ts` into the app. `createAttachmentUploadController`
is the framework-free controller under it, and Vue, Svelte and Solid run it
with `useBlockController` from `better-supabase/vue`, `/svelte` and `/solid`
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)).

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

import { useAttachmentUpload } from "@/hooks/use-attachments";

export function ProjectFiles({ projectId }: { projectId: string }) {
  const { uploads, add, retry, dismiss } = useAttachmentUpload({
    subject: { type: "project", id: projectId },
  });

  return (
    <input
      type="file"
      multiple
      onChange={(event) =>
        add(
          [...(event.target.files ?? [])].map((file) => ({
            file,
            name: file.name,
          })),
        )
      }
    />
  );
}
```

Each upload has a `status` (`uploading`, `confirming`, `scanning`, `done`,
`blocked` for an infected file, or `error`), a `progress` from 0 to 1, the
attachment `id` once created and the `error`. `retry(key)` starts a failed
upload again, `dismiss(key)` stops one and drops it, `clear()` drops the
uploads that ended, and `busy` says whether any is still running. Pass
`start` and `confirm` server actions to create the record on the server,
`onUploaded` to refresh the list, and `send` to replace the default
`XMLHttpRequest` upload (in React Native, for example).

In Vue, Svelte and Solid, `useBlockController` passes the controller's
options as they are, so pass `start` and `confirm` yourself, and `refresh` to wait for
the scan. Vue returns `state` as a shallow ref with the actions on
`actions`:

```vue title="ProjectFiles.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createAttachments,
  createAttachmentUploadController,
  rpcTransport,
} from "better-supabase/blocks/attachments";

const { projectId } = defineProps<{ projectId: string }>();
const supabase = useSupabase();
const attachments = createAttachments({
  transport: rpcTransport(supabase),
  storage: supabase.storage,
});
const status = (id: string) =>
  attachments.get(id).map((file) => ({ status: file.status }));

const { state, actions } = useBlockController(
  createAttachmentUploadController,
  {
    start: (file) =>
      attachments
        .upload({ type: "project", id: projectId }, file)
        .map((upload) => ({
          id: upload.attachment.id,
          signedUrl: upload.signedUrl,
        })),
    confirm: (id) =>
      attachments.confirm(id).map((file) => ({ status: file.status })),
    refresh: status,
  },
  false,
);
</script>
```

Svelte runs the same controller with `useBlockController` from
`better-supabase/svelte`, which returns a readable store (`$uploads.uploads`,
with the actions on `.actions`), and Solid runs it with `useBlockController`
from `better-supabase/solid` and reads the snapshot with `state()`:

```ts
import { createAttachmentUploadController } from "better-supabase/blocks/attachments";
import { useBlockController } from "better-supabase/svelte";
import { useBlockController as useSolidController } from "better-supabase/solid";

const uploads = useBlockController(
  createAttachmentUploadController,
  options,
  false,
);
const { state, actions } = useSolidController(
  createAttachmentUploadController,
  options,
  false,
);
```

## Versions [#versions]

`replace(id, file)` starts a new version of a file and returns a signed
upload URL, like `upload`: the current file becomes an earlier version, and
the new one goes through the upload, `confirm` and the scan before it can be
downloaded. Only the uploader (with `attachments.upload`) or someone with
`attachments.manage` can replace a file. `versions(id)` lists the earlier
versions newest first, `download(id, { version })` signs one of them, and
`restore(id, version)` makes one current again, keeping the current file as
an earlier version. `remove(id)` deletes every version's file.

```ts
const next = await attachments
  .replace(attachmentId, {
    name: "plan-v2.pdf",
    mimeType: "application/pdf",
    size: file.size,
  })
  .orThrow();
// Upload to next.signedUrl, then confirm(attachmentId).
const [previous] = await attachments.versions(attachmentId).orThrow();
await attachments.restore(attachmentId, previous.version).orThrow();
```

## Image previews [#image-previews]

`attachments.images(variants)` signs resized URLs of image files, in the shape
`useImage` from `better-supabase/react` takes. Name the sizes once, and pass
an attachment or its id. The URLs stay behind the scan gate
(`ATTACHMENT_NOT_SCANNED`), and a file that isn't `image/*` returns
`ATTACHMENT_NOT_IMAGE`.

```tsx
const images = attachments.images({
  thumb: { width: 160, height: 160, resize: "cover" },
});

const { src, srcSet, width, height } = useImage(images, attachment, "thumb");
```

The variants use Supabase image transformations, so the project needs them
turned on (see [image transformations](/docs/platform/storage#image-transformations)).

## 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/queue) 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`                                       |
| `attachment.replaced` | `replace` started a new version                        | `attachmentId`, `subjectType`, `subjectId`, `uploadedBy`, `version`          |
| `attachment.restored` | `restore` made an earlier version current              | `attachmentId`, `subjectType`, `subjectId`, `version`                        |
| `object.uploaded`     | an object landed in a `scanBuckets` bucket (no tenant) | `bucket`, `path`                                                             |

## Your own fields [#your-own-fields]

`blocks.attachments.options.extraColumns` adds columns to the `attachments` table,
keyed by column name with their SQL type. Pass `createAttachments` a Standard Schema
for them as `fields`: `upload`, `put` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and every attachment read return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAttachments({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.attachments` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const attachments = createAttachments({
  transport: rpcTransport(supabase),
  storage: supabase.storage,
  tenantless: ["note"],
});

await attachments
  .upload(
    { type: "note", id: noteId },
    { name: file.name, mimeType: file.type, size: file.size },
  )
  .orThrow();
```

# Collaboration

> Comments, files, messages and the other blocks people use together.

Source: https://bettersupabase.com/docs/blocks/catalog-collaboration

- [Notifications](/docs/blocks/notifications)
- [Push notifications](/docs/blocks/push)
- [Inbox](/docs/blocks/inbox)
- [Comments](/docs/blocks/comments)
- [Activity](/docs/blocks/timeline)
- [Messaging](/docs/blocks/messaging)
- [Channels](/docs/blocks/channels)
- [Attachments](/docs/blocks/attachments)
- [Share links](/docs/blocks/share-links)
- [Reactions](/docs/blocks/reactions)
- [Posts](/docs/blocks/posts)
- [Moderation](/docs/blocks/moderation)
- [Enrollments](/docs/blocks/enrollments)
- [Guests](/docs/blocks/guests)

# Channels

> Public and private team channels on the messaging core, with topics, membership, linked records and live hooks for a chat screen.

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

The `channels` block adds Slack-style channels to an organization. Each
channel is a [messaging](/docs/blocks/messaging) conversation of kind
`channel` with a unique name, a topic, a purpose and an optional linked
record, so messages, threads, mentions, typing, files and read markers all
come from messaging. Direct messages stay in messaging (`openDirect`).

```bash
better-supabase add channels   # adds messaging, tenant, access and subjects as well
```

The `chat` preset installs channels with messaging, reactions and
attachments on top of `saas`.

The block's entity is `channel` (the `channels` table). It registers as a
subject, so search, share links and reactions accept it, and its search
document is the name, the topic and the purpose. Rename it with
`blocks.channels.names`; see [Typed blocks](/docs/blocks/typed-blocks).
`channelInputSchema` and `channelChangeSchema` validate what `create` and
`update` take.

| Permission        | Lets a member                                       | Default roles          |
| ----------------- | --------------------------------------------------- | ---------------------- |
| `messages.read`   | browse public channels and read the ones they're in | `member`, `admin`      |
| `channels.create` | create a channel, which they then own               | `member`, `admin`      |
| `channels.manage` | rename, change and archive any channel              | `admin` (`channels.*`) |

## Create and browse [#create-and-browse]

Server code calls the `channels` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useChannels } = defineDataHooks(
  "channels",
  (db: AppDb) => db.channels,
);
```

```ts
import { createChannels, sqlTransport } from "better-supabase/blocks/channels";

const general = await db.channels.create({ name: "General" }).orThrow();
const launch = await db.channels
  .create({
    name: "launch",
    visibility: "private",
    topic: "Ship date and blockers",
    memberIds: [alice, bob],
  })
  .orThrow();
```

```tsx title="src/features/channels/list.tsx"
"use client";

import { useChannels } from "@/lib/supabase/hooks";

const rows = useChannels({ limit: 50 });
```

Names are trimmed and lowercased, and may hold letters, digits, `-` and `_`
(`general`). A name already taken in the tenant fails with code `23505`, and
an invalid one with `22023`. The creator owns the channel. A channel's `id` is
also its conversation id, so pass it to `messaging.send` and the hooks below.

| Visibility | Conversation access | Who sees and reads it                                          |
| ---------- | ------------------- | -------------------------------------------------------------- |
| `public`   | `tenant`            | everyone with `messages.read`; anyone of them can `join`       |
| `private`  | `members`           | its members only; others get `undefined` from `get` and `find` |

`list()` returns the caller's channels by name. `list({ browse: true })`
returns every channel the caller can see, joined or not, with `memberCount`
and the caller's `role`. Archived channels are left out unless
`includeArchived` is set.

## Membership [#membership]

`join` adds the caller to a public channel; joining a private one fails as if
it did not exist. `leave` returns `false` when the caller wasn't a member.
`invite(channelId, userIds)` adds people who hold `messages.read` in the
tenant and returns how many joined. Any member of a private channel can
invite; in a public channel, where people join themselves, only a holder of
`messages.moderate` adds others. Sending to a public channel also joins the
sender.

`update` takes a partial change; `null` clears the topic or the purpose. Only
the owner or a holder of `channels.manage` updates a channel.
`archive(channelId)` makes it read-only and `archive(channelId, false)`
restores it; the owner or a holder of `messages.moderate` archives.

## Channels on a record [#channels-on-a-record]

Pass `subject` to link a channel to a record, such as a deal room for one
customer. The caller must be able to read the record, and the record must
belong to the channel's tenant.
`list({ on: { type, id } })` lists the channels linked to it that the caller
can see.

```ts
await channels.create({
  name: "deal-acme",
  subject: { type: "customer", id: customerId },
});
const rooms = await channels
  .list({ on: { type: "customer", id: customerId } })
  .orThrow();
```

## A chat screen [#a-chat-screen]

The hooks are a registry item: install them with
`npx shadcn add @better-supabase/use-messaging`, which writes
`hooks/use-messaging.ts` into the app. They read the client from the app's
provider (see
[block controllers](/docs/frontend/block-controllers)). Both stay live over
the conversations' Realtime topics.

```tsx
"use client";

import { useConversations, useMessages } from "@/hooks/use-messaging";

export function ChannelList() {
  const { conversations } = useConversations({ kinds: ["channel"] });

  return (
    <ul>
      {conversations?.map((channel) => (
        <li key={channel.id}>
          {channel.title} ({channel.unread})
        </li>
      ))}
    </ul>
  );
}

export function Messages({ channelId }: { channelId: string }) {
  const { messages, send } = useMessages({ conversationId: channelId });

  return (
    <>
      {messages?.map((message) => (
        <p key={message.id}>{message.body}</p>
      ))}
      <button type="button" onClick={() => void send("Hello")}>
        Say hello
      </button>
    </>
  );
}
```

| Hook                                                         | Returns                                                                                                                |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `useConversations({ kinds, limit })`                         | `conversations` with `unread` and `mentions`, `status`, `error` and `refresh`                                          |
| `useMessages({ conversationId, threadId, limit, markRead })` | `messages`, `hasOlder`, `typing`, and `send`, `edit`, `remove`, `pin`, `resolve`, `loadOlder`, `setTyping`, `markRead` |

`useMessages` with a `threadId` shows that thread's replies, and `send` then
replies into it. It moves the read marker as messages arrive unless
`markRead` is `false`. `setTyping(true)` broadcasts again every three seconds
while it is held. `createMessagesController` and
`createConversationsController` from `better-supabase/blocks/messaging` run
the same state without a framework.

The UI recipes [`channel-sidebar`](/docs/registry/ui-recipes/channel-sidebar),
[`message-list`](/docs/registry/ui-recipes/message-list),
[`message-composer`](/docs/registry/ui-recipes/message-composer) and
[`thread-panel`](/docs/registry/ui-recipes/thread-panel) build the full screen
from shadcn components; the Next.js example serves it at `/chat`.

## Vue, Svelte and Solid [#vue-svelte-and-solid]

`useBlockController` from `better-supabase/vue`, `/svelte` and `/solid` runs
`createMessagesController` or `createConversationsController` with its options,
so pass a messaging client
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)); `auth` and
`ports` come from the app's better-supabase plugin, store or provider unless you
pass them. `createConversationsController` also takes `enabled`, which is `true`
once the caller acts in a tenant.

```vue title="Messages.vue"
<script setup lang="ts">
import {
  createMessagesController,
  createMessaging,
  rpcTransport,
} from "better-supabase/blocks/messaging";
import { useBlockController } from "better-supabase/vue";

const { channelId } = defineProps<{ channelId: string }>();
const client = createMessaging({
  transport: rpcTransport(supabase),
  tenant: () => organizationId,
});
const { state, actions } = useBlockController(createMessagesController, {
  client,
  conversationId: channelId,
});
</script>

<template>
  <p v-for="message in state.messages ?? []" :key="message.id">
    {{ message.body }}
  </p>
  <button type="button" @click="actions.send('Hello')">Say hello</button>
</template>
```

```svelte title="Channels.svelte"
<script lang="ts">
  import { createConversationsController } from "better-supabase/blocks/messaging";
  import { useBlockController } from "better-supabase/svelte";

  const channels = useBlockController(createConversationsController, {
    client,
    enabled: true,
    kinds: ["channel"],
  });
</script>

<ul>
  {#each $channels.conversations ?? [] as channel (channel.id)}
    <li>{channel.title}</li>
  {/each}
</ul>
```

```tsx title="Channels.tsx"
import { For } from "solid-js";
import { createConversationsController } from "better-supabase/blocks/messaging";
import { useBlockController } from "better-supabase/solid";

export function Channels() {
  const { state } = useBlockController(createConversationsController, {
    client,
    enabled: true,
    kinds: ["channel"],
  });

  return (
    <ul>
      <For each={state().conversations ?? []}>
        {(channel) => <li>{channel.title}</li>}
      </For>
    </ul>
  );
}
```

`supabase`, `organizationId` and `client` stand for your Supabase client, the
active organization and the messaging client above.

## Events [#events]

The block writes `channel.created` and `channel.updated` to the
[outbox](/docs/blocks/outbox). Their payloads carry the channel id, name and
visibility, and name a linked record with `subjectType` and `subjectId`; see
[Events](/docs/extending/events). Messages in a channel write the messaging
block's events.

## Without the client [#without-the-client]

`db.channels` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createChannels, sqlTransport } from "better-supabase/blocks/channels";

const channels = createChannels({
  transport: sqlTransport(sql),
  tenant: () => organizationId,
});

const general = await channels.create({ name: "General" }).orThrow();
const launch = await channels
  .create({
    name: "launch",
    visibility: "private",
    topic: "Ship date and blockers",
    memberIds: [alice, bob],
  })
  .orThrow();
```

# Comments

> Comments on any record as its conversation on the messaging core, with replies, mentions, resolved threads, pins, edit history, anchors, files and comments turned into tasks.

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

The `comments` block lets members comment on any record in a tenant (a
project, a ticket, an invoice). Each record's comments are its conversation
on the [messaging](/docs/blocks/messaging) core (conversation kind
`subject`), and each comment is a message. Replies, mentions, resolved
threads, pins, edit history, anchors, files and read markers come from
messaging; the block adds the record lookup, comment counts, copying a thread,
following the record and turning a comment into a work item.

```bash
better-supabase add comments   # adds tenant, access, subjects and messaging as well
```

Comments need tenants: the module runs in the `memberships`
[scope](/docs/blocks/sql#scope-and-naming) only, and `user` or `none` scope
refuses it. The records come from the
[subject registry](/docs/blocks/sql#subject-registry) (`config.records`).
Whoever can read a record (`can_subject(type, id, 'read')`) and holds
`messages.read` in the tenant reads its comments. A comment goes in the record's own
tenant only: `create_comment` refuses a record whose tenant column names
another tenant, even for a member of both.

| Table                       | Holds                                                                                                    |
| --------------------------- | -------------------------------------------------------------------------------------------------------- |
| `conversations` (messaging) | One `subject` conversation per record, with `subject_type` and `subject_id`, opened by the first comment |
| `messages` (messaging)      | The comments: `body`, `document`, `anchor`, `mentions`, `parent_id`, pins, resolution, edits, deletes    |
| `message_edits` (messaging) | The earlier versions of an edited comment                                                                |
| `comment_tasks`             | The work item a comment was turned into (`message_id`, `task_id`), with the tasks block                  |

The block has no entity of its own: a comment is a `message`, so reactions,
search and share links accept it under the message entity
(`blocks.messaging.names` renames it). `commentInputSchema` and
`commentChangeSchema` validate what `create` and `edit` take.

## Permissions [#permissions]

The comments operations check the messaging keys:

| Operation           | Key                 | Lets a member                                            | Default roles          |
| ------------------- | ------------------- | -------------------------------------------------------- | ---------------------- |
| `comments.read`     | `messages.read`     | read the comments on the records they can read           | `member`, `admin`      |
| `comments.create`   | `messages.send`     | comment, reply, edit their own comments, pin and resolve | `member`, `admin`      |
| `comments.moderate` | `messages.moderate` | delete other members' comments                           | `admin` (`messages.*`) |

`blocks.comments.permissions` renames a key per action, as for any block
(see [access](/docs/repository/permissions)). Only the author edits a comment, within
`blocks.messaging.options.editWindow` when it is set. Deleting is a soft
delete: the comment stays as a placeholder so replies keep their parent, and
its body and files are cleared.

## Config [#config]

```ts title="better-supabase.config.ts"
export default defineConfig({
  records: { project: { table: "projects", permission: "projects.read" } },
  blocks: {
    messaging: { options: { maxBodyLength: 10_000 } },
    comments: { options: { follow: true } },
  },
});
```

`blocks.comments.options` takes three keys:

| Option          | Default | What it does                                                                                                |
| --------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `follow`        | `true`  | The author and the mentioned members follow the record, so they hear of later comments (with notifications) |
| `mentionGroups` | none    | Expands a mention of a group, such as a team, into its members                                              |
| `notify`        | `true`  | Sends `comment.posted` to the record's followers (with notifications); `false` sends none                   |

The body limit, the extra columns and the Realtime topic are messaging
options (`blocks.messaging.options.maxBodyLength`, `extraColumns` and
`topic`); see [Messaging](/docs/blocks/messaging#options).

## Comments [#comments]

Every method that names a record takes it as a `RecordRef`, `{ type, id }`,
and acts in the block's tenant. On the server, `createBlockClient` builds the
client for the request: it calls through `ctx.db` and reads the tenant from
`db.$context.tenant`, so you pass no organization id (see
[createBlocks](/docs/blocks/create-blocks#one-block-per-request)).

```ts title="app/projects/[id]/actions.ts"
import { createBlockClient } from "better-supabase/blocks";
import { commentsBlock } from "better-supabase/blocks/comments";

import { blockConfig } from "@/lib/supabase/blocks.generated";
import { bsb } from "@/lib/supabase/server";

const ctx = await bsb.context();
const comments = createBlockClient(ctx.db, commentsBlock, {
  config: blockConfig,
});
const project = { type: "project", id: projectId };

const comment = await comments
  .create(project, { body: "Ready for review @[Ada](8c5a3d3e-...)" })
  .orThrow();
await comments.create(project, { body: "On it", parentId: comment.id });

const thread = await comments.list(project).orThrow();
```

`createComments({ transport, tenant })` builds the same client by hand.
`tenant` is an organization id or a function that returns the current one;
a call without a tenant fails with `invalid_request` and the hint
`TENANT_REQUIRED`.

A `Comment` is a messaging `Message` with two more fields: `subject`, the
record it is on, and `taskId`, the work item it became. So a comment carries
`authorId` and `author` (name and avatar, with the
[profiles](/docs/blocks/profiles) module), `body`, `document`, `anchor`,
`mentionIds`, `parentId` and `threadId`, `sentAt`, `editedAt`, `deletedAt`,
`pinnedAt`, `resolvedAt`, `attachments`, `reactions` and `fields`.

| Method                             | What it does                                                                            |
| ---------------------------------- | --------------------------------------------------------------------------------------- |
| `create(subject, comment)`         | Posts a comment, opening the record's conversation the first time                       |
| `get(id)`                          | One comment, or `not_found` when the caller can't read it                               |
| `edit(id, { body, document? })`    | The author edits; the earlier version is kept                                           |
| `remove(id)`                       | Soft-deletes (the author, or a member with `messages.moderate`)                         |
| `resolve(id, resolved?)`           | Resolves a thread by its root comment, or opens it again with `false` (`messages.send`) |
| `pin(id, pinned?)`                 | Pins a comment to the top of its record, or unpins it with `false` (`messages.send`)    |
| `edits(id)`                        | The earlier versions of an edited comment, newest first (`list_message_edits`)          |
| `assign(id, assignment?)`          | Turns a comment into a work item and returns its id                                     |
| `list(subject, options?)`          | A record's comments and replies, oldest first, deleted ones as placeholders             |
| `counts(subjects)`                 | How many comments each record has that the caller can read, keyed `type:id`             |
| `copy(from, to)`                   | Copies a record's thread to another record, with a service-role transport               |
| `conversation(subject, { open? })` | The record's conversation id, for Realtime and uploads; `open: true` creates it         |
| `markRead(subject, { readAt? })`   | Moves the caller's read marker on the record's comments forward                         |

Threads are one level deep: a reply to a reply joins the root's thread.
`list` pages with `cursor` (an instant) and `cursorId` (the last comment's
id, so comments sent in the same instant aren't skipped), or with `offset` and
`limit` (default 100, at most 500). `counts` leaves out deleted comments and
returns 0 for a record without any; `subjectKey(subject)` from
`better-supabase/blocks` builds its keys.

```ts
const counts = await comments
  .counts(rows.map((row) => ({ type: "project", id: row.id })))
  .orThrow();
counts[`project:${projectId}`]; // 3

await comments.resolve(comment.id).orThrow();
const history = await comments.edits(comment.id).orThrow();
```

### Mentions [#mentions]

`create` and `edit` read the mentions from the body when you pass no
`mentionIds`: `mentionsIn(body)` returns the user ids written as
`@[Name](<user id>)`, the markup most mention inputs produce. For an editor
that stores mentions as nodes, `createComments({ mentionsOf })` reads them
from the comment's `document`:

Server code calls the `comments` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useComments } = defineDataHooks(
  "comments",
  (db: AppDb) => db.comments,
);
```

```ts
await db.comments.create(subject, { body, document });
await db.comments.edit(id, { body, document: null }); // null removes the document
```

```tsx title="src/features/comments/list.tsx"
"use client";

import { useComments } from "@/lib/supabase/hooks";

const rows = useComments({ limit: 50 });
```

A comment can also mention another record as `@[Name](subject:<type>/<id>)`.
Comments are messages, so the record lists the comment among its backlinks
through `messaging.recordMentions`; see
[record mentions](/docs/blocks/messaging#record-mentions).

`options.mentionGroups` expands a mention of a group 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. The app writes the group id like a user id, for example as
`@[Support](<team id>)`:

```ts title="better-supabase.config.ts"
comments: {
  options: {
    mentionGroups: {
      table: "public.team_members",
      group: "team_id",
      member: "user_id",
      tenant: "organization_id",
    },
  },
},
```

### Anchors [#anchors]

`anchor` says where on the record a top-level comment points, such as a text
range in a document or a field on a form. It takes any JSON and comes back
on the comment as `anchor`:

```ts
await comments.create(
  { type: "document", id: documentId },
  { body: "Is this figure right?", anchor: { from: 120, to: 164 } },
);
```

A file anchor points at a page, a region or a moment of one of the record's
files (see [file anchors](/docs/blocks/messaging#file-anchors)), and
`anchoredTo` lists a file's comments with their records, for a PDF or video
viewer that shows them in place:

```ts
import { fileAnchor } from "better-supabase/blocks/comments";

await comments.create(
  { type: "contract", id: contractId },
  {
    body: "Check this clause",
    anchor: fileAnchor(pdfId, {
      page: 3,
      region: { x: 0.1, y: 0.2, width: 0.5, height: 0.1 },
    }),
  },
);

const onPdf = await comments.anchoredTo(pdfId).orThrow();
```

### Attachments [#attachments]

With the [attachments](/docs/blocks/attachments) block, upload files to the
record's conversation (subject type `conversation`) and pass their ids as
`attachmentIds`; `create` moves them onto the comment, and the comment
returns them in `attachments`. `conversation(subject, { open: true })` gives
the conversation id before the first comment:

```ts
const conversationId = await comments
  .conversation(project, { open: true })
  .orThrow();
const file = await attachments
  .put(
    { type: "conversation", id: conversationId },
    { name: "spec.pdf", mimeType: "application/pdf", size: blob.size },
    blob,
  )
  .orThrow();

await comments.create(project, {
  body: "The updated spec",
  attachmentIds: [file.id],
});
```

### Assign to a task [#assign-to-a-task]

With the [tasks](/docs/blocks/tasks) block, `assign` turns a comment
into a work item through `assign_comment(message, assignee, attrs)`. The
title defaults to the comment's first 120 characters and the body to the
comment's body; `attrs` passes other `create_work_item` attributes, such as
`projectId` or `priority`. The link lives in `comment_tasks`, so assigning the
same comment again returns the same work item, and the comment carries its id
as `taskId`. Creating the work item needs `work_items.write`.

```ts
const taskId = await comments
  .assign(comment.id, { assigneeId: adaId, attrs: { priority: 2 } })
  .orThrow();
```

### Copying a thread [#copying-a-thread]

When the app duplicates a record (a quote into an invoice, say),
`comments.copy({ type, id }, { type, id })` copies the thread to the new
record 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.

### Your own columns [#your-own-columns]

The extra columns are messaging's: add them with
`blocks.messaging.options.extraColumns`, then pass a Standard Schema for them
as `fields`. `create` and `edit` validate the `fields` you pass, and `get` and
`list` return them parsed under `fields`. The block also takes `hooks` around
its methods; see [extending blocks](/docs/extending/blocks).

```ts title="lib/comments.ts"
import { createComments, rpcTransport } from "better-supabase/blocks/comments";
import { z } from "zod";

const comments = createComments({
  transport: rpcTransport(supabase),
  tenant: organizationId,
  fields: z.object({ visibility: z.enum(["internal", "customer"]) }),
});
```

## Notifications [#notifications]

With the [notifications](/docs/blocks/notifications) block, a new comment
notifies two groups:

| Type                | Who                                                                                 |
| ------------------- | ----------------------------------------------------------------------------------- |
| `message.mentioned` | the mentioned members who can read the record, sent by messaging                    |
| `comment.posted`    | the record's followers, less the author and the members the mention already reached |

`comment.posted` names the record as its subject, with the record's
`subject_title` from `config.records` as its `subject_label`, the first 140
characters of the body as its `summary` and the commenter as the actor, and
its data holds `messageId` and `conversationId`. It is sent only when the
record has a follower besides the author and the mentioned members, or when a
`notification_audience` hook can add recipients, so a comment nobody follows
writes no notification. `notify: false` turns `comment.posted` off; mentions
still notify through messaging unless `blocks.messaging.options.notify` is
`false` too. With `follow` on (the default), the author
and every mentioned member, after group expansion, follow the record: each
gets a `participating` row in `notification_subscriptions`. A user who
already has a row for the record keeps it, so someone who chose `ignore`
stays unsubscribed. When `notification_subscriptions` is a table your app
already has, it must accept every record type you comment on.

## Events [#events]

The comments block writes no events of its own. A comment is a message, so
the outbox gets the messaging events: `message.sent`, `message.edited`,
`message.deleted`, `message.pinned` and `message.resolved`. On a comment
their data carries `conversationKind: "subject"` and the record as
`recordType` and `recordId`, next to `messageId`, `conversationId` and
`authorId`; see [Events](/docs/extending/events). To keep a record's history,
send them to the [activity](/docs/blocks/timeline) feed.

## Realtime [#realtime]

Each record conversation has a private Realtime topic,
`conversationTopic(conversationId, topic)`, which is `<topic>:<conversation id>`
with `blocks.messaging.options.topic` (default `messages`). Only callers who
read the record can join it. The messaging triggers broadcast a ping for each
new, edited or deleted comment with its `messageId` and `threadId`. The
comments hooks fetch that comment and its thread's root, for the reply count,
so a busy thread never loads whole again; they load it whole only after the
topic rejoins.

## In the browser [#in-the-browser]

`useComments` loads one record's thread through the provider's client and keeps
it current after `add`, `edit`, `remove`, `resolve` and `pin`. It joins the
record conversation's topic once the record has a conversation, so other
people's comments arrive too. The same hook runs in React DOM and React Native.
It is a registry item: install it with `npx shadcn add
@better-supabase/use-comments`, which writes `hooks/use-comments.ts` into the
app.

```tsx title="components/comment-thread.tsx"
import { useComments } from "@/hooks/use-comments";

const { comments, error, add, resolve } = useComments({
  subject: { type: "project", id: projectId },
});

await add("Ready for review");
await add("Agreed", { parentId: commentId });
```

| Option    | Default                                         | What it does                                    |
| --------- | ----------------------------------------------- | ----------------------------------------------- |
| `subject` | none                                            | The record; the thread waits while it is `null` |
| `topic`   | `blocks.messaging.options.topic` (`"messages"`) | The topic prefix; `null` turns Realtime off     |
| `limit`   | `100`                                           | The most comments to load, at most 500          |

`add(body, draft?)` takes the rest of a `NewComment` as its draft (a reply,
a document, an anchor, mentions or files). Every action returns a `Result`
and keeps a failure in `error`. The hook calls the schema and function names
in the `blocks` config you pass to the browser client
(`createClient(betterSupabase, { blocks: blockConfig })`) and acts in the
client's organization: the client's `organization` option, then the
session's organization claim. The thread waits while there is no tenant.

`createCommentsController` from `better-supabase/blocks/comments` is the same
logic without React. Vue, Svelte and Solid run it with `useBlockController` from
`better-supabase/vue`, `/svelte` and `/solid`, taking the controller's options
(a client built with its `tenant`, `subject` and `topic`) with `auth` and
`ports` from the provider
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)). The
[comment thread recipe](/docs/registry/ui-recipes/comment-thread) shows it in
the Next.js and Expo examples.

```vue title="CommentCount.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createComments,
  createCommentsController,
  rpcTransport,
} from "better-supabase/blocks/comments";

const { organizationId, projectId } = defineProps<{
  organizationId: string;
  projectId: string;
}>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createCommentsController, {
  client: createComments({
    transport: rpcTransport(supabase),
    tenant: organizationId,
  }),
  subject: { type: "project", id: projectId },
});
</script>

<template>
  <p>{{ state.comments?.length ?? 0 }} comments</p>
  <button @click="actions.add('Looks good')">Approve</button>
</template>
```

```svelte title="CommentCount.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createComments,
    createCommentsController,
    rpcTransport,
  } from "better-supabase/blocks/comments";

  let { organizationId, projectId }: { organizationId: string; projectId: string } = $props();
  const { supabase } = getBetterSupabase();
  const thread = useBlockController(createCommentsController, {
    client: createComments({
      transport: rpcTransport(supabase),
      tenant: organizationId,
    }),
    subject: { type: "project", id: projectId },
  });
</script>

<p>{$thread.comments?.length ?? 0} comments</p>
```

```tsx title="CommentCount.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createComments,
  createCommentsController,
  rpcTransport,
} from "better-supabase/blocks/comments";

export function CommentCount(props: {
  organizationId: string;
  projectId: string;
}) {
  const supabase = useSupabase();
  const { state } = useBlockController(createCommentsController, {
    client: createComments({
      transport: rpcTransport(supabase),
      tenant: props.organizationId,
    }),
    subject: { type: "project", id: props.projectId },
  });
  return <p>{state().comments?.length ?? 0} comments</p>;
}
```

## Reactions [#reactions]

With the [reactions](/docs/blocks/reactions) module installed, react to a
comment as a message: `reactions.react({ type: "message", id: comment.id },
emoji)`. Each comment also carries its reaction summary in `reactions`.

## Without the client [#without-the-client]

`db.comments` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const comments = createComments({
  transport,
  tenant: organizationId,
  mentionsOf: ({ document }) =>
    mentionNodes(document).map((node) => node.userId),
});

await comments.create(subject, { body, document });
await comments.edit(id, { body, document: null }); // null removes the document
```

# Enrollments

> Programs of steps per organization, and enrollments of a party, a member or any record in them with step progress and completion. Onboarding checklists run on it.

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

`enrollments` keeps programs of ordered steps per organization and enrolls
subjects in them: a party in a customer onboarding plan, a member in a
training path, or any record your app owns. Each enrollment tracks which
steps are done, completes when the last one is, and can be cancelled while
it is active. [Onboarding](/docs/blocks/onboarding) checklists run on the
same tables.

Reading programs and enrollments needs `enrollments.read`, enrolling and
changing an enrollment needs `enrollments.enroll`, and managing programs
needs `enrollments.manage`. Admins get all three and members get
`enrollments.read`. A user enrolled as a `user` subject always reads their own
enrollment and completes or resets its steps.

```bash
better-supabase add enrollments
```

The module needs `memberships` and `access`. Enrollments in an organization go to
the outbox, and program changes go to the audit log as well.

Server code calls the `enrollments` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useEnrollments } = defineDataHooks(
  "enrollments",
  (db: AppDb) => db.enrollments,
);
```

```ts
import { createEnrollments } from "better-supabase/blocks/enrollments";

await db.enrollments
  .saveProgram({
    key: "customer-onboarding",
    name: "Customer onboarding",
    subjectTypes: ["party"],
    steps: [
      { id: "kickoff", title: "Kickoff call" },
      { id: "import", title: "Import data" },
      { id: "training", title: "Train the team" },
    ],
  })
  .orThrow();

const enrollment = await db.enrollments
  .enroll("customer-onboarding", { type: "party", id: partyId })
  .orThrow();

await db.enrollments.complete(enrollment.id, "kickoff").orThrow();
```

```tsx title="src/features/enrollments/list.tsx"
"use client";

import { useEnrollments } from "@/lib/supabase/hooks";

const rows = useEnrollments({ limit: 50 });
```

| Option     | Default | What it does                   |
| ---------- | ------- | ------------------------------ |
| `maxSteps` | `100`   | The most steps a program holds |

| Table                 | Holds                                                                                  |
| --------------------- | -------------------------------------------------------------------------------------- |
| `enrollment_programs` | One row per program: `key`, `name`, `subject_types`, the ordered `steps` and `version` |
| `enrollments`         | One row per program and subject: the step snapshot, `completed_steps` and `status`     |
| `enrollment_steps`    | One row per completed step, with who completed it and when                             |

## Programs [#programs]

`saveProgram(program)` creates a program, or changes one when you pass its
`id`. Keys left out keep their value, and a program's key can't change.
`subjectTypes` limits the subject types it enrolls; leave it empty to enroll
any. Each step has an `id` (lower-case letters, digits, `_`, `.` or `-`) and
an optional `title` and `description`.

Changing the steps raises the program's `version`. Pass the `version` you read
to fail with `ENROLLMENTS_PROGRAM_CONFLICT` when someone else changed it
first. Turn a program off with `enabled: false` to stop new enrollments; its
enrollments stay. `deleteProgram(id)` deletes a program with its enrollments.

## Enrolling [#enrolling]

`enroll(program, subject)` enrolls a subject and returns its enrollment.
Enrolling an enrolled subject returns the same enrollment, and enrolling a
cancelled one starts it again with its completed steps. A `user` subject
takes the user's id, so the user can follow their own progress.

An enrollment keeps the program's step ids from when it enrolled, so changing
a program doesn't change running enrollments. Enrolling the subject again
takes the program's current steps and recounts its progress against them.

## Progress [#progress]

`complete(enrollmentId, step)` marks a step done and `reset` marks it not
done. Completing the last step sets `status` to `completed` with
`completedAt`; resetting a step of a completed enrollment makes it active
again. `cancel(enrollmentId)` cancels an active enrollment: its completed steps
stay, a cancelled enrollment takes no new steps, and a completed one can't be
cancelled.

`enrollment(program, subject)` returns one subject's enrollment, or `null`.
`list()` returns the organization's enrollments, most recently changed first:
filter by `program`, `subject` or `status`, and pass `before` with the last
enrollment's `updatedAt` for the next page. Without `enrollments.read`, `list`
returns the caller's own enrollments.

`enrollmentsBinding` loads a record's enrollments for a record page and
reloads on the enrollment events. See [Record bindings](/docs/repository/record-bindings).

## Onboarding [#onboarding]

Each onboarding checklist runs as the program `onboarding:<checklist>`. The
organization is enrolled in an organization checklist, and the user, without
an organization, in a user checklist. Those programs live in your
`defineChecklist` calls instead of `enrollment_programs`, so `saveProgram`
can't change them and their enrollments can't be cancelled. See
[Onboarding](/docs/blocks/onboarding).

## Functions [#functions]

| Function                                                                      | Granted to                      | Does                                   |
| ----------------------------------------------------------------------------- | ------------------------------- | -------------------------------------- |
| `save_enrollment_program(tenant, program)`                                    | `authenticated`, `service_role` | Creates or changes a program           |
| `enrollment_programs(tenant)`                                                 | `authenticated`, `service_role` | The organization's programs by key     |
| `delete_enrollment_program(program_id)`                                       | `authenticated`, `service_role` | Deletes a program with its enrollments |
| `enroll(tenant, program, subject_type, subject_id)`                           | `authenticated`, `service_role` | Enrolls a subject                      |
| `enrollment(tenant, program, subject_type, subject_id)`                       | `authenticated`, `service_role` | A subject's enrollment, or null        |
| `list_enrollments(tenant, program, subject_type, subject_id, status, ...)`    | `authenticated`, `service_role` | The organization's enrollments         |
| `complete_enrollment_step(enrollment_id, step)`, `reset_enrollment_step(...)` | `authenticated`, `service_role` | Marks a step done or not done          |
| `cancel_enrollment(enrollment_id)`                                            | `authenticated`, `service_role` | Cancels an active enrollment           |

## Errors [#errors]

| Hint                            | Cause                                                                                 |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| `ENROLLMENTS_FORBIDDEN`         | the caller lacks the permission                                                       |
| `ENROLLMENTS_INVALID`           | an invalid program, a step outside the program, or a change to a cancelled enrollment |
| `ENROLLMENTS_PROGRAM_NOT_FOUND` | no program in the organization has this id or key                                     |
| `ENROLLMENTS_PROGRAM_EXISTS`    | a program with this key already exists                                                |
| `ENROLLMENTS_PROGRAM_CONFLICT`  | the program changed since you read its `version`                                      |
| `ENROLLMENTS_NOT_FOUND`         | no enrollment the caller can see has this id                                          |

## Events [#events]

| Event                                                                                             | Data                                                                                                            |
| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `enrollment_program.saved`, `enrollment_program.deleted`                                          | `organizationId`, `id`, `key`, `version`                                                                        |
| `enrollment.created`, `enrollment.step_completed`, `enrollment.completed`, `enrollment.cancelled` | `organizationId`, `id`, `program`, `subjectType`, `subjectId`, `status`, `step`, `completedSteps`, `totalSteps` |

`step` is set for `enrollment.step_completed`. Only enrollments in an
organization emit events, so a user's own onboarding checklist doesn't. A
rule on `enrollment.completed` can hand a customer to their account manager
once onboarding is done.

## With ReUI [#with-reui]

There is no prebuilt enrollments UI. List enrollments in a `data-grid` with
the subject, a `Progress` of `completedSteps` over `totalSteps` and `status`
as a `Badge` (`success-light` for completed, `secondary` for cancelled). Show
an enrollment's steps as a `Stepper` or a checklist of `Checkbox` rows, and
edit a program's steps in a `Sheet` with a sortable list. Confirm
`cancel` with an `AlertDialog`.

## Why it works this way [#why-it-works-this-way]

An enrollment snapshots the program's step ids, so changing a program never
rewrites a subject's history: progress counts only the steps it enrolled
with. The tenant is nullable so a user's own onboarding checklist fits the
same tables, and the module's internal functions let other blocks drive
programs they own without going through `enrollment_programs`.

## Without the client [#without-the-client]

`db.enrollments` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createEnrollments } from "better-supabase/blocks/enrollments";

const enrollments = createEnrollments({ transport, tenant: organizationId });

await enrollments
  .saveProgram({
    key: "customer-onboarding",
    name: "Customer onboarding",
    subjectTypes: ["party"],
    steps: [
      { id: "kickoff", title: "Kickoff call" },
      { id: "import", title: "Import data" },
      { id: "training", title: "Train the team" },
    ],
  })
  .orThrow();

const enrollment = await enrollments
  .enroll("customer-onboarding", { type: "party", id: partyId })
  .orThrow();

await enrollments.complete(enrollment.id, "kickoff").orThrow();
```

# Guests

> Invite clients and contractors by email to read, comment on and upload to the records you grant them, and let a customer see the invoices and bookings of the party they act for. Guests are never members, and no authorization library is involved.

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

`guests` lets people outside the organization in without making them
members. You invite a client or contractor by email, they sign in and accept,
and they reach only the records you grant them: an invoice, a project, a
booking. A guest who acts for a party (a customer company or person) also
sees that party's invoices and bookings, which is what a customer portal
needs.

```bash
better-supabase add guests
```

```ts
import { createGuests } from "better-supabase/blocks/guests";
```

The module requires `memberships` and `access`. With `outbox` its events are
recorded, with `audit` every invite and grant is audited, and with
`invoices`, `bookings` and `parties` a grant can name an invoice, a booking
or a party. It renders ahead of `comments` so the comments, attachments,
invoices and bookings policies can let guests in (see
[ADR 0070](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0070-guest-and-portal-access.md)).

## Tables [#tables]

| Table          | Holds                                                                                                                      |
| -------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `guests`       | An invited email in an organization: `name`, the `user_id` once accepted, the party it acts for, `status`, `expires_at`    |
| `guest_grants` | One record a guest reaches: `subject_type`, `subject_id`, the `actions` (`read`, `comment`, `upload`), and an `expires_at` |

A guest is `invited`, `active` or `revoked`. The invite token is stored as a
SHA-256 hash and dropped once the guest accepts or is revoked. Members with
`guests.read` see every row; a guest sees their own guest rows and grants.

## Invite and accept [#invite-and-accept]

Server code calls the `guests` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useGuests } = defineDataHooks(
  "guests",
  (db: AppDb) => db.guests,
);
```

```ts
const invite = await db.guests
  .invite({
    email: "jane@client.example",
    name: "Jane",
    party: { type: "customer", id: customerId },
  })
  .orThrow();

await sendInviteEmail(invite.email, `/guest/accept?token=${invite.token}`);
```

```tsx title="src/features/guests/list.tsx"
"use client";

import { useGuests } from "@/lib/supabase/hooks";

const rows = useGuests({ limit: 50 });
```

`invite` returns the guest with a one-time `token` for the invite link.
Inviting the same email again issues a new token, unless the guest has
already accepted, in which case `token` is `undefined` and only the name and
party are updated. `expiresAt` makes the invite lapse.

The invitee signs in and calls `accept(token)`. Their confirmed email must
match the invite, so a forwarded link doesn't let someone else in.

```ts
await createGuests({ transport }).accept(token).orThrow();
```

`revoke(guestId)` stops every grant and drops the invite token. Inviting the
email again restores the guest.

## Grant records [#grant-records]

```ts
await guests
  .grant(guest.id, { type: "invoice", id: invoiceId }, { actions: ["comment"] })
  .orThrow();
```

A grant always includes `read`; `comment` and `upload` add the comments and
attachments the guest may write. Granting the same record again replaces the
actions and the expiry. The record must exist in the organization: invoices
and bookings, and every subject in `config.records`.

| Method                           | Returns                                                     |
| -------------------------------- | ----------------------------------------------------------- |
| `grant(guestId, subject, input)` | the grant, with `actions` and `expiresAt`                   |
| `revokeGrant(grantId)`           | `true` when a grant was removed                             |
| `grants(subject)`                | the guests who reach a record, each grant with its `guest`  |
| `list({ status, before })`       | the organization's guests, newest first                     |
| `mine()`                         | for a guest: each organization, the party, the live grants  |
| `can(subject, action)`           | whether the caller reaches the record as a guest for action |

The [guests binding](/docs/repository/record-bindings) lists a record's guests on its
page, with `grant` and `revoke` writes.

## What guests reach [#what-guests-reach]

A guest is never a member: `can()` stays false for them, and they don't see
anything a member query returns. Policies add them through
`ctx.scope.guest(tenant, type, id, action)` and
`ctx.scope.guestParty(tenant, type, id)`:

| Module      | A guest reaches                                                                  |
| ----------- | -------------------------------------------------------------------------------- |
| comments    | the comments on a record granted `read`, and writes them with `comment`          |
| attachments | the files on a record granted `read`, and uploads them with `upload`             |
| invoices    | an invoice granted `read`, and every invoice addressed to the party they act for |
| bookings    | a booking granted `read`                                                         |

Both checks read the sets `better_supabase.portal_subjects(action)` and
`better_supabase.portal_parties()`, which Postgres hashes once per statement
instead of calling per row. Without the `guests` block they render `false`,
so a layout without guests keeps its policies as they were.

## With an authorization provider [#with-an-authorization-provider]

A provider can answer for guests without the block: the optional
`portalSubjects` and `portalParties` templates in `authorization.functions`
return the records and parties the caller reaches. With the block installed
its grants are added to the provider's. See
[authorization providers](/docs/extending/authorization-providers).

## Errors [#errors]

| Hint                      | Cause                                                       |
| ------------------------- | ----------------------------------------------------------- |
| `GUEST_FORBIDDEN`         | the caller lacks `guests.read` or `guests.manage`           |
| `GUEST_PARTY_INVALID`     | only one of the party type and id was passed                |
| `GUEST_PARTY_NOT_FOUND`   | the party doesn't exist in the organization                 |
| `GUEST_EXPIRES_IN_PAST`   | `expiresAt` isn't in the future                             |
| `GUEST_SIGN_IN_REQUIRED`  | an anonymous caller tried to accept                         |
| `GUEST_INVITE_INVALID`    | the token is unknown, used or expired                       |
| `GUEST_EMAIL_MISMATCH`    | the invite was sent to another email                        |
| `GUEST_ALREADY_GUEST`     | the caller is already a guest of this organization          |
| `GUEST_NOT_FOUND`         | no guest with this id in an organization the caller manages |
| `GUEST_REVOKED`           | invite the guest again before granting access               |
| `GUEST_ACTION_INVALID`    | actions are `read`, `comment` and `upload`                  |
| `GUEST_SUBJECT_NOT_FOUND` | the record doesn't exist in the organization                |

## Events [#events]

`guest.invited`, `guest.accepted` and `guest.revoked`, and
`guest_grant.created` and `guest_grant.revoked`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.guests` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const guests = createGuests({ transport, tenant: organizationId });

const invite = await guests
  .invite({
    email: "jane@client.example",
    name: "Jane",
    party: { type: "customer", id: customerId },
  })
  .orThrow();

await sendInviteEmail(invite.email, `/guest/accept?token=${invite.token}`);
```

# 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 and contacts per
organization and keeps its conversations and messages on the
[messaging core](/docs/blocks/messaging): each conversation is a core
conversation of kind `inbox`, each message and note a core message, and the
inbox adds its own fields (status, assignee, bot mode, direction, delivery)
in `inbox_conversations` and `inbox_message_details`. `createInbox` from
`better-supabase/blocks/inbox` reads and writes them, and the React hooks
keep a list, a thread and an in-app widget current over Supabase Realtime.
The hooks are a registry item: install them with
`npx shadcn add @better-supabase/use-inbox`, which writes
`hooks/use-inbox.ts` into the app. Channel messages reach the same
tables through the [Chat SDK adapters](/docs/chat-sdk).

```bash
pnpm better-supabase add inbox
```

The module needs the [access contract](/docs/repository/permissions), the
[messaging](/docs/blocks/messaging) block and the
[attachments](/docs/blocks/attachments) block. With the
[jobs](/docs/blocks/queue) 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`           |

Under the provider access model, an operations entry `inbox.command.<name>`
replaces the key for the `assign`, `set_status`, `set_bot_mode` and `reply`
commands; without one, the key above applies after the rename in
`blocks.inbox.permissions`.

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:

Server code calls the `inbox` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useInbox } = defineDataHooks(
  "inbox",
  (db: AppDb) => db.inbox,
);
```

```ts
const rows = await db.inbox.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/inbox/list.tsx"
"use client";

import { useInbox } from "@/lib/supabase/hooks";

const rows = useInbox({ limit: 50 });
```

`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                              |
| `messages.createUpload`, `confirmUpload`                           | A file upload: an attachment row, then its id in `attachments`               |
| `files.pending`, `stored`, `failed`                                | Service only: channel files to fetch, upload and confirm                     |
| `templates.upsert`, `delete`                                       | Approved channel templates, such as WhatsApp's                               |
| `purgeContact(contactId)`                                          | Erases a contact and its conversations; returns attachment paths to delete   |

A note is a message with `kind: "note"`: staff with `inbox.read` read it,
and a contact never does. Each message has the core fields (`mentionIds`,
`sentAt`, `editedAt`, `deletedAt`, `attachments`, `reactions`), and a
contact's messages have no `authorId`; the contact is the conversation's
`contactId`.

`messages.react` needs the [reactions](/docs/blocks/reactions) block and
stores the reaction on the core message (subject type `message`), so
reactions in the inbox, in channels and on comments share custom emoji,
events and counts. Without the block it fails with the hint
`INBOX_REACTIONS_MISSING`.

`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).

Files are rows of the attachments block on the message, readable by the
staff and the contact of that conversation once the scan marks them clean.
A file a contact sends on a channel arrives as a URL; the message keeps a
pending attachment, and the chat-sdk `importFiles` (or your own loop over
`files.pending`) fetches it, uploads it and confirms it.

`purgeContact` deletes the rows and returns the `{ bucket, path }` of each
stored file; remove them 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.

## Snooze and scheduled replies [#snooze-and-scheduled-replies]

Snooze a conversation until a time in the future, or schedule a staff reply
to go out later:

```ts
const tomorrow = Temporal.Now.instant().add({ hours: 24 });

await inbox.conversations.snooze(conversation.id, tomorrow).orThrow();
const scheduled = await inbox.messages
  .schedule(conversation.id, { body: "Following up as promised." }, tomorrow)
  .orThrow();

await inbox.messages.reschedule(scheduled.id, tomorrow.add({ hours: 1 }));
await inbox.messages.cancelScheduled(scheduled.id);
```

| Method                                               | Does                                                                 |
| ---------------------------------------------------- | -------------------------------------------------------------------- |
| `conversations.snooze(id, until)`                    | Hides the conversation until `until`; a past time fails with `22023` |
| `messages.schedule(id, input, at)`                   | A staff reply for later: messages only, no notes, `at` in the future |
| `messages.scheduled(conversationId?)`                | The caller's scheduled replies, soonest first                        |
| `messages.reschedule(id, at)`, `cancelScheduled(id)` | Moves or deletes the caller's scheduled reply                        |
| `publishDue(maxRows?)`                               | Service only: sends the replies whose time came, as their authors    |
| `wakeSnoozed()`                                      | Service only: reopens the conversations whose snooze ended           |

Only the author sees a scheduled reply before it is sent, and it needs
`inbox.reply`. When it is due, `publishDue` sends it through the same path
as `messages.send` (deliveries, the outbound job, the bot hand-off) as the
author; a reply whose author has lost `inbox.reply` is dropped. Waking a
conversation sets it to `open`, logs `unsnoozed` in its events, emits
`inbox_conversation.unsnoozed` and notifies the assignee, or the inbox's
members while nobody is assigned (`inbox.snooze_ended`).

With the [jobs](/docs/blocks/queue) block, snoozing and scheduling each
queue a delayed job on `inbox_schedule` (`scheduleQueue`) for the right
time: `{ type: "inbox.wake", conversation_id }` or
`{ type: "inbox.publish", scheduled_id }`. A handler calls `wakeSnoozed()`
or `publishDue()` on a service client; a job left by a reschedule finds
nothing due and does nothing. Without jobs, run both from a cron;
`maintain()` in the [Chat SDK adapters](/docs/chat-sdk/channels) calls
them.

```ts title="app/api/jobs/drain/route.ts"
import "server-only";

import { jobs } from "@/lib/jobs";
import { serviceInbox } from "@/lib/inbox";

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: {
    inbox_schedule: async () => {
      await serviceInbox.wakeSnoozed().orThrow();
      await serviceInbox.publishDue().orThrow();
    },
  },
});
```

## Realtime [#realtime]

Changes broadcast without row data on private topics. A thread uses the
messaging topic `messages:<conversationId>`, whose pings name the message
that changed, and `typing` pings carry `{ conversationId, userId, typing }`.
The lists use `inbox:org:<organizationId>`. `useInbox` reloads the list
through your server action when a broadcast arrives; `useConversation` and
`useInboxWidget` fetch only the pinged message through `get` and reload the
whole thread only after a reconnect.

```tsx title="components/conversation-list.tsx"
"use client";

import { useInbox } from "@/hooks/use-inbox";

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, get })` | The messages as `items`, who is `typing` and a `setTyping`       |
| `useInboxWidget({ open, send, load, get })`      | An in-app widget: `submit`, `reset` and the open conversation    |

`useInboxWidget` keeps the open conversation id under `storageKey` in the
client's storage port: `localStorage` on the web, and the store passed to
`nativePorts({ storage })` on React Native (see
[block controllers](/docs/frontend/block-controllers)). Without a storage
port it forgets the conversation on reload. Throttle `setTyping` yourself;
a ping counts for `typingMs` (5000 by default).

Each hook wraps a controller from `better-supabase/blocks/inbox`
(`createInboxController`, `createConversationController` and
`createInboxWidgetController`) that a plain TypeScript UI can use directly.
Vue, Svelte and Solid run them with `useBlockController` from
`better-supabase/vue`, `/svelte` and `/solid`; see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid).

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs `createInboxController`,
`createConversationController` or `createInboxWidgetController` with its
options; `auth` and `ports` come from
`app.use(betterSupabase(bsb))` unless you pass them. `state` is a shallow ref
of the snapshot and `actions` are the controller's actions. The work starts
on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { createInboxController } from "better-supabase/blocks/inbox";
import { useBlockController } from "better-supabase/vue";

const { organizationId } = defineProps<{ organizationId: string }>();
const { state } = useBlockController(createInboxController, {
  organizationId,
  load: () => loadConversations({ status: "open" }),
});
</script>

<template>
  <ul>
    <li v-for="row in state.items ?? []" :key="row.id">{{ row.subject }}</li>
  </ul>
</template>
```

`loadConversations` stands for your server call, as in the React example.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs any of the three
controllers with its options; `auth` and `ports` come from
`setBetterSupabase(bsb)` unless you pass them. It returns a readable
store of the snapshot, read with `$`, with the actions on `.actions`. The
work starts on mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { createInboxController } from "better-supabase/blocks/inbox";
  import { useBlockController } from "better-supabase/svelte";

  let { organizationId }: { organizationId: string } = $props();
  const inbox = useBlockController(createInboxController, {
    organizationId,
    load: () => loadConversations({ status: "open" }),
  });
</script>

<ul>
  {#each $inbox.items ?? [] as row (row.id)}
    <li>{row.subject}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs any of the three
controllers with its options; `auth` and `ports` come from
`<BetterSupabaseProvider client={bsb}>` unless you pass them. `state()` reads
the snapshot through `from`, and `actions` are the controller's actions. The
work starts on mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { createInboxController } from "better-supabase/blocks/inbox";
import { useBlockController } from "better-supabase/solid";

export function Component(props: { organizationId: string }) {
  const { state } = useBlockController(createInboxController, {
    organizationId: props.organizationId,
    load: () => loadConversations({ status: "open" }),
  });
  return (
    <ul>
      <For each={state().items ?? []}>{(row) => <li>{row.subject}</li>}</For>
    </ul>
  );
}
```

## Block events [#block-events]

With the outbox installed the module emits `inbox_message.received`,
`inbox_conversation.opened`, `inbox_conversation.assigned`,
`inbox_conversation.resolved`, `inbox_conversation.reopened` and
`inbox_conversation.unsnoozed`, each with
the conversation, organization, inbox, contact, assignee and status.

## Options [#options]

| Option                               | Default          | Meaning                                                 |
| ------------------------------------ | ---------------- | ------------------------------------------------------- |
| `blocks.inbox.options.topic`         | `inbox`          | The prefix of the organization topic                    |
| `blocks.inbox.options.bucket`        | `inbox-files`    | Where files from before 0.14 live, for imports          |
| `blocks.inbox.options.maxBodyLength` | `20000`          | The longest message body                                |
| `blocks.inbox.options.botQueue`      | `inbox_bot`      | The job queue for bot replies                           |
| `blocks.inbox.options.outboundQueue` | `inbox_outbound` | The job queue for channel deliveries                    |
| `blocks.inbox.options.scheduleQueue` | `inbox_schedule` | The job queue for snooze wake-ups and scheduled replies |
| `blocks.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).

## Hooks [#hooks]

`createInbox` takes `hooks`, keyed by method name, that run your code
before and after a call: refuse it with a `DbError`, rewrite its arguments
or observe the result (see [extending blocks](/docs/extending/blocks#hooks)). Methods in a group take their hooks under the group's name,
such as `hooks: { conversations: { resolve: { before } } }`.

## Without the client [#without-the-client]

`db.inbox` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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()),
});
```

# Messaging

> The conversations, members, messages, threads and read markers that channels, direct messages and record threads share.

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

The `messaging` block is the core that chat features sit on: conversations,
their members, messages with one level of threads, edits, pins, mentions,
scheduled messages and read markers. It has no UI of its own. The
[channels](/docs/blocks/channels) block builds Slack-style channels and direct
messages on it, and later phases move comments and the support inbox onto the
same tables (ADR 0062).

```bash
better-supabase add messaging   # adds tenant, access and subjects as well
```

The block's entities are `message` (the `messages` table) and `conversation`
(the `conversations` table). Both register as subjects, so reactions, share
links and search accept them. Rename them with `blocks.messaging.names`;
see [Typed blocks](/docs/blocks/typed-blocks). `messageInputSchema` and
`messageChangeSchema` validate what `send` and `edit` take.

| Table                  | Holds                                                                                                     |
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
| `conversations`        | `kind`, `access`, `title`, the linked record (`subject_type`, `subject_id`), `last_message_at`, archiving |
| `conversation_members` | `user_id`, `role`, `notify` (`all`, `mentions` or `none`) and the member's `last_read_at`                 |
| `messages`             | `body`, `document`, `mentions`, `parent_id`, `thread_id`, reply counts, pins, resolution and scheduling   |
| `message_edits`        | The earlier versions of an edited message                                                                 |
| `thread_reads`         | Read markers per thread root, and per conversation for readers who aren't members                         |

| Permission          | Lets a member                                     | Default roles          |
| ------------------- | ------------------------------------------------- | ---------------------- |
| `messages.read`     | read the conversations they can see in a tenant   | `member`, `admin`      |
| `messages.send`     | open conversations, send, edit their own messages | `member`, `admin`      |
| `messages.moderate` | delete, pin and archive in any conversation       | `admin` (`messages.*`) |

## Conversations [#conversations]

A conversation's `kind` says what it is for, and its `access` says who reads
it:

| Kind       | Access              | Who reads it                                                         |
| ---------- | ------------------- | -------------------------------------------------------------------- |
| `channel`  | `members`, `tenant` | its members, or everyone with `messages.read` in the tenant          |
| `dm`       | `members`           | the two people in it; opening it again returns the same conversation |
| `group_dm` | `members`           | three to nine people, also opened once per set of people             |
| `subject`  | `subject`           | whoever can read the linked record, one conversation per record      |

Members must hold `messages.read` in the tenant. Sending to a `tenant`
channel joins the sender to it. A `subject` conversation opens only in the
record's own tenant: when the record's type has a tenant column in
`config.records` and the row belongs to another tenant, `open_conversation`
fails with `MESSAGING_NOT_FOUND`, for the service role as well. A type with
`tenant: false` has no tenant to compare.

Server code calls the `messaging` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useMessaging } = defineDataHooks(
  "messaging",
  (db: AppDb) => db.messaging,
);
```

```ts
import {
  createMessaging,
  sqlTransport,
} from "better-supabase/blocks/messaging";

const launch = await db.messaging
  .open({ kind: "channel", title: "launch", memberIds: [alice, bob] })
  .orThrow();
const direct = await db.messaging.openDirect([alice]).orThrow();
const thread = await db.messaging
  .openSubject({ type: "deal", id: dealId })
  .orThrow();
```

```tsx title="src/features/messaging/list.tsx"
"use client";

import { useMessaging } from "@/lib/supabase/hooks";

const rows = useMessaging({ limit: 50 });
```

`conversations()` lists the caller's conversations, newest message first, with
their unread and mention counts. `unreadCounts()` returns the counts alone for
a sidebar badge, and `markRead` moves the caller's read marker.

## Messages and threads [#messages-and-threads]

```ts
const message = await messaging
  .send(launch.id, { body: `Ready @[Alice](${alice})` })
  .orThrow();

await messaging.send(launch.id, { body: "On it", parentId: message.id });
const replies = await messaging.replies(message.id).orThrow();
```

`@[Name](<user id>)` in the body mentions a user, and `@channel` or `@here`
mentions every member. Threads are one level deep by default: a reply to a
reply joins the root's thread. Set `threadDepth: "unlimited"` to nest them.
`list` returns the top-level messages (and replies sent with
`broadcast: true`), oldest first, in pages; `replies` returns a thread.

Only the author edits a message, within `editWindow` seconds when it is set,
and every edit keeps the earlier version (`edits`). Deleting is a soft delete
that clears the body and removes the message's files. `pin` and `resolve`
mark a message for the conversation.

With the [attachments](/docs/blocks/attachments) block, upload files to the
conversation (subject `conversation`) and pass their ids as `attachmentIds`;
`send` moves them onto the message. With the
[reactions](/docs/blocks/reactions) block, every message carries its reaction
summary.

### File anchors [#file-anchors]

`anchor` on a top-level message says where it points. A file anchor points at
an attachment on the conversation's record or on one of its messages: a page,
a region of the page or video frame (fractions from 0 to 1), or a moment or
span in seconds. `fileAnchor` builds one and throws a `TypeError` when a value
is out of range, and `send` fails with `MESSAGING_ANCHOR_INVALID` when the
shape is wrong or the file is on another record. `anchoredTo` lists the
messages anchored to a file, oldest first:

```ts
import { fileAnchor } from "better-supabase/blocks/messaging";

await messaging.send(review.id, {
  body: "The intro runs long",
  anchor: fileAnchor(videoId, { start: 12.5, end: 20 }),
});

const notes = await messaging.anchoredTo(videoId).orThrow();
```

Any other JSON anchor, such as a text range, is stored as it is.
`isFileAnchor` tells a file anchor from the rest when you render a message.

## Record mentions [#record-mentions]

`@[Name](subject:<type>/<id>)` mentions a record registered in
[`config.records`](/docs/blocks/sql#subject-registry), such as a deal or a
customer, and renders as a chip. `recordMention(name, subject)` writes one,
`mentionSegments(body)` splits a body into text, user, record and scope
segments for rendering, and `parseMentions(body)` returns the mentioned
users, records and scope.

```ts
import { recordMention } from "better-supabase/blocks/messaging";

await messaging.send(launch.id, {
  body: `Renewal is close on ${recordMention("Acme", { type: "deal", id: dealId })}`,
});

const backlinks = await messaging
  .recordMentions({ type: "deal", id: dealId })
  .orThrow();
```

`send` and `edit` index the record mentions of a message; an edit that drops
one drops its backlink. `recordMentions(subject, { before, limit })` lists the
messages that mention a record, newest first (default 50, at most 200), each
with its conversation's `id`, `kind`, `title` and `subject`. It returns
nothing to a caller who can't read the record, and only the messages in
conversations the caller reads. A mention of an unregistered type stays plain
text. `message.sent` carries the mentioned records as `recordMentions`.

## Thread summaries [#thread-summaries]

`summarize(target, summarizer)` writes a short summary of a conversation or
a thread for someone who missed it, and `summary(target)` reads the saved
one. The block never calls a model: the summarizer is a versioned
`ThreadSummarizer` (`apiVersion: 1`) that receives the messages and returns
the text, so any SDK can provide it. With the AI SDK, use
[`summarizeWith`](/docs/ai-sdk#thread-summaries):

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

const summarizer = summarizeWith("anthropic/claude-sonnet-4.5");

const summary = await messaging
  .summarize({ threadId: rootId }, summarizer)
  .orThrow();
// { summary, model, messageCount, throughAt, stale, ... }
```

A target is `{ conversationId }` for the conversation's top-level messages
or `{ threadId }` for a root message and its replies. `summarize` reads the
messages as the caller, so a summary only covers what they can read, and
leaves out internal notes, deleted messages and unsent scheduled ones. It
sends at most `maxMessages` (default and maximum 200, the newest ones) and
the previous summary, so the model updates it instead of starting over.

A saved summary is `stale` once a message in its scope is sent after its
`throughAt`, edited or deleted. `summarize` returns a current summary without
calling the model again; `{ force: true }` writes a new one. Only the service
role saves summaries, so pass the block the `service` transport
(`createBlocks` does this): a member can't store a summary that misstates the
thread. Every reader of the conversation reads it.

## Notifications [#notifications]

With the [notifications](/docs/blocks/notifications) block, sending a message
notifies:

| Type                | Who                                                                                |
| ------------------- | ---------------------------------------------------------------------------------- |
| `message.mentioned` | mentioned users who can read the conversation, unless their `notify` is `none`     |
| `message.direct`    | the other people in a direct or group message whose `notify` is `all`              |
| `message.replied`   | the thread's followers: its author and everyone who replied, besides the mentioned |

Each one carries a `subject_label`: the record's `subject_title` for a
record's conversation, else the conversation's title. `message.replied` is
sent only when the thread has a follower besides the replier and the
mentioned members. `notify: false` sends none of the three, and following
threads still works.

`follow` starts or stops following a conversation or a thread, and
`setNotify` sets a member's level for a conversation.

## Scheduled messages [#scheduled-messages]

`send` with `scheduledFor` stores the message unsent: only its author sees it
until then. With the [jobs](/docs/blocks/queue) block the module enqueues a
delayed `message.publish` job on `scheduleQueue`; without it, call
`publishDue()` from a cron with the service role.

## Realtime [#realtime]

Each conversation has a private Realtime topic, `<topic>:<conversation id>`
(`messages` by default). A trigger broadcasts `message`, `message_updated` and
`message_deleted` with the ids, and `typing(conversationId)` broadcasts
`typing`. Only callers who read the conversation can join its topic.
`conversationTopic(id)` builds the topic name.

The React hooks `useMessages` and `useConversations` follow these topics for
a chat screen; see [Channels](/docs/blocks/channels#a-chat-screen). They are a
registry item: install them with `npx shadcn add @better-supabase/use-messaging`,
which writes `hooks/use-messaging.ts` into the app.

## Options [#options]

| Option          | Default       | What it does                                                |
| --------------- | ------------- | ----------------------------------------------------------- |
| `topic`         | `"messages"`  | The Realtime topic prefix                                   |
| `threadDepth`   | `1`           | `1` keeps threads one level deep; `"unlimited"` nests them  |
| `maxBodyLength` | `40000`       | The longest body, in characters                             |
| `editWindow`    | `0`           | Seconds an author may edit a message; `0` for always        |
| `scheduleQueue` | `"messaging"` | The jobs queue for scheduled messages                       |
| `extraColumns`  | none          | Extra columns on `messages`, set through `fields` in `send` |
| `notify`        | `true`        | `false` sends no message notifications                      |

## Events [#events]

The block writes `conversation.created`, `conversation.archived`,
`conversation.member_added`, `conversation.member_removed`, `message.sent`,
`message.edited`, `message.deleted`, `message.pinned` and `message.resolved`
to the [outbox](/docs/blocks/outbox). Their payloads name the subject with
`subjectType` and `subjectId`; see [Events](/docs/extending/events).

The hooks `before_message_send` and `after_message_send` run inside `send`,
and `before_message_edit` and `after_message_edit` inside `edit`; see [Extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.messaging` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createMessaging,
  sqlTransport,
} from "better-supabase/blocks/messaging";

const messaging = createMessaging({
  transport: sqlTransport(sql),
  tenant: () => organizationId,
});

const launch = await messaging
  .open({ kind: "channel", title: "launch", memberIds: [alice, bob] })
  .orThrow();
const direct = await messaging.openDirect([alice]).orThrow();
const thread = await messaging
  .openSubject({ type: "deal", id: dealId })
  .orThrow();
```

# Moderation

> Reports on any record, a review queue, decisions (approve, hide, remove, restore, ban) with a log, user bans and an automatic hide threshold. Reviews and messages, comments included, moderate through it.

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

`moderation` keeps one moderation item per moderated subject in an
organization: its status, its open reports, its last decision and a log of
every decision. Members report content, moderators work through a queue, and
a ban keeps a user from writing in the organization until it ends.

A subject is `pending` (waiting for a first decision), `visible`, `hidden` or
`removed`. Any subject type can be reported, so the same queue holds comments,
reviews and your own records.

Reporting needs `moderation.report`, reading the queue needs
`moderation.read`, deciding needs `moderation.manage`, and banning needs
`moderation.ban`. Admins get all four and members get `moderation.report`, so
anonymous visitors can't report.

```bash
better-supabase add moderation
```

The module needs `memberships` and `access`. It moderates messages when
[messaging](/docs/blocks/messaging) is installed (so
[comments](/docs/blocks/comments) too, which are messages), and
[reviews](/docs/blocks/reviews) requires it. Decisions and bans go to the
outbox and the audit log.

Server code calls the `moderation` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useModeration } = defineDataHooks(
  "moderation",
  (db: AppDb) => db.moderation,
);
```

```ts
import { createModeration } from "better-supabase/blocks/moderation";

await db.moderation
  .report({ type: "message", id: comment.id }, { reason: "spam" })
  .orThrow();

const queue = await db.moderation.queue().orThrow();

await db.moderation.decide(queue[0].id, "hide", { reason: "Spam" }).orThrow();
```

```tsx title="src/features/moderation/list.tsx"
"use client";

import { useModeration } from "@/lib/supabase/hooks";

const rows = useModeration({ limit: 50 });
```

| Option              | Default                                                        | What it does                                                                       |
| ------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `autoHideThreshold` | `3`                                                            | Open reports that hide visible content until a moderator decides; `0` turns it off |
| `reasons`           | `spam`, `abuse`, `harassment`, `off_topic`, `illegal`, `other` | The reasons a report may give, as lower-case keys                                  |

## Reporting [#reporting]

`report(subject, { reason, note })` reports a subject for one of the
configured reasons. A member has one open report per subject: reporting again
returns that report. Nobody can report their own content, and a banned member
can't report. The report that reaches `autoHideThreshold` hides visible
content and returns `autoHidden: true`; the content stays hidden until a
moderator decides, and its reports stay open.

## The queue [#the-queue]

`queue()` lists what needs a moderator: pending content and content with open
reports, most recently changed first. Pass `status` for one status,
`subjectType` for one kind of content, and `before` with the last item's
`updatedAt` for the next page. `item(subject)` returns one subject's item with
its latest reports and log entries, or `null` when nothing moderated it.

`statuses(subjectType, ids)` returns the statuses of the subjects that aren't
visible, keyed by id, so a list can grey out or drop hidden rows in one call.

## Decisions [#decisions]

| Decision  | Status    | Also                                        |
| --------- | --------- | ------------------------------------------- |
| `approve` | `visible` |                                             |
| `hide`    | `hidden`  |                                             |
| `remove`  | `removed` |                                             |
| `restore` | `visible` | only from `hidden` or `removed`             |
| `ban`     | `removed` | bans the author; needs `moderation.ban` too |

`decide(itemId, decision, { reason })` records the decision with who made it.
A decision that hides or removes content resolves the open reports; one that
shows it dismisses them. The reason is kept on the item while the content is
hidden or removed.

## Bans [#bans]

`ban(userId, { reason, expiresAt })` bans a member until `expiresAt`, or for
good without it. `unban(banId)` lifts a ban and returns `false` when it was
already lifted, and `bans()` lists the active bans (`active: false` lists
every ban). Banning a user again replaces their active ban.

## Messages and comments [#messages-and-comments]

With the messaging block, report a message or a comment under the subject type
`message` (the message entity's name). The item names the message's author.
Hidden, removed and pending messages stay readable only to their author and to
moderators with `moderation.read`. Removing a message empties it the way
`delete_message` does, so a removed message can't be restored, and a banned
member can't send new messages or comments.

## Your own content [#your-own-content]

Report any subject type: an item for a record you own has no author until you
set one, so `ban` needs content whose author is known. To hide your own rows,
read `statuses` for the ids you list, or filter with a policy on the
`moderation_items` table the same way the messages integration does.

`moderationBinding` loads `item` for a record page and reloads on
`moderation_report.created` and `moderation_item.decided`. See [Record bindings](/docs/repository/record-bindings).

## Errors [#errors]

| Hint                           | Cause                                                             |
| ------------------------------ | ----------------------------------------------------------------- |
| `MODERATION_FORBIDDEN`         | the caller lacks the permission, or isn't signed in               |
| `MODERATION_INVALID`           | an unknown reason, a self-report, or a restore of visible content |
| `MODERATION_BANNED`            | the caller is banned in the organization                          |
| `MODERATION_ITEM_NOT_FOUND`    | no item the caller can see has this id                            |
| `MODERATION_SUBJECT_NOT_FOUND` | the reported message doesn't exist                                |
| `MODERATION_BAN_NOT_FOUND`     | no ban has this id                                                |

## Events [#events]

| Event                                             | Data                                                                                                              |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `moderation_report.created`                       | `organizationId`, `id`, `itemId`, `subjectType`, `subjectId`, `reason`                                            |
| `moderation_item.decided`                         | `organizationId`, `id`, `subjectType`, `subjectId`, `authorId`, `action`, `status`, `previousStatus`, `automatic` |
| `moderation_ban.created`, `moderation_ban.lifted` | `organizationId`, `id`, `userId`, `expiresAt`                                                                     |

`automatic` is true for the hide at the report threshold. A rule on
`moderation_report.created` can notify moderators, and one on
`moderation_item.decided` can tell an author why their post was removed.

## With ReUI [#with-reui]

There is no prebuilt moderation UI. List the queue in a `data-grid` with the
subject, `openReports` and `status` as a `Badge` (`warning-light` for pending,
`destructive-light` for hidden or removed). Open an item in a `Sheet` with its
reports and log, and put the decisions in a `DropdownMenu`; confirm `remove`
and `ban` with an `AlertDialog`. A report form is a `Dialog` with a
`RadioGroup` of reasons and a `Textarea` for the note.

## Why it works this way [#why-it-works-this-way]

One item per subject, whatever the content, gives one queue, one log and one
set of permissions for messages, reviews and your own records. Content
modules keep their own tables and read the item's status, so they hide what
moderators hide without copying the decision.

## Without the client [#without-the-client]

`db.moderation` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createModeration } from "better-supabase/blocks/moderation";

const moderation = createModeration({ transport, tenant: organizationId });

await moderation
  .report({ type: "message", id: comment.id }, { reason: "spam" })
  .orThrow();

const queue = await moderation.queue().orThrow();

await moderation.decide(queue[0].id, "hide", { reason: "Spam" }).orThrow();
```

# 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 the `useNotifications` hook keeps a
list current over Supabase Realtime. The hook is a registry item: install it
with `npx shadcn add @better-supabase/use-notifications`, which writes
`hooks/use-notifications.ts` into the app.

```bash
pnpm better-supabase 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/repository/permissions) 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:

Server code calls the `notifications` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useNotifications } = defineDataHooks(
  "notifications",
  (db: AppDb) => db.notifications,
);
```

```ts
const rows = await db.notifications.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/notifications/list.tsx"
"use client";

import { useNotifications } from "@/lib/supabase/hooks";

const rows = useNotifications({ limit: 50 });
```

```ts
const ctx = await bsb.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                                                                                     |
| `permission`   | A permission key. The holders in `tenant` are added. It needs `tenant`. A provider uses `authorization.functions.holdersOf` and refuses the send when that is unset, rather than notifying every member |
| `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 "@/hooks/use-notifications";

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 ?? []} />;
}
```

The same hook runs in React Native: the Expo example
(`apps/examples/expo-powersync/src/lib/notifications.ts`) shows an unread
count with it. Outside React, `createNotificationsController` from
`better-supabase/blocks/notifications` holds the same state (see
[block controllers](/docs/frontend/block-controllers)).

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

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createNotificationsController` with its options, as
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid) explains;
`auth` and `ports` come from `app.use(betterSupabase(bsb))` unless you pass
them. `state` is a shallow ref of the snapshot and `actions` are the
controller's actions. The work starts on mount, and the controller is disposed
with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { createNotificationsController } from "better-supabase/blocks/notifications";
import { useBlockController } from "better-supabase/vue";

const { userId } = defineProps<{ userId: string }>();
const { state, actions } = useBlockController(createNotificationsController, {
  topic: `notifications:${userId}`,
  load: () => listNotifications(),
});
</script>

<template>
  <p>{{ state.count }}</p>
  <button @click="actions.refresh()">Refresh</button>
</template>
```

`listNotifications` stands for your server call that runs
`notifications.list()`.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createNotificationsController` with its options; `auth` and `ports` come from
`setBetterSupabase(bsb)` unless you pass them. It returns a
readable store of the snapshot, read with `$`, with the actions on
`.actions`. The work starts on mount, and the controller is disposed with
the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { createNotificationsController } from "better-supabase/blocks/notifications";
  import { useBlockController } from "better-supabase/svelte";

  let { userId }: { userId: string } = $props();
  const notifications = useBlockController(createNotificationsController, {
    topic: `notifications:${userId}`,
    load: () => listNotifications(),
  });
</script>

<p>{$notifications.count}</p>
<button onclick={() => notifications.actions.refresh()}>Refresh</button>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createNotificationsController` with its options; `auth` and `ports` come from
`<BetterSupabaseProvider client={bsb}>` unless you pass them. `state()` reads
the snapshot through `from`, and `actions` are the controller's actions. The
work starts on mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { createNotificationsController } from "better-supabase/blocks/notifications";
import { useBlockController } from "better-supabase/solid";

export function Component(props: { userId: string }) {
  const { state, actions } = useBlockController(createNotificationsController, {
    topic: `notifications:${props.userId}`,
    load: () => listNotifications(),
  });
  return (
    <>
      <p>{state().count}</p>
      <button onClick={() => actions.refresh()}>Refresh</button>
    </>
  );
}
```

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

## Deleted subjects [#deleted-subjects]

`subject_type` and `subject_id` are text with no foreign key, so deleting a
subject row leaves its notifications in every recipient's inbox. List the
subject types in `blocks.notifications.on` with
`cascade: true`, and the module writes an `after delete` trigger on each
table that deletes the subject's notifications (their recipients and
deliveries go with them) and the subscriptions to it:

```ts title="better-supabase.config.ts"
blocks: {
  notifications: {
    on: {
      expense: { table: "expenses", cascade: true },
      "support-ticket": { table: "support_tickets", cascade: true },
    },
  },
},
```

The key is the `subject_type` your notifications store, and it may contain
hyphens. `id` names the subject's id column when it isn't `id`. Without
`on` the module reads `config.records`, so a registered type
with `cascade: true` cleans up its notifications too. A list of registered
type names (`on: ["expense"]`) limits it to those. The subject tables
must exist before the module file, and the events table needs its
`subjectType` and `subjectId` columns.

Time off doesn't need a table of your own: the [leave](/docs/blocks/leave)
block keeps requests, their approvals and each member's balance.

## Email, push and other channels [#email-push-and-other-channels]

Every channel except `in_app` gets a `pending` delivery row per recipient.
Pass `email` for mail and a `NotificationChannel` for any other channel,
then 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,
  resendTransport,
  sqlTransport,
} from "better-supabase/blocks/notifications";

import { env } from "@/env";
import { types } from "@/lib/notifications";
import { unsubscribeUrl } from "@/lib/unsubscribe";

const notifications = createNotifications({
  transport: sqlTransport(postgres.admin),
  types,
  email: {
    transport: resendTransport({ apiKey: env.RESEND_API_KEY }),
    from: "Acme <notifications@acme.com>",
    unsubscribe: (message) => ({
      url: unsubscribeUrl(message.userId, message.notification.type),
    }),
  },
  channels: [
    {
      apiVersion: 1,
      name: "push",
      send: async ({ userId, text }) => {
        const id = await push.send(userId, text?.title ?? "");
        return { provider: "web-push", 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.

### Email [#email]

`email` adds a channel named `email` that sends through a
`DeliveryTransport`: `{ apiVersion: 1, channel: "email", provider, send(message, { idempotencyKey }) }`.
The idempotency key is the delivery id, so a retried delivery reaches the
provider under the same key and sends one email. Passing `email` next to a
channel named `email` throws.

| Option        | Use                                                                                                                    |
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `transport`   | The `DeliveryTransport` that sends the mail                                                                            |
| `from`        | The sender, e.g. `Acme <notifications@acme.com>`                                                                       |
| `replyTo`     | An optional reply address                                                                                              |
| `compose`     | `(message) => ({ subject, text?, html?, replyTo?, category?, templateKey? })`; defaults to the rendered title and body |
| `unsubscribe` | `(message) => ({ url?, mailto? })`: your signed unsubscribe link for notification mail                                 |

A recipient without an email address is skipped. Without `compose`, the
block needs `render`, and a notification it can't render fails its delivery.
A `templateKey` from `compose` (for example `assigned-v2`) is stored on the
delivery's `template_key` column, so you can tell which template a sent
email used.

`resendTransport({ apiKey })` posts to `https://api.resend.com/emails` with
`fetch` and the delivery id in the `Idempotency-Key` header. Read the key
from your own environment; the block never reads one. A failed call throws a
`ResendError` with Resend's HTTP status and error name, and the delivery
retries. `baseUrl` and `fetch` replace the endpoint and the client, e.g. in
tests.

#### Headers per category [#headers-per-category]

`compose` returns a `category`, `notification` by default. The channel sets
the headers from it with `mailHeaders(category, unsubscribe)`:

| Header                                              | `notification` | `security` | `approval` |
| --------------------------------------------------- | -------------- | ---------- | ---------- |
| `Auto-Submitted: auto-generated` (RFC 3834)         | Yes            | Yes        | Yes        |
| `List-Unsubscribe` (RFC 2369)                       | Yes            | No         | No         |
| `List-Unsubscribe-Post: List-Unsubscribe=One-Click` | With a URL     | No         | No         |

`List-Unsubscribe` lists the `url` and the `mailto` target that
`unsubscribe` returns. RFC 8058 one-click needs an `https` URL, so the
`List-Unsubscribe-Post` header appears only with one. A URL that isn't
`https`, or an address that doesn't start with `mailto:`, throws. Security
and approval mail never carries an unsubscribe header, and the channel
doesn't call `unsubscribe` for it. The unsubscribe endpoint is yours: it
receives a `POST` with `List-Unsubscribe=One-Click` in the body and turns the
type off through `setPreference`.

#### Audience [#audience]

`audience` is a `SubscriberPolicy` that every recipient passes before
`deliver` sends to them. Without it, `deliver` uses the server's
`authorization.delivery.audience` ([Runtime hooks](/docs/extending/authorization-providers#runtime-hooks)).
When the server has hooks but no `delivery.audience`, `deliver` uses
`subjectReaders(transport)`: a notification about a
[registered subject](/docs/blocks/sql#subject-registry) only goes to
recipients who hold its `read` key.

```ts
const notifications = createNotifications({
  transport: sqlTransport(postgres.admin),
  types,
  email,
  server: { authorization },
});
```

The policy runs once per notification for the claimed recipients, after
suppressed addresses are left out and before any send. It receives
`{ type, tenant, actorId, payload, subject }` and the recipients as
`{ principal: userId, kind: "user" }`. A recipient it leaves out is
`skipped` with the error `audience`. It can narrow the list but never widen
it: a principal that wasn't passed in is ignored. A policy that throws, or
returns something other than a list, sends to no one; the deliveries stay
`pending` with `audience check failed: …` and retry.

#### Suppressions [#suppressions]

`notification_suppressions` holds the addresses a channel must not send to:
one row per channel and lowercase address, with a `reason` (`bounce`,
`complaint` or `manual`), the user it belongs to, the provider and its event
id. `deliver` skips a suppressed address before the audience check and any
send, and records the delivery as `skipped` with the error
`suppressed: <reason>`.

RLS lets users read their own rows; only the service role writes them.

| Method                          | SQL                                         | Who           |
| ------------------------------- | ------------------------------------------- | ------------- |
| `suppress({ address, reason })` | `suppress_notification_address`             | service role  |
| `unsuppress(address, channel?)` | `unsuppress_notification_address`           | service role  |
| `suppressions({ limit? })`      | `list_notification_suppressions(max_items)` | the user, RLS |

`suppress` returns `true` for a new row and `false` when the address was
already suppressed; the first reason stays. `unsuppress` returns whether a
row was removed.

`resendWebhook` records every delivery status Resend reports (see
[Delivery tracking](#delivery-tracking)) and writes the suppressions from
its bounce and complaint events. It verifies the Svix signature (`svix-id`, `svix-timestamp`,
`svix-signature`, or the `webhook-` names) with the endpoint's signing
secret, and answers 204:

```ts title="app/api/webhooks/resend/route.ts"
import "server-only";

import {
  createNotifications,
  resendWebhook,
  sqlTransport,
} from "better-supabase/blocks/notifications";

import { env } from "@/env";
import { types } from "@/lib/notifications";

export const POST = resendWebhook({
  secret: env.RESEND_WEBHOOK_SECRET,
  notifications: createNotifications({
    transport: sqlTransport(postgres.admin),
    types,
  }),
});
```

| Resend event                                 | Delivery event | Suppression                |
| -------------------------------------------- | -------------- | -------------------------- |
| `email.delivered`                            | `delivered`    | None                       |
| `email.delivery_delayed`                     | `delayed`      | None                       |
| `email.opened`                               | `opened`       | None                       |
| `email.clicked`                              | `clicked`      | None                       |
| `email.bounced`, bounce type not `Transient` | `bounced`      | `bounce`, per recipient    |
| `email.bounced`, bounce type `Transient`     | `bounced`      | None                       |
| `email.complained`                           | `complained`   | `complaint`, per recipient |
| Anything else                                | None           | None                       |

A bad signature answers 401, a body over `maxBodyBytes` (256 KiB) 413, and a
method other than `POST` 405. A failed write answers with its problem, so
Resend retries the event. Pass `seen` to refuse a replayed message id, and
an array of secrets while rotating. `resendDeliveryEvent(event, id)` and
`resendSuppressions(event, id)` map an event without the handler, e.g. from
a queue.

#### Delivery tracking [#delivery-tracking]

`notification_delivery_events` keeps what the provider reports after a send:
one row per event, with the delivery, a `kind` (`delivered`, `opened`,
`clicked`, `bounced`, `complained` or `delayed`), the provider's event id
(unique, so a retried webhook writes one row), `occurred_at` and a `detail`
object. The delivery itself gets `provider_status` and `provider_status_at`,
which only move forward: an `opened` that arrives after a `clicked` is stored
as an event but leaves the status at `clicked`.

`recordDeliveryEvent` finds the delivery by `provider` and
`providerMessageId` and calls `record_delivery_event`, which only the
service role can run. A hard bounce or a complaint also suppresses the
address through `suppress_notification_address`, unless you pass
`suppress: false`. A new event emits `notification.<kind>` with the
notification's subject (`subjectType`, `subjectId`), through the outbox in
SQL and on `betterSupabase.events` in TypeScript. It returns `null` when no
delivery has that message id, and `recorded: false` for an event id it has
already stored.

```ts
await notifications
  .recordDeliveryEvent({
    provider: "postmark",
    providerMessageId: event.MessageID,
    kind: "bounced",
    providerEventId: String(event.ID),
    occurredAt: Temporal.Instant.from(event.BouncedAt),
    detail: { type: event.Type },
  })
  .orThrow();
```

`subjectDeliveries({ subject, tenant?, limit? })` calls
`list_subject_deliveries` and lists the deliveries of every notification
about one record, newest first, each with its status, provider status,
template key and events. The service role can always read it; with the
access module installed, a member with `notifications.send` in the
organization can too. Use it to show "sent, opened, bounced" next to a deal
or an invoice.

| Method                                          | SQL                       | Who                      |
| ----------------------------------------------- | ------------------------- | ------------------------ |
| `recordDeliveryEvent(input)`                    | `record_delivery_event`   | service role             |
| `subjectDeliveries({ subject, tenant, limit })` | `list_subject_deliveries` | service role, or senders |

#### Other providers [#other-providers]

A transport for another provider is an object with `send`. Postmark, Amazon
SES and SMTP have no built-in transport; these sketches put the same pieces
on each provider's API. Each one passes `testDeliveryTransport` from
`better-supabase/testing`.

```ts title="lib/postmark-transport.ts"
import type { DeliveryTransport } from "better-supabase/blocks/notifications";

export const postmark = (token: string): DeliveryTransport => ({
  apiVersion: 1,
  channel: "email",
  provider: "postmark",
  async send(message, { idempotencyKey }) {
    const response = await fetch("https://api.postmarkapp.com/email", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-postmark-server-token": token,
      },
      body: JSON.stringify({
        From: message.from,
        To: message.to,
        Subject: message.subject,
        TextBody: message.text,
        HtmlBody: message.html,
        MessageStream:
          message.category === "notification" ? "broadcast" : "outbound",
        Headers: [
          ...Object.entries(message.headers),
          ["X-Delivery-Id", idempotencyKey],
        ].map(([Name, Value]) => ({ Name, Value })),
      }),
    });
    if (!response.ok) throw new Error(`Postmark answered ${response.status}`);
    const { MessageID }: { MessageID: string } = await response.json();
    return { providerMessageId: MessageID };
  },
});
```

Postmark has no idempotency key, so the sketch sends the delivery id as a
header to deduplicate on. For Amazon SES, call `SendEmail` of the SES v2 API
with `@aws-sdk/client-sesv2` and pass the headers in
`Content.Simple.Headers`; SES has no idempotency key either. For SMTP, send
with `nodemailer` from a Node.js runtime, with the headers in `headers` and
the delivery id as the `Message-ID` local part, so a retry repeats the same
id. Point the provider's delivery webhooks at a route that calls
`notifications.recordDeliveryEvent` with the service connection; it
suppresses hard bounces and complaints for you.

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

The sink sends up to 1,000 notifications per call through
`better_supabase.send_notifications(events jsonb)`, which runs
`send_notification` for each element. `notifications.sendMany(items)` calls it
directly, each item a `send` input with its `type`, and returns one `Result`
per item. `send` hooks don't run for `sendMany`; hook `sendMany` instead. With
a `send` hook, the sink sends one notification at a time so the hook sees
each of them.

### Partitioned delivery events [#partitioned-delivery-events]

`blocks.notifications.options.partitioned` creates a new
`notification_delivery_events` table partitioned by month on `created_at`,
with the primary key `(id, created_at)`. `purge_notifications` drops every
month past the notifications retention cutoff. The other notification tables
stay unpartitioned: they're referenced and leased by `id`. An existing table
stays as it is, and `better-supabase doctor` reports it as BS335.

SQL modules notify through the notifications module in the same
transaction, not through this sink. The messaging module already sends a
notification for every mention (`message.mentioned`), and the comments
module sends `comment.posted` to a record's followers, so don't map
`message.sent` again.

### Watchers [#watchers]

`watcherHandler(notifications, { types })` from
`better-supabase/blocks/notifications` maps subject events to notifications
for the subject's watchers. An event qualifies when its data carries
`organizationId`, `subjectType` and `subjectId`, which every subject event
does (`SubjectEventData` in `better-supabase/events`). `types` maps an event
type to a notification type, or to a rule with `activity`, `recipients`,
`data` and `summary`:

```ts title="app/api/cron/watchers/route.ts"
import { watcherHandler } from "better-supabase/blocks/notifications";

await outbox.consume(
  "watchers",
  watcherHandler(notifications, {
    types: {
      "status.changed": "deal.moved",
      "subject.tagged": { type: "deal.tagged", activity: "tags" },
      "reminder.sent": {
        type: "reminder.due",
        recipients: (data) => [String(data["userId"])],
      },
    },
  }),
);
```

Each notification is keyed by `watch:<event id>`, so a replayed batch sends
nothing twice. Events without a subject or a tenant, and types not in
`types`, are skipped.

`watcherHandler` takes outbox rows, through `outbox.consume` or a
`relayRoute` consumer, and leaves out the actor each row records, so whoever
tags, moves or shares a subject is not notified of it. `watcherSink` takes
CloudEvents, which carry no actor, so it can only leave out an actor the
event data names (`actorId`, or a message's `authorId`). Use it when the
events come from a relay or a broker rather than from the outbox itself.

## 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) |
| `extraColumns` and `fields`     | Columns on `notification_events` from `options.extraColumns`: `send` writes and validates them, `get`, `list` and `page` parse them      |
| `audience` option               | Narrows who `deliver` sends to; see [Audience](#audience)                                                                                |
| `onSent` option                 | Runs after `send` stores a notification, e.g. to call Next's `updateTag`                                                                 |
| `notification.*` block events   | `created`, `delivered`, `failed`, and the provider's `opened`, `clicked`, `bounced`, `complained` and `delayed`, with the subject        |
| `notifications.send` permission | Rename it with `blocks.notifications.permissions.send`                                                                                   |

Columns the app adds with `options.extraColumns` belong to the event, so
every recipient sees the same values:

```ts
const notifications = createNotifications({
  transport,
  types,
  fields: v.object({ campaign: v.optional(v.string()) }),
});
await notifications.send("digest.weekly", {
  recipients,
  data: { count: 3 },
  fields: { campaign: "weekly" },
});
```

## 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`,
`notification_preferences`, keyed on `organization_id`,
`notification_suppressions` and `notification_delivery_events`. When your
app has its own, the module runs over them: rename what differs and map the
optional columns and tables you don't want to `null`. Over your tables the
module honours suppressions only when `tables.suppressions` names your table,
and tracks delivery events only when `tables.deliveryEvents` does (its
`provider_event_id` must be unique):

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    notifications: {
      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`                        |
| `NOTIFICATION_SUPPRESSION_ADDRESS` | `suppress` without an address                                       |
| `NOTIFICATION_SUPPRESSION_REASON`  | A suppression reason other than `bounce`, `complaint` or `manual`   |
| `NOTIFICATION_DELIVERY_EVENT_KIND` | `recordDeliveryEvent` with a kind the block doesn't know            |
| `NOTIFICATION_DELIVERY_MESSAGE_ID` | `recordDeliveryEvent` without a provider message id                 |

## Without the client [#without-the-client]

`db.notifications` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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 ?? ""}` }),
  });
```

# Posts

> Forum and feedback boards with voted posts, roadmap statuses, merged duplicates, replies and moderation, on the messaging core.

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

The `posts` block keeps boards of posts per organization: a `forum` board
for questions and discussion, or a `feedback` board where members post ideas
and vote on them. Posts take up and down votes through
[reactions](/docs/blocks/reactions), replies through
[comments](/docs/blocks/comments) on the [messaging](/docs/blocks/messaging)
core, and reports, hiding and bans through
[moderation](/docs/blocks/moderation). Moderators set a roadmap status, pin,
lock and merge duplicates.

```bash
better-supabase add posts   # adds tenant, access, subjects, messaging, comments, reactions and moderation
```

| Permission       | Lets a member                                    | Default roles       |
| ---------------- | ------------------------------------------------ | ------------------- |
| `posts.read`     | read boards and posts, and vote                  | `member`, `admin`   |
| `posts.write`    | write posts and edit or delete their own         | `member`, `admin`   |
| `posts.moderate` | set statuses, pin, lock, merge and edit any post | `admin` (`posts.*`) |
| `posts.manage`   | create, rename and archive boards                | `admin` (`posts.*`) |

The example app's [post board](/docs/registry/ui-recipes/post-board) recipe
shows a feedback board's posts by votes, with an upvote toggle and a New
post dialog.

## Boards and posts [#boards-and-posts]

Server code calls the `posts` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: usePosts } = defineDataHooks(
  "posts",
  (db: AppDb) => db.posts,
);
```

```ts
import { createPosts, rpcTransport } from "better-supabase/blocks/posts";

const ideas = await db.posts
  .saveBoard({ key: "ideas", name: "Ideas", kind: "feedback" })
  .orThrow();

const post = await db.posts
  .create(ideas.id, {
    title: "Dark mode for reports",
    body: "The exported PDFs ignore the theme.",
  })
  .orThrow();
```

```tsx title="src/features/posts/list.tsx"
"use client";

import { usePosts } from "@/lib/supabase/hooks";

const rows = usePosts({ limit: 50 });
```

`saveBoard` creates a board, or with `id` changes it; the key can't change
once set and `archived: true` hides it from `boards()`. `create` writes a
post that shows at once, or with the `moderation: "pre"` option waits for a
moderator. A banned member can't post. `update(postId, input)` changes the
caller's own post while it isn't locked, or any post for a moderator, and
`delete(postId)` removes a post with its votes. `postInputSchema` and
`postBoardInputSchema` validate the inputs.

`list({ boardId, status, authorId, sort })` returns the posts the caller
reads. `sort` is `active` (pinned first, then the latest activity, the
default), `new` or `top` (most votes). Posts merged into others stay out
unless you pass `merged: true`. Each post carries its `votes` tally with the
caller's own vote, its `replyCount` and whether the caller wrote it
(`mine`).

## Votes and replies [#votes-and-replies]

```ts
const tally = await posts.vote(post.id, 1).orThrow();
// { score: 4, up: 4, down: 0, vote: 1 }
```

`vote(postId, value)` sets the caller's vote: `1` up, `-1` down, `0` clears
it. Replies are comments on the subject `post`, so the comments block's
client and recipes thread them:

```ts
await comments
  .create({ type: "post", id: post.id }, { body: "Agreed" })
  .orThrow();
```

A reply on a locked, merged or hidden post fails unless a moderator writes
it.

## Moderate [#moderate]

```ts
await posts
  .setState(post.id, { status: "planned", pinned: true, locked: false })
  .orThrow();

const kept = await posts.merge(duplicate.id, post.id).orThrow();
```

`setState` sets the status (`open`, `under_review`, `planned`,
`in_progress`, `done` or `closed`), the pin and the lock. `merge` closes a
duplicate, points it at the post it kept and moves its votes there, unless a
voter already voted on that post; replies stay on the duplicate.

Every post has a moderation item. A report through the moderation block
queues it, and hiding or removing it there sets the post's `moderation` to
`hidden` or `removed`, which keeps it out of members' lists and stops votes
and replies. Under `moderation: "pre"`, a new post and an author's edit
start as `pending` until a moderator approves them.

## Extra columns [#extra-columns]

`options.extraColumns` adds columns to the posts table, such as a category or
an effort estimate. `create` and `update` write them from `fields`, and
every post carries them in `fields`:

```ts title="better-supabase.config.ts"
blocks: {
  posts: { options: { extraColumns: { category: "text", effort: "integer" } } },
},
```

Pass a Standard Schema as `fields` to `createPosts` to type them: writes
validate them before the database and reads parse them.

## Options [#options]

```ts title="better-supabase.config.ts"
blocks: {
  posts: { options: { moderation: "pre" } },
},
```

| Option          | Default  | What it does                                                           |
| --------------- | -------- | ---------------------------------------------------------------------- |
| `moderation`    | `"post"` | `post` shows posts at once; `pre` holds new posts and edits for review |
| `maxBodyLength` | `20000`  | the longest post body, in characters                                   |
| `extraColumns`  | none     | extra columns on the posts table                                       |

## Events [#events]

The block writes `post_board.saved`, and per post `post.created`,
`post.updated`, `post.status_changed`, `post.moderated`, `post.merged` and
`post.deleted`, to the [outbox](/docs/blocks/outbox). See
[Events](/docs/extending/events).

| Error code             | When                                                             |
| ---------------------- | ---------------------------------------------------------------- |
| `POST_FORBIDDEN`       | the caller lacks the permission, is banned or the post is locked |
| `POST_INVALID`         | a title, status, vote or body doesn't validate                   |
| `POST_NOT_FOUND`       | there is no such post, or the caller can't read it               |
| `POST_BOARD_NOT_FOUND` | there is no such board in the organization                       |

## Without the client [#without-the-client]

`db.posts` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createPosts, rpcTransport } from "better-supabase/blocks/posts";

const posts = createPosts({
  transport: rpcTransport(supabase),
  tenant: organizationId,
});

const ideas = await posts
  .saveBoard({ key: "ideas", name: "Ideas", kind: "feedback" })
  .orThrow();

const post = await posts
  .create(ideas.id, {
    title: "Dark mode for reports",
    body: "The exported PDFs ignore the theme.",
  })
  .orThrow();
```

# 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 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]

Server code calls the `push` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: usePush } = defineDataHooks(
  "push",
  (db: AppDb) => db.push,
);
```

```ts
const rows = await db.push.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/push/list.tsx"
"use client";

import { usePush } from "@/lib/supabase/hooks";

const rows = usePush({ limit: 50 });
```

`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 { createPush, expoPush } from "better-supabase/blocks/push";

const devices = createPush({ 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.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `push_devices`. `register` writes them from `fields`,
and `tokensFor`'s targets return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { push: { options: { extraColumns: { app_build: "text" } } } },
});
```

```ts
const client = createPush({
  transport,
  fields: v.object({ app_build: v.optional(v.string()) }),
});
await client.register({ token, platform: "ios", fields: { app_build: "142" } });
```

## 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`.

## Without the client [#without-the-client]

`db.push` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```tsx title="app/_layout.tsx"
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { Platform } from "react-native";
import {
  createPush,
  registerDevice,
  rpcTransport,
  unregisterOnSignOut,
} from "better-supabase/blocks/push";

const devices = createPush({ 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,
});
```

# Reactions

> Emoji reactions and up or down votes on any subject, messages (comments included) and inbox messages, with custom emoji per organization, counts under each subject's read rules and a realtime topic per organization.

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

`reactions` stores one row per actor and emoji on a subject. A member reacts to a subject they can read; counts, whether the user reacted and the first actors come back in one call. Reading needs `reactions.read`, reacting needs `reactions.create`, and custom emoji need `reactions.manage`.

```bash
better-supabase add reactions
```

The block's entity is `reaction` (the `reactions` table). It isn't registered as a subject. Rename it with `blocks.reactions.names`.

Server code calls the `reactions` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useReactions } = defineDataHooks(
  "reactions",
  (db: AppDb) => db.reactions,
);
```

```ts
import { createReactions } from "better-supabase/blocks/reactions";

const doc = { type: "doc", id: docId };

await db.reactions.react(doc, "👍").orThrow();
const byDoc = await db.reactions
  .summary(docIds.map((id) => ({ type: "doc", id })))
  .orThrow();
byDoc[`doc:${docId}`]; // the doc's ReactionCount[]
await db.reactions.react(doc, "👍", { present: false }).orThrow();
```

```tsx title="src/features/reactions/list.tsx"
"use client";

import { useReactions } from "@/lib/supabase/hooks";

const rows = useReactions({ limit: 50 });
```

Every call acts in the block's `tenant`, an organization id or a function that returns the current one; `createBlocks` and `createBlockClient` pass the request's tenant, and a call without one fails with `invalid_request` and the hint `TENANT_REQUIRED`. A subject is a `RecordRef`, `{ type, id }`. `summary(subjects)` takes up to 200 subjects of each type and returns their reactions keyed `type:id` (`subjectKey(subject)` from `better-supabase/blocks`); a subject without reactions may be left out.

`react` returns the subject's reactions, most used first:

| Field     | What it holds                                               |
| --------- | ----------------------------------------------------------- |
| `emoji`   | The emoji, or a custom emoji's `:shortcode:`                |
| `count`   | How many actors reacted with it                             |
| `reacted` | Whether the signed-in user is one of them                   |
| `actors`  | The first ten actors: user ids, or names the service passed |

## Subjects [#subjects]

A reaction's subject is any [`config.records`](/docs/blocks/sql#subject-registry) type, or a message (with the [messaging](/docs/blocks/messaging) module, which includes [comments](/docs/blocks/comments) and the [inbox](/docs/blocks/inbox)). Messages are always included, under the message entity's name (`message`, or the name `blocks.messaging.names.message` sets), even when `on` lists other types. `react` and the read policy check that the caller sees the subject: the functions run as the caller, so the subject table's own policies apply, and the caller also needs the subject's read permission (`messages.read` for a message). A subject with a `deletedAt` column, such as a message, must not be deleted. Without registry subjects every type is accepted for members of the organization.

The service role reacts for anyone and passes the actor's name:

```ts
await reactions
  .react({ type: "message", id: commentId }, "✅", { actor: "deploy-bot" })
  .orThrow();
```

## Votes [#votes]

A vote is a reaction with `kind: "vote"`: up (`1`) or down (`-1`), one per actor and subject. `vote(subject, value)` sets the caller's vote, `0` clears it, and voting the other way switches it. It needs the same permissions as `react` and returns the subject's tally:

```ts
const tally = await reactions.vote({ type: "idea", id: ideaId }, 1).orThrow();
// { score: 12, up: 15, down: 3, vote: 1 }

const byIdea = await reactions
  .votes(ideaIds.map((id) => ({ type: "idea", id })))
  .orThrow();
byIdea[`idea:${ideaId}`]; // the idea's VoteTally
```

`votes(subjects)` reads up to 200 subjects of each type, keyed `type:id`; a subject without votes may be left out. `vote` is the caller's own vote, `0` when they haven't voted. The tally lives in the `reaction_scores` table, which a trigger keeps current and which the subject's read rules guard, so you can sort a list by `score` in SQL. `summary()` stays emoji-only, and a [scores](/docs/blocks/scores) model can count a record's vote score as a metric.

## Inbox messages [#inbox-messages]

Inbox messages are messages too: `inbox.messages.react()` calls `react_to_inbox_message`, which applies the inbox's rules (a contact reacts on their own conversation, staff with `inbox.reply` on notes) and stores the reaction on the `message` subject in this table. `react({ type: "message", id })` on an inbox message applies the messaging rules instead.

## Custom emoji [#custom-emoji]

An organization's custom emoji are shortcodes with an image: an attachment id from [attachments](/docs/blocks/attachments) or an `https://` URL. A reaction `:shortcode:` must name one of them, or `react` fails with `REACTION_INVALID`. Deleting a custom emoji deletes the reactions that used it.

```ts
await reactions
  .saveCustomEmoji({
    shortcode: "ship-it",
    url: "https://cdn.example.com/ship-it.png",
  })
  .orThrow();
const emoji = await reactions.customEmoji().orThrow();
```

## Realtime [#realtime]

Every change broadcasts `changed` with the `subjectType` and `subjectId` to `reactions:{organizationId}` (`options.topic` renames the prefix). Members with `reactions.read` can join it. `useReactions` is a registry item: `npx shadcn add @better-supabase/use-reactions` writes `hooks/use-reactions.ts` into the app. The hook loads the subjects on screen and loads them again when one of them changes. It reads the schema and the topic from the `blocks` config passed to the browser client (`createClient(betterSupabase, { blocks: blockConfig })`), so `topic` is only needed to override it (`null` loads without listening), and it acts in the client's tenant: the client's `organization` option, then the session's organization claim. It waits while there is no tenant.

```tsx
import { useReactions } from "@/hooks/use-reactions";

const { reactions, toggle } = useReactions({
  subjects: comments.map((comment) => ({ type: "message", id: comment.id })),
});

await toggle({ type: "message", id: commentId }, "🎉");
```

`reactions` is keyed `type:id`, like `summary`. When the subjects on screen change, the hook loads the new ones without building a new controller.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createReactionsController` with its options: a client built with its `tenant`, the
same `tenant` for the topic, `subjects` and an optional `topic`; `auth` and `ports` come from
`app.use(betterSupabase(bsb))` unless you pass them. `state` is a shallow ref
of the snapshot and `actions` are the controller's actions. The work starts
on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createReactions,
  createReactionsController,
  rpcTransport,
} from "better-supabase/blocks/reactions";

const { organizationId, commentIds } = defineProps<{
  organizationId: string;
  commentIds: string[];
}>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createReactionsController, {
  client: createReactions({
    transport: rpcTransport(supabase),
    tenant: organizationId,
  }),
  tenant: organizationId,
  subjects: commentIds.map((id) => ({ type: "message", id })),
});
</script>

<template>
  <p>{{ Object.keys(state.reactions ?? {}).length }}</p>
  <button @click="actions.refresh()">Refresh</button>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createReactionsController` with its options; `auth` and `ports` come from
`setBetterSupabase(bsb)` unless you pass them. It returns a readable
store of the snapshot, read with `$`, with the actions on `.actions`. The
work starts on mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createReactions,
    createReactionsController,
    rpcTransport,
  } from "better-supabase/blocks/reactions";

  let { organizationId, commentIds }: { organizationId: string; commentIds: string[] } = $props();
  const { supabase } = getBetterSupabase();
  const reactions = useBlockController(createReactionsController, {
    client: createReactions({
      transport: rpcTransport(supabase),
      tenant: organizationId,
    }),
    tenant: organizationId,
    subjects: commentIds.map((id) => ({ type: "message", id })),
  });
</script>

<p>{Object.keys($reactions.reactions ?? {}).length}</p>
<button onclick={() => reactions.actions.refresh()}>Refresh</button>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createReactionsController` with its options; `auth` and `ports` come from
`<BetterSupabaseProvider client={bsb}>` unless you pass them. `state()` reads
the snapshot through `from`, and `actions` are the controller's actions. The
work starts on mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createReactions,
  createReactionsController,
  rpcTransport,
} from "better-supabase/blocks/reactions";

export function Component(props: {
  organizationId: string;
  commentIds: string[];
}) {
  const supabase = useSupabase();
  const { state, actions } = useBlockController(createReactionsController, {
    client: createReactions({
      transport: rpcTransport(supabase),
      tenant: props.organizationId,
    }),
    tenant: props.organizationId,
    subjects: props.commentIds.map((id) => ({ type: "message", id })),
  });
  return (
    <>
      <p>{Object.keys(state().reactions ?? {}).length}</p>
      <button onClick={() => actions.refresh()}>Refresh</button>
    </>
  );
}
```

## Events and hooks [#events-and-hooks]

The module records `reaction.added` and `reaction.removed` with `organizationId`, `subjectType`, `subjectId`, `emoji`, `actor`, `kind` (`emoji` or `vote`) and, for a vote, `value`. A consumer that only wants emoji reactions filters on `kind`. `react` calls the `before_react` and `after_react` SQL hooks with the tenant, subject type, subject id, emoji and whether the reaction is added.

| Error                | When                                                                                                |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `REACTION_FORBIDDEN` | The caller lacks the permission or can't see the subject                                            |
| `REACTION_INVALID`   | The emoji is empty, longer than 64 characters, has spaces or is unknown, or a vote isn't -1, 0 or 1 |

## With ReUI [#with-reui]

Render the ReUI Emoji Picker for new reactions and one Bubble per `ReactionCount`, highlighted when `reacted` is true. `toggle(subject, emoji)` adds the emoji, or removes it when the user already reacted with it.

## Without the client [#without-the-client]

`db.reactions` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createReactions } from "better-supabase/blocks/reactions";

const reactions = createReactions({ transport, tenant: organizationId });
const doc = { type: "doc", id: docId };

await reactions.react(doc, "👍").orThrow();
const byDoc = await reactions
  .summary(docIds.map((id) => ({ type: "doc", id })))
  .orThrow();
byDoc[`doc:${docId}`]; // the doc's ReactionCount[]
await reactions.react(doc, "👍", { present: false }).orThrow();
```

# Share links

> Hashed share links, view counts and per-user state. The token is returned once.

Source: https://bettersupabase.com/docs/blocks/share-links

`share-links` creates a link, returns the token once and stores only its SHA-256. `resolve_share_link` is granted to `anon` and hashes the token it is given, so a row that still holds a plaintext token keeps working after you store `sha256` of it. Revoking, listing, recording a view and setting starred or last-opened state are the other calls.

```bash
better-supabase add share-links
```

```ts
import { createShareLinks } from "better-supabase/blocks/share-links";
```

When the rate-limit module is installed, resolve is a place to apply it.

`create(subject, { role, versionId, expiresAt })` takes the subject as a `RecordRef` (`{ type, id }`) and acts in the block's `tenant`. It takes the role (`view` by default), an optional `Temporal.Instant` after which the link stops resolving and, with the [versions](/docs/blocks/versions#share-a-version) module installed, a version of the subject to pin the link to. An `expiresAt` that is not in the future fails with `SHARE_EXPIRY_INVALID`. `resolve(token)` returns the link's id, `organizationId`, `subject` (a `RecordRef`), role, `versionId` and `expiresAt`, or `null` when the token is unknown, revoked or expired.

```ts
const link = await shareLinks
  .create(
    { type: "calendar", id: calendarId },
    { expiresAt: Temporal.Now.instant().add({ hours: 24 * 30 }) },
  )
  .orThrow();
```

The other calls on a subject take it the same way: `list(subject)` returns its
links, `recordView(subject, viewerKind, viewer?)` counts a view in the
block's tenant, and `setState(subject, starred)` stars or unstars it for the
caller. A call that needs a tenant and has none fails with `invalid_request`
and the hint `TENANT_REQUIRED`.

## Permissions [#permissions]

| Call         | Who may call it                                                                                          |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| `create`     | a member with `share_links.create` in the organization                                                   |
| `revoke`     | the link's creator, or a member with `share_links.create`; anyone else gets `SHARE_NOT_FOUND`            |
| `list`       | the links the caller created, plus every link in organizations where the caller holds `share_links.read` |
| `recordView` | a member of the organization (`SHARE_FORBIDDEN` otherwise)                                               |
| `resolve`    | anyone, including `anon`                                                                                 |

The service role may make every call.

## Events [#events]

| Event                | When                        |
| -------------------- | --------------------------- |
| `share_link.created` | `create` stored a link      |
| `share_link.revoked` | `revoke` revoked one        |
| `subject.viewed`     | `recordView` counted a view |

## Guest pages [#guest-pages]

Every module that reads a subject through a link (calendar feeds, booking pages, public forms, drive links, shared AI chats, shared versions) checks the same four things: the token's hash, the subject type, that the link is not revoked and that it has not expired. A guest page has two ways to read the subject:

- Call the module's own function (`calendar_feed(token)`, `get_public_form(token)`, `get_shared_version(token)`). It is `security definer`, returns only what the link grants and needs no session.
- Exchange the token for a short-lived anonymous session whose claims name the link, then query under row-level security. An authorization provider can do this from the row `resolve` returns; see [Authorization providers](/docs/extending/authorization-providers#share-links-as-guests). Keep the token's lifetime capped by the row's `expiresAt`, and revoke the row to end every session minted from it.

## AI chat [#ai-chat]

AI chat sharing uses this block. `share_ai_chat`, `revoke_ai_chat_share`, `list_ai_chat_shares` and `get_shared_ai_chat` stay callable and read or write `share_links`. Existing `ai_chat_shares` rows are copied, including `leaf_id`.

## Over your own tables [#over-your-own-tables]

To run over tables you already have, map `tables.links`, `tables.views` and `tables.state`. CentraKit's `file_share_links` stores plaintext tokens: run `update ... set token = encode(sha256(convert_to(token, 'utf8')), 'hex')` once. `document_views` and `file_node_user_state` map onto `views` and `state` when their columns fit.

## Your own columns [#your-own-columns]

Add columns to the share link table with `blocks.share-links.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`create` (in its options) validates and writes them, and `create` and `list` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

Server code calls the `share-links` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useShareLinks } = defineDataHooks(
  "shareLinks",
  (db: AppDb) => db.shareLinks,
);
```

```ts
const rows = await db.shareLinks.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/share-links/list.tsx"
"use client";

import { useShareLinks } from "@/lib/supabase/hooks";

const rows = useShareLinks({ limit: 50 });
```

## Without the client [#without-the-client]

`db.shareLinks` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createShareLinks } from "better-supabase/blocks/share-links";
import { z } from "zod";

const client = createShareLinks({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

# Activity

> An activity feed per tenant and a timeline per record, filled from outbox events and joined with audit entries, notification deliveries and workflow runs.

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

The `timeline` block keeps a tenant's activity feed and each record's
timeline. An outbox sink, `activitySink`, writes outbox events into
`activity_entries`, and `createTimeline().list` reads the feed or one
record's timeline, joined with the record's audit entries, notification
deliveries and workflow runs when those blocks are installed.

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

Until 0.14 the feed was part of the comments block. Add `activity: {}` to
`blocks` to keep it; see [From 0.13 to 0.14](/docs/migration/0.13-to-0.14#comments-on-the-messaging-core).
The crm, projects, helpdesk and docs [presets](/docs/presets) include it.

| Table              | Holds                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `activity_entries` | One row per outbox event: `type`, `actor_id`, `subject_type`, `subject_id`, `summary`, `data`, `occurred_at` |

| Permission      | Lets a member                   | Default roles     |
| --------------- | ------------------------------- | ----------------- |
| `activity.read` | read the tenant's activity feed | `member`, `admin` |

`blocks.timeline.permissions.read` renames the key.

## Writing the feed [#writing-the-feed]

`activitySink` is an outbox sink that writes events into `activity_entries`.
Run it as a consumer with a service-role transport, since
`record_activity()` is granted to `service_role` only:

```ts title="app/api/cron/activity/route.ts"
import { activitySink, sqlTransport } from "better-supabase/blocks/timeline";

await outbox.relay(
  "activity",
  activitySink({
    transport: sqlTransport(postgres.admin),
    types: ["message.sent", "status.changed", "billing.*"],
    describe: (event, type) =>
      type === "message.sent" ? { summary: "commented" } : {},
  }),
);
```

Events without a tenant are skipped, and the event id is unique, so a
replayed batch writes nothing twice. `types` keeps the events whose type
matches exactly or by a `.*` prefix. The actor is the event's `actorId`,
`authorId` or `userId`. The subject is the event's `recordType` and
`recordId`, then its `subjectType` and `subjectId`, then the CloudEvents
`subject`, so a comment (`message.sent` on a record conversation) lands on
the record's timeline. `describe` can set the summary, actor, subject and
data, or return `null` to skip an event.

## Reading the feed [#reading-the-feed]

`createTimeline({ transport, tenant })` builds the client, or
`createBlockClient(ctx.db, timelineBlock, { config: blockConfig })` builds it
for a request (see
[createBlocks](/docs/blocks/create-blocks#one-block-per-request)).
`list(subject?, { cursor, limit, sources })` reads the feed through
`list_activity`, which runs as the caller: the tenant's entries newest first,
or one record's timeline when you pass `{ type, id }`. Pass `null` or nothing
for the tenant feed. It works over `rpcTransport` too, without exposing the
module schema. Each entry's `subject` is a `RecordRef`, or `undefined` for
an entry without one.

Server code calls the `timeline` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTimeline } = defineDataHooks(
  "timeline",
  (db: AppDb) => db.timeline,
);
```

```ts
import { createTimeline, rpcTransport } from "better-supabase/blocks/timeline";

const timeline = await db.timeline
  .list({ type: "quote", id: quoteId }, { limit: 20 })
  .orThrow();
```

```tsx title="src/features/timeline/list.tsx"
"use client";

import { useTimeline } from "@/lib/supabase/hooks";

const rows = useTimeline({ limit: 50 });
```

`limit` defaults to 50 and is at most 500, and `cursor` (an instant) pages
back. Each entry carries a `source`: `event` for a row of `activity_entries`,
`audit` for an audit log entry, `delivery` for a notification delivery and
`run` for a workflow run (see below). Pass `sources` to keep some of them:

```ts
const sent = await activity
  .list({ type: "quote", id: quoteId }, { sources: ["delivery"] })
  .orThrow();
```

### Visibility per record [#visibility-per-record]

With the `subjects` module of the
[subject registry](/docs/blocks/sql#subject-registry) installed, the feed only
shows a caller the entries on records they can read. The read policy on
`activity_entries` checks `can_subject(subject_type, subject_id, 'read')` row
by row for every subject type in the registry, so the tenant feed, the
record timeline and `activityListQuery` all leave out entries on records the
caller can't read, and entries on a deleted record. `list_activity` returns
an empty timeline for a record the caller can't read. Entries without a
subject, or on a type the registry doesn't list, keep the tenant check alone.

### Audit entries in a timeline [#audit-entries-in-a-timeline]

With the [audit](/docs/blocks/audit) module installed with
`options.readPolicy: true`, a record's timeline also returns the audit log
entries for the record's row: the entries whose table is the type's
registry table and whose record id is the subject id. The audit read policy
gates them, so only callers who hold `audit.read` (by default `admin` and
`owner`) see them. An audit entry has `source: "audit"`, the audit entry id
as `id`, the audit event type as `type` (`audit.<op>` when it has none), and
`op`, `changed` and `table` in `data`. The tenant feed and
`activityListQuery` list no audit entries.

### Deliveries and workflow runs in a timeline [#deliveries-and-workflow-runs-in-a-timeline]

With the subject registry and the [notifications](/docs/blocks/notifications)
module, a record's timeline also returns the deliveries of notifications
about the record: one entry per channel and recipient, with
`source: "delivery"`, `delivery.<status>` as `type` and `channel`, `status`,
`userId`, `notificationId` and `notificationType` (plus `providerStatus` and
`templateKey` when tracked) in `data`. A recipient can read only their own
deliveries, so `list_activity` reads them through the security definer
function `activity_subject_deliveries`, which returns them to callers who
hold both `activity.read` and `notifications.send`, the same bar as
`list_subject_deliveries`.

With the [workflows](/docs/blocks/workflows) module, it returns the
record's workflow runs with `source: "run"`, `run.<status>` as `type` and
`runId`, `definition`, `status` and `error` in `data`. Runs are read as the
caller, so the run's read policy applies: its actor, or members with
`workflow.read` (by default only `owner`).

Both need `can_subject` read on the record, and the tenant feed lists
neither.

## A list query [#a-list-query]

`activityListQuery` reads `activity_entries` as a cursor-paged
[list query](/docs/platform/list) filtered by type, actor and subject,
newest first (sorts `newest` and `oldest`, facets `type`, `actor`,
`subjectType` and `subjectId`). Generate types for the `better_supabase`
schema to have the table in your models:

```ts
import { activityListQuery } from "better-supabase/blocks/timeline";

export const activity = activityListQuery(betterSupabase, "activityEntries", {
  pageSize: 30,
});
```

The block also takes `hooks` around `list`; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.timeline` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createTimeline, rpcTransport } from "better-supabase/blocks/timeline";

const activity = createTimeline({
  transport: rpcTransport(supabase),
  tenant: organizationId,
});

const timeline = await activity
  .list({ type: "quote", id: quoteId }, { limit: 20 })
  .orThrow();
```

# Billable time

> Price tracked time with an hourly rate card per organization, member and record, preview what it would bill, and turn it into draft invoice lines in bigint minor units.

Source: https://bettersupabase.com/docs/blocks/billable-time

`billable-time` connects [time tracking](/docs/blocks/time-tracking) to
[invoices](/docs/blocks/invoices). You keep a rate card of hourly rates, see
what the unbilled time on a project or customer would bill, and put it on a
draft invoice in one call. Each billed entry remembers the invoice and the
rate it was billed at, so it can't be billed twice.

```bash
better-supabase add billable-time
```

```ts
import { createBillableTime } from "better-supabase/blocks/billable-time";
import { money } from "better-supabase/commerce";
```

The module requires `tenant`, `access`, `subjects`, `time-tracking` and
`invoices`. With `outbox` it records a `time.invoiced` event, and with
`audit` rate changes and invoicing are audited. Amounts are bigint minor
units ([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)),
and the choices behind locking billed time are in
[ADR 0074](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0074-billable-time-rate-card.md).

## Tables [#tables]

| Table                  | Holds                                                                                                                                                 |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `time_rates`           | The rate card: an hourly `rate` and `currency`, an optional member (`user_id`) and record (`subject_type`, `subject_id`), and the day it takes effect |
| `time_invoice_entries` | One row per billed time entry: the invoice, the rate and currency it was billed at, and the seconds billed                                            |

Both tables are read through the functions only. Members with
`billable_time.read` read the rate card and the billed entries.

## Set rates [#set-rates]

Server code calls the `billable-time` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useBillableTime } = defineDataHooks(
  "billableTime",
  (db: AppDb) => db.billableTime,
);
```

```ts
await db.billableTime.setRate({ rate: money(9000n, "EUR") }).orThrow();
await db.billableTime
  .setRate({ rate: money(12000n, "EUR"), subject: { type: "projects", id } })
  .orThrow();
await db.billableTime
  .setRate({
    rate: money(15000n, "EUR"),
    userId,
    subject: { type: "projects", id },
  })
  .orThrow();
```

```tsx title="src/features/billable-time/list.tsx"
"use client";

import { useBillableTime } from "@/lib/supabase/hooks";

const rows = useBillableTime({ limit: 50 });
```

A rate without `userId` and `subject` is the organization's rate. A rate can
name a member, a record in `config.records`, or both. For each entry the most
specific rate wins: the member on the record, then the record, then the
member, then the organization. A rate takes effect on `effectiveFrom`
(default today) and stays until a newer rate on the same key takes over, so a
raise doesn't change what older time bills. The day is read in the
time-tracking block's `timeZone`. Setting a rate on the same key and day
replaces it. An entry that carries its own `rate` keeps that rate. Setting
and deleting rates needs `billable_time.manage`.

| Method                             | Returns                                                    |
| ---------------------------------- | ---------------------------------------------------------- |
| `setRate(input)`                   | the rate                                                   |
| `deleteRate(rateId)`               | the deleted rate                                           |
| `listRates({ userId, subject })`   | the rate card                                              |
| `rateFor({ userId, subject, on })` | the rate that applies, or `null`                           |
| `unbilled(query)`                  | the lines, one total per currency, and the unrated entries |
| `invoice(input)`                   | the draft invoice's id, its total and the billed entry ids |
| `invoicedEntries(invoiceId)`       | the entries an invoice billed, with their rates            |

## Preview and invoice [#preview-and-invoice]

```ts
const preview = await billing
  .unbilled({ subject: { type: "projects", id } })
  .orThrow();

const { invoiceId, total } = await billing
  .invoice({
    subject: { type: "projects", id },
    company: { type: "companies", id: companyId },
    title: "October",
    termsDays: 30,
  })
  .orThrow();
```

`unbilled` shows what `invoice` would bill without billing it. Only finished
entries marked billable that aren't on an invoice count. A line's `quantity`
is hours to four decimals, as text; its `amount` is the quantity times the
hourly rate, rounded to a minor unit. `unrated` lists the entries no rate
covers.

`invoice` creates a draft with the [invoices](/docs/blocks/invoices) block
and adds a line per group, all in one transaction. It fails with
`TIME_RATE_MISSING` while an entry has no rate, and with
`BILLABLE_TIME_CURRENCIES` when the entries bill in more than one currency
and `currency` doesn't pick one. The title defaults to `Time`. Invoicing
needs `billable_time.invoice` and `invoices.write`.

The scope narrows the entries: `subject`, `userId`, `starts` and `ends` (by
start time), and `entryIds`. `groupBy` decides the lines:

| `groupBy`           | One line per                 |
| ------------------- | ---------------------------- |
| `subject` (default) | record and rate              |
| `member`            | record, member and rate      |
| `entry`             | entry, described by its note |

## Billed time is locked [#billed-time-is-locked]

A billed entry can't be deleted, and its start, end, billable flag and rate
can't change: the database raises `TIME_ENTRY_INVOICED`. Its record can still
change, so merging two records keeps working. Voiding the invoice releases
its entries, and they show up in `unbilled` again.

An organization purge deletes the billed rows with the invoices. Erasing a
member doesn't delete their billed time; it stays as an accounting record
until the invoice is voided.

## Section [#section]

The [billable time binding](/docs/repository/record-bindings) shows a record's unbilled
time on its page, with `setRate` and `invoice` writes.

## Errors [#errors]

| Hint                             | Cause                                                                                                      |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `BILLABLE_TIME_FORBIDDEN`        | the caller lacks `billable_time.read`, `.manage` or `.invoice`                                             |
| `TIME_RATE_INVALID`              | the rate is negative, the currency isn't a currency code, or only one of the record type and id was passed |
| `TIME_RATE_SUBJECT_INVALID`      | the record is unknown or not in the organization                                                           |
| `TIME_RATE_MEMBER_INVALID`       | `userId` isn't a member of the organization                                                                |
| `TIME_RATE_NOT_FOUND`            | no rate with this id in the organization                                                                   |
| `BILLABLE_TIME_GROUPING_INVALID` | `groupBy` isn't `subject`, `member` or `entry`                                                             |
| `TIME_RATE_MISSING`              | a billable entry has no rate                                                                               |
| `BILLABLE_TIME_CURRENCIES`       | the entries bill in more than one currency and none was picked                                             |
| `BILLABLE_TIME_EMPTY`            | the scope selects no unbilled time                                                                         |
| `INVOICE_NOT_FOUND`              | `invoicedEntries` named an invoice outside the organization                                                |
| `TIME_ENTRY_INVOICED`            | the entry is on an invoice; void the invoice to change it                                                  |

## Events [#events]

`time.invoiced` when time is billed. The
[events page](/docs/extending/events) lists its payload.

## Without the client [#without-the-client]

`db.billableTime` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const billing = createBillableTime({ transport, tenant: organizationId });

await billing.setRate({ rate: money(9000n, "EUR") }).orThrow();
await billing
  .setRate({ rate: money(12000n, "EUR"), subject: { type: "projects", id } })
  .orThrow();
await billing
  .setRate({
    rate: money(15000n, "EUR"),
    userId,
    subject: { type: "projects", id },
  })
  .orThrow();
```

# 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 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 `auth.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]

Server code calls the `billing` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useBilling } = defineDataHooks(
  "billing",
  (db: AppDb) => db.billing,
);
```

```ts
const rows = await db.billing.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/billing/list.tsx"
"use client";

import { useBilling } from "@/lib/supabase/hooks";

const rows = useBilling({ limit: 50 });
```

`stripeFromKey()` from `better-supabase/stripe` creates the client from the
optional `stripe` package on the first Stripe call, once, with its fetch HTTP
client so it runs on every runtime. It imports `stripe` by name, so Next.js,
Vite and other bundlers resolve the package and include it in the server
build. `stripe` also takes `{ secretKey }`, the plain Node shortcut: it loads
the package at runtime with a native import that bundlers leave alone, so it
works in Node, Bun and Deno and in apps that keep `stripe` external, but not
in a bundle that leaves the package out. There it throws an error that names
`stripeFromKey()`.

`stripe` also takes 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.

### Billing tenants as Stripe accounts [#billing-tenants-as-stripe-accounts]

Stripe's Accounts v2 API models a business as one `v2.core.account` with
configurations. The `customer` configuration lets the platform bill that
account (Billing subscriptions, Checkout, the customer portal and invoices)
by passing `customer_account: "acct_..."` instead of a v1 `customer`, and the
`merchant` configuration lets the same account accept payments through
Connect. Set `customerAccounts` to bill tenants this way:

```ts title="src/lib/billing.ts"
export const billing = createBilling({
  stripe: stripeFromKey({ secretKey: env.STRIPE_SECRET_KEY }),
  transport: sqlTransport(postgres.admin),
  customerAccounts: {
    // Optional: the account the tenant already sells through.
    resolve: (tenant) => connectedAccountOf(tenant),
    // Optional: more v2.core.accounts.create parameters, merged deeply.
    params: { identity: { country: "de" } },
  },
});
```

`checkout` then creates the tenant's account the first time, with
`contact_email`, `display_name`, the `customer` configuration and the tenant
in `metadata.organization_id`, links it (`link_billing_account`, which
records `billing.account_linked`), and opens Checkout with
`customer_account`. `resolve` returns an existing account to reuse instead,
which needs the `customer` configuration before Stripe bills it. `portal`
passes `customer_account` too. `account(organizationId)` reads the linked
account and `ensureAccount(organizationId, details)` creates or reuses it
without a checkout. Creating an account needs `v2.core.accounts` on the
Stripe client, which the `stripe` package has; `customerAccounts: true` uses
the defaults.

Stripe creates a v1 customer linked to the account when the first
subscription or invoice is made, and customers can't be listed or searched by
`customer_account`, so the block records that `cus_` id from the first
Checkout session, subscription or invoice event that carries both ids (see
[Stripe webhooks](#stripe-webhooks)). Until then `customer(organizationId)`
returns `undefined`, the Sync Engine reads return nothing, and
`ensureCustomer`, `addTaxId` and `updateCustomer` fail with `not_found`
(`BILLING_CUSTOMER_PENDING`). After that, the readers, tax ids, seat sync and
plan changes work on the linked customer as they do for a v1 customer. A
tenant that already has a v1 customer keeps it: `checkout` and `portal` go on
passing `customer` for it.

`billing_customers` keeps the account in `stripe_customer_account_id`
(`^acct_`), next to `stripe_customer_id`, which is `null` until the customer
is recorded; a row needs one of the two. Stripe rejects some values for the
account's profile (for example a `*.localhost` URL as
`defaults.profile.business_url`), so pass production-like values in
`params` when you test against a sandbox.

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

### A plan picker [#a-plan-picker]

`usePlanPicker` lists your plans with the tenant's current one and starts
Checkout with `choose(plan)`. The hook is a registry item: install it with
`npx shadcn add @better-supabase/use-billing`, which writes
`hooks/use-billing.ts` into the app. It never calls Stripe: `checkout` is your server action or API route,
which calls `billing.checkout()` with the secret key and returns the
Checkout URL, and `open` sends the user there. The same hook runs in React
DOM and React Native.

```ts title="app/settings/billing/actions.ts"
"use server";

export const startCheckout = bsb.action(
  {
    input: v.object({ plan: v.string() }),
    requireOrganization: true,
    authorize: (session) => can(session, "billing.manage"),
  },
  async ({ plan }, { tenant }) =>
    billing.checkout(tenant, { plan, successUrl, cancelUrl }),
);
```

```tsx title="components/plan-picker.tsx"
import { usePlanPicker } from "@/hooks/use-billing";

const { plans, current, pending, choose } = usePlanPicker({
  load: loadPlanCatalog, // { plans, current }, such as a server action
  checkout: (plan) => startCheckout({ plan }),
  open: (url) => {
    window.location.assign(url); // WebBrowser.openBrowserAsync(url) in Expo
  },
});
```

`initial` takes the catalog a server already rendered, so the first load
is skipped. A failed checkout comes back as a `Result` and stays in
`error`; a checkout that returns no URL, such as a plan change the server
finished itself, loads the catalog again. `createPlanPickerController`
from `better-supabase/blocks/billing` is the same logic without React. Vue,
Svelte and Solid run it with `useBlockController` from `better-supabase/vue`,
`/svelte` and `/solid`, with the same options and `auth` from the provider
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)). The [plan picker recipe](/docs/registry/ui-recipes/plan-picker) shows it in
the Next.js and Expo examples.

```vue title="PlanPicker.vue"
<script setup lang="ts">
import { useBlockController } from "better-supabase/vue";
import { createPlanPickerController } from "better-supabase/blocks/billing";

const { state, actions } = useBlockController(createPlanPickerController, {
  load: loadPlanCatalog,
  checkout: startCheckout,
  open: (url) => window.location.assign(url),
});
</script>

<template>
  <button
    v-for="plan in state.plans"
    :key="plan.key"
    :disabled="plan.key === state.current"
    @click="actions.choose(plan.key)"
  >
    {{ plan.name }}
  </button>
</template>
```

```svelte title="PlanPicker.svelte"
<script lang="ts">
  import { useBlockController } from "better-supabase/svelte";
  import { createPlanPickerController } from "better-supabase/blocks/billing";

  const picker = useBlockController(createPlanPickerController, {
    load: loadPlanCatalog,
    checkout: startCheckout,
    open: (url) => window.location.assign(url),
  });
</script>

{#each $picker.plans ?? [] as plan}
  <button onclick={() => picker.actions.choose(plan.key)}>{plan.name}</button>
{/each}
```

```tsx title="PlanPicker.tsx"
import { useBlockController } from "better-supabase/solid";
import { createPlanPickerController } from "better-supabase/blocks/billing";

export function PlanPicker() {
  const { state, actions } = useBlockController(createPlanPickerController, {
    load: loadPlanCatalog,
    checkout: startCheckout,
    open: (url) => window.location.assign(url),
  });
  return (
    <For each={state().plans}>
      {(plan) => (
        <button onClick={() => actions.choose(plan.key)}>{plan.name}</button>
      )}
    </For>
  );
}
```

## 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({
  blocks: { 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.

## Subscriptions without the Sync Engine [#subscriptions-without-the-sync-engine]

By default the block reads subscriptions from the Stripe Sync Engine tables.
An app that only receives webhooks sets `subscriptions: "webhook"` in
`blocks.billing.options` and in `createBilling`. The migration then creates
`billing_subscriptions`, and `handleStripeEvent` stores every
`customer.subscription.*` event in it with the event's `created` time. An older
event never overwrites a newer one, so retries and out-of-order delivery are
safe. `billing_tenant_subscription` and `billing_subscription_item` read the
table, and `billing_plan_of_price(price)` maps a Stripe price to a key of
`options.plans`.

In this mode `checkout` fails with a `conflict` error whose hint is
`BILLING_ALREADY_SUBSCRIBED` when the tenant has an active, trialing or past
due subscription. To refuse tenants that another provider bills (a
marketplace or an invoice contract), pass an `external` function to
`createBilling` that returns the provider's name, or `undefined`. `checkout` then fails with the hint
`BILLING_EXTERNAL`. The SQL hook `public.billing_external_provider(tenant)`
does the same in SQL, and `external` works in both modes.

## 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, sqlTransport } from "better-supabase/blocks/queue";

export const inbox = createWebhookInbox({
  transport: sqlTransport(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                                                  |
| Any other event                 | Records the customer of a tenant's account when the object carries `customer` and `customer_account`           |

Other events are ignored. An event whose object has a `customer_account`
resolves the tenant by its customer or its account, links the account from
`metadata.organization_id` or `client_reference_id` when needed, and reports
it as `accountId` on the outcome and the block event.

Connect events, the ones Stripe sends for a connected account, carry
`event.account`. They are handled like platform events by default, for apps
that bill through a per-tenant Connect client. An app that bills on the
platform and receives connected account events on the same endpoint or event
destination sets `connectEvents: "ignore"`, so a connected account's own
customers are never linked to a tenant. `stripeInboxVerify` verifies both
kinds, and also the thin event notifications that event destinations send
(see [Stripe webhooks](/docs/standards/webhooks#stripe-webhooks)). 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.account_linked`,
`billing.checkout_completed`,
`billing.subscription_created`, `billing.subscription_updated`,
`billing.subscription_deleted` and `billing.seats_synced` carry
`organizationId` and, where they apply, `customerId`, `accountId`,
`subscriptionId`,
`status`, `quantity`, `previousQuantity` and `stripeEventId`. Listen with
`onBlockEvent(bsb, billingEvents, "billing.*", handler)`, with
`billingEvents` from `better-supabase/blocks/billing`.

## Functions [#functions]

| Function                                                          | Granted to                      | Returns                                                                                     |
| ----------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
| `billing_customer(tenant)`                                        | `authenticated`, `service_role` | the customer id, for callers with `billing.read`                                            |
| `billing_account(tenant)`                                         | `authenticated`, `service_role` | the Accounts v2 account id, for callers with `billing.read`                                 |
| `billing_status(tenant)`                                          | `authenticated`, `service_role` | `{ customer, account, 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; records it on a row that has only an account                           |
| `link_billing_account(tenant, account)`                           | `service_role`                  | the linked account                                                                          |
| `billing_customer_tenant(customer)`                               | `service_role`                  | the tenant id of a customer or an account 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 }]`  |

## Without the client [#without-the-client]

`db.billing` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="src/lib/billing.ts"
import { createBilling, sqlTransport } from "better-supabase/blocks/billing";
import { stripeFromKey } from "better-supabase/stripe";

export const billing = createBilling({
  stripe: stripeFromKey({ secretKey: env.STRIPE_SECRET_KEY }),
  transport: sqlTransport(postgres.admin),
  events: bsb.events, // billing.* block events
  seatPrice: env.STRIPE_SEAT_PRICE,
  sessions: {
    sql: postgres.admin,
    invalidate: (id) => bsb.invalidateSession(id),
  },
});
```

# Commerce

> Catalog, orders, invoices and the blocks that charge money.

Source: https://bettersupabase.com/docs/blocks/catalog-commerce

- [Usage and quotas](/docs/blocks/usage)
- [Billing](/docs/blocks/billing)
- [Entitlements](/docs/blocks/entitlements)
- [Catalog](/docs/blocks/catalog)
- [Inventory](/docs/blocks/inventory)
- [Ledger](/docs/blocks/ledger)
- [Orders](/docs/blocks/orders)
- [Promotions](/docs/blocks/promotions)
- [Invoices](/docs/blocks/invoices)
- [Reviews](/docs/blocks/reviews)
- [Billable time](/docs/blocks/billable-time)
- [Shipments](/docs/blocks/shipments)
- [Leases](/docs/blocks/leases)
- [Expenses](/docs/blocks/expenses)
- [Donations](/docs/blocks/donations)

# Catalog

> Products, options and variants, prices in bigint minor units per currency and price list, categories, collections, sales channels, translations, product media and a public storefront switch.

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

`catalog` stores what an organization sells. A product has options (size,
colour), and each variant picks one value per option and carries its own SKU.
Prices live per variant, currency and optional price list, as `bigint` minor
units next to an ISO 4217 code, so an amount never passes through a float.
Categories form a tree, and collections are either a ranked list of products
or a saved filter.

```bash
better-supabase add catalog
```

```ts
import { createCatalog } from "better-supabase/blocks/catalog";
import { money } from "better-supabase/commerce";
```

## Permissions [#permissions]

Every write checks `catalog.manage` in the product's organization, and the
read policies check `catalog.read`. The owner role holds `*`. Give the roles
that work on the catalog these keys:

```ts title="better-supabase.config.ts"
roles: {
  admin: ["catalog.*", "members.*"],
  merchandiser: ["catalog.read", "catalog.manage"],
  member: ["catalog.read"],
},
```

The service role passes every check, for imports and sync jobs.

## Products [#products]

Server code calls the `catalog` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCatalog } = defineDataHooks(
  "catalog",
  (db: AppDb) => db.catalog,
);
```

```ts
const shirt = await db.catalog
  .createProduct(organizationId, { title: "Linen Shirt", kind: "physical" })
  .orThrow();
// { id, slug: "linen-shirt", status: "draft" }

await db.catalog.setProductStatus(shirt.id, "active").orThrow();
```

```tsx title="src/features/catalog/list.tsx"
"use client";

import { useCatalog } from "@/lib/supabase/hooks";

const rows = useCatalog({ limit: 50 });
```

The slug comes from the title when you leave it out, and it is unique per
organization (`CATALOG_SLUG_TAKEN`). `kind` is `physical`, `digital` or
`service`.

A product starts as `draft`. The built-in transitions are draft to active or
archived, active to draft or archived, and archived back to draft; anything
else fails with `CATALOG_TRANSITION_INVALID`. When a
[state machine](/docs/blocks/state-machine) is declared on the products
table's `status` column, the machine owns the transitions instead, so you can
add a `review` state.

## Options and variants [#options-and-variants]

```ts
const size = await catalog
  .saveOption(shirt.id, { name: "Size", values: ["S", "M", "L"] })
  .orThrow();

await catalog
  .createVariant(shirt.id, {
    sku: "SHIRT-LINEN-S",
    optionValues: [size.values[0].id],
    weightGrams: 220,
  })
  .orThrow();
```

A variant names exactly one value of every option of its product
(`CATALOG_VARIANT_OPTIONS`), and two variants can't pick the same values
(`CATALOG_VARIANT_DUPLICATE`). SKUs are unique per organization
(`CATALOG_SKU_TAKEN`). A product without options has one variant.

Once a product has variants, its options can't gain or lose an option
(`CATALOG_OPTION_HAS_VARIANTS`), and a value a variant uses can't be removed
(`CATALOG_OPTION_VALUE_IN_USE`). Variants keep a rank key, and
`reorderVariant(id, { previous, next })` computes a new one with
[`rankBetween`](/docs/blocks/rank).

`trackInventory` and `allowBackorder` are read by the
[inventory](/docs/blocks/inventory) block when it is installed.

## Prices [#prices]

```ts
await catalog
  .setPrice(variantId, {
    amount: money(4900n, "EUR"),
    compareAt: money(5900n, "EUR"),
  })
  .orThrow();

const price = await catalog.variantPrice(variantId, "EUR").orThrow();
// { id, variantId, amount: { amount: 4900n, currency: "EUR" }, compareAt, priceListId: null }
```

Amounts travel as strings and come back as `bigint`, so values beyond
`Number.MAX_SAFE_INTEGER` stay exact. The database checks the currency against
the pinned ISO 4217 list (`CATALOG_CURRENCY_INVALID`) and refuses a
compare-at amount that isn't above the price.

A price can belong to a price list (for a customer group or a market) and can
have a validity window. Two prices for the same variant, currency and list
can't overlap in time (`CATALOG_PRICE_OVERLAP`). `variantPrice` returns the
list price when you pass `priceListId` and one is valid, and the base price
otherwise; `asOf` resolves a price at another instant.

Every change to an amount emits `catalog.price_changed` with the previous
amount, when `outbox` is installed.

## Categories and collections [#categories-and-collections]

Categories form a tree per organization. `moveCategory` refuses a move under
its own descendant (`CATALOG_CATEGORY_CYCLE`), and a category with children
can't be deleted (`CATALOG_CATEGORY_HAS_CHILDREN`).
`setProductCategories(productId, ids)` replaces a product's categories.

A manual collection is a ranked list: `addToCollection` appends a product, or
places it between two neighbours. A rule collection stores a filter tree, the
same `FilterQuery` the [list query](/docs/platform/list) reads, so the app
renders it with the filter UI it already has:

```ts
await catalog
  .saveCollection(organizationId, {
    title: "Digital",
    kind: "rule",
    rule: {
      id: "root",
      type: "group",
      combinator: "and",
      rules: [
        {
          id: "kind",
          type: "rule",
          path: ["kind"],
          operator: "is",
          value: "digital",
        },
      ],
    },
  })
  .orThrow();
```

## Channels [#channels]

A channel is a place the catalog sells through: a web shop, a marketplace or
a point of sale. Each one has a key, a currency and an optional price list,
and lists the products it sells:

```ts
const web = await catalog
  .saveChannel(organizationId, {
    key: "web-de",
    name: "Web shop Germany",
    currency: "EUR",
    priceListId: retailListId,
  })
  .orThrow();

await catalog.listOnChannel(web.id, [shirt.id]).orThrow();

const listed = await catalog
  .channelProducts(web.id, { locale: "de", limit: 50 })
  .orThrow();
// [{ id, slug, status, kind, locale: "de", title, description, listedAt }]

const price = await catalog.channelPrice(web.id, variantId).orThrow();
```

The key is lower-case letters, digits and hyphens, unique per organization
(`CATALOG_CHANNEL_KEY_INVALID`, `CATALOG_CHANNEL_TAKEN`). `listOnChannel` and
`unlistFromChannel` resolve to how many listings they added or removed, and
a product that isn't in the channel's organization fails with
`CATALOG_PRODUCT_NOT_FOUND`. `channelProducts` returns the listed products in
slug order, 100 per page by default and at most 500, with `cursor` for the
next page.

`channelPrice` resolves the variant's price in the channel's currency through
`variantPrice`, so a channel with a price list gets the list price when one is
valid and the base price otherwise. A channel without a currency returns
`null`. Deleting a channel removes its listings, and deleting a price list
clears it on the channels that used it.

## Locales and translations [#locales-and-translations]

The `locales` option lists the languages the catalog is written in, as
language tags. The first one is the default, and its text stays on the
product and variant themselves:

```ts title="better-supabase.config.ts"
blocks: { catalog: { options: { locales: ["en", "de", "pt-BR"] } } },
```

```ts
await catalog
  .setProductTranslation(shirt.id, "de", {
    title: "Leinenhemd",
    description: "Aus reinem Leinen.",
  })
  .orThrow();
await catalog.setVariantTranslation(variantId, "de", "Klein").orThrow();

const text = await catalog.productText(shirt.id, { locale: "de-AT" }).orThrow();
// { id, slug, locale: "de", title: "Leinenhemd", description, variants: [{ id, sku, title }] }
```

A read takes any locale and resolves it to a configured one: the locale
itself, then its language (`de-AT` reads `de`), then the default. A field the
translation leaves empty falls back to the default text, so a partial
translation still reads as a whole product. A translation for a locale that
isn't configured fails with `CATALOG_LOCALE_INVALID`, and one for the default
locale fails with `CATALOG_LOCALE_DEFAULT`; update the product instead.
Setting every field to `null` removes the translation. The default is
`["en"]`, and a locale list with a tag twice is refused when the SQL renders.

Options, option values, categories and collections are not translated yet.

## Product media [#product-media]

With [attachments](/docs/blocks/attachments) installed, a product keeps an
ordered list of images and other files, each with alt text per locale and an
optional variant it shows:

```ts
const file = await attachments
  .put(
    { type: "product", id: shirt.id },
    { name: "front.jpg", mimeType: "image/jpeg", size: image.byteLength },
    image,
  )
  .orThrow();

const media = await catalog
  .addProductMedia(shirt.id, file.id, {
    alt: { en: "Linen shirt, front", de: "Leinenhemd, vorne" },
  })
  .orThrow();
// { id, position: 0 }

await catalog.reorderProductMedia(shirt.id, [media.id, otherId]).orThrow();

const gallery = await catalog
  .productMedia(shirt.id, { locale: "de" })
  .orThrow();
// [{ id, attachmentId, variantId, position, alt: "Leinenhemd, vorne", altByLocale }]
```

The attachment must belong to the product or one of its variants
(`CATALOG_MEDIA_ATTACHMENT_NOT_FOUND`), and a file appears in a product's
media once (`CATALOG_MEDIA_TAKEN`). Alt text keys are configured locales
with 1 to 1000 characters (`CATALOG_MEDIA_ALT_INVALID`). `reorderProductMedia`
takes every media item of the product once (`CATALOG_MEDIA_ORDER_INVALID`),
and `removeProductMedia` closes the gap it leaves. Deleting the attachment
removes its media item. `updateProductMedia` changes the variant and the alt
text.

## Storefront [#storefront]

Set the `storefront` option to `public` to let anyone read the published
catalog of an organization:

```ts title="better-supabase.config.ts"
blocks: { catalog: { options: { storefront: "public" } } },
```

With it, `anon` can read the active products of organizations that called
`publishStorefront(organizationId, true)`, their variants, options,
categories and collections, and their base prices (prices without a price
list). Price lists stay private. The default, `private`, adds no `anon`
policies.

The storefront also reads active channels and their listings, translations
and product media. `channelProducts`, `channelPrice`, `productText` and
`productMedia` are granted to `anon`, and they return only what these
policies allow. A media row names its attachment; the file itself stays
behind the attachments block's own policies.

## Subjects [#subjects]

Installing the block registers products (`product`) and variants
(`product_variant`) as subjects, so comments, tags, attachments and search
work on them. A product links to `/products/{slug}` unless you set its page:

```ts title="better-supabase.config.ts"
blocks: {
  catalog: { entities: { product: { url: "/admin/products/{id}" } } },
},
```

## Events [#events]

| Event                                                   | When                                      |
| ------------------------------------------------------- | ----------------------------------------- |
| `product.created`, `product.updated`                    | `createProduct`, `updateProduct`          |
| `product.status_changed`                                | A status transition, with `from` and `to` |
| `product.deleted`                                       | `deleteProduct`                           |
| `variant.created`, `variant.updated`, `variant.deleted` | A variant change                          |
| `catalog.price_changed`                                 | A price's amount changes                  |
| `storefront.published`, `storefront.unpublished`        | `publishStorefront`                       |

## Hooks [#hooks]

The SQL module calls `before_product_save`, `after_product_save`,
`before_variant_save`, `after_variant_save`, `before_price_set` and
`after_price_set` when you define them (see [blocks](/docs/extending/blocks)).
The TypeScript client takes `hooks` keyed by method name.

## Adopting existing tables [#adopting-existing-tables]

To run over tables you already have, map `products`, `variants` and `prices`
to yours. Every other table is optional: set it to `null` and the functions that need it are
left out. Columns such as `description`, `barcode`, `compareAt` or
`validFrom` can also be `null`.

The tables added in version 3 (`channels`, `channelProducts`,
`productTranslations`, `variantTranslations` and `media`) run over a table of
yours only when `tables` maps them. Unmapped, the module creates its own, so
a table of yours under one of those default names is an error until you map
it.

## Not included [#not-included]

Product import from CSV goes through the [imports](/docs/blocks/imports)
block with a mapping you write. Rule collections are not evaluated in SQL;
the app runs the stored filter through its list query. Price lists don't
know who belongs to a customer group yet, so only base prices are public.

## Your own fields [#your-own-fields]

`blocks.catalog.options.extraColumns` adds columns to the `products` table,
keyed by column name with their SQL type. Pass `createCatalog` a Standard Schema
for them as `fields`: `createProduct`, `updateProduct` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and the saved product return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createCatalog({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.catalog` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const catalog = createCatalog({ transport });

const shirt = await catalog
  .createProduct(organizationId, { title: "Linen Shirt", kind: "physical" })
  .orThrow();
// { id, slug: "linen-shirt", status: "draft" }

await catalog.setProductStatus(shirt.id, "active").orThrow();
```

# Donations

> Record one-off and recurring gifts from donors, post them on the ledger per fund or campaign, charge pledges through a payments provider once per period, and issue numbered tax receipts with a yearly summary.

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

`donations` records gifts from donors, who are any records in
`config.records`. Gifts go to funds or campaigns and post on the
[ledger](/docs/blocks/ledger), so what a fund raised is a ledger balance.
Pledges repeat a gift every week, month, quarter or year. Card and bank
charges go through a payments provider, and tax receipts get numbers from a
sequence per organization.

```bash
better-supabase add donations
```

```ts
import { createDonations } from "better-supabase/blocks/donations";
import { money } from "better-supabase/commerce";
```

The module requires `tenant`, `access`, `subjects`, `ledger` and
[numbering](/docs/blocks/numbering), which numbers the receipts. With
`outbox` it records the `donation.*`, `pledge.*` and `donation_receipt.*`
events, and with `audit` every change is audited. Amounts are bigint minor units
([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)).
Charges go through the versioned `PaymentProvider` interface
([ADR 0024](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0024-money-and-commerce-providers.md)),
so the block imports no payments SDK. How pledges post on the ledger is in
[ADR 0080](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0080-recurring-charges-on-the-ledger.md).

## Tables [#tables]

| Table               | Holds                                                                                                           |
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
| `donation_funds`    | A fund or campaign per organization, by `key`, with an optional goal and dates                                  |
| `pledges`           | A recurring gift: the donor, the amount per `frequency`, the dates, the status and the provider that charges it |
| `donations`         | A gift: the donor, the fund, the amount, the status, the payment and its ledger transactions                    |
| `donation_receipts` | A numbered tax receipt for one gift or for a donor's year in one currency                                       |

Members with `donations.read` read every row of the organization. Writes go
through the functions and need `donations.manage`; provider events are
applied with the service role.

## Your own columns [#your-own-columns]

`extraColumns` adds columns to the `donations` table, and the client's `fields` schema types them.

Server code calls the `donations` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDonations } = defineDataHooks(
  "donations",
  (db: AppDb) => db.donations,
);
```

```ts
const rows = await db.donations.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/donations/list.tsx"
"use client";

import { useDonations } from "@/lib/supabase/hooks";

const rows = useDonations({ limit: 50 });
```

`recordGift` and `give` validate them before any call, and every read that returns a gift parses them under `gift.fields`. A gift a pledge run opens has none set.

## Funds and gifts [#funds-and-gifts]

```ts
const donations = createDonations({
  transport,
  tenant: organizationId,
  payments: stripePayments,
});

const fund = await donations
  .saveFund({
    key: "roof_appeal",
    name: "Roof appeal",
    kind: "campaign",
    goal: money(5_000_000n, "EUR"),
  })
  .orThrow();

await donations
  .recordGift({
    donor: { type: "contacts", id: contactId },
    amount: money(25_000n, "EUR"),
    fundId: fund.id,
    method: "bank_transfer",
  })
  .orThrow();
```

`recordGift` records a gift received outside a provider (cash, a check, a
bank transfer) and posts it at once: cash in, fund income out. `give` opens
a pending gift and charges it through the provider; the gift posts when the
payment succeeds, either at once or from the webhook. Each posting carries a
key per gift (`donation:<id>:posted`, `donation:<id>:refunded`), so a
repeated event changes nothing. A gift
without a fund posts to the `general` income account. `funds()` returns each
fund with what it `raised` in its goal's currency.

| Method                                      | Returns                                                           |
| ------------------------------------------- | ----------------------------------------------------------------- |
| `saveFund(input)`                           | the fund, created or updated by key                               |
| `archiveFund(fundId)`                       | the archived fund                                                 |
| `funds({ includeArchived })`                | funds with what they raised                                       |
| `recordGift(input)`                         | the posted gift                                                   |
| `give(input)`                               | the gift and the provider's payment                               |
| `getGift(giftId)`, `gifts(options)`         | gifts, newest first                                               |
| `createPledge(input)`                       | the pledge                                                        |
| `updatePledge(pledgeId, update)`            | the pledge, paused, resumed, ended or with a new amount           |
| `pledges(options)`                          | pledges                                                           |
| `dueInstallments(options)`                  | the installments due without a gift, oldest first                 |
| `chargeDuePledges(options)`                 | one outcome per installment: charged, skipped or failed           |
| `handlePaymentEvent(event)`                 | what the provider event did to its gift                           |
| `webhook(input)`                            | the verified webhook's events applied                             |
| `issueGiftReceipt(giftId)`                  | a receipt for one posted gift                                     |
| `issueYearlyReceipt(donor, year, currency)` | a donor's receipt for the year                                    |
| `voidReceipt(receiptId, reason)`            | the voided receipt                                                |
| `receipts(options)`                         | receipts, newest first                                            |
| `givingSummary(donor, year)`                | a donor's posted totals per currency and fund, and their receipts |

## Pledges [#pledges]

```ts
await donations
  .createPledge({
    donor: { type: "contacts", id: contactId },
    amount: money(1_000n, "EUR"),
    frequency: "monthly",
    provider: stripePayments.name,
    providerCustomer: "cus_123",
  })
  .orThrow();

const outcomes = await donations.chargeDuePledges().orThrow();
```

`chargeDuePledges` charges every due installment of the pledges this
provider charges. A period has at most one gift (the `donations` row is
unique per pledge and period), and the charge carries the idempotency key
`pledge:<id>:<period start>`. A run that stops halfway charges nothing twice
when it runs again: a gift opened but never charged stays due under the same
key. A declined charge marks the gift `failed`, and the period isn't charged
again. Another error leaves it pending for the next run. Pledges without a
provider are recorded by hand with `recordGift({ pledgeId, periodStart })`;
the charge run skips them.

`updatePledge` pauses, resumes or ends a pledge, or changes its amount for
installments from then on. An ended pledge can't restart; create a new one.

## Payment events [#payment-events]

```ts
export async function POST(request: Request) {
  const outcome = await serviceDonations.webhook({
    body: await request.text(),
    headers: request.headers,
  });
  return new Response(null, { status: outcome.ok ? 204 : 400 });
}
```

`webhook` verifies the request with the provider and applies each payment
event to its gift: a succeeded payment posts it, a failed one marks it, and
a refund reverses the posting and voids the gift's receipt. Run the webhook
client with the service role.

## Tax receipts [#tax-receipts]

```ts
await donations.issueGiftReceipt(giftId).orThrow();

await donations
  .issueYearlyReceipt({ type: "contacts", id: contactId }, 2026, "EUR")
  .orThrow();
```

A gift receipt covers one posted gift; issuing it again returns the same
receipt. A yearly receipt covers a donor's
posted gifts received in a year and currency; issuing it again returns the
same receipt until it is voided. Numbers come from the
[numbering](/docs/blocks/numbering) block: donations contributes a
`donation_receipt` sequence that counts up from 1 per organization with the
format `R-{n}`, and an entry under `blocks.numbering.options.entities`
replaces it. Version 2 seeds each organization's sequence with its highest
receipt number, so numbers continue. A voided receipt keeps its number, and a
voided gift receipt frees the gift for a new one.

## Section [#section]

The [giving binding](/docs/repository/record-bindings) shows a donor's giving this year
on their record page.

## Merging donors [#merging-donors]

When two records merge, the dropped record's gifts, pledges and receipts
move to the kept record.

## Errors [#errors]

| Hint                         | Cause                                                                   |
| ---------------------------- | ----------------------------------------------------------------------- |
| `DONATIONS_FORBIDDEN`        | the caller lacks `donations.read` or `donations.manage`                 |
| `DONATIONS_NO_PAYMENTS`      | `give`, `chargeDuePledges` or `webhook` ran without a payments provider |
| `DONATION_AMOUNT_INVALID`    | the amount isn't above zero or its currency isn't valid                 |
| `DONATION_DONOR_INVALID`     | the donor isn't a record of the organization in `config.records`        |
| `DONATION_FUND_INVALID`      | the fund's key, name, goal or dates aren't valid, or it's archived      |
| `DONATION_FUND_NOT_FOUND`    | no fund with this id in the organization                                |
| `DONATION_NOT_FOUND`         | no gift with this id that the caller can read                           |
| `DONATION_STATUS_INVALID`    | the gift isn't in a status that allows the change, or isn't posted yet  |
| `DONATION_PLEDGE_INVALID`    | the pledge's amount, frequency, dates or period aren't valid            |
| `DONATION_PLEDGE_NOT_FOUND`  | no pledge with this id in the organization                              |
| `DONATION_PLEDGE_ENDED`      | the pledge has ended                                                    |
| `DONATION_RECEIPT_EMPTY`     | the donor has no posted gifts to receipt for that year and currency     |
| `DONATION_RECEIPT_INVALID`   | a yearly receipt has no year or currency                                |
| `DONATION_RECEIPT_NOT_FOUND` | no receipt with this id in the organization                             |

## Events [#events]

`donation.received` (a gift posted), `donation.failed`, `donation.refunded`,
`pledge.created`, `pledge.updated`, `donation_receipt.issued` and
`donation_receipt.voided`. The [events page](/docs/extending/events) lists
their payloads.

## Without the client [#without-the-client]

`db.donations` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
// better-supabase.config.ts
blocks: {
  donations: { options: { extraColumns: { appeal: "text" } } },
},

// the client
const donations = createDonations({
  transport,
  tenant,
  fields: v.object({ appeal: v.optional(v.string()) }),
});
```

# 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 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; `add` stops without one:

```ts title="better-supabase.config.ts"
export default defineConfig({
  auth: {
    entitlements: {
      customer: "billing_customers.stripe_customer_id",
      key: "organization_id", // the tenant id column, defaults to id
    },
  },
});
```

`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.
`auth.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({
  auth: {
    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 `memberships` 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 `auth.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.

`auth.entitlements.claim` controls the claim's size and shape:

```ts title="better-supabase.config.ts"
export default defineConfig({
  auth: {
    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 upgrade`
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 `auth.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 bsb.session();
if (!hasEntitlement(session, organizationId, "exports"))
  return <UpgradePrompt />;
```

This is UX: the policy above still decides.

`hasEntitlement` also takes the client's `useAuth()` snapshot. In a Client
Component, `useFeatureGate` checks the active organization (or `organizationId`)
and follows the session, so the gate opens as soon as a refresh after checkout
brings the new claim. `allowed` is `undefined` while the session loads. The same
hook runs in React DOM and React Native. It is a registry item: install it with
`npx shadcn add @better-supabase/use-entitlements`, which writes
`hooks/use-entitlements.ts` into the app.

```tsx title="components/feature-gate.tsx"
import { useFeatureGate } from "@/hooks/use-entitlements";

export function FeatureGate({ feature, children }) {
  const { allowed } = useFeatureGate(feature);
  if (allowed === undefined) return null;
  return allowed ? children : <UpgradePrompt />;
}
```

It takes `claim` and `tenantClaim` when `auth.claims.features` or
`auth.organization.claim` rename them. `createFeatureGateController` from
`better-supabase/blocks/entitlements` is the same logic without React. Vue,
Svelte and Solid run it with `useBlockController` from `better-supabase/vue`,
`/svelte` and `/solid`, taking the controller's options with `auth` from the
provider
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)). The
[feature gate recipe](/docs/registry/ui-recipes/feature-gate) shows it in the
Next.js and Expo examples.

```vue title="ExportButton.vue"
<script setup lang="ts">
import { createFeatureGateController } from "better-supabase/blocks/entitlements";
import { useBlockController } from "better-supabase/vue";

const { state } = useBlockController(createFeatureGateController, {
  feature: "exports",
});
</script>

<template>
  <button v-if="state.allowed">Export CSV</button>
</template>
```

```svelte title="ExportButton.svelte"
<script lang="ts">
  import { createFeatureGateController } from "better-supabase/blocks/entitlements";
  import { useBlockController } from "better-supabase/svelte";

  const gate = useBlockController(createFeatureGateController, {
    feature: "exports",
  });
</script>

{#if $gate.allowed}<button>Export CSV</button>{/if}
```

```tsx title="ExportButton.tsx"
import { createFeatureGateController } from "better-supabase/blocks/entitlements";
import { useBlockController } from "better-supabase/solid";

export function ExportButton() {
  const { state } = useBlockController(createFeatureGateController, {
    feature: "exports",
  });
  return state().allowed ? <button>Export CSV</button> : null;
}
```

## From the server [#from-the-server]

`createEntitlements` asks the database instead of the token, so it sees a
plan change before the next refresh. `has` calls `has_entitlement` and
`value` calls `entitlement_value`, both as the caller, so a non-member gets
`false` and `undefined`. `hooks` runs your code around either method (see
[extending blocks](/docs/extending/blocks#hooks)):

```ts
import {
  createEntitlements,
  sqlTransport,
} from "better-supabase/blocks/entitlements";

const entitlements = createEntitlements({
  transport: sqlTransport(postgres.asUser(ctx.auth.claims)),
});

if (!(await entitlements.has(organizationId, "exports").orThrow()))
  return new Response("Upgrade to export", { status: 402 });
const seats = await entitlements.value(organizationId, "seats").orThrow();
```

## 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 `memberships` module, and
`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`, `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.

`auth.entitlements.memberships: "tenant"` keeps the `memberships` module's
`better_supabase.memberships` even with a provider.

`features` is not a claim a provider usually owns, so `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/queue#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, sqlTransport } from "better-supabase/blocks/queue";

const inbox = createWebhookInbox({
  transport: sqlTransport(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) bsb.invalidateSession(userId);
  });
  return Response.json(result);
};
```

`invalidateSession` drops `bsb.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.

# Expenses

> Collect expense reports with lines, categories and receipts, check spend policies on submit, approve through the approvals block, and post the approved total and its reimbursement on the ledger.

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

`expenses` lets members file expense reports. A report has lines, each in a
category, with receipts uploaded as attachments. Categories carry a spend
policy, which `submit` checks before the report goes to the
[approvals](/docs/blocks/approvals) block. An approved report posts on the
[ledger](/docs/blocks/ledger), and so does its reimbursement, so what the
organization owes a member is a ledger balance.

```bash
better-supabase add expenses
```

```ts
import { createExpenses } from "better-supabase/blocks/expenses";
import { money } from "better-supabase/commerce";
```

The module requires `tenant`, `access`, `approvals` and `ledger`. With
`outbox` it records the `expense_report.*` events, with `audit` every change
is audited, and with [attachments](/docs/blocks/attachments) lines take
receipts. Amounts are bigint minor units
([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)),
and how the approved total posts on the ledger is in
[ADR 0080](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0080-recurring-charges-on-the-ledger.md).

## Tables [#tables]

| Table                | Holds                                                                                                     |
| -------------------- | --------------------------------------------------------------------------------------------------------- |
| `expense_categories` | A category per organization, by `key`, with its currency, `limit_amount` and `receipt_over`               |
| `expense_reports`    | A member's report: the title, the currency, the approval, and the approval and reimbursement transactions |
| `expense_lines`      | A line: the category, the day, the amount, the description and the merchant                               |

A member reads their own reports and lines. Members with `expenses.read`
read every report of the organization. Writes go through the functions:
`expenses.submit` to file reports, `expenses.approve` to approve them (the
approvals block checks it), and `expenses.manage` for categories and
reimbursement.

## Your own columns [#your-own-columns]

`extraColumns` adds columns to the `expense_reports` table, and the client's `fields` schema types them.

Server code calls the `expenses` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useExpenses } = defineDataHooks(
  "expenses",
  (db: AppDb) => db.expenses,
);
```

```ts
const rows = await db.expenses.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/expenses/list.tsx"
"use client";

import { useExpenses } from "@/lib/supabase/hooks";

const rows = useExpenses({ limit: 50 });
```

`create` validates them before the call, and every read that returns a report parses them under `report.fields`.

## Categories and spend policies [#categories-and-spend-policies]

```ts
const expenses = createExpenses({ transport, tenant: organizationId });

await expenses
  .saveCategory({
    key: "travel",
    name: "Travel",
    limit: money(150_000n, "EUR"),
    receiptOver: money(2_500n, "EUR"),
  })
  .orThrow();
```

`limit` caps a report's total in the category. `receiptOver` requires a
receipt on every line above it, and needs the attachments block. Archiving a
category keeps its lines; a report with a line in an archived category can't
be submitted.

## File and submit a report [#file-and-submit-a-report]

```ts
const report = await expenses
  .create({ title: "Client visit, Lisbon", currency: "EUR" })
  .orThrow();

const line = await expenses
  .addLine(report.id, {
    categoryId,
    spentOn: Temporal.PlainDate.from("2026-10-14"),
    amount: money(18_900n, "EUR"),
    description: "Flight",
    merchant: "TAP",
  })
  .orThrow();

const findings = await expenses.check(report.id).orThrow();
await expenses.submit(report.id).orThrow();
```

A receipt is an attachment on the line: upload it with the
[attachments](/docs/blocks/attachments) client and the subject
`{ type: "expense_line", id: line.id }`. The line's `receipts` counts the
uploads that finished and weren't flagged as infected.

`check` returns what the spend policy would refuse: a category over its
limit, a line over its receipt threshold without a receipt, or a line in an
archived category. `submit` fails with `EXPENSE_POLICY_VIOLATION` while there
are findings, and otherwise opens an approval. Lines can't change while the
report is submitted; a rejected report can be edited and submitted again.

| Method                               | Returns                                             |
| ------------------------------------ | --------------------------------------------------- |
| `saveCategory(input)`                | the category, created or updated by key             |
| `archiveCategory(categoryId)`        | the archived category                               |
| `categories({ includeArchived })`    | categories                                          |
| `create(input)`                      | the caller's draft report                           |
| `addLine(reportId, line)`            | the line                                            |
| `updateLine(lineId, update)`         | the line                                            |
| `removeLine(lineId)`                 | the report without the line                         |
| `check(reportId)`                    | the spend policy findings, empty when it may submit |
| `submit(reportId)`                   | the report with its approval                        |
| `reimburse(reportId, input)`         | the reimbursed report                               |
| `cancel(reportId)`                   | the cancelled report                                |
| `get(reportId)`                      | the report with its lines                           |
| `list({ status, userId, everyone })` | reports, newest first                               |

## Approval and the ledger [#approval-and-the-ledger]

The report's status follows its approval: `submitted` while it's pending,
`approved` or `rejected` once it's decided. Members with `expenses.approve`
decide it through the approvals block. When the approval is final, the
report posts on the ledger: each category's total on its expense account
(`expenses:category:<key>:<currency>`) against the member's payable account
(`expenses:payable:<user>:<currency>`).

```ts
await expenses
  .reimburse(report.id, { reference: "payout-2026-10-31" })
  .orThrow();
```

`reimburse` settles the payable account from expense cash
(`expenses:cash:<currency>`) and needs `expenses.manage`. Each posting has a
key per report (`expense:<id>:approved`, `expense:<id>:reimbursed`), so it
can't post twice. `cancel` withdraws a report that isn't approved yet and
cancels its pending approval.

## Errors [#errors]

| Hint                         | Cause                                                                           |
| ---------------------------- | ------------------------------------------------------------------------------- |
| `EXPENSES_FORBIDDEN`         | the caller lacks the permission for the change                                  |
| `EXPENSE_CATEGORY_INVALID`   | the key, name, limit or threshold isn't valid, or a threshold lacks attachments |
| `EXPENSE_CATEGORY_NOT_FOUND` | no category with this id in the organization                                    |
| `EXPENSE_CURRENCY_MISMATCH`  | the line's amount or its category's policy isn't in the report's currency       |
| `EXPENSE_LINE_INVALID`       | the line's day, amount or description isn't valid                               |
| `EXPENSE_LINE_NOT_FOUND`     | no line with this id that the caller can change                                 |
| `EXPENSE_POLICY_VIOLATION`   | the report breaks the spend policy; the message lists the findings              |
| `EXPENSE_REPORT_EMPTY`       | the report has no lines                                                         |
| `EXPENSE_REPORT_INVALID`     | the title, currency or note isn't valid                                         |
| `EXPENSE_REPORT_LOCKED`      | the report isn't in a status that allows the change                             |
| `EXPENSE_REPORT_NOT_FOUND`   | no report with this id that the caller can read                                 |

## Events [#events]

`expense_report.created`, `expense_report.submitted` (with the approval),
`expense_report.approved` and `expense_report.reimbursed` (with the ledger
transaction) and `expense_report.cancelled`. A rejection is the approvals
block's `approval.rejected` event. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.expenses` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
// better-supabase.config.ts
blocks: {
  expenses: { options: { extraColumns: { project_code: "text" } } },
},

// the client
const expenses = createExpenses({
  transport,
  tenant,
  fields: v.object({ project_code: v.optional(v.string()) }),
});
```

# Inventory

> Stock per catalog variant and location on the ledger block, reservations that expire, transfers between locations and purchase orders with partial receiving.

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

`inventory` counts the stock of [catalog](/docs/blocks/catalog) variants at
each location. Every change is a transaction on the
[ledger](/docs/blocks/ledger): each variant and location has a stock account,
and a movement posts against a counter account (suppliers, customers,
adjustments or in transit). One level row per variant and location keeps
on-hand, reserved, incoming, and available (on-hand minus reserved) in step
with it. On top of the ledger it adds reservations that expire, transfers
between locations, and purchase orders that you receive in parts.

```bash
better-supabase add inventory
```

```ts
import { createInventory } from "better-supabase/blocks/inventory";
import { money } from "better-supabase/commerce";
```

The module needs `tenant`, `permissions` and `catalog`, and emits events when
`outbox` is installed.

## Permissions [#permissions]

| Key                  | What it allows                                            |
| -------------------- | --------------------------------------------------------- |
| `inventory.read`     | Read locations, levels, movements and the other tables    |
| `inventory.manage`   | Locations, adjustments, counts, reorder points, transfers |
| `inventory.reserve`  | Reserve, commit and release stock                         |
| `inventory.purchase` | Create, place, receive and cancel purchase orders         |

The owner role holds `*`. Give the other roles the keys they need:

```ts title="better-supabase.config.ts"
roles: {
  admin: ["catalog.*", "inventory.*", "members.*"],
  warehouse: ["catalog.read", "inventory.read", "inventory.manage", "inventory.purchase"],
  member: ["catalog.read", "inventory.read"],
},
```

Checkout code that runs as the customer usually reserves through a service
transport instead of granting `inventory.reserve` to every member.

## Locations [#locations]

Server code calls the `inventory` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useInventory } = defineDataHooks(
  "inventory",
  (db: AppDb) => db.inventory,
);
```

```ts
const warehouse = await db.inventory
  .saveLocation(organizationId, { code: "WH-AMS", name: "Amsterdam" })
  .orThrow();
```

```tsx title="src/features/inventory/list.tsx"
"use client";

import { useInventory } from "@/lib/supabase/hooks";

const rows = useInventory({ limit: 50 });
```

`kind` is `warehouse` (the default), `store` or `virtual`. Codes are unique
per organization. `setLocationActive(id, false)` stops new reservations and
transfers at a location, and a location with stock history can't be deleted
(`INVENTORY_LOCATION_IN_USE`).

### Stock at the assets locations [#stock-at-the-assets-locations]

With the [assets](/docs/blocks/assets) block installed, turn the
`inventory_locations` table off and stock lives at the assets block's
locations instead of a second copy:

```ts
// better-supabase.config.ts
blocks: {
  assets: {},
  inventory: { tables: { locations: null } },
},
```

Levels, movements, reservations, transfers, purchase orders, checkout in
`orders` and work order parts in `dispatch` then reference `asset_locations`.
An archived asset location is an inactive stock location, so
`setLocationActive(id, false)` archives it.

## Your own fields [#your-own-fields]

`blocks.inventory.options.extraColumns` adds columns to the `inventory_locations` table,
keyed by column name with their SQL type. `saveLocation` writes them from `fields` and returns them under `fields`; a location you save without `fields` keeps its values. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const inventory = createInventory({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await inventory.saveLocation(organizationId, {
  code: "WH-AMS",
  name: "Amsterdam",
  fields: { region: "emea" },
});
```

## The ledger [#the-ledger]

```ts
await inventory.adjustStock(variantId, warehouse.id, 40, "receipt").orThrow();
await inventory
  .adjustStock(variantId, warehouse.id, -2, "damage", "dropped")
  .orThrow();
// { movementId, onHand: 38, available: 38 }

await inventory
  .countStock(variantId, warehouse.id, 36, "cycle count")
  .orThrow();
// { movementId, delta: -2, onHand: 36 }
```

`adjustStock` takes `adjustment`, `damage`, `loss`, `return` or `receipt`.
The other movement reasons (`count`, `sale`, `transfer_out`, `transfer_in`)
come from the functions that write them. A movement is a ledger transaction
of kind `stock.<reason>`, and `movementId` is its id. Ledger entries can't be
updated; a correction is a new movement. Read a variant's history with the
ledger client's `entries(accountId)` on its stock accounts (`kind: "stock"`,
`managedBy: "inventory"`).

When the catalog variant has `trackInventory` on and `allowBackorder` off,
the ledger refuses a movement that would take on-hand below zero, or take
available below zero for a sale or a transfer (`INVENTORY_INSUFFICIENT`). A
variant with `allowBackorder` can go negative.

`setReorderPoint(variantId, locationId, 10)` stores a threshold. When
available stock drops to it or below, the module emits
`stock.reorder_point_reached`, once per crossing.

A catalog variant or product with stock history can't be deleted
(`CATALOG_VARIANT_IN_USE`, `CATALOG_PRODUCT_IN_USE`); archive it instead.

## Reservations [#reservations]

```ts
const hold = await inventory
  .reserve(variantId, warehouse.id, {
    quantity: 2,
    referenceType: "cart",
    referenceId: cartId,
  })
  .orThrow();
// { id, expiresAt, available }

await inventory.commitReservation(hold.id).orThrow(); // writes a `sale` movement
await inventory.releaseReservation(hold.id).orThrow(); // false once closed
```

A reservation holds stock until it is committed, released or expires, 15
minutes from now by default (`expiresAt` sets another instant in the future).
A strict variant can't reserve more than is available. Committing an expired
hold fails with `INVENTORY_RESERVATION_EXPIRED`.

Expired holds stop counting the next time someone reserves the same variant
at the same location. To release the rest, run `expireReservations` from a
scheduled job. It is a service-only function, so pass a service transport:

```ts title="lib/inventory.ts"
export const inventory = createInventory({
  transport: sqlTransport(ctx.postgres),
  service: sqlTransport(servicePostgres),
});
```

```ts
await jobs.schedule("inventory:expire", "*/5 * * * *", "inventory.expire", {});

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: { "inventory.expire": inventory.job },
});
```

`inventory.job` reads an optional `maxRows` from the payload.

## Transfers [#transfers]

```ts
const transfer = await inventory
  .createTransfer(organizationId, {
    fromLocationId: warehouse.id,
    toLocationId: store.id,
    lines: [{ variantId, quantity: 5 }],
  })
  .orThrow();

await inventory.shipTransfer(transfer.id).orThrow(); // transfer_out at the source
await inventory.receiveTransfer(transfer.id).orThrow(); // transfer_in at the destination
```

A transfer goes from `draft` to `in_transit` to `received`. Cancelling an
in-transit transfer returns the stock to the source. The two locations must
differ (`INVENTORY_TRANSFER_SAME_LOCATION`), and a step out of order fails
with `INVENTORY_TRANSFER_STATUS`.

## Purchase orders [#purchase-orders]

```ts
const order = await inventory
  .savePurchaseOrder(organizationId, {
    locationId: warehouse.id,
    supplier: { type: "company", id: supplierId },
    currency: "EUR",
    lines: [{ variantId, quantity: 100, unitCost: money(450n, "EUR") }],
  })
  .orThrow();

await inventory.placePurchaseOrder(order.id).orThrow(); // incoming += 100

await inventory
  .receivePurchaseOrder(order.id, [{ lineId, quantity: 60 }])
  .orThrow();
// { status: "partially_received", backordered: 40 }
```

A purchase order can change only while it is a `draft`
(`INVENTORY_PURCHASE_ORDER_LOCKED`). Placing it counts its lines as incoming
stock at the location. Each receipt writes `receipt` movements and lowers
incoming; units still owed stay on backorder until a later receipt, and a
receipt above what is owed fails with `INVENTORY_OVER_RECEIPT`. Cancelling
removes the units still owed from incoming.

Unit costs are `bigint` minor units in the order's currency, checked against
the catalog's ISO 4217 list. The supplier is a `config.records` type and id,
such as a CRM company; the module stores the pair and doesn't check it.

## Events [#events]

| Event                                                                          | When                                             |
| ------------------------------------------------------------------------------ | ------------------------------------------------ |
| `stock.adjusted`                                                               | `adjustStock` and `countStock`                   |
| `stock.reorder_point_reached`                                                  | Available stock drops to the reorder point       |
| `reservation.created`, `reservation.committed`                                 | `reserve`, `commitReservation`                   |
| `reservation.released`                                                         | A release, with `reason` `released` or `expired` |
| `transfer.shipped`, `transfer.received`, `transfer.cancelled`                  | A transfer step                                  |
| `purchase_order.placed`, `purchase_order.received`, `purchase_order.cancelled` | A purchase order step                            |

`stock.reorder_point_reached` is a subject event on the `product_variant`
subject, so the [watcher sink](/docs/blocks/notifications#watchers) notifies
the variant's watchers. The [catalog block](/docs/blocks/catalog#subjects)
registers the subject.

## Hooks [#hooks]

The SQL module calls `before_stock_adjust`, `after_stock_adjust`,
`before_stock_reserve` and `after_stock_reserve` when you define them (see
[blocks](/docs/extending/blocks)). The TypeScript client takes `hooks` keyed
by method name.

## Adopting existing tables [#adopting-existing-tables]

To run over tables you already have, map `locations`, `levels` and
`reservations` to yours;
movements live in the ledger block's tables. Transfers (`transfers`, `transferLines`) and purchase orders
(`purchaseOrders`, `purchaseOrderLines`) are optional: set them to `null`
and their functions are left out.

## Not included [#not-included]

Sales orders, fulfilment and returns belong to an orders block. Inventory
doesn't value stock (no FIFO or average cost), doesn't pick a location for
an order, and doesn't track lots, serial numbers or expiry dates.

## Without the client [#without-the-client]

`db.inventory` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const inventory = createInventory({ transport });

const warehouse = await inventory
  .saveLocation(organizationId, { code: "WH-AMS", name: "Amsterdam" })
  .orThrow();
```

# Invoices

> Invoices an organization sends its own customers, with drafts and lines, a gapless number on issue, payments and refunds, credit notes, void and write-off, overdue and aging per currency, and invoices from accepted quotes.

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

`invoices` is accounts receivable: the invoices an organization sends its own customers for work, licences or goods. It is not [billing](/docs/blocks/billing), which charges your organizations for your app through Stripe. An invoice starts as a draft with lines, gets its number when you issue it, collects payments until its balance is zero, and can be credited, voided or written off. With the [quotes](/docs/blocks/quotes) module installed, an accepted quote becomes a deposit invoice and a final invoice.

Reading needs `invoices.read`, creating, issuing, recording payments and credit notes needs `invoices.write`, and voiding and writing off needs `invoices.manage`. Admins get every permission, and members get `invoices.read`.

```bash
better-supabase add invoices
```

The module needs `tenant`, `permissions` and `subjects`.

Server code calls the `invoices` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useInvoices } = defineDataHooks(
  "invoices",
  (db: AppDb) => db.invoices,
);
```

```ts
import { createInvoices } from "better-supabase/blocks/invoices";
import { money } from "better-supabase/commerce";

const draft = await db.invoices
  .create(
    organizationId,
    { title: "October support", company: { type: "company", id: acmeId } },
    [
      {
        description: "Support hours",
        quantity: 12,
        unitAmount: money(9_500n, "EUR"),
        taxRateBps: 2100,
      },
    ],
  )
  .orThrow();
const invoice = await db.invoices.issue(draft.id).orThrow(); // INV-1, due in 30 days
await db.invoices
  .recordPayment(invoice.id, {
    amount: money(137_940n, "EUR"),
    reference: "bank-2026-10-21-1",
  })
  .orThrow();
```

```tsx title="src/features/invoices/list.tsx"
"use client";

import { useInvoices } from "@/lib/supabase/hooks";

const rows = useInvoices({ limit: 50 });
```

## Your own fields [#your-own-fields]

`blocks.invoices.options.extraColumns` adds columns to the `invoices` table,
keyed by column name with their SQL type. `create` writes them from the input by name, and every invoice comes back with them under `fields`. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const invoices = createInvoices({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await invoices.create(organizationId, {
  title: "October",
  fields: { region: "emea" },
});
```

## Issue and number [#issue-and-number]

A draft has no number. `issue(invoiceId, issuedOn?)` gives it the next number of its kind (`INV-1`, credit notes `CN-1`, or the formats of `invoice` and `credit_note` entities in [numbering](/docs/blocks/numbering)), sets the issue date and a due date `termsDays` later (`options.termsDays`, default 30), and freezes its lines. Numbers are taken only on issue, so a voided draft leaves no gap. An issued invoice voided by mistake keeps its number.

## Payments, credits and balance [#payments-credits-and-balance]

Amounts are `Money` values from [commerce](/docs/concepts/commerce): lines, totals, payments, the paid, credited and outstanding sums and the aging buckets. The database functions send them as text, so they stay exact past 2^53. Every line, credit and payment must be in the invoice's currency: the client returns `CURRENCY_MISMATCH` before calling the database, and `record_invoice_payment` refuses a payment in another currency (`INVOICE_CURRENCY_MISMATCH`). An invoice created without a `currency` takes the currency of its lines.

The balance is the total minus credits minus payments. `recordPayment` adds a payment (a negative amount is a refund, which can't exceed what was paid: `INVOICE_REFUND_TOO_LARGE`) and moves the invoice to `partially_paid` or `paid`. A `reference`, such as a bank transaction or a payment provider id, is unique per organization, so a webhook that delivers the same payment twice records it once.

Payments live on the [ledger](/docs/blocks/ledger). Each invoice has a receivable account (code `invoice:<id>`) and each currency a cash account (`invoices:cash:<currency>`); a payment is a transaction of kind `invoice.payment` (or `invoice.refund`) that moves the amount from the receivable to cash, and its `paymentId` is the transaction id. The ledger client's `holderAccounts({ type: "invoices", id })` reads an invoice's receivable account with its entries.

`credit(invoiceId, { lines? })` makes a draft credit note for the whole invoice, or for the given lines; issuing it reduces the original's balance and can settle it. A credit note can't exceed what is still owed (`INVOICE_CREDIT_TOO_LARGE`).

`void(invoiceId, reason)` voids a draft, or an issued invoice without payments or credits. `writeOff(invoiceId)` marks an open invoice uncollectible. Both are written to the audit log.

| Status           | Meaning                            |
| ---------------- | ---------------------------------- |
| `draft`          | lines can change; no number yet    |
| `issued`         | numbered and sent; nothing paid    |
| `partially_paid` | paid or credited in part           |
| `paid`           | balance zero                       |
| `void`           | cancelled before anything was paid |
| `uncollectible`  | written off                        |

Every invoice a read returns has `balance` and `overdue` (open and past its due date).

## From a quote [#from-a-quote]

```ts
const deposit = await invoices
  .fromQuote(quoteId, { depositBps: 3000 })
  .orThrow(); // 30%
const final = await invoices.fromQuote(quoteId).orThrow();
```

The quote must be accepted (`INVOICE_QUOTE_NOT_ACCEPTED`). A deposit invoice has one line for that share of the quote's subtotal at the quote's tax rate. The final invoice copies the quote's lines and subtracts each deposit invoice already issued; it is made once per quote, so calling it again returns the same draft.

## Aging [#aging]

`aging(organizationId, currency, asOf?)` sums what is owed in one currency by days past due: not yet due, 1 to 30, 31 to 60, 61 to 90 and over 90, with the total outstanding and the number of overdue invoices.

## Events [#events]

| Event                                                                                        | Data                                                                                                                             |
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `invoice.created`, `invoice.issued`, `invoice.paid`, `invoice.voided`, `invoice.uncollected` | `organizationId`, `invoiceId`, `subjectType`, `subjectId`, `identifier`, and `total`, `currency`, `dueOn`, `reason` or `balance` |
| `invoice.payment_recorded`                                                                   | the same, with `paymentId`, `amount` and `balance`                                                                               |
| `invoice.credited`                                                                           | the same, with `creditNoteId` and `amount`                                                                                       |

Send dunning reminders from `invoice.issued` with a [workflow](/docs/blocks/workflows) that waits until the due date and checks the balance.

## Hooks [#hooks]

The module calls `before_invoice_issue(invoice uuid)`, `after_invoice_issue(invoice uuid)`, `before_invoice_payment(invoice uuid, amount bigint)` and `after_invoice_payment(invoice uuid, payment uuid)`, so an app can export an issued invoice to its accounting system or release an order once it is paid.

## Errors [#errors]

| Hint                         | Cause                                                                                       |
| ---------------------------- | ------------------------------------------------------------------------------------------- |
| `INVOICE_FORBIDDEN`          | the caller lacks `invoices.write`                                                           |
| `INVOICE_ATTRS_INVALID`      | a missing title or a field with a bad value                                                 |
| `INVOICE_NOT_FOUND`          | no invoice the caller can edit (or manage, for void)                                        |
| `INVOICE_NOT_DRAFT`          | the invoice was already issued                                                              |
| `INVOICE_EMPTY`              | an invoice without lines can't be issued                                                    |
| `INVOICE_NOT_PAYABLE`        | only an issued invoice takes payments                                                       |
| `INVOICE_PAYMENT_INVALID`    | a payment needs an amount                                                                   |
| `INVOICE_CURRENCY_MISMATCH`  | a payment isn't in the invoice's currency                                                   |
| `INVOICE_REFUND_TOO_LARGE`   | a refund is more than was paid                                                              |
| `INVOICE_NOT_CREDITABLE`     | only an issued invoice is credited                                                          |
| `INVOICE_CREDIT_TOO_LARGE`   | the credit is more than is owed                                                             |
| `INVOICE_NOT_VOIDABLE`       | an invoice with payments or credits is credited instead                                     |
| `INVOICE_NOT_OPEN`           | only an open invoice is written off                                                         |
| `INVOICE_QUOTE_NOT_ACCEPTED` | the quote isn't accepted                                                                    |
| `INVOICE_DEPOSIT_INVALID`    | a deposit is between 0 and 100 percent                                                      |
| `INVOICE_ORIGIN_NOT_FOUND`   | no quote the caller can invoice                                                             |
| `CURRENCY_MISMATCH`          | a line, credit or payment isn't in the record's currency; returned before any database call |
| `CURRENCY_INVALID`           | the currency isn't an ISO 4217 code                                                         |
| `LINES_CURRENCY`             | a line sent straight to SQL isn't in the record's currency                                  |

## Without the client [#without-the-client]

`db.invoices` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createInvoices } from "better-supabase/blocks/invoices";
import { money } from "better-supabase/commerce";

const invoices = createInvoices({ transport });
const draft = await invoices
  .create(
    organizationId,
    { title: "October support", company: { type: "company", id: acmeId } },
    [
      {
        description: "Support hours",
        quantity: 12,
        unitAmount: money(9_500n, "EUR"),
        taxRateBps: 2100,
      },
    ],
  )
  .orThrow();
const invoice = await invoices.issue(draft.id).orThrow(); // INV-1, due in 30 days
await invoices
  .recordPayment(invoice.id, {
    amount: money(137_940n, "EUR"),
    reference: "bank-2026-10-21-1",
  })
  .orThrow();
```

# Leases

> Let units to parties with rent in bigint minor units, post each rent period on the ledger once, and hold deposits through ledger holds.

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

`leases` lets units from the [assets](/docs/blocks/assets) block to parties
such as contacts. Rent posts on the [ledger](/docs/blocks/ledger) once per
period, so the balance due is a ledger balance and never a second copy.
Deposits are held through ledger holds while the lease runs. Maintenance
requests are a [tasks](/docs/blocks/tasks) instance on the unit.

```bash
better-supabase add leases
```

```ts
import { createLeases } from "better-supabase/blocks/leases";
import { money } from "better-supabase/commerce";
```

The module requires `tenant`, `access`, `subjects`, `assets` and `ledger`.
With `outbox` it records the `lease.*` events,
and with `audit` every change is audited. Amounts are bigint minor units
([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)),
and how recurring charges post on the ledger is in
[ADR 0080](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0080-recurring-charges-on-the-ledger.md).

## Tables [#tables]

| Table              | Holds                                                                                                                                 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `lease_agreements` | The unit (an asset), the party (`party_type`, `party_id`), the dates, `rent` and `currency` per `frequency`, the deposit and its hold |
| `lease_charges`    | One row per rent period posted: the period, the amount and the ledger transaction, unique per lease and period                        |

Members with `leases.read` read every lease and charge of the organization.
Writes go through the functions, which need `leases.manage`.

## Your own columns [#your-own-columns]

`extraColumns` adds columns to the `lease_agreements` table, and the client's `fields` schema types them.

Server code calls the `leases` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useLeases } = defineDataHooks(
  "leases",
  (db: AppDb) => db.leases,
);
```

```ts
const rows = await db.leases.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/leases/list.tsx"
"use client";

import { useLeases } from "@/lib/supabase/hooks";

const rows = useLeases({ limit: 50 });
```

`create` validates them before the call, and every read that returns a lease parses them under `lease.fields`.

## Create and start a lease [#create-and-start-a-lease]

```ts
const leases = createLeases({ transport, tenant: organizationId });

const lease = await leases
  .create({
    unitId,
    party: { type: "contacts", id: contactId },
    startsOn: Temporal.PlainDate.from("2026-11-01"),
    endsOn: Temporal.PlainDate.from("2027-10-31"),
    rent: money(120_000n, "EUR"),
    frequency: "monthly",
    deposit: money(240_000n, "EUR"),
  })
  .orThrow();

await leases.activate(lease.id).orThrow();
```

`create` makes a draft. The party is any record in `config.records`, and the
deposit is in the rent's currency. `activate` starts the lease: it fails with
`LEASE_OVERLAP` while another active lease of the unit overlaps the dates,
posts the deposit on the lease's deposit account and holds it there. A lease
without `endsOn` runs until it is terminated.

| Method                            | Returns                                        |
| --------------------------------- | ---------------------------------------------- |
| `create(input)`                   | the draft lease                                |
| `activate(leaseId)`               | the active lease, with the deposit held        |
| `renew(leaseId, renewal)`         | the lease with its new end and rent            |
| `terminate(leaseId, options)`     | the ended or terminated lease                  |
| `get(leaseId)`                    | the lease, with `balanceDue` and `depositHeld` |
| `list({ status, unitId, party })` | leases, newest first                           |
| `postDueRent({ through })`        | how many rent periods posted                   |
| `recordPayment(leaseId, input)`   | the payment's transaction and the balance due  |
| `charges(leaseId)`                | the posted rent periods, newest first          |

## Rent on the ledger [#rent-on-the-ledger]

```ts
const posted = await leases.postDueRent().orThrow();

await leases
  .recordPayment(lease.id, {
    amount: money(120_000n, "EUR"),
    reference: "bank-2026-11-03-0042",
  })
  .orThrow();
```

`postDueRent` posts every period of the organization's active leases that
starts on or before `through` (default today). Each period posts once: the
`lease_charges` row is unique per lease and period, and its ledger
transaction carries the key `leases.rent:<id>:<period start>`. Run it as
often as you like; a second run posts nothing new, and `maxRows` caps one
run. The SQL function `post_due_rent` called with the service role and a
null tenant runs for every organization, which suits a
[scheduled job](/docs/blocks/queue).

Each lease has a receivable account on the ledger (`leases.rent:<id>`). Rent
moves an amount from rent income onto it, and `recordPayment` moves a
payment from it to rent received. `balanceDue` on the lease is that
account's balance, so a prepayment shows as a negative balance. A payment
with a `reference` returns the first payment when you record it again.

A period that starts before the lease's end posts in full, even when the
lease ends partway through it.

## Renew and terminate [#renew-and-terminate]

```ts
await leases
  .renew(lease.id, {
    endsOn: Temporal.PlainDate.from("2028-10-31"),
    rent: money(125_000n, "EUR"),
  })
  .orThrow();

await leases
  .terminate(lease.id, {
    on: Temporal.PlainDate.from("2027-04-30"),
    reason: "Moved out",
    depositDeduction: money(15_000n, "EUR"),
  })
  .orThrow();
```

`renew` moves the end of an active lease with a fixed end to a later day.
A new rent applies to the periods posted from then on. `terminate` ends the
lease on `on` (default today): its status is `ended` on or after its end and
`terminated` before. No rent posts for periods from that day. The deposit
hold is captured for `depositDeduction` into rent income, and the rest goes
back to the party. A draft is terminated without touching the ledger.

## Maintenance requests [#maintenance-requests]

Install a second [tasks](/docs/blocks/tasks) instance for maintenance
requests, with the unit and the lease as extra columns:

```ts
// better-supabase.config.ts
blocks: {
  maintenance: {
    block: "tasks",
    names: { work_item: "maintenance_request" },
    tables: { projects: null, iterations: null },
    options: { prefix: "MR", extraColumns: { unit_id: "uuid", lease_id: "uuid" } },
  },
},
```

Its tables are `maintenance_requests`, `maintenance_request_comments` and the
other tasks tables, its client is `maintenanceBlock`, and its events are
`maintenance_request.*`. Leases version 2 dropped its own
`maintenance_requests` table and functions; the upgrade renames the table to
`maintenance_requests_legacy` so the instance can take the name. Copy the rows
you keep into the instance before you generate the next schema migration,
which drops the legacy table.

## Section [#section]

The [leases binding](/docs/repository/record-bindings) lists a party's leases on its
record page, with the rent due and the deposit held.

## Merging parties [#merging-parties]

When two records merge, the dropped record's leases move to the kept
record.

## Errors [#errors]

| Hint                    | Cause                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------- |
| `LEASES_FORBIDDEN`      | the caller lacks `leases.read` or `leases.manage`                                            |
| `LEASE_UNIT_INVALID`    | the unit isn't an asset of the organization                                                  |
| `LEASE_PARTY_INVALID`   | the party isn't a record of the organization in `config.records`                             |
| `LEASE_TERMS_INVALID`   | the rent isn't above zero, the dates don't make sense, or the deposit is in another currency |
| `LEASE_DEPOSIT_INVALID` | the deposit deduction is negative or above the deposit                                       |
| `LEASE_OVERLAP`         | another active lease of the unit overlaps these dates                                        |
| `LEASE_STATUS_INVALID`  | the lease isn't in a status that allows the change                                           |
| `LEASE_PAYMENT_INVALID` | the payment isn't above zero                                                                 |
| `LEASE_NOT_FOUND`       | no lease with this id that the caller can read                                               |

## Events [#events]

`lease.created`, `lease.activated`, `lease.renewed`, `lease.terminated`,
`lease.charged` (one rent period) and `lease.paid`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.leases` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
// better-supabase.config.ts
blocks: {
  leases: { options: { extraColumns: { cost_center: "text" } } },
},

// the client
const leases = createLeases({
  transport,
  tenant,
  fields: v.object({ cost_center: v.optional(v.string()) }),
});
```

# Ledger

> Double-entry accounts in a currency or a custom unit, balanced transactions, running balances and holds. Inventory stock, leave balances and invoice payments post through it, and credits, loyalty points, gift cards and trust balances are account kinds.

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

`ledger` keeps double-entry accounts per organization. An account holds an
amount in one unit: an ISO 4217 currency in minor units (`Money`, see
[commerce](/docs/concepts/commerce)), or a custom unit such as `points`,
`item` or `days` at a fixed number of decimal places. Every change is a
transaction whose entries sum to zero per unit, so value only moves between
accounts. Entries can't be changed or deleted: a correction is a reversal.

Reading every account needs `ledger.read`, posting needs `ledger.post`, and
opening and closing accounts needs `ledger.manage`. Admins get all three.
An account can also name a `readPermission` (such as `parties.read`) that
grants reading it, and an `ownerId` who can always read it, so a member sees
their own balance without `ledger.read`.

```bash
better-supabase add ledger
```

The module needs `memberships` and `access`. [Inventory](/docs/blocks/inventory),
[leave](/docs/blocks/leave) and [invoices](/docs/blocks/invoices) require it
and install it with them.

Server code calls the `ledger` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useLedger } = defineDataHooks(
  "ledger",
  (db: AppDb) => db.ledger,
);
```

```ts
import { createLedger } from "better-supabase/blocks/ledger";
import { money } from "better-supabase/commerce";

const credits = await db.ledger
  .openAccount({
    kind: "credit",
    code: `credit:${partyId}`,
    name: "Store credit",
    unit: "EUR",
    holder: { type: "party", id: partyId },
    readPermission: "parties.read",
  })
  .orThrow();

const promotions = await db.ledger
  .openAccount({
    kind: "credit_source",
    code: "credit:promotions",
    name: "Promotions",
    unit: "EUR",
    allowNegative: true,
  })
  .orThrow();

await db.ledger
  .post({
    kind: "credit.grant",
    lines: [
      { accountId: credits.id, amount: money(2500n, "EUR") },
      { accountId: promotions.id, amount: money(-2500n, "EUR") },
    ],
    idempotencyKey: `welcome:${partyId}`,
  })
  .orThrow();
```

```tsx title="src/features/ledger/list.tsx"
"use client";

import { useLedger } from "@/lib/supabase/hooks";

const rows = useLedger({ limit: 50 });
```

## Accounts [#accounts]

An account has a `kind` and a `code` that is unique in the organization. The
kind is free text: `credit`, `loyalty`, `gift_card` and `trust` balances are
accounts of those kinds, each against a source account the app opens once.
`holder` ties an account to a record (`{ type, id }`), and
`holderAccounts(subject)` lists a record's accounts with their balances; the
[ledger binding](/docs/repository/record-bindings) renders them on the record page.

`balance` is the sum of the account's entries, `held` what active holds
reserve, and `available` the balance less what is held. Amounts come back as
`bigint` in the account's unit; `ledgerMoney(account)` turns a currency
account's balance into `Money`. An account refuses a posting that takes its
available balance below zero (`LEDGER_INSUFFICIENT`) unless it was opened
with `allowNegative`.

`closeAccount(accountId)` closes an account with a zero balance and no active
holds. Accounts another block manages (`managedBy` is set) refuse postings
from the client (`LEDGER_ACCOUNT_MANAGED`): only that block's functions post
to them.

## Transactions [#transactions]

`post(input)` writes a transaction of at least two lines that sum to zero per
unit (`LEDGER_UNBALANCED`). `kind` is lowercase words joined by dots, such as
`credit.grant`. `reference` links the transaction to a record, `effectiveAt`
dates it (default now), and an `idempotencyKey` makes a second post return
the first transaction. `reverse(transactionId)` posts the opposite entries
once. `entries(accountId)` pages an account's entries newest first, and
`transaction(id)` reads one transaction with its entries.

## Holds [#holds]

`hold(accountId, { amount, expiresAt })` reserves part of the available
balance, such as a gift card amount during checkout. `capture(holdId,
{ toAccountId, amount })` moves held funds to another account in one
transaction and releases the rest, and `release(holdId)` gives the amount
back. Holds past `expiresAt` are released by `expire_ledger_holds`, which the
service role calls from a cron.

## Blocks that post to the ledger [#blocks-that-post-to-the-ledger]

| Block     | Accounts                                                                   | Transaction kinds                        |
| --------- | -------------------------------------------------------------------------- | ---------------------------------------- |
| inventory | `stock:<variant>:<location>` per variant and location, plus counters       | `stock.receipt`, `stock.sale` and so on  |
| leave     | `leave:<type>:<user>:<year>` per member, type and year, plus pool accounts | `leave.accrual`, `leave.taken` and so on |
| invoices  | `invoice:<id>` receivable per invoice, `invoices:cash:<currency>`          | `invoice.payment`, `invoice.refund`      |

Their functions keep the same names and results; the ledger is where the
amounts live.

## Errors [#errors]

| Hint                           | Cause                                                          |
| ------------------------------ | -------------------------------------------------------------- |
| `LEDGER_FORBIDDEN`             | the caller lacks the permission for this                       |
| `LEDGER_ACCOUNT_CODE_TAKEN`    | an account with this code already exists                       |
| `LEDGER_ACCOUNT_NOT_FOUND`     | no open account with this id in the organization               |
| `LEDGER_ACCOUNT_MANAGED`       | the account belongs to another block                           |
| `LEDGER_ACCOUNT_NOT_EMPTY`     | only an account with a zero balance and no holds can be closed |
| `LEDGER_KIND_INVALID`          | the kind isn't lowercase words joined by dots                  |
| `LEDGER_LINES_INVALID`         | fewer than two lines, a zero amount, or an invalid hold amount |
| `LEDGER_UNBALANCED`            | the lines don't sum to zero per unit                           |
| `LEDGER_INSUFFICIENT`          | the account doesn't have enough available                      |
| `LEDGER_ALREADY_REVERSED`      | the transaction is a reversal or is already reversed           |
| `LEDGER_TRANSACTION_NOT_FOUND` | no transaction with this id                                    |
| `LEDGER_HOLD_NOT_FOUND`        | no hold with this id                                           |
| `LEDGER_HOLD_CLOSED`           | the hold was already captured, released or expired             |
| `LEDGER_APPEND_ONLY`           | an update or delete on entries or transactions                 |

## Events [#events]

`ledger_account.opened` and `ledger_account.closed`,
`ledger_transaction.posted` and `ledger_transaction.reversed`, and
`ledger_hold.placed`, `ledger_hold.captured` and `ledger_hold.released`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.ledger` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createLedger } from "better-supabase/blocks/ledger";
import { money } from "better-supabase/commerce";

const ledger = createLedger({ transport, tenant: organizationId });

const credits = await ledger
  .openAccount({
    kind: "credit",
    code: `credit:${partyId}`,
    name: "Store credit",
    unit: "EUR",
    holder: { type: "party", id: partyId },
    readPermission: "parties.read",
  })
  .orThrow();

const promotions = await ledger
  .openAccount({
    kind: "credit_source",
    code: "credit:promotions",
    name: "Promotions",
    unit: "EUR",
    allowNegative: true,
  })
  .orThrow();

await ledger
  .post({
    kind: "credit.grant",
    lines: [
      { accountId: credits.id, amount: money(2500n, "EUR") },
      { accountId: promotions.id, amount: money(-2500n, "EUR") },
    ],
    idempotencyKey: `welcome:${partyId}`,
  })
  .orThrow();
```

# Orders

> Carts for anonymous and signed-in shoppers, a checkout that prices from the catalog, quotes tax, reserves stock and takes the payment once, then fulfillments, returns, refunds and invoices.

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

`orders` turns [catalog](/docs/blocks/catalog) variants into orders. A cart
belongs to the signed-in user, which can be an anonymous Supabase user, so a
shopper can fill one before creating an account. Checkout prices the cart
from the catalog, quotes tax through a `TaxProvider`, places the order with
a number from `numbering`, reserves its stock when
[inventory](/docs/blocks/inventory) is installed, and creates the payment
through a `PaymentProvider` with an idempotency key. After the sale it
records fulfillments, returns, refunds and the invoice.

```bash
better-supabase add orders
```

Server code calls the `orders` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useOrders } = defineDataHooks(
  "orders",
  (db: AppDb) => db.orders,
);
```

```ts
const rows = await db.orders.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/orders/list.tsx"
"use client";

import { useOrders } from "@/lib/supabase/hooks";

const rows = useOrders({ limit: 50 });
```

The module needs `tenant`, `permissions` and `catalog`. It uses `inventory` for
reservations and stock movements, `promotions` for discount codes,
`numbering` for order and invoice numbers, `state-machine` for the
status column, and `outbox` for events, each when it is installed. The
providers are described on the [commerce](/docs/concepts/commerce) page; both
are optional, and without `payments` only free orders can be placed.

## Permissions [#permissions]

| Key              | What it allows                                                                 |
| ---------------- | ------------------------------------------------------------------------------ |
| `orders.read`    | Read every cart and order in the organization                                  |
| `orders.manage`  | Shipping rates, fulfillments, returns, refunds, cancelling orders and stations |
| `orders.prepare` | Send orders to the kitchen and move their lines through the preparation states |

Shoppers need neither key: a user reads and edits their own carts and reads
their own orders. Placing an order, recording a payment, merging carts and
expiring orders are service-only, so pass a `service` transport as above.

## Carts [#carts]

```ts
await supabase.auth.signInAnonymously();

const cart = await orders
  .createCart(organizationId, { currency: "EUR" })
  .orThrow();
await orders.addToCart(cart.id, variantId, 2).orThrow();
await orders.setCartLine(cart.id, variantId, 1).orThrow(); // 0 removes the line
await orders
  .setCartDetails(cart.id, {
    email: "ada@example.com",
    shippingAddress: {
      line1: "Damrak 1",
      city: "Amsterdam",
      postalCode: "1012 LG",
      country: "NL",
    },
  })
  .orThrow();

const priced = await orders.priceCart(cart.id).orThrow();
// { lines, subtotal, discountTotal, shippingTotal, total, problems }
```

`createCart` returns the caller's open cart for the organization and
currency, and creates one when there is none. Only variants of active
products can be added (`ORDERS_VARIANT_UNAVAILABLE`). `priceCart` reads
today's catalog price for each line; a line whose price changed carries
`previousUnitAmount`, and `problems` lists what keeps the cart from checkout
(an unavailable or unpriced line, a missing shipping rate or address).

When an anonymous shopper signs in to an existing account, Supabase gives
them a different user id. Move their carts on the server:

```ts
await orders.mergeCarts(anonymousUserId, signedInUserId).orThrow();
```

Lines for the same variant add up, and the cart's promotion codes move
with them. Linking an identity to the anonymous user
(`supabase.auth.linkIdentity`) keeps the id, so it needs no merge.

## Shipping [#shipping]

```ts
await orders
  .saveShippingRate(organizationId, {
    name: "Standard",
    countries: ["NL", "BE"],
    amount: money(495n, "EUR"),
    freeOver: money(5000n, "EUR"),
  })
  .orThrow();

const options = await orders.shippingOptions(cart.id).orThrow();
await orders.setShippingRate(cart.id, options[0].id).orThrow();
```

A `flat` rate charges `amount`; a `weight` rate adds `amountPerKg` for each
started kilogram of the variants' weights. `freeOver` makes the rate free once
the discounted subtotal reaches it. A cart with only digital variants needs
no rate.

## Checkout [#checkout]

```ts
const { order, payment } = await orders
  .checkout(cart.id, { returnUrl: "https://shop.example.com/thanks" })
  .orThrow();
// Hand payment.clientSecret to the browser to confirm the payment.
```

`checkout` prices the cart, quotes tax for its lines and shipping, and
places the order in one transaction with the quote's total. If a price
changed in between, it fails with `ORDERS_PRICE_CHANGED` and the shopper
sees the new total. Placing converts the cart, assigns the order number
(`SO-0001` from the `order` sequence when one is configured), copies each
line's SKU, title and price, redeems the cart's promotions and reserves the
stock at `locationId`, or at the location with the most available.

The order starts as `pending_payment` and holds its stock for 30 minutes
(the `paymentWindowMinutes` module option). The payment is created with the
idempotency key `order:<id>:payment`, so calling `checkout` again for the
same cart returns the same order and the same payment. A free order is paid
at once.

With the [invoices block](/docs/blocks/invoices) installed, the payment that
marks an order paid also creates its invoice: one line per order line (the
title and quantity, the net amount and the tax rate it was charged), a
shipping line, issued today and paid in full by the order's payment. The
order's invoice number is that invoice's identifier, and `record_payment`
returns its `invoiceId`.

### Payment events [#payment-events]

Route the provider's webhook to `orders.webhook`. It verifies the delivery,
then applies each payment and refund event:

```ts title="app/api/payments/route.ts"
export async function POST(request: Request) {
  const outcomes = await orders.webhook(request).orThrow();
  return Response.json({ handled: outcomes.length });
}
```

When the captured amount reaches the total, the order becomes `paid`: its
reservations turn into `sale` movements, it gets an invoice number
(`INV-00001` from the `invoice` sequence) and the sale is committed to the
tax provider. A failed commit is logged; retry it with `commitTax(orderId)`.
With `capture: "manual"` the funds are held until `orders.capture(orderId)`.

`cancelOrder(orderId)` cancels an unpaid order, releases its stock and its
open payments. Schedule `orders.job` to cancel orders whose payment window
passed:

```ts
await jobs.schedule("orders:expire", "*/5 * * * *", "orders.expire", {});
```

## After the sale [#after-the-sale]

```ts
const shipment = await orders
  .createFulfillment(order.id, { carrier: "PostNL" }) // every unit not fulfilled yet
  .orThrow();
await orders
  .shipFulfillment(shipment.id, { trackingNumber: "3SABC123" })
  .orThrow();
await orders.deliverFulfillment(shipment.id).orThrow();

const ret = await orders
  .requestReturn(order.id, [{ lineId, quantity: 1 }], "too small")
  .orThrow();
await orders.receiveReturn(ret.id).orThrow(); // restocks tracked variants

await orders
  .refund(order.id, { amount: money(2420n, "EUR"), returnId: ret.id })
  .orThrow();
// { status: "succeeded", orderStatus: "paid", refundedTotal, taxReversal }

const invoice = await orders.invoice(order.id).orThrow();
// { number: "INV-00001", issuedAt, order }
```

An order becomes `fulfilled` when every unit is in a fulfillment and
`completed` when they are all delivered. `refund` defaults to everything
paid and not refunded yet, refunds through the provider with the key
`refund:<id>`, and reverses the refunded share of each line's tax. Refunding
the whole total moves the order to `refunded`.

| Status            | Next                                                      |
| ----------------- | --------------------------------------------------------- |
| `pending_payment` | `paid`, `cancelled`                                       |
| `paid`            | `fulfilled`, `refunded`                                   |
| `fulfilled`       | `paid` (a cancelled fulfillment), `completed`, `refunded` |
| `completed`       | `refunded`                                                |

To change these rules, register a [state machine](/docs/blocks/state-machine)
on the orders `status` column; the module then leaves the check to it.

## Kitchen [#kitchen]

With `options.kitchen`, each order line gets a preparation state at a
station, for a kitchen display and an order board. The option is off by
default and adds three tables: `kitchen_stations`, `kitchen_routes` (which
station prepares a product) and `order_line_preparations`.

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { orders: { options: { kitchen: true } } },
});
```

```ts
const grill = await orders.kitchen
  .saveStation(organizationId, {
    key: "grill",
    name: "Grill",
    default: true,
    productIds: [burgerId],
  })
  .orThrow();

await orders.kitchen.send(orderId, { [lineId]: "no onions" }).orThrow();

const tickets = await orders.kitchen
  .queue(organizationId, { stationId: grill.id })
  .orThrow();
// [{ orderId, number, sentAt, lines: [{ lineId, title, quantity, state, note }] }]

await orders.kitchen.setState(lineId, "preparing").orThrow();
await orders.kitchen.bump(orderId, "ready", grill.id).orThrow(); // every line at the grill

const cards = await orders.kitchen.board(organizationId).orThrow();
// [{ orderId, number, state, counts: { queued, preparing, ready, served } }]
```

`send` queues each line of a paid or placed order at the station its product
is routed to, or at the default station, and skips lines already sent, so
calling it again is safe. A cancelled or refunded order can't be sent
(`ORDERS_KITCHEN_CLOSED`). A line moves through `queued`, `preparing`,
`ready` and `served`; `setState` can also move it back, which clears the
later timestamps. `bump` moves every line of the order (or of one station)
up to a state and leaves lines that are further along. The board shows the
orders sent since a day ago by the state of their least advanced line.

Each change broadcasts a `preparation` message with `{ lineId, orderId,
stationId, state }` on the private Realtime topic `kitchen:<organizationId>`,
which members with `orders.read` or `orders.prepare` can receive. The
`KITCHEN_EVENT` and `kitchenTopic(organizationId)` exports name them:

```tsx
const kitchen = defineTopic("kitchen:{organizationId}");

useBroadcast(
  kitchen,
  { organizationId },
  { [KITCHEN_EVENT]: () => router.refresh() },
);
```

The [kitchen display](/docs/registry/ui-recipes/kitchen-display) and
[order state board](/docs/registry/ui-recipes/order-state-board) recipes
build both screens from this API.

## Events [#events]

| Event                                          | When                                       |
| ---------------------------------------------- | ------------------------------------------ |
| `order.placed`                                 | `checkout` places the order                |
| `order.paid`                                   | The captured amount reaches the total      |
| `order.cancelled`                              | `cancelOrder` or an expired payment window |
| `order.fulfilled`, `order.completed`           | Every unit is fulfilled, then delivered    |
| `order.refunded`                               | A refund succeeds                          |
| `fulfillment.shipped`, `fulfillment.delivered` | A fulfillment step                         |
| `return.requested`, `return.received`          | A return step                              |
| `order.prepared`                               | Every kitchen line is ready or served      |
| `order.served`                                 | Every kitchen line is served               |

They are subject events on the `order` subject (`organizationId`,
`subjectType` and `subjectId`), so the
[watcher sink](/docs/blocks/notifications#watchers) can notify an order's
watchers. The payloads are listed on the [events](/docs/extending/events)
page.

## Metrics [#metrics]

`orderMetrics({ currency })` defines three [metrics](/docs/blocks/metrics) over the orders table: `orders` counts placed orders by `placed_at`, and `revenue` and `average_order_value` sum and average the `total` of paid orders in that currency by `paid_at`, in minor units. `ORDER_METRIC_EVENTS` maps `order.placed` and `order.paid` to them for the metrics sink.

```ts title="app/api/outbox/route.ts"
import {
  ORDER_METRIC_EVENTS,
  orderMetrics,
} from "better-supabase/blocks/orders";

const metrics = orderMetrics({ currency: "EUR", tz: "Europe/Amsterdam" });
const app = createBlocks(
  { transport: service },
  {
    metrics: metricsBlock
      .with({ sink: { on: ORDER_METRIC_EVENTS } })
      .using((options) => createMetrics({ ...options, metrics })),
  },
);
```

Pass the same definitions to `blocks.metrics.options.metrics`. The [commerce preset page](/docs/presets/commerce#metrics) adds units sold from the order lines.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `orders`. `checkout` writes them when it places the order from `fields`,
and `checkout`, `getOrder`, `capture` and `invoice` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { orders: { options: { extraColumns: { channel: "text" } } } },
});
```

```ts
const client = createOrders({
  transport,
  fields: v.object({ channel: v.optional(v.picklist(["web", "pos"])) }),
});
await client.checkout(cartId, { fields: { channel: "pos" } });
```

## Hooks [#hooks]

The SQL module calls `before_order_place`, `after_order_place`,
`before_fulfillment_create`, `after_fulfillment_create`,
`before_refund_create` and `after_refund_create` when you define them (see
[blocks](/docs/extending/blocks)). The TypeScript client takes `hooks` keyed
by method name.

## Not included [#not-included]

Orders doesn't export e-invoices (EN 16931), issue credit notes, sell
subscriptions or license keys for digital goods, or handle B2B quotes and
purchase orders from customers.

## Over your own tables [#over-your-own-tables]

Carts, cart lines, shipping rates, orders, order lines, payments,
fulfillments, returns and refunds can be tables your app already has (see
[Over your own tables](/docs/blocks/sql#over-your-own-tables)); the fulfillment
and return lines and the kitchen tables stay the block's own. The cart and
order `email` and `billingAddress`, the shipping rate `freeOver`, the order
`cancelReason` and `taxTransaction`, the fulfillment `trackingUrl` and the
return and refund `reason` may map to `null`. A call that sets one of them
then fails with an `ORDERS_<COLUMN>_UNSUPPORTED` hint, and reads return
`null`. Status columns may be enums or domains: the block casts what it
writes to the column's type.

## Without the client [#without-the-client]

`db.orders` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/orders.ts"
import { fixedRates, stripePayments } from "better-supabase/commerce";
import { createOrders, sqlTransport } from "better-supabase/blocks/orders";

export const orders = createOrders({
  transport: sqlTransport(ctx.postgres),
  service: sqlTransport(servicePostgres),
  payments: stripePayments({
    stripe: { secretKey: env.STRIPE_SECRET_KEY },
    webhookSecret: env.STRIPE_WEBHOOK_SECRET,
  }),
  tax: fixedRates({ rates: { NL: { standard: "0.21" } } }),
});
```

# Promotions

> Percentage, fixed and free-shipping promotions that apply automatically or by code, with usage limits, stacking rules and priorities, priced into orders carts and redeemed at checkout.

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

`promotions` adds discounts to [orders](/docs/blocks/orders) carts. A
promotion takes a percentage or a fixed amount off, or ships for free. It
applies to the whole cart or to some products, categories or collections,
either automatically or when the shopper enters a code. `priceCart` applies
the promotions by priority, follows their stacking rules and lists the ones
the cart gets. Checkout redeems them inside the same transaction that places
the order, with a row lock, so a usage limit holds under concurrent orders.

```bash
better-supabase add promotions
```

Server code calls the `promotions` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: usePromotions } = defineDataHooks(
  "promotions",
  (db: AppDb) => db.promotions,
);
```

```ts
const rows = await db.promotions.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/promotions/list.tsx"
"use client";

import { usePromotions } from "@/lib/supabase/hooks";

const rows = usePromotions({ limit: 50 });
```

The module needs `tenant`, `access`, `catalog` and `orders`, and records
`promotion.redeemed` through `outbox` when it is installed.

## Permissions [#permissions]

| Key                 | What it allows                                     |
| ------------------- | -------------------------------------------------- |
| `promotions.read`   | Read the organization's promotions, codes and uses |
| `promotions.manage` | Create, change and delete promotions and codes     |

Shoppers need neither key. They add codes to their own carts and see the
discounts in `priceCart`; they can't list promotions or codes. A shopper
reads their own redemptions.

## Promotions [#promotions]

```ts
import { money } from "better-supabase/commerce";

const { id } = await promotions
  .savePromotion(organizationId, {
    name: "Black Friday",
    discount: { kind: "percentage", percent: "20" },
    scope: "categories",
    targets: [apparelCategoryId],
    startsAt: Temporal.Instant.from("2026-11-27T00:00:00Z"),
    endsAt: Temporal.Instant.from("2026-12-01T00:00:00Z"),
    usageLimit: 1000,
    perCustomerLimit: 1,
  })
  .orThrow();
```

| Field              | What it does                                                                                                         |
| ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `discount`         | `percentage` with `percent` (up to two decimals), `fixed` with an `amount`, or `free_shipping`                       |
| `minSubtotal`      | The cart subtotal before discounts that the promotion needs, in the cart's currency                                  |
| `scope`, `targets` | `order` (default) or `products`, `categories` or `collections` with their ids; a category includes its children      |
| `startsAt`         | When the promotion starts; `endsAt` is when it stops                                                                 |
| `usageLimit`       | Redemptions across every customer                                                                                    |
| `perCustomerLimit` | Redemptions per customer, counted by user id and by the cart's email, so a new anonymous session doesn't reset it    |
| `stackable`        | Combines with other stackable promotions; a promotion that isn't stackable never combines with another (default off) |
| `priority`         | Higher applies first (default `0`)                                                                                   |
| `automatic`        | Applies to every cart it fits, without a code (default off)                                                          |
| `active`           | `setPromotionActive` pauses and resumes it (default on)                                                              |

A fixed amount or a minimum fixes the promotion's currency, and a cart in
another currency doesn't get it. A collection scope matches the products
added to a manual collection. `deletePromotion` is refused with
`PROMOTIONS_IN_USE` once an order redeemed the promotion; deactivate it
instead.

## Codes [#codes]

```ts
await promotions.createCodes(id, { codes: ["BLACKFRIDAY"] }).orThrow();

const batch = await promotions
  .createCodes(id, { count: 500, prefix: "BF-", usageLimit: 1 })
  .orThrow();
```

Codes are stored upper case, unique per organization, with 3 to 40 letters,
digits, `-` and `_`. A generated code is the prefix and 8 random characters
without the look-alike `0`, `O`, `1` and `I`; one call creates at most
10,000. `usageLimit` limits each code, on top of the promotion's own limit.

## Pricing a cart [#pricing-a-cart]

```ts
await promotions.applyCode(cart.id, "blackfriday").orThrow();
const priced = await orders.priceCart(cart.id).orThrow();

priced.promotions; // [{ name: "Black Friday", kind: "percentage", code: "BLACKFRIDAY", discount }]
priced.lines[0]?.discount;
```

`applyCode` checks that the code exists, is live and has uses left, and
refuses it with `PROMOTIONS_CODE_INVALID` or `PROMOTIONS_LIMIT_REACHED`. A
cart holds at most 5 codes; `removeCode` takes one off.

Pricing walks the cart's code promotions and the automatic ones from the
highest priority down. A percentage rounds half to even on each line, from
what earlier promotions left. A fixed amount is capped at the eligible lines
and split across them in proportion. Free shipping sets the shipping total
to zero.

A code that doesn't apply stays on the cart as a `promotion_unavailable`
problem with a `reason`: `expired`, `limit_reached`, `currency`,
`not_applicable`, `min_subtotal` or `not_combinable`. Like every cart
problem, it keeps the cart from checkout until the shopper removes the code
or changes the cart. An automatic promotion that doesn't fit is left out
without a problem.

## Redemption [#redemption]

`orders.checkout` locks each applied promotion and code, checks the limits
again, writes a redemption per promotion and records `promotion.redeemed`.
When another order took the last use in between, checkout fails with
`PROMOTIONS_LIMIT_REACHED` and the shopper can price the cart again.
Cancelling an order, or letting its payment expire, releases its
redemptions and gives the uses back.

## Events [#events]

| Event                | Payload                                                                        |
| -------------------- | ------------------------------------------------------------------------------ |
| `promotion.redeemed` | `organizationId`, `id`, `promotionId`, `code`, `orderId`, `currency`, `amount` |

## Hooks [#hooks]

The SQL module calls `before_promotion_save` and `after_promotion_save`
around `save_promotion` when you define them, with the promotion id (null
before the first save; see [blocks](/docs/extending/blocks)). The
TypeScript client takes `hooks` keyed by method name.

`before_promotion_activate(promotion uuid)` runs whenever a promotion ends
up active: from `set_promotion_active(id, true)` and from every
`save_promotion` that leaves it active, including an edit to a promotion
that already is. It runs after the write, so it reads the promotion as it
will be, and raising refuses the change. Use it for an approval step, such as
holding discounts above a threshold until an approver signs off (the
[commerce preset page](/docs/presets/commerce) shows one).

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `promotions`. `savePromotion` writes them from `fields`,
and `savePromotion` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { promotions: { options: { extraColumns: { campaign: "text" } } } },
});
```

```ts
const client = createPromotions({
  transport,
  fields: v.object({ campaign: v.optional(v.string()) }),
});
await client.savePromotion(organizationId, {
  name: "Black Friday",
  discount: { kind: "percentage", percent: "20" },
  fields: { campaign: "bf-2026" },
});
```

## Not included [#not-included]

Buy-X-get-Y and tiered discounts, gift cards and store credit, and rule
collections in a collection scope. Model a gift card as a fixed code with a
usage limit of one until a gift card block exists.

## Without the client [#without-the-client]

`db.promotions` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/promotions.ts"
import {
  createPromotions,
  sqlTransport,
} from "better-supabase/blocks/promotions";

export const promotions = createPromotions({
  transport: sqlTransport(ctx.postgres),
});
```

# Reviews

> Ratings and reviews of catalog products and other records, one per user and subject, verified against paid orders, moderated before or after publishing, with an aggregate a public storefront can read.

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

`reviews` lets signed-in users rate and review a catalog product or another record they can read. Each user writes one review per subject and can replace it. A review is marked verified when the [orders](/docs/blocks/orders) module shows the reviewer paid for the product, and a moderator publishes or rejects it before or after it goes live.

Moderating, replying and reading the queue need `reviews.manage`. Admins get it. Writing a review needs no permission: any signed-in user who can read the subject can review it.

```bash
better-supabase add reviews
```

The module needs `tenant`, `permissions` and [moderation](/docs/blocks/moderation). With [catalog](/docs/blocks/catalog) installed, products are reviewable (subject type `product`); with `orders` installed, reviews of a product carry `verified`.

Server code calls the `reviews` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useReviews } = defineDataHooks(
  "reviews",
  (db: AppDb) => db.reviews,
);
```

```ts
import { createReviews } from "better-supabase/blocks/reviews";

await db.reviews
  .submit(organizationId, "product", productId, {
    rating: 5,
    title: "Fits well",
    body: "True to size and the fabric holds up.",
    authorName: "Ada",
  })
  .orThrow();
```

```tsx title="src/features/reviews/list.tsx"
"use client";

import { useReviews } from "@/lib/supabase/hooks";

const rows = useReviews({ limit: 50 });
```

## Who can review [#who-can-review]

A product can be reviewed while it is active and either listed on a published public storefront or readable by the caller through `catalog.read`. Any other subject type must be named in `on` (or the [subject registry](/docs/blocks/sql#subject-registry)), and the reviewer must be a member of the subject's organization who can read the row.

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    reviews: {
      on: { course: { table: "public.courses" } },
      options: { moderation: "post", requirePurchase: true },
    },
  },
});
```

| Option            | Default | What it does                                                              |
| ----------------- | ------- | ------------------------------------------------------------------------- |
| `scale`           | `5`     | The highest rating, from 2 to 10                                          |
| `maxBodyLength`   | `5000`  | The longest body and reply                                                |
| `moderation`      | `pre`   | `pre` waits for `publish`; `post` publishes at once and moderators reject |
| `requirePurchase` | `false` | Only customers with a paid, uncancelled order for the product can review  |
| `extraColumns`    | none    | Your own columns on `reviews`, typed with the client's `fields` schema    |

`requirePurchase` needs the `orders` block with its tables. Submitting again replaces the caller's review and, under pre-moderation, sends it back to `pending`.

## The product page [#the-product-page]

`list` returns a subject's published reviews, newest first, without author ids. Anonymous visitors can call it for products on a public storefront. `summary` returns the count, mean rating and histogram of up to 200 subjects of one type, read from the `review_stats` table the module keeps current, so a product grid costs one call.

```ts
const page = await reviews
  .list(organizationId, "product", productId, { pageSize: 20, verified: true })
  .orThrow();

const stats = await reviews
  .summary(organizationId, "product", productIds)
  .orThrow();
// stats[productId]: { count: 12, average: 4.25, histogram: { 5: 7, 4: 3, 3: 2 } }
```

Subjects without a published review are left out of `summary`. `mine` returns the caller's own review in any status, with the rejection reason, so the product page can show it next to the form.

## Moderation [#moderation]

Each review has a [moderation](/docs/blocks/moderation) item (subject type `review`), so reviews share the moderation queue, reports and bans with comments and other content. A review is `pending` while its item waits for a decision, `published` while it is visible, and `rejected` while it is hidden or removed; the item's reason becomes the rejection reason.

`queue(organizationId)` lists pending reviews for moderators; pass `status: null` for every status, or `subjectType` and `subjectId` for one subject. `publish`, `reject(reviewId, reason)` and `reply(reviewId, text)` need `reviews.manage`; `publish` approves the review's item and `reject` hides it. A moderator with `moderation.manage` can decide on reviews from the moderation queue as well, with the same effect, and members can report a published review. The author sees the rejection reason, and everyone sees the reply under the review. With the [notifications](/docs/blocks/notifications) module installed, the author is notified when their review is published, rejected or answered. `remove` deletes a review for its author or a moderator, with its moderation item.

The last decision lives on the item (`decidedBy`, `decidedAt` from `moderation.item({ type: "review", id })`), not on the review.

## Errors [#errors]

| Hint                       | Cause                                                                    |
| -------------------------- | ------------------------------------------------------------------------ |
| `REVIEW_FORBIDDEN`         | the caller can't read the subject, or lacks `reviews.manage`             |
| `REVIEW_INVALID`           | a rating outside the scale, or a body or reply over `maxBodyLength`      |
| `REVIEW_NOT_FOUND`         | no review the caller can see has this id                                 |
| `REVIEW_PURCHASE_REQUIRED` | `requirePurchase` is on and the caller has no paid order for the product |

## Events [#events]

The block records `review.submitted`, `review.published`, `review.rejected`, `review.replied` and `review.deleted` with `organizationId`, `id`, `subjectType`, `subjectId`, `authorId`, `rating`, `status` and `verified`. See [events](/docs/extending/events).

The block takes `hooks` around its methods, and `before_review_submit` and `after_review_submit` run in the database; see [extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.reviews` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createReviews } from "better-supabase/blocks/reviews";

const reviews = createReviews({ transport });

await reviews
  .submit(organizationId, "product", productId, {
    rating: 5,
    title: "Fits well",
    body: "True to size and the fabric holds up.",
    authorName: "Ada",
  })
  .orThrow();
```

# Shipments

> Ship packages between addresses, buy labels through a versioned carrier interface, and keep a tracking timeline that a signed carrier webhook updates without duplicates.

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

`shipments` records what you send and where it is. You create a shipment with
its packages and two addresses, ask your carriers for rates, buy a label, and
follow the tracking timeline until it is delivered. The status always follows
the latest tracking event, whether it came from a carrier poll, a signed
webhook or a person.

```bash
better-supabase add shipments
```

```ts
import { createShipments } from "better-supabase/blocks/shipments";
```

The module requires `memberships` and `access`. With `assets` a shipment can name
the asset location it ships from, with `outbox` every change records an
event, and with `audit` the writes are audited. Carriers plug in through the
versioned `ShippingProvider` interface; the block ships no carrier SDK (see
[ADR 0082](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0082-shipment-carriers.md)).

## Tables [#tables]

| Table                      | Holds                                                                                                                                                                            |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shipments`                | One shipment: `reference`, `status`, the `from_address` and `to_address`, the record it is for, and once labeled the `carrier`, `service`, `tracking_number`, label and its cost |
| `shipment_packages`        | The packages, by `position`: `weight_grams` and the sizes in millimeters                                                                                                         |
| `shipment_tracking_events` | The timeline: the carrier's event id, `status`, `occurred_at`, `description`, `location` and the `source` (`carrier`, `webhook` or `manual`)                                     |

Every table is read through the functions only. Members with
`shipments.read` read shipments (members hold it by default), and
`shipments.manage` creates, labels and cancels them.

## Create and label a shipment [#create-and-label-a-shipment]

Server code calls the `shipments` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useShipments } = defineDataHooks(
  "shipments",
  (db: AppDb) => db.shipments,
);
```

```ts
import { createShipments } from "better-supabase/blocks/shipments";
import { money } from "better-supabase/commerce";

const shipment = await db.shipments
  .create({
    from: { line1: "Dock 4", city: "Rotterdam", country: "NL" },
    to: { name: "Jane", line1: "Main St 1", city: "Berlin", country: "DE" },
    packages: [
      { weightGrams: 1200, lengthMm: 300, widthMm: 200, heightMm: 100 },
    ],
  })
  .orThrow();

const rates = await db.shipments.rates(shipment.id).orThrow();
const labeled = await db.shipments
  .buyLabel(shipment.id, {
    carrier: rates[0].carrier,
    service: rates[0].service,
  })
  .orThrow();
```

```tsx title="src/features/shipments/list.tsx"
"use client";

import { useShipments } from "@/lib/supabase/hooks";

const rows = useShipments({ limit: 50 });
```

A new shipment is a `draft`. `rates` asks every carrier and returns the
quotes cheapest first, grouped by currency. `buyLabel` buys the label with
the idempotency key `label:<shipment id>`, so a retry never buys twice, and
stores it on the shipment, which moves to `label_created`. When storing
fails, the block voids the label it just bought. A shipment without a
`reference` gets a generated one (`SHP-` and ten characters).

| Method                               | Returns                                                    |
| ------------------------------------ | ---------------------------------------------------------- |
| `create(input)`                      | the draft shipment with its packages                       |
| `get(id)`                            | the shipment, or `null`                                    |
| `list({ status, subject, carrier })` | the organization's shipments, newest first                 |
| `rates(id, { currency })`            | every carrier's quotes                                     |
| `buyLabel(id, choice)`               | the shipment with its label                                |
| `refresh(id)`                        | the events the carrier added since, and the shipment       |
| `recordTracking(id, events)`         | the events recorded by hand, and the shipment              |
| `ingest(carrier, request)`           | one entry per tracking number in a verified webhook        |
| `cancel(id)`                         | the cancelled shipment, after the carrier voided its label |
| `trackingEvents(id)`                 | the timeline, oldest first                                 |

The [shipments binding](/docs/repository/record-bindings) lists a record's shipments on
its page, with `create` and `cancel` writes.

## Tracking [#tracking]

Each tracking event carries the carrier's id, and the database records an id
once per shipment, so a re-polled event or a redelivered webhook changes
nothing. After every write the shipment takes the status of the event that
happened last (not the one recorded last), unless it was cancelled.
Delivery stamps `deliveredAt`.

| Status             | Meaning                                   |
| ------------------ | ----------------------------------------- |
| `draft`            | no label yet                              |
| `label_created`    | labeled, not picked up                    |
| `in_transit`       | on its way                                |
| `out_for_delivery` | on the truck to the recipient             |
| `delivered`        | at the recipient                          |
| `exception`        | held up: a wrong address, damage, customs |
| `returned`         | on its way back, or back                  |
| `cancelled`        | cancelled before it left                  |

Only a `draft` or a `label_created` shipment can be cancelled; with a label,
`cancel` voids it at the carrier first.

## Ingest carrier webhooks [#ingest-carrier-webhooks]

A carrier that pushes tracking updates implements `parseWebhook`, which
verifies the signature with the carrier's secret before it reads the body.
Point the carrier at a route and pass the request to `ingest` on a client
with the service transport:

```ts title="app/api/carriers/[carrier]/route.ts"
export async function POST(
  request: Request,
  { params }: { params: Promise<{ carrier: string }> },
) {
  const { carrier } = await params;
  const result = await serviceShipments.ingest(carrier, request);

  if (!result.ok) {
    return Response.json(result.error, {
      status: result.error.kind === "unauthorized" ? 401 : 400,
    });
  }

  return new Response(null, { status: 204 });
}
```

`ingest` runs `ingest_tracking_events`, which only the service role may
call, because the route has verified the signature and the request carries
no user. It finds the shipment by carrier and tracking number, so the route
needs no tenant. A tracking number no shipment has comes back with
`recorded: 0`, and a forged signature with `unauthorized`.

## Write a carrier [#write-a-carrier]

```ts
import type { ShippingProvider } from "better-supabase/blocks/shipments";

export const myCarrier: ShippingProvider = {
  apiVersion: 1,
  name: "my-carrier",
  rates: (request, options) => quote(request, options),
  createLabel: (request, options) => buy(request, options),
  // The events so far, oldest first.
  track: (trackingNumber, options) => events(trackingNumber, options),
  cancel: (label, options) => voidLabel(label, options),
  // Verifies the signature, then reads the updates.
  parseWebhook: (input) => updates(input),
};
```

Every method returns a `Result`: `invalid_input` for a request the carrier
can't ship, `not_found` for an unknown tracking number, `conflict` for a
label it can no longer void and `unauthorized` for a forged webhook. Honor
`idempotencyKey` in `createLabel` and `cancel`. `invalidShipmentRequest`
checks addresses and packages the way the block does, and
`trackingUpdatesOf` reads the `{ trackingNumber, events }` webhook body. Run
`testShippingProvider` from `better-supabase/testing` against your carrier in
its tests (see [conformance](/docs/extending/conformance)).

`flatRateCarrier` is a carrier without a network: fixed prices per service,
labels with generated tracking numbers, and webhooks signed with
[Standard Webhooks](/docs/blocks/webhooks-in). Use it for local development,
tests and pickup-only shops.

```ts
const carrier = flatRateCarrier({
  services: {
    standard: {
      amount: money(495, "EUR"),
      additional: money(200, "EUR"),
      estimatedDays: 3,
    },
  },
  webhookSecret: env.CARRIER_WEBHOOK_SECRET,
});
```

## UI recipes [#ui-recipes]

The [`shipment-tracking`](/docs/registry/ui-recipes/shipment-tracking) UI recipe renders a
shipment's packages, label and tracking timeline, with buttons to buy a label
and refresh. It is in the Next.js example.

## Errors [#errors]

| Hint                           | Cause                                                                      |
| ------------------------------ | -------------------------------------------------------------------------- |
| `SHIPMENT_FORBIDDEN`           | the caller lacks `shipments.read` or `shipments.manage`                    |
| `SHIPMENT_NOT_FOUND`           | no shipment with this id that the caller can read                          |
| `SHIPMENT_INVALID`             | the reference isn't 1 to 100 characters                                    |
| `SHIPMENT_ADDRESS_INVALID`     | an address lacks `line1`, `city` or a two-letter `country`                 |
| `SHIPMENT_EMPTY`               | the shipment has no packages, or more than 50                              |
| `SHIPMENT_PACKAGE_INVALID`     | a package has no weight, or a size that isn't whole millimeters above zero |
| `SHIPMENT_SUBJECT_INVALID`     | only one of the record type and id was passed                              |
| `SHIPMENT_SUBJECT_NOT_FOUND`   | the record doesn't exist in the organization                               |
| `SHIPMENT_LOCATION_NOT_FOUND`  | the location doesn't exist in the organization                             |
| `SHIPMENT_ASSETS_MISSING`      | a location was named without the assets block                              |
| `SHIPMENT_CARRIER_UNKNOWN`     | no carrier with this name was passed to `createShipments`                  |
| `SHIPMENT_SERVICE_UNKNOWN`     | the carrier doesn't offer the service                                      |
| `SHIPMENT_NOT_DRAFT`           | the shipment already has a label                                           |
| `SHIPMENT_LABEL_INVALID`       | the label the carrier returned is missing a field                          |
| `SHIPMENT_TRACKING_TAKEN`      | another shipment has the carrier and tracking number                       |
| `SHIPMENT_NOT_LABELED`         | tracking was asked for a shipment without a label                          |
| `SHIPMENT_TRACKING_INVALID`    | an event lacks an id, a known status or an ISO 8601 `occurredAt`           |
| `SHIPMENT_TRACKING_UNKNOWN`    | the carrier doesn't know the tracking number                               |
| `SHIPMENT_WEBHOOK_UNSUPPORTED` | the carrier has no `parseWebhook`                                          |
| `SHIPMENT_SHIPPED`             | the shipment has left and can't be cancelled                               |

## Events [#events]

`shipment.created`, `shipment.labeled`, `shipment.status_changed` and
`shipment.cancelled`. The [events page](/docs/extending/events) lists their
payloads.

## Without the client [#without-the-client]

`db.shipments` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createShipments } from "better-supabase/blocks/shipments";
import { money } from "better-supabase/commerce";

const shipments = createShipments({
  transport,
  tenant: organizationId,
  carriers: [myCarrier],
});

const shipment = await shipments
  .create({
    from: { line1: "Dock 4", city: "Rotterdam", country: "NL" },
    to: { name: "Jane", line1: "Main St 1", city: "Berlin", country: "DE" },
    packages: [
      { weightGrams: 1200, lengthMm: 300, widthMm: 200, heightMm: 100 },
    ],
  })
  .orThrow();

const rates = await shipments.rates(shipment.id).orThrow();
const labeled = await shipments
  .buyLabel(shipment.id, {
    carrier: rates[0].carrier,
    service: rates[0].service,
  })
  .orThrow();
```

# 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 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
`auth.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 });
```

A tenant that the [billing](/docs/blocks/billing#billing-tenants-as-stripe-accounts)
block bills as a Stripe Accounts v2 account is reported under the v1 customer
Stripe links to the account, in the meter's default `stripe_customer_id`
payload key. Its counters wait (they count as `skipped`) until the billing
webhook has recorded that customer.

`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]

Server code calls the `usage` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useUsage } = defineDataHooks(
  "usage",
  (db: AppDb) => db.usage,
);
```

```ts
const rows = await db.usage.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/usage/list.tsx"
"use client";

import { useUsage } from "@/lib/supabase/hooks";

const rows = useUsage({ limit: 50 });
```

`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, ... }]
```

### A usage meter in the browser [#a-usage-meter-in-the-browser]

`useUsageMeter` is a registry item: `npx shadcn add
@better-supabase/use-usage` writes `hooks/use-usage.ts` into the app. The hook
reads the overview through the provider's client and adds `fraction` (used over the
limit, from 0 to 1, `undefined` without a limit) and `exceeded` to each
meter. `meters` keeps only the meters you name, in that order, and
`refresh()` reads them again after the app records usage. The same hook
runs in React DOM and React Native.

```tsx title="components/usage-meter.tsx"
import { useUsageMeter } from "@/hooks/use-usage";

const { meters } = useUsageMeter({
  organizationId,
  meters: ["api_calls"],
  schema: "api",
});
// meters?.[0]: { meter: "api_calls", used: 420, limit: 1000, fraction: 0.42, exceeded: false, ... }
```

`createUsageMeterController` from `better-supabase/blocks/usage` is the
same logic without React. Vue, Svelte and Solid run it with
`useBlockController` from `better-supabase/vue`, `/svelte` and `/solid` (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)),
passing `createUsage(...)` as `client`. The [usage meter recipe](/docs/registry/ui-recipes/usage-meter) shows it in
the Next.js and Expo examples.

```vue title="UsageMeter.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createUsage,
  createUsageMeterController,
  rpcTransport,
} from "better-supabase/blocks/usage";

const supabase = useSupabase();
const { state } = useBlockController(createUsageMeterController, {
  client: createUsage({ transport: rpcTransport(supabase) }),
  organizationId: "org-id",
});
</script>

<template>
  <p v-for="meter in state.meters" :key="meter.meter">
    {{ meter.meter }}: {{ meter.used }} of {{ meter.limit }}
  </p>
</template>
```

```svelte title="UsageMeter.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createUsage,
    createUsageMeterController,
    rpcTransport,
  } from "better-supabase/blocks/usage";

  const { supabase } = getBetterSupabase();
  const usage = useBlockController(createUsageMeterController, {
    client: createUsage({ transport: rpcTransport(supabase) }),
    organizationId: "org-id",
  });
</script>

{#each $usage.meters ?? [] as meter}<p>{meter.meter}: {meter.used}</p>{/each}
```

```tsx title="UsageMeter.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createUsage,
  createUsageMeterController,
  rpcTransport,
} from "better-supabase/blocks/usage";

export function UsageMeter() {
  const supabase = useSupabase();
  const { state } = useBlockController(createUsageMeterController, {
    client: createUsage({ transport: rpcTransport(supabase) }),
    organizationId: "org-id",
  });
  return <p>{state().meters?.length ?? 0} meters</p>;
}
```

### 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 }`.

## Distinct and computed meters [#distinct-and-computed-meters]

A meter is a counter unless `options.meters` gives it a `kind`. Inline
catalogs (not `{ table }` catalogs) accept two more:

- `distinct` counts each subject once per period, for active users or active
  projects. Call `usage.track(tenant, "mau", subject)`. The first sight in the
  period adds one unit, repeats change nothing.
- `computed` is a gauge a query reports, for storage bytes or seat counts.
  `query` is one select of `(tenant, value)` rows. `usage.refresh()` (or the
  hourly pg_cron job the migration schedules) sets today's counter so the
  period's total equals the value.

```ts title="better-supabase.config.ts"
blocks: {
  usage: {
    options: {
      meters: {
        mau: { kind: "distinct", period: "month", label: "Active users" },
        storage: {
          kind: "computed",
          unit: "bytes",
          query: "select organization_id, sum(size) from app.files group by 1",
        },
      },
    },
  },
}
```

Both kinds write the counters table, so quotas, `current`, `overview` and
`reportUsageToStripe` treat them like any counter. The query runs as the
function owner and is never returned by `usage.meters()`. The tenant is
whatever scope the usage module is installed on, so a meter can be
organization-level or, with a platform-level tenant, environment-level: the
query decides which ids it returns.

## Usage history [#usage-history]

A usage page shows who and what used a meter. Set
`blocks.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). `usage.purgeHistory({ olderThan, batch })` and
`usage.purgeEvents({ olderThan, batch })` call them from TypeScript, and
`usage.purgeJob()` runs both as a job handler; schedule them like the other
[purges](/docs/blocks/sql#retention). A retry
that arrives after its key is purged counts again. Without the option, both reads return `[]`.

With `options.partitioned` as well, a new `usage_history` table is
partitioned by month on `recorded_at` (primary key `(id, recorded_at)`), and
`purge_usage_history` drops every month past the cutoff before it deletes
rows. Schedule `better_supabase.maintain_partitions()` once a month. An
existing table stays as it is, and `better-supabase doctor` reports it as
BS335.

## 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";
import { stripeFromKey } from "better-supabase/stripe";

const stripe = stripeFromKey({ secretKey: env.STRIPE_SECRET_KEY });

export async function GET() {
  const { reported, skipped } = await reportUsageToStripe({
    transport: sqlTransport(postgres),
    stripe,
    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
      .get({ where: { organizationId } }, { optional: true })
      .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. The `stripe`
option takes the same sources as in the [billing](/docs/blocks/billing#setup)
block: `stripeFromKey()` in a bundled app, `{ secretKey }` in plain Node, or a
Stripe client of 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                                                                                              |

## Hooks [#hooks]

`createUsage` takes `hooks` around its methods, such as a rule before
`consume` that returns a `DbError` to refuse the call; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.usage` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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();
```

# Content

> Documents, posts, forms and the blocks that hold content.

Source: https://bettersupabase.com/docs/blocks/catalog-content

- [Imports](/docs/blocks/imports)
- [Versions](/docs/blocks/versions)
- [Trash](/docs/blocks/trash)
- [Documents](/docs/blocks/documents)
- [Drive](/docs/blocks/drive)
- [Forms](/docs/blocks/forms)
- [Signatures](/docs/blocks/signatures)
- [Templates](/docs/blocks/templates)
- [Content](/docs/blocks/content)
- [Courses](/docs/blocks/courses)

# Content

> Pages, posts and other typed entries per organization, with locales, scheduled and approved publishing, versions and a public read by slug.

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

The `content` block keeps an organization's content entries: pages, blog
posts, help articles or any other type you name. Each entry has a slug, a
title, a summary and a JSON body that your editor writes, in one of the
locales you configure. Members draft entries, publish them now or at a set
time, and archive them. Published entries are public: a visitor reads one by
type, slug and locale without signing in.

```bash
better-supabase add content   # adds tenant, access, memberships and subjects
```

| Permission        | Lets a member                                  | Default roles         |
| ----------------- | ---------------------------------------------- | --------------------- |
| `content.read`    | read the organization's entries, drafts too    | `member`, `admin`     |
| `content.write`   | create, edit and archive entries               | `member`, `admin`     |
| `content.publish` | publish and schedule entries, approve requests | `admin` (`content.*`) |

The block works with other blocks when they are installed.
[Versions](/docs/blocks/versions) keeps a version of every edit and a frozen
`published` version each time an entry goes live.
[Approvals](/docs/blocks/approvals) gates publishing under
`requireApproval`. [Attachments](/docs/blocks/attachments) holds the cover
image an entry points at. The [outbox](/docs/blocks/outbox) and
[audit](/docs/blocks/audit) blocks record every change.

## Write entries [#write-entries]

Server code calls the `content` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useContent } = defineDataHooks(
  "content",
  (db: AppDb) => db.content,
);
```

```ts
import { createContent, rpcTransport } from "better-supabase/blocks/content";

const about = await db.content
  .create({
    type: "page",
    slug: "about",
    title: "About us",
    body: { type: "doc", content: [] },
  })
  .orThrow();

const dutch = await db.content
  .create({
    type: "page",
    locale: "nl",
    slug: "over-ons",
    title: "Over ons",
    translationOf: about.id,
  })
  .orThrow();
```

```tsx title="src/features/content/list.tsx"
"use client";

import { useContent } from "@/lib/supabase/hooks";

const rows = useContent({ limit: 50 });
```

`create` writes a draft. `type` must be one of `options.types` and `locale`
one of `options.locales` (the first locale when you leave it out). A slug is
lowercase letters and digits joined by `-` or `/`, such as `about` or
`guides/setup`, and is unique per organization, type and locale. The body is
a JSON object or array of at most 1 MB, so it holds whatever your editor
produces (a ProseMirror or Lexical document, or blocks of your own).
`translationOf` adds the entry to another entry's translation group, one
entry per locale. `coverAttachmentId` points at an attachment and needs the
attachments block. `contentEntryInputSchema` validates the input.

`update(id, input)` changes the slug, title, summary, body or cover, and
`archive(id)` takes an entry offline. `get(id)` returns one entry with its
translations, or null when the caller can't read it. `list({ type, locale,
status, translationGroupId, query, limit, offset })` returns the
organization's entries, latest change first; `query` matches the title or
slug, and `limit` runs from 1 to 200 (default 50).

## Publish and schedule [#publish-and-schedule]

```ts
await content.publish(about.id).orThrow();

await content
  .schedule(post.id, Temporal.Instant.from("2026-03-01T09:00:00Z"))
  .orThrow();

await content.schedule(post.id, null).orThrow(); // back to draft
```

`publish(id)` sets an entry live now. `schedule(id, at)` sets it live at a
future time. The block's `publish` timer (every five minutes) publishes the
entries whose time has come: schedule it with the other
[block timers](/docs/blocks/queue#block-timers) and run it with the service
role's client.

```ts title="src/lib/jobs.ts"
const run = blockTickHandler({ "content.publish": () => content.publishDue() });
```

To publish a published entry again at a new time, archive it first.

### With approval [#with-approval]

With `requireApproval: true`, a member's `publish` or `schedule` opens an
approvals request with the action `publish` and leaves the entry a draft, with
`approvalId` and `approvalStatus: "pending"`. Once the request is approved,
the entry goes live, or is scheduled when its time is still ahead. Editing,
archiving or unscheduling the draft withdraws the request. The default policy
asks for one approval by another member with `content.publish`; set
`publishPolicy` to an [approvals policy](/docs/blocks/approvals) for more
steps. Calls with the service role skip the request. While approval is on,
only members with `content.publish` change scheduled or published entries.

## Public reads [#public-reads]

```ts
const page = await content
  .published({ type: "page", slug: "over-ons", locale: "nl" })
  .orThrow();

const latest = await content
  .listPublished({ type: "post", locale: "en", limit: 10 })
  .orThrow();
```

`published` returns one published entry with its body and its published
translations, or null. When the locale has no entry with that slug, it falls
back to the default locale. `listPublished` returns published entries
newest first, without bodies; `before` pages back from a `publishedAt`, and
`limit` runs from 1 to 100 (default 20). Both run as security definer
functions that the `anon` role may call: they return only published entries
and only their public fields, so they work with the publishable key on a
marketing site.

## Options [#options]

```ts title="better-supabase.config.ts"
blocks: {
  content: {
    options: {
      types: ["page", "post", "article"],
      locales: ["en", "nl", "de"],
      requireApproval: true,
    },
  },
},
```

| Option            | Default            | What it does                                                     |
| ----------------- | ------------------ | ---------------------------------------------------------------- |
| `types`           | `["page", "post"]` | the entry types                                                  |
| `locales`         | `["en"]`           | the locales entries are written in; the first is the default     |
| `requireApproval` | `false`            | publishing opens an approvals request; needs the approvals block |
| `publishPolicy`   | one approver       | the approvals policy a publish request follows                   |

The `before_content_entry_publish` and `after_content_entry_publish` hooks
run around every publish, from the button, the approval or the schedule.

## Events [#events]

The block writes `content_entry.created`, `content_entry.updated`,
`content_entry.publish_requested`, `content_entry.scheduled`,
`content_entry.unscheduled`, `content_entry.published` and
`content_entry.archived` to the [outbox](/docs/blocks/outbox). See
[Events](/docs/extending/events).

| Error code                   | When                                                               |
| ---------------------------- | ------------------------------------------------------------------ |
| `CONTENT_FORBIDDEN`          | the caller lacks the permission                                    |
| `CONTENT_INVALID`            | a type, locale, slug, title, body or publish time doesn't validate |
| `CONTENT_NOT_FOUND`          | there is no such entry, or the caller can't read it                |
| `CONTENT_SLUG_TAKEN`         | another entry of the type and locale has the slug                  |
| `CONTENT_TRANSLATION_EXISTS` | the translation group already has an entry in the locale           |
| `CONTENT_MEDIA_UNSUPPORTED`  | a cover is set without the attachments block                       |
| `APPROVAL_PENDING`           | a publish request for the entry is already open                    |

## Without the client [#without-the-client]

`db.content` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createContent, rpcTransport } from "better-supabase/blocks/content";

const content = createContent({
  transport: rpcTransport(supabase),
  tenant: organizationId,
});

const about = await content
  .create({
    type: "page",
    slug: "about",
    title: "About us",
    body: { type: "doc", content: [] },
  })
  .orThrow();

const dutch = await content
  .create({
    type: "page",
    locale: "nl",
    slug: "over-ons",
    title: "Over ons",
    translationOf: about.id,
  })
  .orThrow();
```

# Courses

> Courses of modules and lessons in rank order, quiz lessons graded by forms, per-lesson progress, completion and certificates, with learners enrolled through enrollments.

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

`courses` lets an organization publish courses and lets its members take
them. A course holds modules, a module holds lessons, and both sort by
fractional rank keys, so moving one item rewrites one row. A lesson is
content, a video, or a quiz that a [Forms](/docs/blocks/forms) form grades in
the database. Each learner's progress is an
[Enrollments](/docs/blocks/enrollments) enrollment, so the course completes
when its required lessons are done and can then issue a certificate.

Reading published courses and taking them needs `courses.read`, enrolling
other users and reading their progress needs `courses.enroll`, and authoring
needs `courses.manage`. Admins get all three and members get `courses.read`.

```bash
better-supabase add courses
```

The module needs `tenant`, `permissions` and `enrollments`. With `forms` installed,
quiz lessons work; without it, saving a quiz lesson fails with
`COURSE_QUIZ_UNAVAILABLE`. Course changes and learner events go to the outbox,
and course, enrollment and certificate changes go to the audit log as well.

Server code calls the `courses` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCourses } = defineDataHooks(
  "courses",
  (db: AppDb) => db.courses,
);
```

```ts
import { createCourses } from "better-supabase/blocks/courses";

const course = await db.courses
  .upsert({ key: "security-basics", title: "Security basics" })
  .orThrow();
const intro = await db.courses
  .saveModule(course.id, { title: "Introduction" })
  .orThrow();

await db.courses
  .saveLesson(course.id, {
    moduleId: intro.id,
    title: "Why it matters",
    body: { text: "Most breaches start with a reused password." },
    minutes: 5,
  })
  .orThrow();
await db.courses
  .saveLesson(course.id, {
    moduleId: intro.id,
    title: "Check yourself",
    kind: "quiz",
    formId: quizFormId,
  })
  .orThrow();
await db.courses.upsert({ id: course.id, status: "published" }).orThrow();

await db.courses.enroll(course.id).orThrow();
```

```tsx title="src/features/courses/list.tsx"
"use client";

import { useCourses } from "@/lib/supabase/hooks";

const rows = useCourses({ limit: 50 });
```

| Option         | Default | What it does                         |
| -------------- | ------- | ------------------------------------ |
| `extraColumns` | none    | Extra columns on the `courses` table |

| Table                    | Holds                                                                                      |
| ------------------------ | ------------------------------------------------------------------------------------------ |
| `courses`                | One row per course: `key`, `title`, `summary`, `status` and whether it issues certificates |
| `course_modules`         | One row per module: its course, `title` and `rank`                                         |
| `course_lessons`         | One row per lesson: its module, `kind`, `body`, `media_url`, `form_id`, `required`, `rank` |
| `course_lesson_progress` | One row per enrollment and lesson: started, completed, quiz attempts and the last grade    |
| `course_certificates`    | One row per completed enrollment of a course that issues certificates, with its `code`     |

The block's entities are `course` (the `courses` table, its primary entity),
`course_module`, `course_lesson` and `course_certificate`. Installing it
registers `course` as a subject, searchable by title and summary when the
search module is installed. Rename them with `blocks.courses.names`, for
example `{ course: "training" }`; see [Typed blocks](/docs/blocks/typed-blocks).
`courseInputSchema` and `courseLessonInputSchema` validate what `upsert` and
`saveLesson` take.

`blocks.courses.options.extraColumns` adds columns to the `courses` table,
keyed by column name with their SQL type. Pass `createCourses` a Standard
Schema for them as `fields`, and `upsert` takes them under `fields`. See
[your own fields](/docs/extending/blocks#your-own-fields).

## Authoring [#authoring]

`upsert(course)` creates a course, or changes one when you pass its `id`. Keys
left out keep their value, and a course's `key` (lower-case letters, digits
and `-`) can't repeat in the organization. A new course is a `draft`: only
members with `courses.manage` see it until its `status` is `published`.
`archived` hides it from learners again. `certificate: false` turns
certificates off for the course. `delete(courseId)` deletes a course with its
modules, lessons, enrollments, progress and certificates.

`saveModule(courseId, module)` and `saveLesson(courseId, lesson)` create or
change a module and a lesson. A new item without a `rank` goes last; pass a
rank key between two neighbours to place it. A quiz lesson names the
`formId` that grades it, and only quiz lessons take one. `required: false`
makes a lesson optional, so it doesn't count toward completion.
`deleteModule` and `deleteLesson` delete an item with what it holds.

`outline(courseId)` returns the course with its modules and lessons in rank
order, without lesson content, and the caller's progress when they are
enrolled. `list()` returns the organization's courses by title; readers get
the published ones.

## Learning [#learning]

`enroll(courseId)` enrolls the caller in a published course and returns their
progress. Pass a `userId` with `courses.enroll` to enroll someone else.
Enrolling again returns the same enrollment, and enrolling a cancelled one
starts it again with its completed lessons. `unenroll(courseId)` cancels an
active enrollment and keeps its progress; a completed one can't be cancelled.

`lesson(lessonId)` returns a lesson's content and, for a quiz, its form
definition without points or correct answers. Only enrolled learners and
managers read lesson content. `start(lessonId)` records that the learner
opened a lesson, `complete(lessonId)` marks a content or video lesson done
and `reset(lessonId)` marks it not done.

`submitQuiz(lessonId, answers)` submits the answers to the quiz form. The
forms block grades the submission in SQL, so the client can't set its score.
A passed submission, or any submission to a form without a pass mark,
completes the lesson; a later failed attempt keeps it complete. Each attempt
raises `attempts` and records the latest `score` and `maxScore`.

`progress(courseId)` returns the caller's progress, or another user's with
`courses.enroll`. `learners(courseId)` lists the course's learners, most
recently changed first, for members with `courses.enroll` or
`courses.manage`. `courseLearnersBinding` loads them for a course page, with
`enroll` and `unenroll` writes. See [Record bindings](/docs/repository/record-bindings).

## Completion and certificates [#completion-and-certificates]

The course's required lessons, in module and lesson rank order, are the steps
of its enrollment program. Completing the last one completes the enrollment
and, when the course issues certificates, creates a certificate with a random
`code`. Adding a required lesson gives every live enrollment the new step, so
a completed learner is active again until they finish it; completing it
keeps their certificate.

`verifyCertificate(code)` tells anyone with the code, signed in or not, the
course, when the certificate was issued and whether it is still `valid`.
`revokeCertificate(certificateId)` revokes one for members with
`courses.manage`.

## Functions [#functions]

| Function                                                                       | Granted to                              | Does                                        |
| ------------------------------------------------------------------------------ | --------------------------------------- | ------------------------------------------- |
| `save_course(tenant, course)`, `delete_course(course_id)`                      | `authenticated`, `service_role`         | Creates, changes or deletes a course        |
| `list_courses(tenant, status)`                                                 | `authenticated`, `service_role`         | The organization's courses by title         |
| `save_course_module(course_id, module)`, `delete_course_module(module_id)`     | `authenticated`, `service_role`         | Creates, changes or deletes a module        |
| `save_course_lesson(course_id, lesson)`, `delete_course_lesson(lesson_id)`     | `authenticated`, `service_role`         | Creates, changes or deletes a lesson        |
| `course_outline(course_id)`, `course_lesson(lesson_id)`                        | `authenticated`, `service_role`         | A course's outline, or one lesson to play   |
| `enroll_in_course(course_id, user_id)`, `unenroll_from_course(course_id, ...)` | `authenticated`, `service_role`         | Enrolls a learner or cancels the enrollment |
| `start_course_lesson(lesson_id)`, `complete_course_lesson(lesson_id, done)`    | `authenticated`, `service_role`         | Records a lesson opened, done or not done   |
| `submit_course_quiz(lesson_id, answers)`                                       | `authenticated`, `service_role`         | Submits and grades a quiz                   |
| `course_progress(course_id, user_id)`, `list_course_learners(course_id, ...)`  | `authenticated`, `service_role`         | One learner's progress, or all of them      |
| `verify_course_certificate(code)`                                              | `anon`, `authenticated`, `service_role` | Checks a certificate by its code            |
| `revoke_course_certificate(certificate_id)`                                    | `authenticated`, `service_role`         | Revokes a certificate                       |

## Errors [#errors]

| Hint                           | Cause                                                                         |
| ------------------------------ | ----------------------------------------------------------------------------- |
| `COURSE_FORBIDDEN`             | the caller lacks the permission                                               |
| `COURSE_INVALID`               | an invalid course, module or lesson, or a change the course's state rules out |
| `COURSE_NOT_FOUND`             | no course the caller can see has this id                                      |
| `COURSE_EXISTS`                | a course with this key already exists in the organization                     |
| `COURSE_MODULE_NOT_FOUND`      | no module of the course has this id                                           |
| `COURSE_LESSON_NOT_FOUND`      | no lesson the caller can see has this id                                      |
| `COURSE_NOT_PUBLISHED`         | enrolling in a course that isn't published                                    |
| `COURSE_NOT_ENROLLED`          | reading or completing a lesson of a course the caller isn't enrolled in       |
| `COURSE_QUIZ_UNAVAILABLE`      | a quiz lesson without the `forms` module installed                            |
| `COURSE_CERTIFICATE_NOT_FOUND` | no certificate in the organization has this id                                |

## Events [#events]

| Event                                                      | Data                                                         |
| ---------------------------------------------------------- | ------------------------------------------------------------ |
| `course.saved`, `course.deleted`                           | `organizationId`, `id`, `key`, `status`                      |
| `course.enrolled`, `course.unenrolled`, `course.completed` | `organizationId`, `id`, `userId`, `enrollmentId`             |
| `course_lesson.completed`                                  | `organizationId`, `id`, `courseId`, `userId`, `enrollmentId` |
| `course_certificate.issued`, `course_certificate.revoked`  | `organizationId`, `id`, `courseId`, `userId`, `enrollmentId` |

The underlying enrollment emits its own `enrollment.*` events as well. A rule
on `course.completed` can notify a learner's manager, and a sink on
`course_certificate.issued` can email the verification link.

## With ReUI [#with-reui]

The [Course outline](/docs/registry/ui-recipes/course-outline) and
[Lesson player](/docs/registry/ui-recipes/lesson-player) recipes
render a course's modules with the caller's `Progress` and certificate, and a
lesson's content or quiz form. List learners in a `data-grid` with a
`Progress` of `completedLessons` over `totalLessons` and `status` as a
`Badge`, and reorder modules and lessons in a sortable list that saves the
moved item's new rank.

## Why it works this way [#why-it-works-this-way]

Progress lives in an enrollment because enrollments already count steps,
complete, cancel and resume; a course adds lesson content, quiz grades and
certificates on top. The program key is `course:<id>`, and `enrollments`
refuses to complete or reset those steps directly, so a quiz lesson completes
only through a graded submission. Quizzes reuse forms because the
forms block grades submissions in SQL, which keeps scores out of the
client's reach.

## Without the client [#without-the-client]

`db.courses` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createCourses } from "better-supabase/blocks/courses";

const courses = createCourses({ transport, tenant: organizationId });

const course = await courses
  .upsert({ key: "security-basics", title: "Security basics" })
  .orThrow();
const intro = await courses
  .saveModule(course.id, { title: "Introduction" })
  .orThrow();

await courses
  .saveLesson(course.id, {
    moduleId: intro.id,
    title: "Why it matters",
    body: { text: "Most breaches start with a reused password." },
    minutes: 5,
  })
  .orThrow();
await courses
  .saveLesson(course.id, {
    moduleId: intro.id,
    title: "Check yourself",
    kind: "quiz",
    formId: quizFormId,
  })
  .orThrow();
await courses.upsert({ id: course.id, status: "published" }).orThrow();

await courses.enroll(course.id).orThrow();
```

# Documents

> Collaborative Yjs documents attached to any subject. The database keeps an update log and a compacted snapshot, a private Realtime topic carries live edits and presence, and better-supabase/yjs connects a Y.Doc to both.

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

`documents` stores [Yjs](https://docs.yjs.dev) documents for any subject: the body of a page, the notes on a deal. Each document is one row per subject and name, an append-only log of Yjs updates, and a snapshot that compaction folds the log into. Editors send each edit to the log and broadcast it on the document's private Realtime topic, so other editors see it at once and a client that was offline catches up from the log.

The database never parses Yjs. It stores the updates as bytes and checks who may read and write: reading needs `documents.read` and the subject's read rules, editing needs `documents.write` and the subject's write rules, suggesting needs `documents.suggest` and the subject's read rules, and compacting needs the service role or `documents.manage`.

```bash
better-supabase add documents
pnpm add yjs
```

The block's entity is `document` (the `documents` table). Installing it registers `document` as a subject. Rename it with `blocks.documents.names`; parameter names, such as `max_documents`, keep the default noun.

Server code calls the `documents` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDocuments } = defineDataHooks(
  "documents",
  (db: AppDb) => db.documents,
);
```

```ts
const rows = await db.documents.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/documents/list.tsx"
"use client";

import { useDocuments } from "@/lib/supabase/hooks";

const rows = useDocuments({ limit: 50 });
```

## Configure [#configure]

```ts title="better-supabase.config.ts"
blocks: {
  documents: {
    options: {
      maxUpdateBytes: 262_144,
      compactAfter: 500,
    },
  },
},
```

| Option           | Default     | What it does                                                                                              |
| ---------------- | ----------- | --------------------------------------------------------------------------------------------------------- |
| `topic`          | `documents` | The Realtime topic prefix; each document's topic is `<topic>:<document id>`                               |
| `maxUpdateBytes` | `1048576`   | The largest update `push` accepts (`DOCUMENT_UPDATE_TOO_LARGE` otherwise)                                 |
| `compactAfter`   | `200`       | The number of updates since the snapshot that enqueues a `documents.compact` job, with the `queue` module |

A document is created on the first `open` by someone who may edit the subject; a reader who opens a document that doesn't exist yet gets `DOCUMENT_NOT_FOUND`. Deleting a subject deletes its documents.

## The provider [#the-provider]

`createDocumentProvider` from `better-supabase/yjs` connects one `Y.Doc` to one document:

1. `connect()` opens the document, applies the snapshot and the updates after it, and joins the private topic.
2. Each local edit is stored with `push`, then broadcast with its sequence number. Edits made while a push fails wait and are merged into one update.
3. When the channel reconnects, the provider pulls the updates after the last one it has and sends the edits that waited. Yjs ignores updates it already applied, so a repeat is harmless.

A reader (`canWrite: false`) receives edits and presence but never pushes. A reader with `canSuggest: true`, or an editor who calls `setMode("suggest")`, keeps local edits as a draft instead (`hasDraft`); `submitSuggestion(summary)` stores the draft as one suggestion. Pass `mode: "suggest"` to start in that mode. `track(state)` shares a presence state on the topic and `members()` returns everyone from the last sync.

`yjs` is an optional peer. The provider imports it on the first `connect()`, or takes the module as `options.yjs`. It only calls `applyUpdate` and `mergeUpdates`, and listens to the document's `update` event.

## Compaction [#compaction]

Compaction merges the snapshot and the log into one state with `Y.mergeUpdates`, stores it and drops the updates it contains. Run it from the service role, in the `documents.compact` job or a scheduled sweep:

```ts
import { compactDocument } from "better-supabase/yjs";

const documents = createDocuments({ transport: serviceTransport });
for (const documentId of await documents.toCompact().orThrow()) {
  await compactDocument({ documents, documentId }).orThrow();
}
```

A client that pulls updates that compaction dropped gets the snapshot back with them, and starts from it.

## Suggestions [#suggestions]

A suggestion is a Yjs update that waits outside the log until an editor decides on it, so the document stays unchanged while the suggestion is open. Members get `documents.suggest`, so a member who can only read a subject can still suggest. Rename the permission like any other through `modules.documents.permissions.suggest`.

| Method                                  | What it does                                                                                  |
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
| `suggest(documentId, update, summary?)` | Stores an update as an open suggestion                                                        |
| `suggestions(documentId, { status? })`  | Lists suggestions in `open` (the default), `accepted`, `rejected`, `withdrawn` or `all`       |
| `decideSuggestion(id, decision, note?)` | `accepted` appends the update to the log, `rejected` closes it, `withdrawn` is for the author |

Accepting stores the update with the author as its actor and returns its sequence number. The provider's `acceptSuggestion(id)` accepts, applies the update locally and broadcasts it, so the other editors receive it like any edit. Accepting or rejecting needs edit access; a decided suggestion can't change again (`DOCUMENT_SUGGESTION_CLOSED`).

## Versions [#versions]

With the [versions](/docs/blocks/versions) module installed, `saveVersion(documentId, state, label)` saves the merged state as a version of subject type `document`, and `versions(documentId)` and `version(documentId, versionId)` read them back. To restore one, apply the saved state to a new `Y.Doc` and replace the editor's content in a transaction, so the restore is an edit that every editor receives.

```ts
const saved = await documents
  .saveVersion(document.id, Y.encodeStateAsUpdate(doc), "Sent to legal")
  .orThrow();
```

Named versions are versions with a label. `nameVersion(documentId, versionId, label)` names or renames one, `null` removes the name, and `versions(documentId, { named: true })` lists only the named ones. Each version also carries its `source` and whether it is `frozen`; a frozen version keeps its name (`DOCUMENT_VERSION_FROZEN`).

## Errors [#errors]

| Hint                            | When                                                                                               |
| ------------------------------- | -------------------------------------------------------------------------------------------------- |
| `DOCUMENT_NOT_FOUND`            | The document doesn't exist, or the caller may not read it                                          |
| `DOCUMENT_FORBIDDEN`            | The caller may not edit, suggest on or compact the document, or withdraw someone else's suggestion |
| `DOCUMENT_UPDATE_INVALID`       | The update is empty or not base64                                                                  |
| `DOCUMENT_UPDATE_TOO_LARGE`     | The update is larger than `maxUpdateBytes`                                                         |
| `DOCUMENT_COMPACT_INVALID`      | `compact` names a sequence number past the last update                                             |
| `DOCUMENT_VERSION_NOT_FOUND`    | The version doesn't belong to the document                                                         |
| `DOCUMENT_VERSION_FROZEN`       | The version is frozen, so its name can't change                                                    |
| `DOCUMENT_SUGGESTION_NOT_FOUND` | The suggestion doesn't exist, or the caller may not read its document                              |
| `DOCUMENT_SUGGESTION_INVALID`   | The decision isn't `accepted`, `rejected` or `withdrawn`                                           |
| `DOCUMENT_SUGGESTION_CLOSED`    | The suggestion was already decided                                                                 |

## Events and hooks [#events-and-hooks]

`open` records `document.created` with `organizationId`, `subjectType`, `subjectId`, `id` and `name`. `suggest` records `document.suggested`, and the decisions record `document.suggestion_accepted` and `document.suggestion_rejected`, each with `suggestionId`, `authorId` and `status` as well. The `before_document_create` and `after_document_create` SQL hooks run around the insert.

## Without the client [#without-the-client]

`db.documents` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import * as Y from "yjs";
import {
  createDocuments,
  rpcTransport,
} from "better-supabase/blocks/documents";
import { createDocumentProvider } from "better-supabase/yjs";

const documents = createDocuments({ transport: rpcTransport(supabase) });
const doc = new Y.Doc();
const provider = createDocumentProvider({
  documents,
  client: supabase,
  doc,
  ref: { organizationId, subjectType: "page", subjectId: page.id },
  onPresence: (members) => setEditors(members),
});
await provider.connect().orThrow();
await provider.track({ name: user.name, color: "#0a7" });

// Bind doc to an editor (Tiptap, Lexical, CodeMirror), then on unmount:
await provider.destroy();
```

# Drive

> Folders and files on the attachments module. Access is granted to users or teams and inherited down the tree, storage policies enforce it on the objects too, and versions, trash and share-links add revisions, a bin, stars, recent items and folder links.

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

`drive` keeps a tree of folders and files per organization. A file is a node that points at an [attachment](/docs/blocks/attachments), so uploads, scanning and signed downloads work as they do there. Access is granted on a node to a user, a team or an organization role and applies to everything under it: a viewer on `Projects` can read `Projects/Launch/plan.pdf`. The same check runs in the drive functions, in the policies on the attachment rows and in restrictive policies on `storage.objects`, so a signed URL never opens a file the caller can't read.

```bash
better-supabase add drive
```

`drive` installs `drive-files` with it, which adds what needs the attachments module: uploads and revisions, the storage policies, the bin, stars, recent items, folder links and the purge. Install [versions](/docs/blocks/versions), [trash](/docs/blocks/trash), [share-links](/docs/blocks/share-links) and [teams](/docs/blocks/teams) for revisions, the bin, stars and links, and team grants.

Server code calls the `drive` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDrive } = defineDataHooks(
  "drive",
  (db: AppDb) => db.drive,
);
```

```ts
import { createAttachments } from "better-supabase/blocks/attachments";
import { createDrive, rpcTransport } from "better-supabase/blocks/drive";

const transport = rpcTransport(supabase);

const projects = await db.drive
  .createFolder(organizationId, { name: "Projects" })
  .orThrow();
const upload = await db.drive
  .upload(organizationId, {
    parentId: projects.id,
    name: file.name,
    mimeType: file.type,
    size: file.size,
  })
  .orThrow();
await supabase.storage
  .from(upload.attachment.bucket)
  .uploadToSignedUrl(upload.attachment.path, upload.token, file);
await db.drive.commit(upload.node.id, upload.attachment.id).orThrow();

await db.drive.share(projects.id, { type: "team", id: designTeam }, "editor");
await db.drive.share(projects.id, { type: "role", id: memberRoleId }, "viewer");
const { signedUrl } = await db.drive.download(upload.node.id).orThrow();
```

```tsx title="src/features/drive/list.tsx"
"use client";

import { useDrive } from "@/lib/supabase/hooks";

const rows = useDrive({ limit: 50 });
```

## Access [#access]

Each node has a role for the caller: the highest role granted on the node or any folder above it, or `null` when nothing is granted. Members with `drive.manage` have the top role on every node in the organization, and the service role always does. Reading anything needs `drive.read`, and creating a node at the root needs `drive.create`.

| Role     | What it allows                                                     |
| -------- | ------------------------------------------------------------------ |
| `viewer` | List, open and download the node and everything under it           |
| `editor` | Create, upload, rename, move, trash and restore revisions under it |
| `owner`  | Share and unshare, and everything an editor can do                 |

The creator of a node gets the top role on it. Grants with an `expiresAt` stop applying after that time. `drive_role(node)` and `drive_can(node, role)` are SQL functions, so your own policies can use them too. The block's own read policy uses `id in (select drive_readable_ids())` instead, which returns every node `drive_can(node, read)` is true for in one call, so Postgres runs it once per statement rather than once per row; prefer it in your policies on large drives.

## Configure [#configure]

```ts title="better-supabase.config.ts"
blocks: {
  drive: {
    options: {
      roles: ["viewer", "commenter", "editor", "owner"],
      writeRole: "editor",
      shareRole: "owner",
      conflict: "rename",
    },
  },
},
```

| Option         | Default                         | What it does                                                                           |
| -------------- | ------------------------------- | -------------------------------------------------------------------------------------- |
| `roles`        | `["viewer", "editor", "owner"]` | The roles, lowest first; the first one reads and the last one is the top role          |
| `writeRole`    | `editor`, else the top role     | The role that may create, rename, move and trash                                       |
| `shareRole`    | the top role                    | The role that may share and unshare                                                    |
| `memberRole`   | none                            | A role every member of the organization has on every node                              |
| `conflict`     | `fail`                          | What a duplicate name in a folder does: `fail`, `rename` (`plan (2).pdf`) or `replace` |
| `subjectType`  | `drive_node`                    | The subject type of nodes in events, audit entries, attachments, versions and trash    |
| `extraColumns` | none                            | Columns on the node table that `fields` reads and writes                               |

Each call that creates, renames or moves a node can pass its own `conflict`. Names compare case-insensitively within one folder.

Pass a [Standard Schema](https://standardschema.dev) for the `extraColumns`
as `fields` to `createDrive`: every node the client returns has its `fields`
parsed, and `createFolder` and `upload` validate the ones you set before the
call (see [extending blocks](/docs/extending/blocks#your-own-fields)).

## Files and revisions [#files-and-revisions]

`upload` creates the file node and a pending attachment, then signs an upload URL. After the upload, `commit` makes the attachment the file's content. `uploadRevision` uploads new content for an existing file the same way; with versions installed, each commit saves a revision, `revisions(id)` lists them and `restoreRevision(id, revisionId)` makes an old one current again.

`download(id)` signs a download URL for the current content through the `attachments` option, the attachments block over the same transport; `createBlocks` passes it as the `attachments` port when the set has `attachmentsBlock`. Without it, `download` fails with the hint `DRIVE_ATTACHMENTS_CLIENT`. With `requireScan` on the attachments block (the default), a file that isn't scanned clean yet can't be downloaded.

## Labels and approvals [#labels-and-approvals]

With the [tags block](/docs/blocks/tags) installed, `label(id, tagId)` puts a tag on a node and `label(id, tagId, false)` takes it off; it needs write access to the node. Every node the client returns carries its `labels` (`id`, `name`, `color`), and `labeled(organizationId, tagId)` lists the nodes with a tag that the caller may read. Tags of any kind work as labels. Purging a node deletes its labels.

With the [approvals block](/docs/blocks/approvals) installed, `requestApproval(id)` asks for approval of the file's current content, and `requestApproval(id, { attachmentId, reason })` asks for one earlier revision. The request records the attachment and the revision number in its payload, so a decision covers exactly those bytes. Add a policy for the `approve` action on the node subject:

```ts title="better-supabase.config.ts"
approvals: {
  options: {
    policies: {
      drive_node: {
        approve: { steps: [{ approvers: { permission: "approvals.manage" }, required: 1 }] },
      },
    },
  },
},
```

`approvals(id)` lists the file's requests, newest first, each with `attachmentId`, `revision` and `current` (whether that revision is still the file's content). Every node carries `approval`: the latest request for its current content, or `undefined`. Uploading a new revision clears it, and restoring an approved revision brings the approval back. A file has at most one pending request at a time, so a second request fails with `APPROVAL_PENDING`. Requests stay after the node is purged, as the record of what was decided.

Anyone who can read a node sees its labels and its approval state. Tagging, untagging and requesting approval need write access, and the tags and approvals functions check the same drive grants when they are called directly with the `drive_node` subject type.

## The bin [#the-bin]

With trash installed, `trash(id)` moves a node and everything under it to the bin as one batch, and `bin(organizationId)` lists the batches. `restore(binId)` puts the batch back, applying the conflict policy if the name is taken again. A node whose folder is still in the bin can't come back until the folder does. `purge(binId)` deletes it for good and records `drive.node_purged` with the object paths. Run `prune()` from the service role to delete the purged objects from storage:

```ts
const drive = createDrive({ transport: serviceTransport, storage });
await drive.prune({ maxObjects: 500 }).orThrow();
```

## Stars, recent items and links [#stars-recent-items-and-links]

With share-links installed, `star(id)` and `star(id, false)` mark a node for the caller, `open(id)` records that the caller opened it, and `starred` and `recent` list them. A share link on a folder opens it and the nodes under it: `link(token)` returns the folder and its children, and `link(token, { nodeId })` returns one node under it with its stored object.

## With ReUI [#with-reui]

`driveTree(nodes)` shapes the nodes you loaded for a tree view such as the [ReUI](https://reui.io) file tree on headless-tree: it returns `rootItemId`, `getItem`, `getChildren` and `isFolder`. `fileTypeCategory(mimeType, name)` and `nodeType(node)` name a file's kind (`image`, `pdf`, `spreadsheet`, `code` and others) to pick its icon.

```tsx
import { driveTree, nodeType } from "better-supabase/blocks/drive";

const data = driveTree(await drive.list(organizationId).orThrow());
const tree = useTree({
  rootItemId: data.rootItemId,
  getItemName: (item) => data.name(item.getId()),
  isItemFolder: (item) => data.isFolder(item.getId()),
  dataLoader: { getItem: data.getItem, getChildren: data.getChildren },
  features: [syncDataLoaderFeature],
});
```

Load a folder's children with `list(organizationId, { parentId })` as it opens, and build the tree again from all the nodes loaded so far.

## Comments, search and relations on nodes [#comments-search-and-relations-on-nodes]

`driveSubject()` describes the node table as a subject, so the [subject registry](/docs/blocks/sql#subject-registry) lets comments, reactions and other blocks attach to nodes with the drive's read and write rules. It also carries a `search` entry: with the [search block](/docs/blocks/search) installed, node names are indexed as they are created and renamed, trashed nodes drop out of the results, and [relations](/docs/blocks/relations) can link a node to any other subject:

```ts title="better-supabase.config.ts"
import { driveSubject } from "better-supabase/blocks/drive";

export default defineConfig({ records: { drive_node: driveSubject() } });
```

## Over your own tables [#over-your-own-tables]

Map `tables.nodes` and `tables.grants` to run the drive on your own tree and grant tables. A grant table can name its principal with `principalType` and `principalId` columns (users, teams and roles), or with a `user` column and an optional `team` column (users and teams). Set `attachment` to `null` when your files don't live in attachments: the tree, grants, sharing and checks still work, and uploads stay with your code.

```ts title="better-supabase.config.ts"
drive: {
  tables: { nodes: "public.file_node", grants: "public.file_node_grant" },
  columns: {
    nodes: { tenant: "organization_id", attachment: null, path: null, rank: null, deletedAt: null },
    grants: {
      tenant: "organization_id",
      node: "file_node_id",
      principalType: null,
      principalId: null,
      expiresAt: null,
      createdBy: null,
    },
  },
  options: { roles: ["viewer", "commenter", "editor"] },
},
```

## Errors [#errors]

| Hint                         | When                                                                                                 |
| ---------------------------- | ---------------------------------------------------------------------------------------------------- |
| `DRIVE_NOT_FOUND`            | The node doesn't exist, or the caller may not read it                                                |
| `DRIVE_FORBIDDEN`            | The caller's role on the node doesn't allow the change                                               |
| `DRIVE_NAME_CONFLICT`        | The folder already has a node with that name and `conflict` is `fail`                                |
| `DRIVE_NAME_INVALID`         | The name isn't 1 to 255 characters, or holds a `/` or a control character                            |
| `DRIVE_CONFLICT_INVALID`     | `conflict` isn't `fail`, `rename` or `replace`                                                       |
| `DRIVE_PARENT_NOT_FOUND`     | The parent folder doesn't exist in the organization                                                  |
| `DRIVE_PARENT_NOT_FOLDER`    | The parent is a file                                                                                 |
| `DRIVE_PARENT_TRASHED`       | `restore` names a node whose folder is still in the bin                                              |
| `DRIVE_MOVE_CYCLE`           | The move puts a folder inside itself                                                                 |
| `DRIVE_RANK_INVALID`         | The rank isn't a rank key                                                                            |
| `DRIVE_ROLE_UNKNOWN`         | The role isn't one of `options.roles`                                                                |
| `DRIVE_PRINCIPAL_INVALID`    | The principal type isn't `user`, `team` or `role`, or the id is missing                              |
| `DRIVE_PRINCIPAL_NOT_FOUND`  | The user isn't a member, or the team or role isn't in the organization                               |
| `DRIVE_NOT_A_FILE`           | A file call names a folder                                                                           |
| `DRIVE_ATTACHMENT_NOT_FOUND` | `commit` or `requestApproval` names an upload that doesn't belong to the file, or isn't uploaded yet |
| `DRIVE_REVISION_NOT_FOUND`   | The revision doesn't belong to the file                                                              |
| `DRIVE_LINK_NOT_FOUND`       | The link is unknown, expired or revoked, or the node isn't under it                                  |
| `DRIVE_STORAGE_CLIENT`       | A file call ran on a client created without `storage`                                                |

A read-only support session refuses every write with `ACTOR_READ_ONLY`. In a
writable [support session](/docs/auth/impersonation#storage-in-a-support-session),
`commit` first claims the uploaded object for the user the admin acts as,
which records the session on it. A claim Storage refuses returns `forbidden`
with the hint `DRIVE_FORBIDDEN`, and the upload stays uncommitted.

## Events and hooks [#events-and-hooks]

The drive records `drive.node_created`, `drive.node_renamed`, `drive.node_moved`, `drive.node_shared`, `drive.node_unshared` and `drive.node_purged`; the [events page](/docs/extending/events) lists their payloads. Sharing and unsharing also write an `access` audit entry. The `before_` and `after_` SQL hooks run around `drive_node_create`, `drive_node_move`, `drive_node_rename` and `drive_node_share`, and the client takes `withBlockHooks` hooks on every method.

## Without the client [#without-the-client]

`db.drive` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createAttachments } from "better-supabase/blocks/attachments";
import { createDrive, rpcTransport } from "better-supabase/blocks/drive";

const transport = rpcTransport(supabase);
const drive = createDrive({
  transport,
  storage: supabase.storage,
  attachments: createAttachments({ transport, storage: supabase.storage }),
});

const projects = await drive
  .createFolder(organizationId, { name: "Projects" })
  .orThrow();
const upload = await drive
  .upload(organizationId, {
    parentId: projects.id,
    name: file.name,
    mimeType: file.type,
    size: file.size,
  })
  .orThrow();
await supabase.storage
  .from(upload.attachment.bucket)
  .uploadToSignedUrl(upload.attachment.path, upload.token, file);
await drive.commit(upload.node.id, upload.attachment.id).orThrow();

await drive.share(projects.id, { type: "team", id: designTeam }, "editor");
await drive.share(projects.id, { type: "role", id: memberRoleId }, "viewer");
const { signedUrl } = await drive.download(upload.node.id).orThrow();
```

# Forms

> Versioned forms with steps and show or skip logic, a JSON Schema 2020-12 per version that Postgres checks on every submission, drafts, public links with a rate limit and a bot-check hook, and handlers that turn a submission into a record.

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

`forms` stores a form, its published versions and the submissions to them. Each version holds a definition (steps of fields, with `showIf` conditions) and the JSON Schema 2020-12 generated from it, and `pg_jsonschema` checks every submission against that schema inside Postgres. Reading forms needs `forms.read`, submitting needs `forms.submit`, and creating and publishing needs `forms.manage`. Respondents read their own submissions and delete their own drafts; reading anyone else's, reviewing and deleting needs `forms.review`.

```bash
better-supabase add forms
```

Server code calls the `forms` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useForms } = defineDataHooks(
  "forms",
  (db: AppDb) => db.forms,
);
```

```ts
import { createForms } from "better-supabase/blocks/forms";

const form = await db.forms
  .create({ organizationId, key: "signup", title: "Sign up" })
  .orThrow();
await db.forms
  .publish(form.id, {
    steps: [
      {
        key: "contact",
        title: "Contact",
        fields: [
          { key: "name", label: "Name", type: "text", required: true },
          { key: "email", label: "Email", type: "email", required: true },
          {
            key: "plan",
            label: "Plan",
            type: "option",
            options: [
              { value: "free", label: "Free" },
              { value: "team", label: "Team" },
            ],
          },
        ],
      },
      {
        key: "team",
        showIf: { field: "plan", equals: "team" },
        fields: [
          {
            key: "seats",
            label: "Seats",
            type: "integer",
            min: 2,
            required: true,
          },
        ],
      },
    ],
  })
  .orThrow();
await db.forms.update(form.id, { status: "open" }).orThrow();
await db.forms
  .submit(form.id, { name: "Ada", email: "ada@example.com" })
  .orThrow();
```

```tsx title="src/features/forms/list.tsx"
"use client";

import { useForms } from "@/lib/supabase/hooks";

const rows = useForms({ limit: 50 });
```

## Definitions and versions [#definitions-and-versions]

A definition is a list of steps, and each step a list of fields. `publish(formId, definition)` stores it as the form's next numbered version and makes it current; older versions and their submissions stay as they were. A form takes submissions only while its status is `open` (`FORM_CLOSED` otherwise).

| Field type             | Answer                                   | Options                             |
| ---------------------- | ---------------------------------------- | ----------------------------------- |
| `text`, `textarea`     | a string (500 and 10,000 characters max) | `minLength`, `maxLength`, `pattern` |
| `email`, `url`, `date` | a string with that format                |                                     |
| `number`, `integer`    | a number                                 | `min`, `max`                        |
| `bool`                 | `true` or `false`                        |                                     |
| `consent`              | `true`                                   |                                     |
| `option`               | one of `options[].value`                 | `options`                           |
| `multi`                | distinct values from `options`           | `options`, `min`, `max` (items)     |
| `json`                 | any JSON value                           |                                     |
| `nps`                  | an integer from 0 to 10                  |                                     |

`required: true` asks for the field, and for a non-empty string or list. A `showIf` on a step or field is `{ field, equals }`, `{ field, in }` or `{ field, filled: true }`, naming an earlier field. A hidden field is never required, so its `required` becomes an `if`/`then` in the schema. A field with `customField: "<definition id>"` copies its label, type and `required` from a [custom field](/docs/blocks/custom-fields) when the version is published; give an option field its `options` in the form.

`publish` checks the definition against `FORM_DEFINITION_SCHEMA`, then for what the shape can't say (a key used twice, a condition on a later field, a choice field without options), and fails with `FORM_DEFINITION_INVALID` and the problems in `issues`. `formDefinitionProblems(definition)` runs the second check in the app, and `formSchema(definition)` returns the same schema Postgres stores.

## Your own fields [#your-own-fields]

`blocks.forms.options.extraColumns` adds columns to the `forms` table,
keyed by column name with their SQL type. `create` and `update` write them from `fields`, and `create`, `update`, `get` and `list` return them under `fields`. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const forms = createForms({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await forms.update(formId, { fields: { region: "emea" } });
```

## Submissions [#submissions]

A submission that doesn't match the version's schema fails with a `validation` error (hint `JSON_SCHEMA_INVALID`) whose `issues` name the fields. `validateAnswers(schema, answers)` checks the same keywords in the browser, and `answersSchema(schema)` wraps it as a [Standard Schema](https://standardschema.dev) for form libraries.

```ts
const draft = await forms
  .submit(form.id, { name: "Ada" }, { draft: true })
  .orThrow();
await forms
  .submit(
    form.id,
    { name: "Ada", email: "ada@example.com" },
    { submissionId: draft.id },
  )
  .orThrow();
const page = await forms
  .listSubmissions(form.id, { status: "submitted" })
  .orThrow();
await forms.review(page[0]!.id, "accepted").orThrow();
```

`draft: true` saves answers without checking them, and a later `submit` with `submissionId` finishes the draft against the current version. Pass `subjectType` and `subjectId` to link a submission to a record the caller can read, such as a deal. `listSubmissions` returns the newest first, 50 to a page by default; pass the last `createdAt` as `before` for the next page.

## Scores and quizzes [#scores-and-quizzes]

Give a field a `correct` answer to score it, and `points` to weigh it (1 by default). `scoring.passMark` is the percentage of the points a submission needs to pass. Every field type takes a correct answer except `consent`, `json` and `nps`, and the answer has to be one the field accepts.

```ts
await forms
  .publish(form.id, {
    scoring: { passMark: 70 },
    steps: [
      {
        key: "quiz",
        fields: [
          {
            key: "capital",
            type: "text",
            label: "Capital of France",
            correct: "Paris",
          },
          {
            key: "giants",
            type: "multi",
            label: "Gas giants",
            options: [
              { value: "jupiter", label: "Jupiter" },
              { value: "mars", label: "Mars" },
              { value: "saturn", label: "Saturn" },
            ],
            correct: ["jupiter", "saturn"],
            points: 2,
          },
        ],
      },
    ],
  })
  .orThrow();
```

`publish` moves the correct answers into the `form_answer_keys` table, which no client role can read. Respondents get the definition without them; callers with `forms.manage` get them merged back. When a submission is final, Postgres scores it inside `submit` and stores `score`, `maxScore` and `passed` on the row, so a client can't send its own grade. Text answers match without case and surrounding spaces, a `multi` answer matches as a set, and other answers match as the same JSON value. A field hidden by its `showIf` (or its step's) counts toward neither score. `passed` is absent without a pass mark, and `score` is absent on drafts and unscored versions.

`formScore(definition, answers)` computes the same grade in the app, for a preview from a definition that still holds its correct answers. Over a submissions table of your own, the block adds the `score`, `max_score` and `passed` columns it lacks. When `columns.submissions` maps one of them to `null`, scoring is off and `publish` refuses a correct answer with `FORM_SCORING_UNAVAILABLE`.

## Net Promoter Score [#net-promoter-score]

An `nps` field takes an integer from 0 to 10. `nps(formId, options)` counts the submitted and accepted answers: 9 and 10 are promoters, 7 and 8 passives, 0 to 6 detractors, and `score` is the promoters' share minus the detractors', from -100 to 100 to one decimal. It needs `forms.review`.

```ts
const nps = await forms
  .nps(form.id, {
    since: Temporal.Instant.from("2026-01-01T00:00:00Z"),
    bucket: "month",
  })
  .orThrow();
// { field: "nps", responses: 40, promoters: 22, passives: 10, detractors: 8, score: 35, periods: [...] }
```

`field` picks the field (the current version's first `nps` field by default, `FORM_NPS_FIELD_MISSING` without one). `since` and `until` bound `submittedAt`, and `bucket` (`day`, `week`, `month`, `quarter` or `year`, in UTC) adds a row per period. `npsCategory(answer)` and `npsScore(counts)` give the same buckets and score in the app.

## Turn a submission into a record [#turn-a-submission-into-a-record]

`options.handlers` maps a form key to a function that receives the submission id. It runs in the submission's transaction, and when it returns `{ subjectType, subjectId }` the submission is linked to that record.

```ts title="better-supabase.config.ts"
blocks: {
  forms: { options: { handlers: { contact: "public.lead_from_submission" } } },
},
```

```sql
create function public.lead_from_submission(submission_id uuid) returns jsonb
language plpgsql security definer set search_path = '' as $$
declare v_id uuid;
begin
  insert into public.leads (organization_id, name, email)
  select s.tenant_id, s.answers ->> 'name', s.answers ->> 'email'
  from better_supabase.form_submissions s where s.id = submission_id
  returning id into v_id;
  return jsonb_build_object('subjectType', 'lead', 'subjectId', v_id);
end;
$$;
```

## Public forms [#public-forms]

With the [share-links](/docs/blocks/share-links) module, a link with subject type `form` and role `respond` lets anyone who holds it read and submit the form, without an account:

```ts
const link = await shareLinks
  .create(organizationId, "form", form.id, "respond")
  .orThrow();
// On the public page:
const publicForm = await forms.getPublic(token).orThrow();
await forms
  .submitPublic(token, answers, { botToken: turnstileToken })
  .orThrow();
```

An unknown, revoked or expired token fails with `FORM_LINK_INVALID`. With the `rate-limit` SQL module, each caller (the user, else the right-most `x-forwarded-for` address) gets 20 public submissions per form per hour, a `BS429` error with `Retry-After` after that. `options.publicLimit` sets `{ max, period }` (an ISO 8601 duration), or `false` to turn it off. To check a bot challenge, implement the `before_public_form_submission(form_id uuid, answers jsonb, bot_token text)` hook and raise when the token fails; Postgres can't call the challenge's API, so verify it in an Edge Function or keep a table of tokens your server has verified.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createQuestionnaireController` with its options, as
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)
explains. `state` is a shallow ref of the snapshot and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController } from "better-supabase/vue";
import {
  createQuestionnaireController,
  type QuestionnaireForm,
} from "better-supabase/blocks/forms";

const { version } = defineProps<{ version: QuestionnaireForm }>();
const { state, actions } = useBlockController(
  createQuestionnaireController,
  { form: version, onSubmit: (answers) => submitForm(formId, answers) },
  false,
);
</script>

<template>
  <p>{{ state.step?.title }}</p>
  <button @click="actions.next()">Next</button>
</template>
```

`submitForm` stands for your server call that runs `forms.submit(formId,
answers)`.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createQuestionnaireController` with its options. It returns a readable store of
the snapshot, read with `$`, with the actions on `.actions`. The work starts on
mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { useBlockController } from "better-supabase/svelte";
  import {
    createQuestionnaireController,
    type QuestionnaireForm,
  } from "better-supabase/blocks/forms";

  let { version }: { version: QuestionnaireForm } = $props();
  const questionnaire = useBlockController(createQuestionnaireController, {
    form: version,
    onSubmit: (answers) => submitForm(formId, answers),
  }, false);
</script>

<p>{$questionnaire.step?.title}</p>
<button onclick={() => questionnaire.actions.next()}>Next</button>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createQuestionnaireController` with its options. `state()` reads the snapshot
through `from`, and `actions` are the controller's actions. The work starts on
mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { useBlockController } from "better-supabase/solid";
import {
  createQuestionnaireController,
  type QuestionnaireForm,
} from "better-supabase/blocks/forms";

export function Component(props: { version: QuestionnaireForm }) {
  const { state, actions } = useBlockController(
    createQuestionnaireController,
    { form: props.version, onSubmit: (answers) => submitForm(formId, answers) },
    false,
  );
  return (
    <>
      <p>{state().step?.title}</p>
      <button onClick={() => actions.next()}>Next</button>
    </>
  );
}
```

## Events and hooks [#events-and-hooks]

The module records `form.created`, `form_version.published`, `form_submission.submitted` (with `versionId` and `source`: `member`, `public` or `service`), `form_submission.reviewed` (with `status`) and `form_submission.deleted`. The submission events carry the linked `subjectType` and `subjectId`, so an [outbox](/docs/blocks/outbox) consumer can count submissions per record. Creating a form, publishing, reviewing and deleting go to the [audit log](/docs/blocks/audit). It calls `before_form_publish`, `after_form_publish`, `before_form_submission`, `after_form_submission` and `before_public_form_submission`.

## With ReUI [#with-reui]

`useQuestionnaire` is a registry item: install it with `npx shadcn add @better-supabase/use-forms`, which writes `hooks/use-forms.ts` into the app. It holds the answers, the visible steps and the issues per field for one version, so a ReUI Stepper and its questionnaire layouts render the form without other state:

```tsx
"use client";
import { useQuestionnaire } from "@/hooks/use-forms";

const q = useQuestionnaire({
  form: version,
  onSubmit: (answers) => forms.submit(formId, answers).orThrow(),
});
// <Stepper value={q.step?.key} onValueChange={q.goTo}>: one StepperItem per q.steps entry, completed={step.completed}
// q.step?.fields: render each with its input, value={q.answers[field.key]}, error={q.issues[field.key]?.[0]}
// Back: q.back(), hidden when q.isFirst. Next: q.isLast ? q.submit() : q.next()
```

`next()` checks the step's fields against the version's schema and stays on the step when one fails. Answering a field that hides later steps moves past them, and `submit()` sends only the answers of fields still visible. Submissions render in a ReUI data grid from `listSubmissions`, with accept and reject actions that call `review`.

Signatures moved to their own block in 0.14; see [Signatures](/docs/blocks/signatures).

## Without the client [#without-the-client]

`db.forms` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createForms } from "better-supabase/blocks/forms";

const forms = createForms({ transport });
const form = await forms
  .create({ organizationId, key: "signup", title: "Sign up" })
  .orThrow();
await forms
  .publish(form.id, {
    steps: [
      {
        key: "contact",
        title: "Contact",
        fields: [
          { key: "name", label: "Name", type: "text", required: true },
          { key: "email", label: "Email", type: "email", required: true },
          {
            key: "plan",
            label: "Plan",
            type: "option",
            options: [
              { value: "free", label: "Free" },
              { value: "team", label: "Team" },
            ],
          },
        ],
      },
      {
        key: "team",
        showIf: { field: "plan", equals: "team" },
        fields: [
          {
            key: "seats",
            label: "Seats",
            type: "integer",
            min: 2,
            required: true,
          },
        ],
      },
    ],
  })
  .orThrow();
await forms.update(form.id, { status: "open" }).orThrow();
await forms
  .submit(form.id, { name: "Ada", email: "ada@example.com" })
  .orThrow();
```

# Imports

> CSV imports with a suggested mapping, a dry run, and `runImport`, which writes the rows in batches and marks the import failed when a batch throws.

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

`imports` is the counterpart of the data-lifecycle exporter. `parseCsv` reads RFC 4180 CSV (`SPEC_PINS.rfc4180`). `defineImport` names the kind, a Standard Schema for one row, a row key and an `insert` function. `suggestMapping` pairs headers to fields. `dryRun` validates every row and returns errors without writing. `runImport` records the run, skips keys that already imported, inserts the rest in batches of 100 and finishes with `import.completed` or `import.failed`.

```bash
better-supabase add imports
```

```ts
import { defineImport, runImport } from "better-supabase/blocks/imports";
```

The row key makes a rerun skip a row that already imported.

## Upsert into an app table [#upsert-into-an-app-table]

`appTableImport` builds an import that updates the rows a file already has
instead of skipping them. It takes the table's repository and `key`, one of
the table's `unique` sets (or its primary key), and throws when the key is
not one. `previewImport` validates the file like `dryRun` and counts the
rows it would create, change and leave unchanged, without writing:

```ts
import {
  appTableImport,
  previewImport,
  runImport,
} from "better-supabase/blocks/imports";

const tasks = appTableImport({
  table: "tasks",
  columns: TaskRow,
  repository: db.tasks,
  key: ["projectId", "title"],
  update: "fill",
});

const { preview, errors } = await previewImport(tasks, csv, mapping);
// preview: { created: 12, changed: 3, unchanged: 40 }
```

`update` decides what happens to a row that exists. `fill`, the default,
writes only the fields that are empty in the database, so a rerun never
overwrites an edit. `overwrite` writes every field the file has. Pass the
definition to `runImport` without `importedKeys`, so a rerun updates rows
instead of skipping them. The [upsert import](/recipes) recipe maps the
columns, shows the preview and runs the import.

## Over your own tables [#over-your-own-tables]

Map `tables.runs` onto the app's import table. Optional columns can be `null`.

## Hooks [#hooks]

`createImports` takes `hooks`, keyed by method name, that run your code
before and after a call: refuse it with a `DbError`, rewrite its arguments
or observe the result (see [extending blocks](/docs/extending/blocks#hooks)).

# Signatures

> Simple electronic signatures on a frozen version of a record, with sequential or parallel signers, a SHA-256 hash per signature and a check that the signed content is unchanged.

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

The `signatures` SQL module asks members to sign one version of a record, kept by the [versions](/docs/blocks/versions) block. It needs `versions` in `managed` ownership, over its own tables or yours. Requesting needs `signatures.request`, and reading a record's requests needs `signatures.read`; signers always read the requests they are on. The signature is a simple electronic signature: the module records who signed which content, how and when, and the app renders the final document.

```bash
better-supabase add versions signatures
```

Server code calls the `signatures` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSignatures } = defineDataHooks(
  "signatures",
  (db: AppDb) => db.signatures,
);
```

```ts
import { createSignatures } from "better-supabase/blocks/signatures";

const request = await db.signatures
  .request({
    organizationId,
    subjectType: "contract",
    subjectId: contract.id,
    signers: [buyerId, sellerId],
    order: "sequential",
  })
  .orThrow();
await db.signatures
  .sign(request.id, { method: "typed", typedName: "Ada Lovelace" })
  .orThrow();
```

```tsx title="src/features/signatures/list.tsx"
"use client";

import { useSignatures } from "@/lib/supabase/hooks";

const rows = useSignatures({ limit: 50 });
```

Without `versionId`, the request snapshots the record's versioned columns first. The version is frozen, so retention and `delete_version` keep it. Signers must be members of the organization, at most 20 and each once. With `order: "sequential"` each signs after the one before; `parallel` is the default. A signature is `click`, `typed` (with the typed name) or `drawn` (with the attachment that holds the image, which needs the [attachments](/docs/blocks/attachments) module). The request completes when everyone has signed, and a signer can `decline` it, which closes it for everyone. The requester, or a holder of `signatures.manage`, can `cancel` a pending request.

Each signature stores a SHA-256 hash over the request, the version's content hash, the signer, the method and the time, with the caller's address and user agent. `get` returns the request with its signatures and a `verification`: `contentMatches` recomputes the signed version's hash from its content, each signature's `valid` recomputes its hash, and `changedSince` is true when the record has a newer version than the one signed. A signed record that was edited later shows `changedSince: true`, and a changed signature or version row shows `valid: false`.

With the [approvals](/docs/blocks/approvals) module, `approvalRequestId` links a pending approval request on the same record. When a signer is an approver for its current step, their signature approves that step, so signing a contract can be the approval that sends it.

Errors carry these hints: `SIGNATURE_SIGNERS_INVALID`, `SIGNATURE_VERSION_NOT_FOUND`, `SIGNATURE_OUT_OF_ORDER`, `SIGNATURE_ALREADY_SIGNED`, `SIGNATURE_METHOD_INVALID`, `SIGNATURE_CONTENT_CHANGED` (the version changed between the request and the signature), `SIGNATURE_REQUEST_CLOSED` and `SIGNATURE_FORBIDDEN`.

Until 0.14 the client was exported from `better-supabase/blocks/forms`. Import it from `better-supabase/blocks/signatures`; see [From 0.13 to 0.14](/docs/migration/0.13-to-0.14#signatures-get-their-own-block).

## Events and hooks [#events-and-hooks]

The module records `signature.requested`, `signature.signed` (with `signer` and `method`), `signature.completed`, `signature.declined` and `signature.cancelled`, each with the signed record's `subjectType` and `subjectId`, and audits requests, signatures, declines and cancellations. It calls `before_signature_request`, `after_signature_request`, `before_signature_sign` and `after_signature_sign`.

## With ReUI [#with-reui]

For drawn signatures, ReUI's Signature Pad (free, MIT) captures the image. Upload it with the attachments block, then pass its id to `sign(request.id, { method: "drawn", imageAttachmentId })`. A ReUI timeline renders `get(request.id)`: one item per signer, completed when their signature is in `signatures`, with a warning when `verification.changedSince` is true.

## Your own columns [#your-own-columns]

Add columns to the signature request table with `blocks.signatures.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`request` validates and writes them, and `request`, `get`, `list`, `decline` and `cancel` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createSignatures } from "better-supabase/blocks/signatures";
import { z } from "zod";

const client = createSignatures({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.signatures` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createSignatures } from "better-supabase/blocks/signatures";

const signatures = createSignatures({ transport });
const request = await signatures
  .request({
    organizationId,
    subjectType: "contract",
    subjectId: contract.id,
    signers: [buyerId, sellerId],
    order: "sequential",
  })
  .orThrow();
await signatures
  .sign(request.id, { method: "typed", typedName: "Ada Lovelace" })
  .orThrow();
```

# Templates

> Versioned templates that create records, such as an invoice or a project with its tasks, system starters each organization forks, merge fields for text and email, and the workflows each new record starts.

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

`templates` stores templates of a type you define, such as `invoice`, `project` or `onboarding-email`. A template has settings, a versioned body and a language, and applying it creates a record from the newest published version through your type's `apply`. The module records which version made which record, and starts the [workflow-editor](/docs/blocks/workflow-editor) workflows attached to the template on the new record.

System templates (no organization) are starters your app ships and keeps by key with `sync`. An organization forks one to change it, and the fork tracks the version it copied, so you can tell when the starter moved on.

Reading templates needs `templates.read`, applying one needs `templates.apply`, and creating, editing, publishing, forking and attaching workflows needs `templates.manage`. Admins get every permission, and members get `templates.read` and `templates.apply`. Only the service role changes system templates.

```bash
better-supabase add templates
```

The module needs `memberships` and `access`. With `outbox` it records events, with `audit` it audits publishes and applications, with `workflow-editor` it attaches workflows, and with `jsonb-schemas` it checks a workflow's inputs against its whole input schema.

## Template types [#template-types]

A type gives a template its Standard Schemas and creates the record. `settings` and `body` are checked on every write and parsed again before `apply`. `apply` returns the record it created as a subject.

Server code calls the `templates` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTemplates } = defineDataHooks(
  "templates",
  (db: AppDb) => db.templates,
);
```

```ts
const rows = await db.templates.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/templates/list.tsx"
"use client";

import { useTemplates } from "@/lib/supabase/hooks";

const rows = useTemplates({ limit: 50 });
```

A type without `apply` stores templates you render yourself, such as emails. Applying one returns an `invalid_request` error.

## Starters, forks and defaults [#starters-forks-and-defaults]

```ts
await service
  .sync([
    {
      key: "launch",
      type: "project",
      name: "Product launch",
      body: { tasks: ["Kickoff", "Plan", "Retro"] },
    },
  ])
  .orThrow();

const [starter] = await templates.list(null, { type: "project" }).orThrow();
const fork = await templates.fork(starter.id, organizationId).orThrow();
await templates.setDefault(fork.id).orThrow();
```

`sync` upserts system templates by `key` as the service role, and publishes a new version of each starter whose body changed (it compares the SHA-256 of the body). It returns how many versions it published, so running it on every deploy is cheap.

`fork(source, organizationId)` copies the source's newest published version into a new template in that organization, published as version 1, with its workflow attachments. The fork keeps `forkedFrom` and `forkedFromVersion`. `upstreamVersion` is the source's newest published version, and `behind` is true when it is newer than the one the fork copied.

`list(organizationId)` returns the organization's templates and the system ones, defaults first; `list(null)` returns only the system ones. `setDefault` makes a template the default for its organization, type and language; a unique index keeps one.

## Versions [#versions]

```ts
await templates.versions
  .upsert(fork.id, { tasks: ["Kickoff", "Plan"] })
  .orThrow();
await templates.versions.publish(fork.id).orThrow();
```

`versions.upsert` updates the open draft, or opens the next version when the last one is published; a template has at most one draft. `versions.publish` publishes the draft (`TEMPLATE_NO_DRAFT` when there is none) and records `template.published`. Published versions don't change, so an application always points at the exact body it used. `get` returns the template with its newest published version and its draft, bodies included; `versions.list` lists them without bodies.

## Applying a template [#applying-a-template]

```ts
const { subject, runs } = await templates
  .apply(templateId, { into: organizationId, values: { month: "October" } })
  .orThrow();
```

`apply` reads the newest published version (`TEMPLATE_NOT_PUBLISHED` without one), calls the type's `apply` with the parsed settings and body, the organization, your `values` and your `key`, then records the application and its `template.applied` event. A throw from `apply` returns as an error and records nothing. `applications.list(organizationId, { template, subject })` lists what each template made.

Then it starts each enabled attached workflow on the new record through `workflows.run`, with the attachment's inputs as the run's input, the record as its subject and `template-application:<application>:<definition>` as its idempotency key. A run starts as the caller, so a member who may apply a template but not run the workflow gets the record and an `error` in `runs` for that workflow; `apply` doesn't fail. Pass a builder with a service transport when the workflows should run anyway.

## Workflows on new records [#workflows-on-new-records]

```ts
const version = await builder.versions
  .upsert(definition.id, graph, {
    inputSchema: {
      type: "object",
      required: ["owner"],
      properties: { owner: { type: "string" } },
    },
  })
  .orThrow();
await builder.versions.publish(version.id).orThrow();

await templates.workflows
  .attach({
    template: fork.id,
    definition: definition.id,
    inputs: { owner: userId },
  })
  .orThrow();
```

`workflows.attach` checks `inputs` against the `inputSchema` of the definition's published version: its required keys always, and the whole schema with `jsonb-schemas` installed (`TEMPLATE_WORKFLOW_INPUT_INVALID`). `apply` checks them again, because the definition may have published a new schema since. The definition has to belong to the template's organization or be a system one (`TEMPLATE_WORKFLOW_NOT_FOUND`), and it has to run on the subject type `apply` returns. Attaching it again replaces the inputs; `detach` removes it.

## Recurring records [#recurring-records]

The `templates.apply` step creates a record from a workflow, so a scheduled workflow builds the monthly invoice or the weekly checklist.

```ts title="workflows/steps.ts"
import {
  TEMPLATES_APPLY_STEP,
  templatesApplyStep,
} from "better-supabase/blocks/templates";

export const stepLibrary = [TEMPLATES_APPLY_STEP, ...otherSteps];
export const steps = {
  "templates.apply": templatesApplyStep(templatesFor(postgres.admin)),
  ...otherHandlers,
};
```

A node's config takes `template` and optional `values`, and the step applies the template in the run's organization. It passes `<runKey>:<node>` to your type's `apply` as `key`, so a retried step can find the record it already created instead of creating a second one. It returns `{ applicationId, subject, runs }`. Publishing a graph with the node checks `templates.apply`.

## Merge fields [#merge-fields]

```ts
import {
  composeTemplateEmail,
  renderMergeFields,
} from "better-supabase/blocks/templates";

renderMergeFields("Hi {{ customer.name }}", { customer: { name: "Ada" } });

invoiceFields.unknown("Total: {{ total }} {{ vat }}"); // ["vat"]

const email = composeTemplateEmail(
  { subject: "Invoice {{ number }}", html: "<p>Hi {{ customer.name }}</p>" },
  values,
  { category: "notification", templateKey: "invoice-v3" },
);
```

`renderMergeFields` replaces `{{ name }}` with its value and `{{{ name }}}` with its value unescaped. With `escape: "html"`, `{{ name }}` is escaped for HTML. A dotted name reads that key first and then the nested path, never an inherited property. A missing value renders as empty, or as the field itself with `missing: "keep"`. Values print as text: dates as ISO 8601, Temporal values with their `toString`, arrays joined with commas and plain objects as JSON; pass `format` to change that. It has no dependencies and runs on every WinterTC runtime.

`defineMergeFields(subjectType, fields)` declares the fields a record type offers, for an editor's field picker: `names`, `examples()` for a preview, `unknown(text)` for the fields a template uses that the catalog doesn't define, and a typed `render`. `composeTemplateEmail` renders an email body for the [notifications](/docs/blocks/notifications) email channel's `compose`, with the HTML escaped and the subject and text left as text.

## Events and hooks [#events-and-hooks]

The module records `template.published` with the `versionId`, `version` and `type`, and `template.applied` with the `templateId`, `versionId` and the new record as its subject, so the record's activity feed shows which template made it. It calls `before_template_save`, `after_template_save`, `before_template_apply` and `after_template_apply`; `hooks` in `createTemplates` wraps the client's methods.

Errors carry these hints: `TEMPLATE_INVALID`, `TEMPLATE_NOT_FOUND`, `TEMPLATE_FORBIDDEN`, `TEMPLATE_NO_DRAFT`, `TEMPLATE_NOT_PUBLISHED`, `TEMPLATE_WORKFLOW_NOT_FOUND` and `TEMPLATE_WORKFLOW_INPUT_INVALID`.

## Tables [#tables]

| Table                   | What it holds                                                                                                          |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `templates`             | One row per template: organization (none for a system one), type, name, language, default, settings, the fork's source |
| `template_versions`     | The versions of each template, with the body and its SHA-256; the draft has no `published_at`                          |
| `template_applications` | Which version of which template created which record, by whom                                                          |
| `template_workflows`    | The workflow definitions each template starts on a new record, with their inputs (with `workflow-editor`)              |

## Your own columns [#your-own-columns]

Add columns to the template table with `blocks.templates.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`upsert` validates and writes them, and `upsert`, `list`, `get` and `fork` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createTemplates } from "better-supabase/blocks/templates";
import { z } from "zod";

const client = createTemplates({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.templates` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

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

import * as v from "valibot";
import {
  createTemplates,
  defineMergeFields,
  defineTemplateType,
  sqlTransport,
} from "better-supabase/blocks/templates";

const project = defineTemplateType({
  settings: v.object({ color: v.string() }),
  body: v.object({ tasks: v.array(v.string()) }),
  apply: async ({ body, settings, organizationId, key }) => {
    const created = await projects
      .create({ organizationId, color: settings.color, tasks: body.tasks, key })
      .orThrow();
    return { subject: { type: "project", id: created.id } };
  },
});

export const invoiceFields = defineMergeFields("invoice", {
  "customer.name": { label: "Customer", example: "Ada Lovelace" },
  total: { label: "Total", example: "€120.00" },
});

export const templatesFor = (sql: Sql) =>
  createTemplates({
    transport: sqlTransport(sql),
    types: { project },
    workflows: builderFor(sql),
  });
```

# Trash

> Soft delete through each subject's deletedAt column, with children trashed in one batch, restore with unique checks, a list per organization and purge after a retention period.

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

`trash` sets a subject's `deletedAt` column instead of deleting the row, records it in `trash_items`, and trashes the rows that point at it in the same batch. Restoring a trash item restores its whole batch. Reading the trash needs `trash.read`, trashing needs `trash.create` and the subject's `write` permission, restoring needs `trash.restore`, and purging needs `trash.manage`.

```bash
better-supabase add trash
```

Server code calls the `trash` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTrash } = defineDataHooks(
  "trash",
  (db: AppDb) => db.trash,
);
```

```ts
import { createTrash } from "better-supabase/blocks/trash";

const batch = await db.trash
  .trash(organizationId, "project", projectId)
  .orThrow();
const bin = await db.trash.list(organizationId).orThrow();
await db.trash.restore(batch.id).orThrow();
```

```tsx title="src/features/trash/list.tsx"
"use client";

import { useTrash } from "@/lib/supabase/hooks";

const rows = useTrash({ limit: 50 });
```

## Configure subjects [#configure-subjects]

Every [`config.records`](/docs/blocks/sql#subject-registry) entry with a `deletedAt` column can be trashed; `title` names the column the trash list shows. Per subject, `unique` lists the columns no live row in the organization may share when the row is restored, and `children` lists the subjects trashed with it.

```ts title="better-supabase.config.ts"
subjects: {
  project: { table: "public.projects", idType: "uuid", title: "name", deletedAt: "deleted_at" },
  task: { table: "public.tasks", idType: "uuid", title: "title", deletedAt: "deleted_at" },
},
blocks: {
  trash: {
    on: {
      project: { unique: ["slug"], children: [{ type: "task", column: "project_id" }] },
      task: {},
    },
    options: {
      retentionDays: 30,
    },
  },
},
```

| Option                      | Default            | What it does                                                                 |
| --------------------------- | ------------------ | ---------------------------------------------------------------------------- |
| `subjects.<type>.deletedAt` | the registry entry | The `timestamptz` column that marks a trashed row                            |
| `subjects.<type>.title`     | the registry entry | The column the trash list shows                                              |
| `subjects.<type>.unique`    | none               | Columns checked on restore; a clash fails with `TRASH_RESTORE_CONFLICT`      |
| `subjects.<type>.children`  | none               | `{ type, column }`: rows of `type` whose `column` holds this subject's id    |
| `retentionDays`             | `30`               | Days before `purge_expired_trash` deletes a batch; `0` keeps it until purged |

Children are trashed recursively, so a project trashes its tasks and their own children. A child that was already in the trash stays in its own batch.

## Hide trashed rows [#hide-trashed-rows]

Filter on the `deletedAt` column in queries and policies (`deleted_at is null`). For a policy that can't see the column, `better_supabase.is_trashed(subject_type, subject_id)` tells whether a subject is in the trash:

```sql
create policy "projects_read" on public.projects for select to authenticated
  using (deleted_at is null and organization_id in (select better_supabase.tenant_ids_with('projects.read')));
```

The [subject registry](/docs/blocks/sql#subject-registry) functions (`can_subject`, `subject_title`) already skip rows with `deletedAt` set, and so does the block's repository extension below.

## In the repository [#in-the-repository]

`gen` installs the block's repository extension in the generated schema, so repository reads hide trashed rows and `delete()` sets the column:

```ts
const db = defineSupabase(schema).connect(supabase);

await db.customers.delete(id); // sets deleted_at
await db.customers.get(id); // not_found
await db.customers.list({ withDeleted: true });
await db.customers.list({ onlyDeleted: true });
await db.customers.restore(id); // clears deleted_at
await db.customers.delete(id, { hard: true }); // really deletes
```

The extension and the block share the column and not the bin: a repository `delete()` writes no `trash_items` row and trashes no children, so call the block's `trash()` when the row should show in the trash list and restore with its batch. `restore()` and `withDeleted` only exist on tables with the column.

### Which column [#which-column]

`gen` picks one column per table. A table bound to a [`config.records`](/docs/blocks/sql#subject-registry) entry with a `deletedAt` column uses that column, so the repository, search, metrics and the trash all read the same one. Every other table uses `blocks.trash.options.deletedAt` (default `deleted_at`) when it has that column. Either column must be `timestamptz` or `timestamp`, or `gen` fails.

### Where trashed rows are hidden [#where-trashed-rows-are-hidden]

- Reads, counts, `exists` and pagination on the table.
- Updates: an update never touches a trashed row, so `update` returns `not_found`.
- Includes: `organizations.list({ include: { customers: true } })` leaves out trashed customers.
- Relation filters: `{ customers: { some: {...} } }` only considers live rows. `every` is rewritten so trashed 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 `deletedAt` fails with `invalid_request` unless it passes `{ override: true }`. An upsert that updates an existing row clears the column, so upserting a trashed 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 `deleted_at is null`, so index the live rows only. A partial index skips trashed rows, stays small as they pile up, and serves the filter the extension adds:

```sql
create index on customers (organization_id, name) where deleted_at is null;
```

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 deleted_at is null;
```

Doctor's [BS218](/docs/cli/doctor#bs218) points at tables with the column and without a partial index.

### Deletes and RLS [#deletes-and-rls]

A soft delete is an `UPDATE ... SET deleted_at = now()` without `RETURNING`. The time comes from `defineSupabase(schema, { now })`, or from the system clock in a runtime without Temporal. That matters: a common policy hides trashed rows from `SELECT`, and PostgREST checks returned rows against it, so an update that returns the row fails with `forbidden`. The extension never asks for the row back. `better-supabase doctor` flags tables where this would bite.

Deleting a row that is already trashed returns `not_found`. `{ hard: true }` deletes regardless of the column.

## Purge [#purge]

`purge(id)` deletes a batch's rows for good, children first. `purge_expired_trash()` purges batches past their retention; schedule it with pg_cron or the [jobs](/docs/blocks/queue) drain:

```sql
select cron.schedule('purge-trash', '15 3 * * *', 'select better_supabase.purge_expired_trash()');
```

## Events and hooks [#events-and-hooks]

The module records `subject.trashed`, `subject.restored` and `subject.purged` for every row, each with `organizationId`, `subjectType`, `subjectId`, the trash item `id` and the `batchId`, so search indexes and [data lifecycle](/docs/blocks/data-lifecycle) can follow them. It calls `before_trash`, `after_trash`, `before_untrash` and `after_untrash` around trash and restore.

## With ReUI [#with-reui]

A bin page renders `list()` in a ReUI data grid, with restore and delete-forever actions per row. `count` shows how many rows a batch holds, and `purgeAfter` when it goes for good.

## Your own columns [#your-own-columns]

Add columns to the trash item, such as a reason, table with `blocks.trash.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`trash` (the last argument) validates and writes them, and `list` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createTrash } from "better-supabase/blocks/trash";
import { z } from "zod";

const client = createTrash({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.trash` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createTrash } from "better-supabase/blocks/trash";

const trash = createTrash({ transport });
const batch = await trash.trash(organizationId, "project", projectId).orThrow();
const bin = await trash.list(organizationId).orThrow();
await trash.restore(batch.id).orThrow();
```

# Versions

> Numbered snapshots of a subject's content, with JSON Patch diffs, restore, debounced auto snapshots, frozen versions and retention.

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

`versions` keeps numbered snapshots of any subject: a document, a quote, a board. Each version holds JSON content (or a Storage path), a SHA-256 content hash, an optional label and where it came from (`manual`, `auto` or `restore`). Reading versions needs `versions.read`, taking and labeling one needs `versions.create`, restoring needs `versions.restore` and deleting needs `versions.manage`. Every call also checks the subject's own read permission, and a restore checks its `write` permission from [`config.records`](/docs/blocks/sql#subject-registry).

```bash
better-supabase add versions
```

Server code calls the `versions` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useVersions } = defineDataHooks(
  "versions",
  (db: AppDb) => db.versions,
);
```

```ts
import { createVersions } from "better-supabase/blocks/versions";

const v = await db.versions
  .snapshot({ type: "quote", id: quoteId }, { label: "Sent to customer" })
  .orThrow();
const patch = await db.versions.diff(previousId, v.id).orThrow();
await db.versions.restore(previousId).orThrow();
```

```tsx title="src/features/versions/list.tsx"
"use client";

import { useVersions } from "@/lib/supabase/hooks";

const rows = useVersions({ limit: 50 });
```

A snapshot whose content matches the latest version is not written again: `snapshot` returns the latest version with `unchanged: true`. Versions are numbered per subject from 1.

## Configure subjects [#configure-subjects]

Each subject type lists the `columns` a snapshot copies and a restore writes back. A type with an `apply` function restores through it instead: the function takes `(subject_id text, content jsonb)`.

```ts title="better-supabase.config.ts"
subjects: {
  quote: { table: "public.quotes", idType: "uuid", permissions: { read: "quotes.read", write: "quotes.update" } },
  page: { table: "public.pages", idType: "uuid" },
},
blocks: {
  versions: {
    on: {
      quote: {
        columns: ["title", "lines", "total"],
        freeze: { column: "status", values: ["sent", "accepted"] },
      },
      page: { columns: ["body"], auto: true, apply: "public.apply_page_version" },
    },
    options: {
      keep: 50,
      debounce: 300,
    },
  },
},
```

| Option                    | Default | What it does                                                                                          |
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------- |
| `subjects.<type>.columns` | none    | The columns a snapshot without `content` copies, and a restore writes back                            |
| `subjects.<type>.apply`   | none    | A function `(subject_id text, content jsonb)` that restores a version in place of `columns`           |
| `subjects.<type>.auto`    | `false` | Snapshot on every insert or change to `columns`                                                       |
| `subjects.<type>.freeze`  | none    | `{ column, values }`: when `column` changes to one of `values`, keep a frozen version labeled with it |
| `keep`                    | `50`    | How many versions without a label or freeze each subject keeps                                        |
| `debounce`                | `300`   | Seconds an auto snapshot replaces the previous auto snapshot's content instead of adding a version    |

A frozen version can't be relabeled or deleted, so a quote keeps what was sent. A label keeps a version out of retention; `label(id, null)` removes it.

## Diff [#diff]

`diff(fromId, toId)` returns the [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902) JSON Patch that turns one version's content into the other's. `createPatch(from, to)` and `applyPatch(document, patch)` work on any JSON value, and `applyPatch` throws a `JsonPatchError` with the failing operation's `index` for an operation the RFC calls an error. Object members are compared by name and array elements by position.

## Restore [#restore]

`restore(id)` writes the version back to the subject through its `apply` function or `columns`, then records the result as a new version with source `restore`. The auto and freeze triggers skip the write a restore makes. A version kept in Storage (`storagePath` without content) is restored by the app.

`restoreFields(id, fields)` writes back only the named fields and keeps the subject's current values for the others, so a history can undo one field without reverting the rest:

```ts
await versions.restoreFields(versionId, ["budget_minor"]).orThrow();
```

It needs a type with `columns`; a type with only `apply` fails with `VERSION_RESTORE_UNSUPPORTED`. An empty list fails with `VERSION_FIELDS_EMPTY`, and a field outside `columns` with `VERSION_FIELD_UNKNOWN`. An app table whose subject sets `versions` is registered here without an entry in `subjects` (see [app tables](/docs/repository/tables#history)).

## Share a version [#share-a-version]

Install [share links](/docs/blocks/share-links) next to versions to send one version of a subject, such as the quote a customer approved. Pass `versionId` when you create the link; the version must belong to the link's subject, and the caller must be able to read it. `getShared(token)` reads that version and its content without a session, through `get_shared_version`, which is granted to `anon`. It returns `null` for an unknown, revoked or expired token and for a link without a version, and it leaves out `createdBy`.

```ts
const link = await shareLinks
  .create({ type: "quote", id: quoteId }, { versionId: sent.id })
  .orThrow();

// On the public page, with the anon key:
const version = await versions.getShared(token).orThrow();
```

## Events and hooks [#events-and-hooks]

The module records `version.created`, `version.restored` and `version.deleted`, each with `organizationId`, `subjectType` and `subjectId`, so the [activity and watcher sinks](/docs/extending/events) handle them. It calls `before_version_snapshot`, `after_version_snapshot`, `before_version_restore` and `after_version_restore` around its writes.

## With ReUI [#with-reui]

The client has no UI dependency. A history panel lists `list()`, shows `diff()` between two rows, and calls `restore()` from a ReUI timeline or table:

```tsx
const rows = await versions.list({ type: "quote", id: quoteId }).orThrow();
// <Timeline> from ReUI: one item per row, labeled with row.label ?? `Version ${row.number}`
```

## Your own columns [#your-own-columns]

Add columns to the version table with `blocks.versions.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`snapshot` validates and writes them, and `list` and `get` (`getShared` leaves them out) return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createVersions } from "better-supabase/blocks/versions";
import { z } from "zod";

const client = createVersions({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.versions` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createVersions } from "better-supabase/blocks/versions";

const versions = createVersions({ transport, tenant: organizationId });
const v = await versions
  .snapshot({ type: "quote", id: quoteId }, { label: "Sent to customer" })
  .orThrow();
const patch = await versions.diff(previousId, v.id).orThrow();
await versions.restore(previousId).orThrow();
```

# Engagements

> Calls, emails, meetings, notes and tasks logged on one or more records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.

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

`engagements` logs the work people do around a record: a call with its outcome and duration, an email, a meeting, a note, or a task with a due time. One engagement can sit on several records at once, such as a call on a deal, its company and the person on the line, so each record's panel shows it. Any record registered in [`config.records`](/docs/blocks/sql#subject-registry) can carry engagements.

This block is the logged work, not the system history. The [activity feed](/docs/blocks/timeline) records what the system did (a stage change, a comment), and every engagement event carries its main record, so the feed shows both on one timeline.

Reading needs `engagements.read`, and logging needs `engagements.write`. The author and the owner edit an engagement, and `engagements.manage` edits anyone's. Admins get every permission, and members get `engagements.read` and `engagements.write`.

```bash
better-supabase add activities
```

The block's entity is `engagement` (the `activities` table). Installing it registers `engagement` as a subject, searchable by title, body and outcome when the search module is installed. Rename it with `blocks.activities.names`, for example `{ engagement: "touchpoint" }`. `engagementInputSchema` and `engagementChangeSchema` validate what `log` and `update` take.

The module needs `tenant`, `permissions` and `subjects`.

Server code calls the `activities` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useActivities } = defineDataHooks(
  "activities",
  (db: AppDb) => db.activities,
);
```

```ts
import { createActivities } from "better-supabase/blocks/activities";

const deal = { type: "deal", id: dealId };

await db.activities
  .log(
    organizationId,
    {
      type: "call",
      title: "Discovery call",
      outcome: "connected",
      durationSeconds: 900,
      doneAt: Temporal.Now.instant(),
    },
    [deal, { type: "person", id: personId }],
  )
  .orThrow();

await db.activities
  .log(
    organizationId,
    { type: "task", title: "Send proposal", dueAt: friday },
    [deal],
  )
  .orThrow();
```

```tsx title="src/features/activities/list.tsx"
"use client";

import { useActivities } from "@/lib/supabase/hooks";

const rows = useActivities({ limit: 50 });
```

## Log and change [#log-and-change]

`log(organizationId, input, subjects)` needs a `type` and a `title`. The first record in `subjects` is the engagement's main record, which its events carry. Every record must exist in the organization and be readable by the caller (`ENGAGEMENT_SUBJECT_FORBIDDEN`). The owner defaults to the caller, and assigning it to someone else notifies them when the [notifications](/docs/blocks/notifications) module is installed.

`options.types` lists the types (default `call`, `email`, `meeting`, `note`, `task`; `ENGAGEMENT_TYPE_INVALID` for another). An `externalRef`, such as a message id from a mail provider or an event id from a calendar, is unique per organization: logging it again updates the engagement, so a sync can replay.

`update(id, input, subjects?)` changes the keys you pass (`null` clears one) and replaces the records when you pass `subjects`. `complete(id, { outcome })` marks it done now, and `{ done: false }` opens it again. `remove(id)` deletes it.

Installing the block registers engagements as the `engagement` subject, so the [attachments](/docs/blocks/attachments) block keeps files on a task, a call or an email: upload to `{ type: "engagement", id: task.id }` and list them with the same subject. Only people who can read the engagement can add or see its files.

## Your own fields [#your-own-fields]

`blocks.activities.options.extraColumns` adds columns to the `activities` table,
keyed by column name with their SQL type. `log` and `update` write them from `fields`, and every engagement comes back with them under `fields`. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const engagements = createActivities({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await engagements.log(
  organizationId,
  { type: "call", title: "Intro", fields: { region: "emea" } },
  subjects,
);
```

## Bookings [#bookings]

With the [bookings](/docs/blocks/bookings) block installed, set `blocks.activities.options.bookings: true` and every confirmed booking becomes a `meeting` engagement. A trigger on the bookings table keeps it in step, so bookings made through the public link or by a direct write are logged too.

```ts
export default defineConfig({
  blocks: {
    bookings: {},
    contacts: {},
    activities: { options: { bookings: true } },
  },
});
```

| Booking                     | Engagement                                                                                                                                    |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Confirmed                   | A `meeting` titled with the service and the customer's name, due at the start, with the notes as its body and the length in `durationSeconds` |
| Rescheduled while confirmed | The same meeting moves to the new time and opens again                                                                                        |
| Cancelled after confirming  | The meeting is done at the cancel time, with the outcome `cancelled: <reason>`                                                                |

The meeting's `externalRef` is `booking:<id>`, so it is one engagement per booking, and code that logs the same booking with that ref updates it instead of adding a second one. With [parties](/docs/blocks/contacts) installed, the meeting sits on the person whose user or email (in any case) matches the booker's, and on that person's company (the primary link first). A booker with no contact gets a meeting on no record, which every reader of the organization sees. The owner is the member who made the booking, else the contact's owner. It needs `meeting` in `options.types`, and installing it without the bookings block fails at render time. The events are the usual `engagement.logged`, `engagement.updated` and `engagement.completed`.

## Read [#read]

| Method                                         | Returns                                                                                               |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `of(subject, { types, before, limit })`        | A record's engagements, newest first: done ones by when they happened, open ones by when they are due |
| `next(subject)`                                | The open engagement with the earliest due time, or `undefined`: a record's next step                  |
| `open(organizationId, { ownerId, dueBefore })` | A member's open engagements, earliest due first; the caller's by default                              |

Each engagement has `overdue`: open and past its due time. Reads run as the caller: an engagement is visible to members who can read one of its records, or to every reader of the organization when it is on none.

## Recordings and notes [#recordings-and-notes]

`blocks.activities.options.recordings` (off by default) stores call and meeting recordings, their transcripts and notes about an engagement. It needs the [ai-files](/docs/blocks/ai-files) block, which holds the audio or video, and adds the `engagement_recordings`, `engagement_transcript_segments` and `engagement_notes` tables. The block imports no speech or AI SDK (ADR 0010): you pass a `Transcriber` and a `NoteTaker` that wrap the provider you use.

```ts
import { createActivities } from "better-supabase/blocks/activities";

const engagements = createActivities({
  transport,
  service: serviceTransport,
  files: aiFiles,
  transcriber: {
    apiVersion: 1,
    model: "my-stt",
    transcribe: async ({ url, language }, { signal } = {}) => {
      const result = await speechToText(url, { language, signal });
      return {
        language: result.language,
        segments: result.turns.map((turn) => ({
          speaker: turn.speaker,
          startMs: turn.start,
          endMs: turn.end,
          text: turn.text,
        })),
      };
    },
  },
  noteTaker: {
    apiVersion: 1,
    model: "my-model",
    summarize: async ({ engagement, transcript }) =>
      summarizeCall(engagement, transcript),
  },
});

const recording = await engagements
  .attachRecording(engagementId, fileId, { language: "en" })
  .orThrow();
```

`attachRecording(engagementId, fileId)` links a ready ai-files file of the same organization: the author, the owner and `engagements.manage` may attach one, and the file must be the caller's unless they hold `engagements.manage`. The recording starts `pending`. With the [jobs](/docs/blocks/queue) block installed it queues a transcription on `engagements.transcribe`; register `transcribeJob()` as that queue's handler, and its last attempt marks the recording `failed` with the error. Without jobs, call `transcribe(recordingId)` yourself or run `drainRecordings()` on a schedule as the service role.

`transcribe` signs the file's URL and reads its bytes through `files`, calls the transcriber and saves its speaker turns. `saveTranscript(recordingId, transcript)` stores a transcript you already have, such as one a meeting tool exports; saving again replaces it. Each segment has a `speaker` label, an optional `speakerUserId`, `startMs` and `endMs` from the start of the recording, its `text` and an optional `confidence`.

`summarize(engagementId)` gives the note taker the engagement and every transcript turn and saves its summary and action items as the engagement's notes (`source: "ai"`, with the model). `saveNotes(engagementId, { summary, actionItems })` saves notes a member wrote (`source: "member"`), replacing earlier ones.

| Method                                      | Returns                                                      |
| ------------------------------------------- | ------------------------------------------------------------ |
| `recordings(engagementId)`                  | The engagement's recordings, oldest first, with their status |
| `transcript(recordingId, { after, limit })` | Speaker turns in order, 500 by default and 5000 at most      |
| `notes(engagementId)`                       | The engagement's notes, or `undefined`                       |

Recordings, transcripts and notes are readable by whoever can read the engagement. Teammates see the filename and media type without access to the file itself. `testTranscriber` and `testNoteTaker` from `better-supabase/testing` check that your wrappers keep the contract.

## Events [#events]

| Event                                                                                   | Data                                                                             |
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `engagement.logged`, `engagement.updated`, `engagement.completed`, `engagement.deleted` | `organizationId`, `engagementId`, `type`, `subjectType`, `subjectId`, `subjects` |
| `engagement.recorded`, `engagement.transcribed`                                         | The same, and `recordingId`                                                      |
| `engagement.summarized`                                                                 | The same as `engagement.logged`                                                  |

## Hooks [#hooks]

The module calls `before_engagement_log(tenant, attrs jsonb, subjects jsonb)` and `after_engagement_log(engagement uuid)`. A `before_` hook raises to refuse the log. The TypeScript client takes `hooks` for its methods (see [extending blocks](/docs/extending/blocks)).

## Wire it to ReUI [#wire-it-to-reui]

The ReUI CRM Activity Timeline reads `of(subject)` and groups the rows into day buckets by `doneAt` or `dueAt`; its type tabs pass `types`. The Deal Desk composer calls `log` with the deal and its contact as subjects, and the Forecast Call's "No next step" and "Overdue activity" chips read `next(deal)` and its `overdue`.

## Errors [#errors]

| Hint                                                                           | Cause                                                      |
| ------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| `ENGAGEMENT_FORBIDDEN`                                                         | the caller lacks `engagements.write`                       |
| `ENGAGEMENT_TYPE_INVALID`                                                      | not a configured type                                      |
| `ENGAGEMENT_TITLE_REQUIRED`                                                    | the title is empty                                         |
| `ENGAGEMENT_SUBJECTS_INVALID`                                                  | `subjects` isn't a list of `{ type, id }`                  |
| `ENGAGEMENT_SUBJECT_FORBIDDEN`                                                 | a record is unknown, in another organization or unreadable |
| `ENGAGEMENT_NOT_FOUND`                                                         | no engagement the caller can edit                          |
| `AI_FILE_NOT_FOUND`                                                            | no ready file of the organization the caller may attach    |
| `ENGAGEMENT_RECORDING_EXISTS`                                                  | the file is already a recording of the engagement          |
| `ENGAGEMENT_RECORDING_NOT_FOUND`                                               | no such recording                                          |
| `ENGAGEMENT_TRANSCRIPT_INVALID`                                                | a segment lacks its text or ends before it starts          |
| `ENGAGEMENT_NOTES_INVALID`                                                     | an empty summary or an action item without text            |
| `ENGAGEMENT_NO_TRANSCRIBER`, `ENGAGEMENT_NO_FILES`, `ENGAGEMENT_NO_NOTE_TAKER` | `createActivities` lacks the option the method needs       |
| `ENGAGEMENT_TRANSCRIBE_FAILED`, `ENGAGEMENT_SUMMARIZE_FAILED`                  | the transcriber or note taker threw                        |

## Without the client [#without-the-client]

`db.activities` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createActivities } from "better-supabase/blocks/activities";

const engagements = createActivities({ transport });
const deal = { type: "deal", id: dealId };

await engagements
  .log(
    organizationId,
    {
      type: "call",
      title: "Discovery call",
      outcome: "connected",
      durationSeconds: 900,
      doneAt: Temporal.Now.instant(),
    },
    [deal, { type: "person", id: personId }],
  )
  .orThrow();

await engagements
  .log(
    organizationId,
    { type: "task", title: "Send proposal", dueAt: friday },
    [deal],
  )
  .orThrow();
```

# Assignment

> Route new leads, deals or tickets to an owner with round robin or weighted queues, each limited to a territory of matching records.

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

The `assignment` block picks an owner for a record. Each queue covers one
subject type and an optional territory, a [rules](/docs/blocks/automations) filter
tree such as "country is NL". `assign` tries the enabled queues by `rank`
and gives the record to the next member of the first queue whose territory
matches. It logs every pick and writes a `subject.assigned` event; writing
the owner onto the record stays with the app, so one block works for
companies, deals and tickets alike.

```bash
better-supabase add assignment   # adds tenant, access and rules as well
```

| Permission          | Lets a member                                | Default roles            |
| ------------------- | -------------------------------------------- | ------------------------ |
| `assignment.read`   | list the queues and read a record's history  | `member`, `admin`        |
| `assignment.manage` | create, change and remove queues and members | `admin` (`assignment.*`) |
| `assignment.assign` | assign a record                              | `admin` (`assignment.*`) |

The service role can always assign, so a job or webhook that receives a new
lead routes it without a member session.

## Queues [#queues]

Server code calls the `assignment` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAssignment } = defineDataHooks(
  "assignment",
  (db: AppDb) => db.assignment,
);
```

```ts
import {
  createAssignment,
  rpcTransport,
} from "better-supabase/blocks/assignment";

const benelux = await db.assignment
  .saveQueue(organizationId, {
    name: "Benelux leads",
    subjectType: "company",
    strategy: "weighted",
    rank: 0,
    conditions: {
      id: "root",
      type: "group",
      combinator: "and",
      rules: [
        {
          id: "r1",
          type: "rule",
          path: ["country"],
          operator: "is_any_of",
          value: ["NL", "BE", "LU"],
        },
      ],
    },
  })
  .orThrow();

await db.assignment
  .setMembers(benelux.id, [
    { userId: senior.id, weight: 3 },
    { userId: junior.id, weight: 1 },
  ])
  .orThrow();
```

```tsx title="src/features/assignment/list.tsx"
"use client";

import { useAssignment } from "@/lib/supabase/hooks";

const rows = useAssignment({ limit: 50 });
```

A territory uses the subject type's rule fields (see
[Segments](/docs/blocks/segments#fields)); `conditions: null` takes every
record. `round_robin` gives the record to the member who waited longest.
`weighted` uses smooth weighted round robin, so with weights 3 and 1 the
first member gets three of every four records, spread out rather than in a
row. `setMembers` replaces the members (at most 200, each a member of the
organization); members who stay keep their place in the rotation, and
`active: false` keeps a member in the queue without giving them records.
`assignmentQueueInputSchema` validates what `saveQueue` takes.

## Assign [#assign]

```ts
const pick = await createAssignment({ transport: rpcTransport(admin) })
  .assign(organizationId, {
    type: "company",
    id: company.id,
    fields: { country: company.address?.country },
  })
  .orThrow();

if (pick !== undefined) {
  await parties.saveCompany(organizationId, {
    id: company.id,
    ownerId: pick.userId,
  });
}
```

`fields` holds the values the territories test, keyed by the rule fields'
`column`. `assign` returns `{ queueId, userId, strategy }`, or `undefined`
when no enabled queue with an active member matches. Two picks at once on
the same queue wait for each other, so a rotation never skips or repeats a
member. `history(organizationId, { type, id })` lists a record's picks, the
newest first; a pick keeps its time after its queue or user is removed.

## Events [#events]

Every pick writes `subject.assigned` to the [outbox](/docs/blocks/outbox)
with the queue, the user and the strategy. Handle it to set the owner
column, notify the new owner or start a follow-up task. See
[Events](/docs/extending/events).

| Error code                  | When                                                   |
| --------------------------- | ------------------------------------------------------ |
| `ASSIGNMENT_FORBIDDEN`      | the caller lacks the permission                        |
| `ASSIGNMENT_NAME_TAKEN`     | another queue in the organization has the name         |
| `ASSIGNMENT_INVALID`        | a bad name, strategy or weight                         |
| `ASSIGNMENT_MEMBER_INVALID` | a user is not a member of the queue's organization     |
| `ASSIGNMENT_FIELDS_MISSING` | the territory's subject type has no synced rule fields |

## Without the client [#without-the-client]

`db.assignment` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createAssignment,
  rpcTransport,
} from "better-supabase/blocks/assignment";

const assignment = createAssignment({ transport: rpcTransport(supabase) });

const benelux = await assignment
  .saveQueue(organizationId, {
    name: "Benelux leads",
    subjectType: "company",
    strategy: "weighted",
    rank: 0,
    conditions: {
      id: "root",
      type: "group",
      combinator: "and",
      rules: [
        {
          id: "r1",
          type: "rule",
          path: ["country"],
          operator: "is_any_of",
          value: ["NL", "BE", "LU"],
        },
      ],
    },
  })
  .orThrow();

await assignment
  .setMembers(benelux.id, [
    { userId: senior.id, weight: 3 },
    { userId: junior.id, weight: 1 },
  ])
  .orThrow();
```

# CRM

> Contacts, deals, activities and the blocks around them.

Source: https://bettersupabase.com/docs/blocks/catalog-crm

- [Parties](/docs/blocks/contacts)
- [Engagements](/docs/blocks/activities)
- [Mail sync](/docs/blocks/mail-sync)
- [Segments](/docs/blocks/segments)
- [Assignment](/docs/blocks/assignment)
- [Enrichment](/docs/blocks/enrichment)
- [Sequences](/docs/blocks/sequences)
- [Forecasts](/docs/blocks/forecasts)
- [Deals](/docs/blocks/deals)
- [Quotes](/docs/blocks/quotes)
- [Targets](/docs/blocks/goals)
- [Contracts](/docs/blocks/contracts)

# Parties

> Companies and people an organization works with, with lifecycle stages, owners, who works where with a role and a primary contact, dedupe by domain and email, merges and keyset-paged search.

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

`parties` keeps the companies and people an organization sells to, supports or hires: the counterparty that deals, quotes, invoices, tickets and applications point at. A company has a name, a domain, a website, a VAT id, an address, an owner and a lifecycle stage. A person has a name, an email, a phone, a title, an owner, a lifecycle stage and optionally the user they sign in as. `company_people` records who works where, with a role and one primary contact per company.

Reading needs `parties.read`, creating, changing, linking and archiving needs `parties.write`, and merging needs `parties.manage`. Admins get every permission, and members get `parties.read` and `parties.write`.

```bash
better-supabase add contacts
```

The module needs `memberships` and `access`.

Server code calls the `contacts` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useContacts } = defineDataHooks(
  "contacts",
  (db: AppDb) => db.contacts,
);
```

```ts
import { createContacts } from "better-supabase/blocks/contacts";

const acme = await db.contacts
  .saveCompany(organizationId, {
    name: "Acme",
    domain: "acme.com",
    ownerId: userId,
  })
  .orThrow();
const ada = await db.contacts
  .savePerson(organizationId, {
    name: "Ada Lovelace",
    email: "ada@acme.com",
    title: "CTO",
  })
  .orThrow();
await db.contacts
  .linkPerson(acme.id, ada.id, { role: "Champion", primary: true })
  .orThrow();
```

```tsx title="src/features/contacts/list.tsx"
"use client";

import { useContacts } from "@/lib/supabase/hooks";

const rows = useContacts({ limit: 50 });
```

## Companies and people [#companies-and-people]

`saveCompany` and `savePerson` create a record when the input has no `id`, and change the keys the input names otherwise: a key left out keeps its value, and `null` clears it. A new record needs a `name` (`PARTY_NAME_REQUIRED`). Domains and emails are stored in lower case, and an active record can't share its domain or email with another in the organization (`PARTY_DOMAIN_TAKEN`, `PARTY_EMAIL_TAKEN`), so an import finds the existing record instead of a duplicate.

A lifecycle stage is one of `options.companyLifecycles` (default `lead`, `prospect`, `customer`, `churned`) or `options.personLifecycles` (default `lead`, `mql`, `sql`, `opportunity`, `customer`); a new record starts at the first (`PARTY_LIFECYCLE_INVALID` for another value). `archiveCompany(id)` and `archivePerson(id)` hide a record from the lists, and `archived: false` restores it.

`linkPerson(companyId, personId, { role, primary })` links a person to a company or changes the link; a primary link replaces the company's previous primary contact. `peopleOf(companyId)` returns the company's people, primary contact first, and `unlinkPerson` removes a link.

## Search and pages [#search-and-pages]

`listCompanies` and `listPeople` return the active records by name, 50 per page (at most 200). `search` matches the name and the domain or email, and `lifecycle` and `ownerId` filter. Pass the page's `next` as `cursor` for the next page:

```ts
const page = await parties
  .listPeople(organizationId, { search: "acme", limit: 25 })
  .orThrow();
const more = page.next
  ? await parties
      .listPeople(organizationId, { search: "acme", cursor: page.next })
      .orThrow()
  : undefined;
```

The lists, `peopleOf` and the tables all run as the caller: a member reads the organization's records through the `parties.read` policy, and PostgREST reads the tables directly.

## Merge duplicates [#merge-duplicates]

`mergeCompanies(keep, drop)` and `mergePeople(keep, drop)` fill the kept record's empty fields from the dropped one, move the dropped record's links to it, and archive the dropped record with `merged_into` set. Merging needs `parties.manage` (`PARTY_MERGE_INVALID` for records of two organizations, one record twice, or one already merged). In the same transaction the merge moves the dropped record's comments, attachments, reactions, tags, relations and guest grants to the kept record ([Merging subject records](/docs/blocks/sql#merging-subject-records)). The `company.merged` and `person.merged` events carry `mergedId`, so a consumer can move the app's own references, such as deals, to the kept record, or the app can add its own merge handler in SQL. A merge is written to the audit log.

## Use them as subjects [#use-them-as-subjects]

Installing the block registers `company` and `person` as subjects, so [comments](/docs/blocks/comments), [tags](/docs/blocks/tags), [attachments](/docs/blocks/attachments), [pipelines](/docs/blocks/pipelines) and the activity feed attach to them. Set their pages on the entities, or `subject: false` to register one yourself:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    contacts: {
      names: {
        company: { url: "/companies/{id}" },
        person: { url: "/people/{id}" },
      },
    },
  },
});
```

With the [notifications](/docs/blocks/notifications) module installed, the owner watches a company or person when they become its owner, and a level they already chose stays.

## Inbox contacts [#inbox-contacts]

With the [inbox](/docs/blocks/inbox) module installed, parties keeps which person each inbox contact is, so a conversation shows the person's company, deals and history. One person has many contacts (an email address, a WhatsApp number); a contact has one person.

```ts
const person = await parties
  .personForInboxContact(conversation.contactId, { create: true })
  .orThrow();
```

`personForInboxContact(contactId)` returns the linked person. Without a link, it links the active person with the contact's email; with `create: true` and no match, it saves the contact as a new lead first. `linkInboxContact(personId, contactId)` links by hand (a contact linked to someone else moves), and `unlinkInboxContact(contactId)` removes the link.

## Extra columns [#extra-columns]

`options.extraColumns` adds columns to `companies`, such as a segment or an industry. `saveCompany` writes them from `fields`, and every company it returns carries them in `fields`:

```ts title="better-supabase.config.ts"
blocks: {
  contacts: { options: { extraColumns: { segment: "text", employees: "integer" } } },
},
```

Pass a Standard Schema as `fields` to type them: `saveCompany` validates the fields it writes (a `validation` error, before the database), and `saveCompany`, `mergeCompanies` and `listCompanies` parse them on read.

```ts
const parties = createContacts({
  transport,
  fields: v.object({
    segment: v.optional(v.string()),
    employees: v.optional(v.number()),
  }),
});
await parties.saveCompany(organizationId, {
  name: "Acme",
  fields: { segment: "smb" },
});
```

People take app fields through [custom fields](/docs/blocks/custom-fields) with the `person` subject.

## Events [#events]

| Event                                                    | Data                                              |
| -------------------------------------------------------- | ------------------------------------------------- |
| `company.created`, `company.updated`, `company.archived` | `organizationId`, `companyId`, `name`             |
| `company.merged`                                         | `organizationId`, `companyId`, `name`, `mergedId` |
| `company.person_linked`, `company.person_unlinked`       | `organizationId`, `companyId`, `personId`, `role` |
| `person.created`, `person.updated`, `person.archived`    | `organizationId`, `personId`, `name`              |
| `person.merged`                                          | `organizationId`, `personId`, `name`, `mergedId`  |

## Hooks [#hooks]

The module calls `before_company_save(tenant, company uuid, attrs jsonb)`, `after_company_save(company uuid, created boolean)`, `before_person_save` and `after_person_save` around its saves. A `before_` hook raises to refuse the write. The TypeScript client takes `hooks` for its methods (see [extending blocks](/docs/extending/blocks)).

## Wire it to ReUI [#wire-it-to-reui]

The ReUI CRM Contacts Directory reads `listPeople` for its grid, with the Filters `search` and lifecycle values passed through, and its bulk owner action calls `savePerson` with `ownerId` per selected row (or the [bulk](/docs/blocks/bulk) block for many at once). Its CSV import maps rows to `savePerson`, and an email that is taken points at the existing record.

## Errors [#errors]

| Hint                      | Cause                                                             |
| ------------------------- | ----------------------------------------------------------------- |
| `PARTY_FORBIDDEN`         | the caller lacks `parties.write`, or `parties.manage` for a merge |
| `PARTY_ATTRS_INVALID`     | the attributes aren't an object                                   |
| `PARTY_NAME_REQUIRED`     | a new record has no name                                          |
| `PARTY_LIFECYCLE_INVALID` | not a configured lifecycle stage                                  |
| `PARTY_DOMAIN_TAKEN`      | another active company has the domain                             |
| `PARTY_EMAIL_TAKEN`       | another active person has the email                               |
| `PARTY_NOT_FOUND`         | no such record                                                    |
| `PARTY_MERGE_INVALID`     | the records can't be merged                                       |

## Without the client [#without-the-client]

`db.contacts` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createContacts } from "better-supabase/blocks/contacts";

const parties = createContacts({ transport });
const acme = await parties
  .saveCompany(organizationId, {
    name: "Acme",
    domain: "acme.com",
    ownerId: userId,
  })
  .orThrow();
const ada = await parties
  .savePerson(organizationId, {
    name: "Ada Lovelace",
    email: "ada@acme.com",
    title: "CTO",
  })
  .orThrow();
await parties
  .linkPerson(acme.id, ada.id, { role: "Champion", primary: true })
  .orThrow();
```

# Contracts

> Contracts with counterparties from draft through review and signature to active, expired or terminated, with values in minor units, renewal terms that schedule reminders, and obligations with due dates and owners.

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

`contracts` tracks agreements with the records your app already has, such as
a party or a customer. A contract moves from draft through review and
signature to active, renews or expires on its end date, and carries the
obligations each side owes. It builds on three blocks: a contract is frozen
as a [version](/docs/blocks/versions) and signed with
[signatures](/docs/blocks/signatures), and its owner gets a
[reminder](/docs/blocks/reminders) before the notice deadline.

```bash
better-supabase add contracts
```

```ts
import { createContracts } from "better-supabase/blocks/contracts";
import { money } from "better-supabase/commerce";
```

The module requires `tenant`, `access`, `subjects`, `versions`,
`signatures` and `reminders`. With `outbox` it records the contract events,
and with `audit` every change is audited. Values are bigint minor units
([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)).

## Tables [#tables]

| Table                  | Holds                                                                                                                                                   |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contracts`            | The title, the counterparty (`counterparty_type`, `counterparty_id`), the owner, the status, the `value` and `currency`, the term and the renewal terms |
| `contract_obligations` | What a party owes under a contract: a title, a due date, an owner, and `open`, `done` or `waived`                                                       |

Both tables are read through the functions; members with `contracts.read`
read them. The counterparty is any type in `config.records` (ADR 0018), and
it must belong to the contract's organization.

## Draft a contract [#draft-a-contract]

Server code calls the `contracts` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useContracts } = defineDataHooks(
  "contracts",
  (db: AppDb) => db.contracts,
);
```

```ts
const contract = await db.contracts
  .create(organizationId, {
    title: "Hosting agreement",
    counterparty: { type: "parties", id: partyId },
    value: money(2_400_000n, "EUR"),
    startsOn: Temporal.PlainDate.from("2026-01-01"),
    endsOn: Temporal.PlainDate.from("2026-12-31"),
    autoRenew: true,
    noticeDays: 60,
  })
  .orThrow();
```

```tsx title="src/features/contracts/list.tsx"
"use client";

import { useContracts } from "@/lib/supabase/hooks";

const rows = useContracts({ limit: 50 });
```

The owner defaults to the caller. `renewalMonths` defaults to 12 and
`noticeDays` to 30; `noticeDeadline` is `endsOn` less `noticeDays`. A draft or
a contract in review changes freely with `update`. An active contract changes
only its description, owner and renewal terms; anything else fails with
`CONTRACT_LOCKED`.

| Method                                | Returns                                                 |
| ------------------------------------- | ------------------------------------------------------- |
| `create(organizationId, input)`       | the draft                                               |
| `update(contractId, changes)`         | the contract                                            |
| `transition(contractId, action)`      | the contract in its new status                          |
| `sendForSignature(contractId, input)` | the contract, `out_for_signature`                       |
| `get(contractId)`                     | the contract                                            |
| `list(organizationId, options)`       | the contracts, newest first                             |
| `forCounterparty(counterparty)`       | the contracts with a record, in every organization      |
| `runRenewals(asOf)`                   | how many contracts renewed and expired                  |
| `saveObligation(contractId, input)`   | the obligation                                          |
| `setObligationStatus(id, status)`     | the obligation                                          |
| `deleteObligation(id)`                | the deleted obligation                                  |
| `obligations(organizationId, opts)`   | the obligations, the earliest due first, with `overdue` |

## Lifecycle [#lifecycle]

| Action      | From                        | To           | Needs              |
| ----------- | --------------------------- | ------------ | ------------------ |
| `submit`    | `draft`                     | `in_review`  | `contracts.write`  |
| `return`    | `in_review`                 | `draft`      | `contracts.write`  |
| `activate`  | `in_review`                 | `active`     | `contracts.manage` |
| `recall`    | `out_for_signature`         | `in_review`  | `contracts.manage` |
| `terminate` | any status before `expired` | `terminated` | `contracts.manage` |

`sendForSignature` takes a contract in review, freezes it as a version and
opens a signature request on that version for the members in `signers`
(`signOrder` `parallel` or `sequential`). It needs `contracts.manage` and the
signatures block's request permission. When every signer signs, the contract
becomes `active` and `signedAt` is set; a decline or a cancel returns it to
`in_review`. `terminate` takes an optional reason.

## Renewals [#renewals]

While a contract is active and has an end date, its owner has a reminder
`reminderDays` (default 14) before the notice deadline, kept in step when the
dates or the owner change. Run `runRenewals` daily with the service role, for
example from a [job](/docs/blocks/queue): an active contract past its end date
renews by `renewalMonths` when `autoRenew` is on, and expires otherwise.

```ts
export default defineBlocksConfig({
  blocks: { contracts: { options: { reminderDays: 21 } } },
});
```

## Obligations [#obligations]

`saveObligation` adds an obligation to a contract, or changes one when
`obligationId` is set; the owner must be a member. `setObligationStatus`
marks it `done` or `waived` (and back to `open`). It needs `contracts.write`,
or being the obligation's owner. `obligations` filters by contract, owner,
`openOnly` and `dueBefore`.

## Section [#section]

The [contracts binding](/docs/repository/record-bindings) lists the contracts with a
record on its page, read with `forCounterparty`.

## Merging records [#merging-records]

When two counterparty records merge, their contracts move to the record that
is kept ([ADR 0072](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0072-subject-merge-handlers.md)).

## Errors [#errors]

| Hint                          | Cause                                                                                       |
| ----------------------------- | ------------------------------------------------------------------------------------------- |
| `CONTRACT_FORBIDDEN`          | the caller lacks `contracts.read`, `.write` or `.manage`                                    |
| `CONTRACT_INVALID`            | a field is invalid, or the counterparty or owner isn't in the organization                  |
| `CONTRACT_NOT_FOUND`          | no contract with this id in an organization the caller reads                                |
| `CONTRACT_LOCKED`             | the contract is past review and the change isn't to its description, owner or renewal terms |
| `CONTRACT_TRANSITION_INVALID` | the action doesn't apply to the contract's status                                           |
| `OBLIGATION_NOT_FOUND`        | no obligation with this id                                                                  |

## Events [#events]

`contract.created`, `contract.updated`, `contract.status_changed`,
`contract.renewed`, `obligation.saved` and `obligation.status_changed`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.contracts` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const contracts = createContracts({ transport });

const contract = await contracts
  .create(organizationId, {
    title: "Hosting agreement",
    counterparty: { type: "parties", id: partyId },
    value: money(2_400_000n, "EUR"),
    startsOn: Temporal.PlainDate.from("2026-01-01"),
    endsOn: Temporal.PlainDate.from("2026-12-31"),
    autoRenew: true,
    noticeDays: 60,
  })
  .orThrow();
```

# Deals

> Deals on pipelines with amounts in minor units, a company and contacts, a deal team, line items, forecast calls with attainment, close-date slips, a next step, locking and batched edits that reject per change.

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

`deals` is the sales record: a deal for a company, with contacts, an owner and a team, an amount in minor units and a close date, moving through a [pipeline](/docs/blocks/pipelines) from qualified to won or lost. It brings the fields a sales team works with every day: a motion (new logo, expansion, renewal), a priority, the rep's forecast call, the next step, risk flags and a lock for deals finance has countersigned.

Reading needs `deals.read`, creating and editing needs `deals.write`, and deleting someone else's deal needs `deals.manage`. Admins get every permission, and members get `deals.read` and `deals.write`.

```bash
better-supabase add deals
```

The module needs `tenant`, `access`, `subjects` and `pipelines`. Installing it binds the `deal` type in the pipelines module with the categories `open`, `won` and `lost`, and a first pipeline of Qualified (20%), Discovery (35%), Proposal (55%), Negotiation (75%), Closed won and Closed lost. Set `blocks.pipelines.on.deal` to change the stages or add a rotting limit; your keys are merged over the defaults.

Installing deals and parties registers `deal`, `company` and `person` as subjects. Set the deal page on the entity:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { contacts: {}, deals: { names: { deal: { url: "/deals/{id}" } } } },
});
```

## Create and edit [#create-and-edit]

Server code calls the `deals` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDeals } = defineDataHooks(
  "deals",
  (db: AppDb) => db.deals,
);
```

```ts
import { createDeals } from "better-supabase/blocks/deals";
import { money } from "better-supabase/commerce";

const deal = await db.deals
  .create(
    organizationId,
    {
      name: "Growth plan, 60 seats",
      closeOn: Temporal.PlainDate.from("2026-11-30"),
      amount: money(4_800_000n, "EUR"),
      company: { type: "company", id: acmeId },
      motion: "new_logo",
    },
    [{ type: "person", id: adaId, role: "Champion", primary: true }],
  )
  .orThrow();
```

```tsx title="src/features/deals/list.tsx"
"use client";

import { useDeals } from "@/lib/supabase/hooks";

const rows = useDeals({ limit: 50 });
```

A deal needs a name and a close date. It gets a number per organization and an identifier such as `DEAL-1`, or the format of a `deal` entity in [numbering](/docs/blocks/numbering). Its company and contacts are records in `config.records` (the parties types `company` and `person` by default, or set `options.company` and `options.contact`). Each must be in the organization and readable by the caller (`DEAL_COMPANY_FORBIDDEN`, `DEAL_CONTACT_FORBIDDEN`), so an app can point deals at its own customers table.

Amounts are `Money` values from [commerce](/docs/concepts/commerce): a `bigint` of minor units with its ISO 4217 currency. The database functions send them as text, so they stay exact past 2^53. A deal created without a `currency` takes the currency of its amount. Every amount on a deal, its lines and its forecast target must be in the deal's currency: the client returns `CURRENCY_MISMATCH` before calling the database, an unknown code returns `CURRENCY_INVALID`, and an `amount` change in `update` in another currency is rejected with `DEAL_CURRENCY_MISMATCH`. Changing the `currency` field keeps the amounts and lines as they are, in the new currency.

`update(changes)` applies a batch of cell edits, `{ dealId, field, value }`, each on its own. A change that fails is returned in `rejected` with its hint and reason, and the others stay applied, the shape a data grid's batched edit needs:

```ts
const { accepted, rejected } = await deals
  .update([
    { dealId, field: "stageId", value: proposalStageId },
    { dealId, field: "closeOn", value: Temporal.PlainDate.from("2026-12-15") },
    { dealId, field: "forecast", value: "commit" },
  ])
  .orThrow();
```

The rules the table enforces for every writer:

| Rule                                                                                            | Hint                                           |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| A locked deal takes only its note                                                               | `DEAL_LOCKED`                                  |
| The open date never moves                                                                       | `DEAL_OPENED_ON_FIXED`                         |
| A closed deal is called by its stage, so its forecast doesn't change                            | `DEAL_FORECAST_CLOSED`                         |
| A close date that moves later is a slip: `slippedFrom` keeps the first date, `pushCount` counts | none                                           |
| Motions and priorities come from `options.motions` and `options.priorities`                     | `DEAL_MOTION_INVALID`, `DEAL_PRIORITY_INVALID` |

## Your own fields [#your-own-fields]

`blocks.deals.options.extraColumns` adds columns to the `deals` table,
keyed by column name with their SQL type. `create` writes them from `fields`, `update` takes them as cell edits by name, and every deal comes back with them under `fields`. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const deals = createDeals({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await deals.create(organizationId, {
  name: "Growth",
  closeOn,
  fields: { region: "emea" },
});
await deals.update([{ dealId, field: "region", value: "apac" }]);
```

## Stages, winning and losing [#stages-winning-and-losing]

A deal moves stage when `stageId` changes, through an edit or a board drag with [rank](/docs/blocks/rank) (the `deal` subject groups the board by `stage_id`). The pipelines module checks the move and keeps the stage history. A won or lost stage sets `outcome`, which stamps `closedAt` and records `deal.closed` with the outcome. `win(dealId, note)` and `lose(dealId, reason)` move the deal to its pipeline's first won or lost stage, and `reopen(dealId, stageId?)` moves it back to an open stage.

## Contacts, team and lines [#contacts-team-and-lines]

`setContacts` replaces the contacts, with one primary. `setMembers` replaces the deal team; each member watches the deal and, with `options.readAll: false`, can see it. Without `readAll` (the default is `true`), a member sees only deals they own or are on, and `deals.manage` sees all.

`setLines(dealId, lines)` replaces the line items: a description, a quantity, a unit amount as `Money`, a discount and a tax rate in basis points, and an optional product reference. It returns the totals, and with `amountSource: "line_items"` the deal's amount follows the lines' subtotal. A locked deal's lines don't change.

## Forecast and review [#forecast-and-review]

```ts
const call = await deals
  .forecast(organizationId, {
    since: Temporal.PlainDate.from("2026-10-01"),
    until: Temporal.PlainDate.from("2026-12-31"),
    currency: "EUR",
    target: money(60_000_000n, "EUR"),
  })
  .orThrow();
// { won, commit, bestCase, pipeline, notCalled, omit, later, attainment, gap, owners }
```

`forecast` sums one currency in a period: deals won in it, open deals closing in it by forecast category (`notCalled` when the rep has made no call), and `later` for open deals that close after it. `attainment` is won plus commit over the target, and `owners` splits the same sums per owner. `health(pipelineId)` lists the open deals with their days in stage, whether they are rotting, whether the close date slipped, whether there is no next step, and whether the next step is overdue. With the [engagements](/docs/blocks/activities) module installed, a planned engagement on the deal counts as its next step.

## Events [#events]

| Event                                                                            | Data                                                                     |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `deal.created`, `deal.updated`, `deal.reopened`, `deal.archived`, `deal.deleted` | `organizationId`, `dealId`, `subjectType`, `subjectId`, `name`, `fields` |
| `deal.closed`                                                                    | the same, with `outcome`, `amount`, `currency` and the lost `reason`     |

With the [notifications](/docs/blocks/notifications) module installed, the owner and the team watch the deal, and winning or losing it notifies its watchers.

## Hooks [#hooks]

The module calls `before_deal_create(tenant, attrs jsonb)`, `after_deal_create(deal uuid)`, `before_deal_update(deal uuid, field text, value jsonb)` and `after_deal_update(deal uuid, field text)`. A `before_` hook raises to refuse; in `update`, that refuses only its change.

## Wire it to ReUI [#wire-it-to-reui]

The ReUI CRM Pipeline Workspace sends every gesture through one `actions.write(writes)` call with `{ dealId, field, value }` writes: map it to `update`, with ReUI's `title` as `name`, `closeDate` as `closeOn`, `forecast` as `forecast` and `nextAt` and `nextStep` as they are, and show `rejected` reasons in its toast. Board lanes come from the pipeline's stages with their probability and rotting limit, the Forecast Call's lanes from `forecast`, and its slipped, rotting, no-next-step and overdue chips from `health`. The Deal Desk's line items map to `setLines`, and its Mark won and Mark lost buttons to `win` and `lose`.

## Errors [#errors]

| Hint                     | Cause                                                                       |
| ------------------------ | --------------------------------------------------------------------------- |
| `DEAL_FORBIDDEN`         | the caller lacks `deals.write`                                              |
| `DEAL_ATTRS_INVALID`     | a missing name or close date, or a field with a bad value                   |
| `DEAL_NOT_FOUND`         | no deal the caller can edit                                                 |
| `DEAL_FIELD_UNKNOWN`     | `update` names a field it doesn't write                                     |
| `DEAL_OUTCOME_INVALID`   | close as `won` or `lost`                                                    |
| `DEAL_STAGE_MISSING`     | the pipeline has no stage for that outcome                                  |
| `DEAL_CHANGES_INVALID`   | `changes` isn't a list                                                      |
| `DEAL_CONTACTS_INVALID`  | contacts aren't a list, or name two primaries                               |
| `DEAL_MEMBERS_INVALID`   | members aren't a list                                                       |
| `LINES_RECORD_INVALID`   | no such deal, or it is locked                                               |
| `LINES_INVALID`          | a line lacks a description, a positive quantity or an amount                |
| `DEAL_CURRENCY_MISMATCH` | an `amount` or `existingAmount` change isn't in the deal's currency         |
| `CURRENCY_MISMATCH`      | an amount isn't in the record's currency; returned before any database call |
| `CURRENCY_INVALID`       | the currency isn't an ISO 4217 code                                         |
| `LINES_CURRENCY`         | a line sent straight to SQL isn't in the record's currency                  |

## Without the client [#without-the-client]

`db.deals` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createDeals } from "better-supabase/blocks/deals";
import { money } from "better-supabase/commerce";

const deals = createDeals({ transport });
const deal = await deals
  .create(
    organizationId,
    {
      name: "Growth plan, 60 seats",
      closeOn: Temporal.PlainDate.from("2026-11-30"),
      amount: money(4_800_000n, "EUR"),
      company: { type: "company", id: acmeId },
      motion: "new_logo",
    },
    [{ type: "person", id: adaId, role: "Champion", primary: true }],
  )
  .orThrow();
```

# Enrichment

> Look companies and people up with People Data Labs or your own provider, store each result, and fill the record's empty columns.

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

The `enrichment` block looks a [parties](/docs/blocks/contacts) company or
person up with a data provider and stores the profile it returns, one result
per record and provider. A match fills the record's empty columns (a
company's `domain` and `website`, a person's `phone` and `title`) and never
overwrites a value a member typed. Every result writes a `party.enriched`
event.

```bash
better-supabase add enrichment   # adds tenant, access and parties as well
```

| Permission        | Lets a member                  | Default roles            |
| ----------------- | ------------------------------ | ------------------------ |
| `enrichment.read` | read a record's stored results | `member`, `admin`        |
| `enrichment.run`  | look a record up and store it  | `admin` (`enrichment.*`) |

The service role can always enrich, so a cron job works through the backlog
without a member session.

## Providers [#providers]

Server code calls the `enrichment` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useEnrichment } = defineDataHooks(
  "enrichment",
  (db: AppDb) => db.enrichment,
);
```

```ts
const rows = await db.enrichment.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/enrichment/list.tsx"
"use client";

import { useEnrichment } from "@/lib/supabase/hooks";

const rows = useEnrichment({ limit: 50 });
```

`peopleDataLabsEnrichment` calls the People Data Labs company and person
enrich endpoints over `fetch`, so the block adds no SDK. A person match below
`minLikelihood` (6 by default, out of 10) counts as no match. Keep the key
server-side: create the client in a route handler, a server action or a job,
never in the browser.

Any other source plugs in as an `EnrichmentProvider` with `apiVersion: 1`, a
lowercase `name` that results are stored under, and two lookups that return
a profile or `undefined` for no match:

```ts
import type { EnrichmentProvider } from "better-supabase/blocks/enrichment";
import { AsyncResult, ok } from "better-supabase";

const crm: EnrichmentProvider = {
  apiVersion: 1,
  name: "internal-crm",
  company: ({ domain }) =>
    AsyncResult.from(async () =>
      ok(domain === undefined ? undefined : await lookUp(domain)),
    ),
  person: () => AsyncResult.ok(undefined),
};
```

A company lookup gets the record's `name`, `domain` and `website`; a person
lookup gets `name`, `email`, `phone` and the domain of their primary company.
Return a provider error (`rate_limited`, `unauthorized` or `network`) instead
of throwing; the block then stores nothing.

## Enrich [#enrich]

```ts
const result = await enrichment
  .enrich({ type: "company", id: company.id })
  .orThrow();

result.status; // "matched" or "not_found"
result.data; // the provider's profile, such as industry and employees
result.filled; // ["domain"]: the columns this result filled
```

A second lookup with the same provider replaces the stored result. Pass
`{ fill: false }`, or `fill: false` to `createEnrichment`, to store profiles
without touching the record. A domain another active company in the
organization already holds is not filled, so enrichment never creates a
duplicate. `of({ type, id })` lists a record's results, one per provider,
the newest first.

## Backfill on a schedule [#backfill-on-a-schedule]

```ts
const report = await createEnrichment({
  transport: rpcTransport(admin),
  provider,
  logger,
})
  .enrichDue({ limit: 50, staleAfter: "90 days" })
  .orThrow();
```

`enrichDue` takes the companies with a domain or website and the people with
an email that have no result from the provider, or one older than
`staleAfter` (30 days by default), records never enriched first. It returns
`{ checked, matched, notFound, failed }`, logs each failed lookup, and stops
early when the provider rate limits or refuses the key. Failed records stay
due for the next run.

## Events [#events]

Every stored result writes `party.enriched` to the
[outbox](/docs/blocks/outbox) with the subject, the provider, the status and
the filled columns. Handle it to re-run [segments](/docs/blocks/segments) or
[assignment](/docs/blocks/assignment) on the new values. See
[Events](/docs/extending/events).

| Error code                   | When                                               |
| ---------------------------- | -------------------------------------------------- |
| `ENRICHMENT_NOT_FOUND`       | no active record you may enrich has the id         |
| `ENRICHMENT_SUBJECT_UNKNOWN` | the subject type is not the company or person type |
| `ENRICHMENT_INVALID`         | a bad provider name, status or profile             |
| `ENRICHMENT_KEY_REFUSED`     | the provider refused the API key (401 or 403)      |

## Without the client [#without-the-client]

`db.enrichment` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createEnrichment,
  peopleDataLabsEnrichment,
  rpcTransport,
} from "better-supabase/blocks/enrichment";

const enrichment = createEnrichment({
  transport: rpcTransport(supabase),
  provider: peopleDataLabsEnrichment({ apiKey: env.PDL_API_KEY }),
});
```

# Forecasts

> Snapshot the deals forecast for a period, keep the deals behind each number, and compare two snapshots deal by deal.

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

The `forecasts` block freezes the [deals](/docs/blocks/deals) forecast call
at a point in time. A snapshot stores the totals for a period and currency
(won, commit, best case, pipeline, not called, omitted, later, attainment and
the gap to the target) and a copy of every deal behind them, so next week's
review can show what moved: which deals were won, lost, added or changed.

```bash
better-supabase add forecasts   # adds tenant, access and deals as well
```

| Permission           | Lets a member                   | Default roles           |
| -------------------- | ------------------------------- | ----------------------- |
| `forecasts.read`     | list and open snapshots         | `member`, `admin`       |
| `forecasts.snapshot` | take a snapshot of the forecast | `admin` (`forecasts.*`) |

The service role can always take a snapshot, so a weekly cron job works
without a member session.

## Take a snapshot [#take-a-snapshot]

Server code calls the `forecasts` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useForecasts } = defineDataHooks(
  "forecasts",
  (db: AppDb) => db.forecasts,
);
```

```ts
import {
  createForecasts,
  rpcTransport,
} from "better-supabase/blocks/forecasts";
import { money } from "better-supabase/commerce";

const snapshot = await db.forecasts
  .snapshot(organizationId, {
    since: Temporal.PlainDate.from("2026-10-01"),
    until: Temporal.PlainDate.from("2026-12-31"),
    currency: "EUR",
    target: money(25_000_000n, "EUR"),
  })
  .orThrow();

snapshot.totals.commit; // a Money value, as deals.forecast returns it
snapshot.dealCount; // the deals copied into the snapshot
```

```tsx title="src/features/forecasts/list.tsx"
"use client";

import { useForecasts } from "@/lib/supabase/hooks";

const rows = useForecasts({ limit: 50 });
```

A snapshot sums every deal of the organization in the currency, whoever owns
it, so the numbers match for everyone who opens it later. It copies the open
deals that close in the period and the deals won or lost in it. Pass a
`pipelineId` to snapshot one pipeline. A period runs at most 366 days.

## Compare two snapshots [#compare-two-snapshots]

```ts
const [latest, previous] = await forecasts
  .list(organizationId, { limit: 2 })
  .orThrow();

const { deals } = await forecasts.changes(previous.id, latest.id).orThrow();

for (const deal of deals) {
  deal.change; // "added", "removed", "won", "lost" or "changed"
  deal.before?.amount;
  deal.after?.amount;
}
```

`list` returns snapshots newest first and filters by `pipelineId`, `since`,
`until` and `limit`. `get(snapshotId)` returns one snapshot with its deals.
`changes` lists only the deals whose stage, owner, forecast category,
outcome, amount or close date differ between the two snapshots, and needs
two snapshots of the same organization and currency.

## Events [#events]

Every snapshot writes `forecast.snapshotted` to the
[outbox](/docs/blocks/outbox) with the snapshot id, the pipeline, the period,
the currency and the deal count. See [Events](/docs/extending/events).

| Error code                  | When                                                        |
| --------------------------- | ----------------------------------------------------------- |
| `FORECAST_FORBIDDEN`        | the caller lacks `forecasts.snapshot`                       |
| `FORECAST_INVALID`          | a bad period, currency or target, or a period over 366 days |
| `FORECAST_PIPELINE_UNKNOWN` | the pipeline is not one of the organization's               |
| `FORECAST_NOT_FOUND`        | no snapshot you may read has the id                         |
| `FORECAST_MISMATCH`         | the two snapshots differ in organization or currency        |

## Without the client [#without-the-client]

`db.forecasts` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createForecasts,
  rpcTransport,
} from "better-supabase/blocks/forecasts";
import { money } from "better-supabase/commerce";

const forecasts = createForecasts({ transport: rpcTransport(supabase) });

const snapshot = await forecasts
  .snapshot(organizationId, {
    since: Temporal.PlainDate.from("2026-10-01"),
    until: Temporal.PlainDate.from("2026-12-31"),
    currency: "EUR",
    target: money(25_000_000n, "EUR"),
  })
  .orThrow();

snapshot.totals.commit; // a Money value, as deals.forecast returns it
snapshot.dealCount; // the deals copied into the snapshot
```

# Targets

> Goals per period for a member, a team or the organization, with progress the database computes for won deals, completed work items and paid invoices, or a value the app keeps.

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

`targets` keeps goals: a sales quota per rep per quarter, a number of completed work items per team per sprint, cash collected per month. Each target names a metric, a period, an owner or team when it isn't for the whole organization, and a goal. The database computes the progress of the metrics the other blocks own, and the app keeps the value of any other metric.

Reading needs `targets.read`, and setting targets needs `targets.manage`. Admins get both, and members get `targets.read`.

```bash
better-supabase add goals
```

The module needs `memberships` and `access`.

Server code calls the `goals` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useGoals } = defineDataHooks(
  "goals",
  (db: AppDb) => db.goals,
);
```

```ts
import { createGoals } from "better-supabase/blocks/goals";

await db.goals
  .upsert(organizationId, {
    name: "Q4 quota",
    metric: "deals.won_amount",
    ownerId: repId,
    currency: "EUR",
    goal: 60_000_000n,
    periodStart: Temporal.PlainDate.from("2026-10-01"),
    periodEnd: Temporal.PlainDate.from("2026-12-31"),
  })
  .orThrow();

const running = await db.goals
  .progress(organizationId, { ownerId: repId })
  .orThrow();
// [{ name: "Q4 quota", progress: 18_400_000n, attainment: 0.3067, ... }]
```

```tsx title="src/features/goals/list.tsx"
"use client";

import { useGoals } from "@/lib/supabase/hooks";

const rows = useGoals({ limit: 50 });
```

## Metrics [#metrics]

| Metric                 | Progress                                                                      | Needs      |
| ---------------------- | ----------------------------------------------------------------------------- | ---------- |
| `deals.won_amount`     | the amount of deals won in the period, in the target's currency, by its owner | `deals`    |
| `deals.won_count`      | the number of deals won in the period, by its owner                           | `deals`    |
| `work_items.completed` | the work items completed in the period, by its team and assignee              | `tasks`    |
| `invoices.paid_amount` | payments recorded in the period, in the target's currency                     | `invoices` |
| anything else          | the `actual` value the app saves                                              |            |

`progress(organizationId, { on, ownerId })` returns the targets whose period contains `on` (today by default), with `progress` and `attainment` (progress over goal). Goals and progress are `bigint` values (minor units for money metrics) with no currency of their own, sent as text so they stay exact past 2^53. Progress reads run as the caller, so they count what the caller can see. The [deals](/docs/blocks/deals) forecast takes a target as its period goal.

## Errors [#errors]

| Hint               | Cause                                                         |
| ------------------ | ------------------------------------------------------------- |
| `TARGET_FORBIDDEN` | the caller lacks `targets.manage`                             |
| `TARGET_INVALID`   | a target needs a name, a metric, a period and a positive goal |
| `TARGET_NOT_FOUND` | no such target                                                |

## Your own columns [#your-own-columns]

Add columns to the target table with `blocks.goals.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`upsert` validates and writes them, and `upsert` and `progress` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createGoals } from "better-supabase/blocks/goals";
import { z } from "zod";

const client = createGoals({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.goals` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createGoals } from "better-supabase/blocks/goals";

const targets = createGoals({ transport });
await targets
  .upsert(organizationId, {
    name: "Q4 quota",
    metric: "deals.won_amount",
    ownerId: repId,
    currency: "EUR",
    goal: 60_000_000n,
    periodStart: Temporal.PlainDate.from("2026-10-01"),
    periodEnd: Temporal.PlainDate.from("2026-12-31"),
  })
  .orThrow();

const running = await targets
  .progress(organizationId, { ownerId: repId })
  .orThrow();
// [{ name: "Q4 quota", progress: 18_400_000n, attainment: 0.3067, ... }]
```

# Mail sync

> Sync each member's Gmail or Outlook mailbox through their own OAuth grant, match threads to people and companies, log them as email engagements and send replies.

Source: https://bettersupabase.com/docs/blocks/mail-sync

The `mail-sync` block pulls each member's mailbox into Postgres and puts the
mail on the records it concerns. A sync job stores new messages by thread,
links each thread to the [parties](/docs/blocks/contacts) whose addresses it
names (a person by email, a company by its people or its domain) and logs it
as an `email` [engagement](/docs/blocks/activities) on them. Members can
also send from their mailbox, and the sent message shows on the thread
before the next sync.

```bash
better-supabase add mail-sync   # adds tenant, access and subjects as well
```

Install `contacts` and `engagements` too for the matching and the timeline;
without them, threads stay in the mailbox and can be linked by hand to any
registered subject.

| Permission     | Lets a member                                    | Default roles      |
| -------------- | ------------------------------------------------ | ------------------ |
| `mail.read`    | read the shared threads on records they can read | `member`, `admin`  |
| `mail.connect` | connect their own mailbox                        | `member`, `admin`  |
| `mail.send`    | send from their own mailbox                      | `member`, `admin`  |
| `mail.manage`  | see every member's mailbox and its sync status   | `admin` (`mail.*`) |

A mailbox's `share` decides who reads its threads. With `records` (the
default), a member with `mail.read` sees a thread when they can read one of
its linked records, and the thread is logged as an engagement. With
`private`, only the mailbox's owner sees its threads and nothing is logged.

## Providers and grants [#providers-and-grants]

Every member signs in to their mail provider with OAuth, and the app keeps
the grant in a [credential provider](/docs/extending/credentials). The
block stores only the `credentialRef` that names the grant, with the
organization as its `tenant`, and asks the provider for a token as that
member each time it syncs or sends.

`gmailMail()` and `outlookMail()` call the Gmail REST API and Microsoft
Graph with `fetch`, so no Google or Microsoft SDK is installed. The first
sync of a mailbox reads the last 30 days (`initialDays`); later syncs read
Gmail's history or Graph's delta since the stored cursor. A provider of your
own implements `MailProvider` (`apiVersion: 1`, `name`, `list`, `send`).

Server code calls the `mail-sync` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useMailSync } = defineDataHooks(
  "mailSync",
  (db: AppDb) => db.mailSync,
);
```

```ts
const rows = await db.mailSync.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/mail-sync/list.tsx"
"use client";

import { useMailSync } from "@/lib/supabase/hooks";

const rows = useMailSync({ limit: 50 });
```

## Connect a mailbox [#connect-a-mailbox]

After the OAuth flow, connect the member's mailbox. Connecting the same
address again updates its grant and keeps the sync cursor when the provider
stays the same.

```ts
const account = await mailFor(supabase)
  .connect(organizationId, {
    provider: "gmail",
    address: "rep@acme.com",
    credentialRef: {
      provider: "vercel-connect",
      connector: "gmail",
      tenant: organizationId,
    },
  })
  .orThrow();
```

`update(accountId, { share, paused })` switches the mailbox between
`records` and `private`, or pauses its sync. `disconnect(accountId)` stops
the sync; `{ purge: true }` also deletes its threads and messages.
`accounts(organizationId)` lists the caller's mailboxes with their `status`,
`lastSyncedAt` and `lastError`. Deleting a member's account purges their
mailboxes and revokes their grants through
[data lifecycle](/docs/blocks/data-lifecycle).

## Run the sync [#run-the-sync]

The sync runs as the service role, from a job or a cron route.
`syncDue()` syncs the active mailboxes not synced within `staleAfter`
(`5 minutes`), at most `accounts` (25) of them, reading up to `pages` (5)
pages of `limit` (50) messages each. A mailbox whose provider fails is
marked `error` with the message; a rate limit leaves it `active` for the
next run.

```ts title="app/api/cron/mail/route.ts"
export async function GET() {
  const report = await createMailSync({
    transport: rpcTransport(admin),
    credentials: vercelConnectCredentials(),
    providers: [gmailMail(), outlookMail()],
  })
    .syncDue()
    .orThrow();

  return Response.json(report);
}
```

`report` holds the number of mailboxes synced, the messages stored and the
mailboxes that failed. `sync(account)` syncs one mailbox. Storing is
idempotent by the provider's message id, and a sent message matches its
synced copy by its `Message-ID`, so a message is never stored twice.

## Threads on a record [#threads-on-a-record]

```ts
const mail = mailFor(supabase);
const threads = await mail
  .threadsOf({ type: "person", id: personId })
  .orThrow();
const thread = await mail.thread(threads[0].id).orThrow();
```

`threads(organizationId, { accountId, before, limit })` lists the threads
the caller can see, the latest first, and `thread(id)` returns one with its
messages, or `undefined` when the caller can't see it. A mailbox's owner
links a thread to records the matching missed with
`link(threadId, [{ type, id }])` and removes one with `unlink`.

## Send [#send]

```ts
await mail
  .send(account.id, {
    threadId: thread.id,
    to: [{ address: "ada@client.com" }],
    subject: "Re: Pricing",
    text: "Here is our offer.",
  })
  .orThrow();
```

With a `threadId`, the message replies to the thread's latest message, so
the provider keeps it in the same conversation. `mailDraftSchema` validates
the draft and `mailAccountInputSchema` what `connect` takes.

## Templates and tracking [#templates-and-tracking]

A send can come from a [templates](/docs/blocks/templates) version and be
tracked. `templateVersionId` stores the version on the sent message, and
with `tracking` set on `createMailSync`, the HTML body's `http(s)` links go
through your tracking route and a 1x1 pixel records opens. The provider
sends the rewritten HTML; the database keeps the original, a SHA-256 of the
message's random token and its links, so the tracking URLs can't be
guessed from the data.

```ts
import { composeTemplateEmail } from "better-supabase/blocks/templates";

const mail = createMailSync({
  transport: rpcTransport(supabase),
  credentials: vercelConnectCredentials(),
  providers: [gmailMail(), outlookMail()],
  tracking: { url: "https://app.acme.com/api/mail/t" },
});

const { subject, text, html } = composeTemplateEmail(body, { person });

await mail
  .send(account.id, {
    to: [{ address: person.email }],
    subject,
    ...(text === undefined ? {} : { text }),
    ...(html === undefined ? {} : { html }),
    templateVersionId: version.id,
  })
  .orThrow();
```

`track: false` sends one message untracked, and `track: true` without
`tracking` fails with `MAIL_TRACKING_MISSING`. The route answers each hit
with `track(request)` as the service role: an open (`/o/<token>`) returns
the pixel, a click (`/c/<token>/<n>`) redirects to the stored link, and
an unknown token gets a 404.

```ts title="app/api/mail/t/[...hit]/route.ts"
export async function GET(request: Request) {
  const tracked = await mailFor(admin).track(request);

  return tracked.ok ? tracked.data : new Response(null, { status: 503 });
}
```

A sent message carries `tracked`, `openCount`, `clickCount`, `openedAt` and
`clickedAt`. `templateStats(organizationId, { since })` returns, per
template version, the messages `sent`, `opened`, `clicked` and `replied`
(an inbound message on the thread after the sent one), over the mailboxes
the caller can read. Open counts are approximate: mail clients that block
images never load the pixel, and some (Apple Mail's privacy protection,
Gmail's image proxy) load it for the reader. Link scanners can click
links before the recipient does.

## Options [#options]

```ts title="better-supabase.config.ts"
blocks: {
  "mail-sync": { options: { matchDomains: false } },
},
```

| Option           | Default | What it does                                                       |
| ---------------- | ------- | ------------------------------------------------------------------ |
| `engagementType` | `email` | the engagement type a thread is logged as                          |
| `matchDomains`   | `true`  | link a thread to the company whose domain one of its addresses has |

## Events [#events]

The block writes `mail_account.connected`, `mail_account.disconnected`,
`mail_message.received`, `mail_message.sent`, `mail_message.opened` and
`mail_message.clicked` to the [outbox](/docs/blocks/outbox). A message
event names the thread, the direction, the sender and the records the
thread is linked to, so a workflow can react to a reply from a contact. An
open is written once per message, on the first open; a click is written
every time, with its `url` and the `templateVersionId`. See
[Events](/docs/extending/events).

## Without the client [#without-the-client]

`db.mailSync` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/mail.ts"
import { vercelConnectCredentials } from "better-supabase/vercel-connect";
import {
  createMailSync,
  gmailMail,
  outlookMail,
  rpcTransport,
} from "better-supabase/blocks/mail-sync";

export const mailFor = (supabase: SupabaseClient) =>
  createMailSync({
    transport: rpcTransport(supabase),
    credentials: vercelConnectCredentials(),
    providers: [gmailMail(), outlookMail()],
  });
```

# Quotes

> Quotes with line items and totals, a revision chain, a public link the customer accepts, rejects or asks changes through, expiry, and quotes made from deals.

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

`quotes` is the offer you send a customer: a title, a company and a contact, lines with quantities, unit amounts, discounts and tax in minor units, totals the database keeps, a valid-until date and terms. You send it as a link the customer opens without an account, and they accept it, reject it or ask for changes. A new version is a revision with the same number. With the [deals](/docs/blocks/deals) module installed, a quote starts from a deal.

Reading needs `quotes.read`, and creating, sending and recording decisions needs `quotes.write`. Admins get every permission, and members get `quotes.read` and `quotes.write`.

```bash
better-supabase add quotes
```

The module needs `tenant`, `permissions` and `subjects`.

Server code calls the `quotes` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useQuotes } = defineDataHooks(
  "quotes",
  (db: AppDb) => db.quotes,
);
```

```ts
import { createQuotes } from "better-supabase/blocks/quotes";
import { money } from "better-supabase/commerce";

const quote = await db.quotes.fromDeal(dealId).orThrow();
await db.quotes
  .setLines(quote.id, [
    {
      description: "Licences",
      quantity: 10,
      unitAmount: money(100_000n, "EUR"),
      taxRateBps: 2100,
    },
    {
      description: "Setup",
      unitAmount: money(50_000n, "EUR"),
      discountBps: 2000,
      taxRateBps: 2100,
    },
  ])
  .orThrow();
const { token } = await db.quotes.send(quote.id).orThrow();
// Email the customer a link such as https://app.example.com/q/{token}
```

```tsx title="src/features/quotes/list.tsx"
"use client";

import { useQuotes } from "@/lib/supabase/hooks";

const rows = useQuotes({ limit: 50 });
```

## Lifecycle [#lifecycle]

| Status                 | How it gets there                                                           |
| ---------------------- | --------------------------------------------------------------------------- |
| `draft`                | `create` or `fromDeal`; lines and fields can change                         |
| `sent`                 | `send`; the quote freezes and gets its link                                 |
| `viewed`               | the customer opened the link                                                |
| `accepted`, `rejected` | the customer answered through the link, or `decide` recorded their answer   |
| `changes_requested`    | the customer asked for changes; it can be edited and sent again, or revised |
| `expired`              | its valid-until date passed (`expire_quotes`, or an answer after the date)  |
| `withdrawn`            | a newer revision replaced it                                                |

`create` gives a quote the next number (`Q-12`, or the format of a `quote` entity in [numbering](/docs/blocks/numbering)) and a valid-until date `options.validDays` (default 30) from today. Its company and contact are records in `config.records` (the parties types `company` and `person` by default), checked like a deal's (`QUOTE_COMPANY_FORBIDDEN`).

Unit amounts and totals are `Money` values from [commerce](/docs/concepts/commerce), sent to the database as text so they stay exact past 2^53. Every line must be in the quote's currency (`CURRENCY_MISMATCH`, checked before the database call), and a quote created without a `currency` takes the currency of its lines. An unknown code returns `CURRENCY_INVALID`.

With the [catalog block](/docs/blocks/catalog) installed, a line can name a
product variant instead of pricing it: `{ product: { type: "product_variant", id }, quantity: 2 }`.
The database takes the variant's price in the quote's currency (from
`variant_price`) and its title, or its product's title, when the line has no
`unitAmount` or `description`. A variant of another organization or without
a price in that currency fails with `LINES_INVALID`.

`send` needs at least one line (`QUOTE_EMPTY`). It returns the link's token once; only its hash is stored. `revise(quoteId)` makes a new draft with the same number, the next revision, the same lines and a fresh valid-until date, and withdraws the old one unless it was accepted, which also ends its link.

## The customer's page [#the-customers-page]

The customer's page calls two functions that run without signing in:

```ts
const quote = await quotes.view(token).orThrow(); // marks it viewed
await quotes.respond(token, "accepted", { name: "Els Jansen" }).orThrow();
```

`view` returns the quote without internal notes: its identifier, title, status, currency, valid-until date, terms, totals and lines. `respond` takes `accepted`, `rejected` or `changes_requested` with the customer's name and a note; after the valid-until date it expires the quote instead. A withdrawn or unknown link answers `QUOTE_LINK_INVALID`. Rate-limit the page with the [rate-limit](/docs/blocks/sql) module or your edge.

`decide(quoteId, decision, { by, note })` records an answer the customer gave another way, such as by phone.

## From a deal [#from-a-deal]

`fromDeal(dealId)` makes a draft from the deal's name, company, primary contact, currency, owner and lines, or one line for its amount when it has none. It returns the same quote when called again, and with the [relations](/docs/blocks/relations) module installed, it links the quote to the deal with `created_from`, so `lineage` shows deal, quote, invoice.

## Events [#events]

| Event                                                                           | Data                                                                  |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `quote.created`, `quote.updated`, `quote.sent`, `quote.viewed`, `quote.expired` | `organizationId`, `quoteId`, `subjectType`, `subjectId`, `identifier` |
| `quote.accepted`                                                                | the same, with `total`, `currency` and `byName`                       |
| `quote.rejected`, `quote.changes_requested`                                     | the same, with the customer's `note`                                  |
| `quote.revised`                                                                 | the same, with `revisionId`                                           |

With the [notifications](/docs/blocks/notifications) module installed, the quote's owner gets a notification when the customer answers.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `quotes`; a revision copies them. `create` and `update` write them from `fields`,
and every method that returns a quote return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { quotes: { options: { extraColumns: { region: "text" } } } },
});
```

```ts
const client = createQuotes({
  transport,
  fields: v.object({ region: v.optional(v.picklist(["eu", "us"])) }),
});
await client.create(organizationId, {
  title: "Rollout",
  fields: { region: "eu" },
});
```

## Hooks [#hooks]

The module calls `before_quote_send(quote uuid)`, `after_quote_send(quote uuid)`, `before_quote_accept(quote uuid)` and `after_quote_accept(quote uuid)`, so an app can check margins before a quote goes out or start an order when it is accepted.

## Errors [#errors]

| Hint                     | Cause                                                                    |
| ------------------------ | ------------------------------------------------------------------------ |
| `QUOTE_FORBIDDEN`        | the caller lacks `quotes.write`                                          |
| `QUOTE_ATTRS_INVALID`    | a missing title or a field with a bad value                              |
| `QUOTE_NOT_FOUND`        | no quote the caller can edit                                             |
| `QUOTE_NOT_EDITABLE`     | the quote was sent; revise it                                            |
| `QUOTE_EMPTY`            | a quote without lines can't be sent                                      |
| `QUOTE_NOT_OPEN`         | the quote doesn't take an answer now                                     |
| `QUOTE_NOT_SENT`         | a draft is edited, not revised                                           |
| `QUOTE_ALREADY_REVISED`  | the quote already has a newer revision                                   |
| `QUOTE_DECISION_INVALID` | answer `accepted`, `rejected` or `changes_requested`                     |
| `QUOTE_LINK_INVALID`     | the link is unknown or was withdrawn                                     |
| `QUOTE_NAME_REQUIRED`    | the customer's answer needs a name                                       |
| `QUOTE_ORIGIN_NOT_FOUND` | no deal the caller can quote                                             |
| `CURRENCY_MISMATCH`      | a line isn't in the record's currency; returned before any database call |
| `CURRENCY_INVALID`       | the currency isn't an ISO 4217 code                                      |
| `LINES_CURRENCY`         | a line sent straight to SQL isn't in the record's currency               |

## Without the client [#without-the-client]

`db.quotes` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createQuotes } from "better-supabase/blocks/quotes";
import { money } from "better-supabase/commerce";

const quotes = createQuotes({ transport });
const quote = await quotes.fromDeal(dealId).orThrow();
await quotes
  .setLines(quote.id, [
    {
      description: "Licences",
      quantity: 10,
      unitAmount: money(100_000n, "EUR"),
      taxRateBps: 2100,
    },
    {
      description: "Setup",
      unitAmount: money(50_000n, "EUR"),
      discountBps: 2000,
      taxRateBps: 2100,
    },
  ])
  .orThrow();
const { token } = await quotes.send(quote.id).orThrow();
// Email the customer a link such as https://app.example.com/q/{token}
```

# Segments

> Save a filter over companies, people, deals or any registered subject, keep its members in Postgres and react when records enter or leave it.

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

The `segments` block saves a [rules](/docs/blocks/automations) filter tree over
one subject type and stores the records it matches. A refresh evaluates the
filter in Postgres, adds the records that now match, removes the ones that
no longer do and writes a `segment.entered` or `segment.left` event for
each, so a workflow can start a sequence or notify an owner when a deal
crosses a threshold.

```bash
better-supabase add segments   # adds tenant, access, subjects and rules as well
```

| Permission      | Lets a member                                         | Default roles          |
| --------------- | ----------------------------------------------------- | ---------------------- |
| `segments.read` | list segments and read the members they can also read | `member`, `admin`      |
| `segments.edit` | create, change, refresh and remove segments           | `admin` (`segments.*`) |

A segment is visible to everyone in the organization with `segments.read`,
but its member list also checks the subject type's read permission, so a
member without `deals.read` sees a deal segment's count and not its deals.

## Fields [#fields]

A segment's conditions use the subject type's rule fields. Sync them once
per deploy, as for rules. A segment reads the stored row, so each field's
`column` names a database column of the subject's table (`lifecycle`,
`amount_minor`, `archived_at`), not a camelCase key.

Server code calls the `segments` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSegments } = defineDataHooks(
  "segments",
  (db: AppDb) => db.segments,
);
```

```ts
const rows = await db.segments.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/segments/list.tsx"
"use client";

import { useSegments } from "@/lib/supabase/hooks";

const rows = useSegments({ limit: 50 });
```

A subject with a `deletedAt` column in `config.records` leaves its deleted
rows out. Parties and deals archive with `archived_at` instead, so add an
`archivedAt` field and an "is empty" condition to keep archived records out
of a segment.

## Save and refresh [#save-and-refresh]

```ts
import { createSegments, rpcTransport } from "better-supabase/blocks/segments";

const segments = createSegments({ transport: rpcTransport(supabase) });

const saas = await segments
  .upsert(organizationId, {
    subjectType: "company",
    name: "SaaS customers",
    conditions: {
      id: "root",
      type: "group",
      combinator: "and",
      rules: [
        {
          id: "r1",
          type: "rule",
          path: ["lifecycle"],
          operator: "is",
          value: "customer",
        },
        {
          id: "r2",
          type: "rule",
          path: ["industry"],
          operator: "is",
          value: "saas",
        },
        { id: "r3", type: "rule", path: ["archivedAt"], operator: "empty" },
      ],
    },
  })
  .orThrow();

const { entered, left, memberCount } = await segments
  .refresh(saas.id)
  .orThrow();
```

`upsert` checks the tree against the rule fields and refuses an unknown field
or operator with `RULE_FIELD_UNKNOWN` or `RULE_OPERATOR_INVALID`. Pass `id`
to change a segment; a change to its conditions clears `refreshedAt`, so
the next scheduled refresh picks it up. A segment's subject type can't
change. `segmentInputSchema` validates what `upsert` takes.

`members(segmentId, { after, limit })` pages through the members, the
newest first, `of({ type, id })` lists the segments a record is in, and
`list(organizationId, { subjectType })` and `get(segmentId)` read the
segments themselves. `remove(segmentId)` deletes a segment and its members.

## Refresh on a schedule [#refresh-on-a-schedule]

`refreshDue()` runs as the service role and refreshes the segments not
refreshed within `staleAfter` (`1 hour`), at most `segments` (25) of them.

```ts title="app/api/cron/segments/route.ts"
export async function GET() {
  const report = await createSegments({ transport: rpcTransport(admin) })
    .refreshDue({ staleAfter: "15 minutes" })
    .orThrow();

  return Response.json(report);
}
```

A segment holds at most `maxMembers` records. A refresh that would go over
fails with `SEGMENT_TOO_LARGE` and keeps the previous members, so narrow the
conditions or raise the option.

## Options [#options]

```ts title="better-supabase.config.ts"
blocks: {
  segments: { options: { maxMembers: 50000 } },
},
```

| Option       | Default | What it does                          |
| ------------ | ------- | ------------------------------------- |
| `maxMembers` | `10000` | the most records one segment may hold |

## Events [#events]

The block writes `segment.saved` and `segment.removed`, and per record
`segment.entered` and `segment.left`, to the [outbox](/docs/blocks/outbox).
A member event names the segment and the record (`subjectType`,
`subjectId`), so the [watcher sink](/docs/blocks/notifications#watchers)
and record timelines handle it like any other subject event. See
[Events](/docs/extending/events).

| Error code                | When                                                |
| ------------------------- | --------------------------------------------------- |
| `SEGMENT_FORBIDDEN`       | the caller lacks `segments.read` or `segments.edit` |
| `SEGMENT_NAME_TAKEN`      | another segment in the organization has the name    |
| `SEGMENT_SUBJECT_UNKNOWN` | the subject type is not in `config.records`         |
| `SEGMENT_FIELDS_MISSING`  | the subject type has no synced rule fields          |
| `SEGMENT_TOO_LARGE`       | a refresh matched more than `maxMembers` records    |

## Without the client [#without-the-client]

`db.segments` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createAutomationsAdmin,
  defineRuleFields,
} from "better-supabase/blocks/automations";

export const companyFields = defineRuleFields("company", {
  lifecycle: { type: "select", options: ["lead", "customer", "churned"] },
  industry: { type: "select", options: ["saas", "retail", "finance"] },
  archivedAt: { type: "date", column: "archived_at" },
});

await createAutomationsAdmin({ transport: serviceTransport })
  .syncFields(companyFields)
  .orThrow();
```

# Sequences

> Multi-step outreach with email, wait and task steps that run per contact from their owner's mailbox until the contact replies.

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

The `sequences` block runs outreach: a sequence is an ordered list of email,
wait and task steps, and each contact you enroll runs through it on their
own clock. Email steps go out from a connected [mail-sync](/docs/blocks/mail-sync)
mailbox, later emails reply on the first one's thread, and a reply ends the
contact's run. A sequence is an [enrollments](/docs/blocks/enrollments)
program, so every run is an enrollment with step progress.

```bash
better-supabase add sequences   # adds tenant, access and enrollments as well
```

| Permission         | Lets a member                                      | Default roles           |
| ------------------ | -------------------------------------------------- | ----------------------- |
| `sequences.read`   | list sequences and the runs in them                | `member`, `admin`       |
| `sequences.enroll` | enroll records, and pause, resume or end their run | `member`, `admin`       |
| `sequences.manage` | create, change, activate and delete sequences      | `admin` (`sequences.*`) |

The example app's [sequence builder](/docs/registry/ui-recipes/sequence-builder)
recipe lists the sequences with their steps and counts, adds steps and
activates or pauses a sequence.

## Write a sequence [#write-a-sequence]

Server code calls the `sequences` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSequences } = defineDataHooks(
  "sequences",
  (db: AppDb) => db.sequences,
);
```

```ts
import {
  createSequences,
  rpcTransport,
} from "better-supabase/blocks/sequences";

const welcome = await db.sequences
  .upsert({
    key: "welcome",
    name: "Welcome series",
    status: "active",
    steps: [
      {
        id: "intro",
        kind: "email",
        subject: "Welcome, {{firstName}}",
        text: "Reply to this email if you have questions.",
      },
      { id: "wait", kind: "wait", minutes: 2880 },
      {
        id: "call",
        kind: "task",
        title: "Call {{firstName}}",
        dueInMinutes: 1440,
      },
    ],
  })
  .orThrow();
```

```tsx title="src/features/sequences/list.tsx"
"use client";

import { useSequences } from "@/lib/supabase/hooks";

const rows = useSequences({ limit: 50 });
```

`upsert` creates a sequence, or with `id` changes the keys you pass. A sequence
starts as `draft`; only an `active` one takes enrollments, `paused` holds
every run where it is, and `archived` ends the runs as `archived`. Changing
the steps raises `version`, and recipients past the old end run the new
steps on their next turn. `sequenceInputSchema` validates what `upsert` takes.

| Step    | What it does                                                                                                          |
| ------- | --------------------------------------------------------------------------------------------------------------------- |
| `email` | sends `subject` with `text` or `html` from the run's mailbox; `track: false` turns off open and click tracking for it |
| `wait`  | waits `minutes` (1 to 525600) before the next step                                                                    |
| `task`  | creates a [work item](/docs/blocks/tasks) for whoever enrolled the contact, due `dueInMinutes` later, when installed  |

Email subjects and bodies take `{{key}}` placeholders, filled from the run's
`variables`. An unknown key becomes empty and HTML bodies escape the values.
`fillPlaceholders(template, variables)` does the same in TypeScript, for a
preview.

## Enroll contacts [#enroll-contacts]

```ts
const run = await sequences
  .enroll(
    welcome.id,
    { type: "person", id: person.id },
    { variables: { firstName: person.firstName } },
  )
  .orThrow();
```

`enroll` takes any registered record. A [person](/docs/blocks/contacts) sends
to its email unless you pass `address`, and the mailbox is the run's
`accountId`, then the sequence's, then the caller's own. Enrolling a record
that is already running returns its run; a finished or ended run starts
over.

`pause(enrollmentId)`, `resume(enrollmentId)` and `exit(enrollmentId)`
change one run, and `enrollments({ sequenceId, subject, status })` pages
through runs, newest first. `list()`, `get(sequenceId)` and
`delete(sequenceId)` read and remove sequences; a sequence's `counts` says
how many runs are active, paused, completed, exited and replied.

## Run the due steps [#run-the-due-steps]

`runDue()` runs as the service role. It finishes due wait and task steps in
Postgres and sends due emails through the `mailer` port, which
[`createBlocks`](/docs/blocks/create-blocks#ports) fills from the
`mail-sync` block. Call it from a cron route every few minutes.

```ts title="app/api/cron/sequences/route.ts"
export async function GET() {
  const report = await blocks.sequences.runDue({ limit: 100 }).orThrow();

  return Response.json(report); // { ran, sent, failed }
}
```

A send that fails runs again after `retryMinutes` times the attempts so far.
After `maxAttempts` the run ends with the reason `failed`. A run whose
contact replies on the thread ends with the reason `replied`, unless the
sequence sets `exitOnReply: false`.

## Options [#options]

```ts title="better-supabase.config.ts"
blocks: {
  sequences: { options: { maxAttempts: 5 } },
},
```

| Option         | Default | What it does                                               |
| -------------- | ------- | ---------------------------------------------------------- |
| `maxAttempts`  | `3`     | send attempts of an email step before the run fails        |
| `retryMinutes` | `15`    | minutes before a failed send runs again, times the attempt |

## Events [#events]

The block writes `sequence.saved` and `sequence.deleted`, and per run
`sequence_enrollment.created`, `.step_completed`, `.paused`, `.resumed`,
`.exited` and `.completed`, to the [outbox](/docs/blocks/outbox). A run event
names the record (`subjectType`, `subjectId`), so record timelines and the
[watcher sink](/docs/blocks/notifications#watchers) handle it like any other
subject event. See [Events](/docs/extending/events).

| Error code                      | When                                                  |
| ------------------------------- | ----------------------------------------------------- |
| `SEQUENCE_FORBIDDEN`            | the caller lacks the permission the call needs        |
| `SEQUENCE_INVALID`              | a step, status or key doesn't validate                |
| `SEQUENCE_NOT_FOUND`            | there is no such sequence in the organization         |
| `SEQUENCE_ENROLLMENT_NOT_FOUND` | there is no such run                                  |
| `SEQUENCE_NOT_ACTIVE`           | `enroll` named a sequence that isn't `active`         |
| `SEQUENCE_NO_MAILER`            | `runDue` found due emails but the set has no `mailer` |
| `SEQUENCE_NO_MAILBOX`           | a run has no mailbox to send from                     |

## Without the client [#without-the-client]

`db.sequences` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createSequences,
  rpcTransport,
} from "better-supabase/blocks/sequences";

const sequences = createSequences({
  transport: rpcTransport(supabase),
  tenant: organizationId,
});

const welcome = await sequences
  .upsert({
    key: "welcome",
    name: "Welcome series",
    status: "active",
    steps: [
      {
        id: "intro",
        kind: "email",
        subject: "Welcome, {{firstName}}",
        text: "Reply to this email if you have questions.",
      },
      { id: "wait", kind: "wait", minutes: 2880 },
      {
        id: "call",
        kind: "task",
        title: "Call {{firstName}}",
        dueInMinutes: 1440,
      },
    ],
  })
  .orThrow();
```

# Database

> Tables, relations, search and the other primitives blocks share.

Source: https://bettersupabase.com/docs/blocks/catalog-database

- [Number sequences](/docs/blocks/numbering)
- [Rank](/docs/blocks/rank)
- [Updated at](/docs/blocks/updated-at)
- [Field history](/docs/blocks/field-history)
- [Field encryption](/docs/blocks/field-encryption)

# Field encryption

> Per-tenant data keys in Supabase Vault that encrypt app table columns and custom fields at rest, with decryption only through definer functions that check scope and can audit each read.

Source: https://bettersupabase.com/docs/blocks/field-encryption

`field-encryption` stores selected values as ciphertext. Each owner (a
tenant, a user, or the app for tables without a scope) gets one random data
key in [Supabase Vault](https://supabase.com/docs/guides/database/vault), and
pgcrypto encrypts the value with AES-256 before the row is written. The key
never leaves the database, and nothing in your config or repository holds key
material.

```bash
better-supabase add field-encryption
```

Install it, then mark what to encrypt:

- an [app table column](/docs/repository/tables#encrypted-columns) with
  `encrypted: true`, read back through `public.<table>_reveal`;
- a [custom field](/docs/blocks/custom-fields#encrypted-fields) with
  `encryptDefinition(id)`, read back through `readValues`.

Set `auditReads` on either to write a `read` entry to the
[audit log](/docs/blocks/audit#read-audits) each time a value is decrypted.

## What is stored [#what-is-stored]

A value is stored as `bsf1:` followed by the base64 of the pgcrypto message.
The data key is a Vault secret named `bs:field-key:<owner>`, where the owner
is `tenant:<id>`, `user:<id>` or `app`. A key is created on the first write
of its owner and is never rotated in place.

| Function                                | Who                    | What it does                                            |
| --------------------------------------- | ---------------------- | ------------------------------------------------------- |
| `field_encrypt(owner, value)`           | service role           | Encrypts a value with the owner's key, creating the key |
| `field_decrypt(owner, value)`           | service role           | Decrypts a value; `null` when the key was dropped       |
| `drop_field_key(owner)`                 | service role           | Deletes the owner's key, which shreds every value       |
| `field_key`, `field_seal`, `field_open` | nobody through the API | The helpers the definer functions call                  |

Signed-in users can't call any of them. They read plaintext only through the
definer functions of the table or block that owns the value, which check the
caller's scope first.

## Shredding [#shredding]

`drop_field_key('tenant:<id>')` deletes the tenant's key from Vault. Every
value sealed under it then reads as `null`, in the database and in every
backup taken after the key's Vault row is gone, so dropping the key is how
you erase a tenant's encrypted data without rewriting rows. It records a
`field_key.dropped` event and a `security` audit entry with the owner, never
the key.

## Limits [#limits]

- There is no searchable equality. The database only sees ciphertext, so an
  encrypted column can't be part of a unique key, an index, a reference, a
  search column, a subject title or URL, a rollup or a lookup.
  Search and compare the values after `reveal` in server code, or keep a
  separate non-sensitive column for lookups.
- `text[]` columns, identities and references can't be encrypted. A custom
  field can be encrypted when its type is `text`, `email` or `url`.
- A [data export](/docs/blocks/data-lifecycle) copies the stored ciphertext.
  Reveal the values in server code before you hand an export to a person.
- The `inline` custom fields layout keeps values on an adopted table and is
  not encrypted.
- Row policies you add to an app table yourself don't apply to `reveal`: it
  checks the table's own scope, the same one its generated policies use.
- The service role and `postgres` can always decrypt. Encryption at rest
  protects backups, replicas, logs and a leaked `select`, not a compromised
  service key.

ADR 0087 records why keys live per owner in Vault and why decryption runs in
definer functions instead of in TypeScript.

# Field history

> Record who changed which column of a record, from what to what, with a Postgres trigger on the columns you name.

Source: https://bettersupabase.com/docs/blocks/field-history

The `field-history` block keeps an audit trail per field. You name the
columns to track for each subject type, and a trigger on that table records
every change to them: the column, the old and new value, who made it and
when. Use it for the "amount changed from 40,000 to 48,000 by Ada" line on a
deal, or to see how often a close date slipped.

```bash
better-supabase add field-history   # adds tenant, access and subjects as well
```

```ts title="better-supabase.config.ts"
blocks: {
  deals: {},
  "field-history": {
    on: {
      deal: { fields: ["amount", "stage_id", "owner_user_id", "expected_close_on"] },
      company: { fields: ["owner_user_id", "lifecycle"] },
    },
  },
},
```

The block has no options. `on` names the columns to track per record type,
and it tracks nothing without one. Each key must be a [subject](/docs/repository/record-types) type with a tenant
column, and each column a database name (`stage_id`, not `stageId`). A
change only writes a row when the value differs, so an update that touches
other columns records nothing.

| Permission     | Lets a member                | Default roles     |
| -------------- | ---------------------------- | ----------------- |
| `history.read` | read the changes of a record | `member`, `admin` |

A member reads a record's history only when they may also read the record.

## Read a record's history [#read-a-records-history]

Server code calls the `field-history` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useFieldHistory } = defineDataHooks(
  "fieldHistory",
  (db: AppDb) => db.fieldHistory,
);
```

```ts
import {
  createFieldHistory,
  rpcTransport,
} from "better-supabase/blocks/field-history";

const changes = await db.fieldHistory
  .of({ type: "deal", id: deal.id }, { field: "amount", limit: 20 })
  .orThrow();

for (const change of changes) {
  change.field; // "amount"
  change.before; // 4000000: the values as JSON
  change.after;
  change.changedBy; // undefined for the service role or a job
}
```

```tsx title="src/features/field-history/list.tsx"
"use client";

import { useFieldHistory } from "@/lib/supabase/hooks";

const rows = useFieldHistory({ limit: 50 });
```

`of` returns changes newest first, 50 by default and at most 500. Pass the
`id` of the last change as `before` to read the next page. Values keep their
JSON form: a number for a numeric column, a string for text, a date or an id,
and `null` for SQL null.

## Without the client [#without-the-client]

`db.fieldHistory` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createFieldHistory,
  rpcTransport,
} from "better-supabase/blocks/field-history";

const history = createFieldHistory({ transport: rpcTransport(supabase) });

const changes = await history
  .of({ type: "deal", id: deal.id }, { field: "amount", limit: 20 })
  .orThrow();

for (const change of changes) {
  change.field; // "amount"
  change.before; // 4000000: the values as JSON
  change.after;
  change.changedBy; // undefined for the service role or a job
}
```

# Number sequences

> Gapless document numbers per tenant, entity and period, assigned inside the document's own transaction.

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

`numbering` allocates `INV-2026-00042` style numbers without gaps. `next_number` locks the sequence row in the caller's transaction, and a rollback releases the number. The function is service-only. A document takes its number from a trigger, so a separate client round trip cannot commit a number the document never keeps.

```bash
better-supabase add numbering
```

```ts
import { createNumbering } from "better-supabase/blocks/numbering";
```

| Table              | Holds                                           |
| ------------------ | ----------------------------------------------- |
| `number_sequences` | The last number for a tenant, entity and period |

Set `options.entities` to install a trigger. `period` is `none`, `year`, `month`, `format` or `fiscalYearStartMonth`. `format` uses `{yyyy}`, `{mm}` and `{n:5}`. `assignOn` is a restricted expression such as `new.status = 'sent'`, so a draft stays unnumbered until it is issued (EN 16931). It takes letters, digits, `_`, `.`, quoted strings and identifiers, `=`, `<`, `>`, `!`, `-` and parentheses. A comma may only separate the string or number literals of an `in` or `not in` list, as in `new.status in ('sent', 'overdue')`, so the expression cannot call a function with several arguments. Once a number is set it cannot change (`NUMBER_IMMUTABLE`). `gen` makes `column` optional on insert, so the typed client and the insert validators accept a row without it.

## Sequences other blocks contribute [#sequences-other-blocks-contribute]

A block that numbers its own rows contributes a default entry: donations
adds `donation_receipt` with the format `R-{n}`. The sequence works without
any `options.entities` entry, and an entry under the same name replaces the
contributed one, so you change the format without touching the other block.

## Period from the row [#period-from-the-row]

By default the trigger takes the period and the `{yyyy}`, `{mm}` and `{dd}` tokens from the time of assignment. Set `periodColumn` to a `date`, `timestamp` or `timestamptz` column of the table, such as an invoice's issue date, and the trigger reads them from that column instead, in UTC. An invoice issued on 31 December and sent on 2 January then gets a number of the old year. A row whose column is still null falls back to the time of assignment. `sync_number_sequences` reads the same column (see [existing numbers](#existing-numbers)).

```ts title="better-supabase.config.ts"
invoice: {
  period: "year",
  format: "INV-{yyyy}-{n:4}",
  table: "public.invoices",
  column: "number",
  tenantColumn: "organization_id",
  periodColumn: "issued_on",
  assignOn: "new.status = 'sent'",
},
```

## Formats per tenant [#formats-per-tenant]

Some tenants number invoices continuously and others restart every year. Set `formatFrom` to the column that holds each tenant's format, as `schema.table.column`, with `key` naming the column that matches the tenant id (default `id`). A tenant whose column is null or empty gets the entity's `format`. A format a tenant sets must hold `{n}`, or the assignment fails with `NUMBER_FORMAT_INVALID`.

With `period: "format"` the format decides when the counter restarts: a format with `{yyyy}` or `{yy}` and `{mm}` restarts every month, one with only the year every year, and one without either never. `2026-{n}` style numbers then never repeat, and a tenant who changes `{yyyy}-{n:3}` to `{n}` continues on the continuous counter. A fixed `period` still works with `formatFrom`, and `period: "format"` without `formatFrom` follows the entity's own `format`.

`sequenceColumn` also stores the bare number, for sorting or for an app that keeps the formatted and the bare number apart. It cannot change either once set, and like `column` only the trigger or the service role writes it: a client that sends it on insert, or changes it on update, gets `NUMBER_MANUAL`.

```ts title="better-supabase.config.ts"
invoice: {
  period: "format",
  format: "{yyyy}-{n:4}",
  formatFrom: { column: "public.organizations.invoice_number_format" },
  table: "public.invoices",
  column: "formatted_number",
  sequenceColumn: "number",
  tenantColumn: "organization_id",
  periodColumn: "issued_on",
  assignOn: "new.status = 'sent'",
},
```

## Periods per tenant [#periods-per-tenant]

A format says how a number looks, not always when its counter restarts. `INV-{yyyy}{mm}-{n}` shows the month, and with `period: "format"` it restarts every month, but a tenant may want it to count on through the year. Set `periodFrom` to the column that holds each tenant's period, as `schema.table.column`, with `key` naming the column that matches the tenant id (default `id`). The column holds `none`, `year`, `month` or `format`, as text or an enum:

| Value         | The counter restarts                                    |
| ------------- | ------------------------------------------------------- |
| `none`        | Never                                                   |
| `year`        | Every year, whatever the format shows                   |
| `month`       | Every month                                             |
| `format`      | As the tenant's format decides, like `period: "format"` |
| null or empty | As the entity's `period` decides                        |

Any other value fails the assignment with `NUMBER_PERIOD_INVALID`. The year and month come from `periodColumn` when it is set, otherwise from the time of assignment, in UTC. `number_period(tenant, entity)` returns the value a tenant chose, or null.

A tenant with the format `INV-{yyyy}{mm}-{n}` and the period `year` gets `INV-202601-1`, `INV-202602-2` and then `INV-202701-1` in the next year. Another tenant with the same format and an empty period follows `period: "format"` and restarts every month:

```ts title="better-supabase.config.ts"
invoice: {
  period: "format",
  format: "{yyyy}-{n:4}",
  formatFrom: { column: "public.organizations.invoice_number_format" },
  periodFrom: { column: "public.organizations.invoice_number_reset" },
  table: "public.invoices",
  column: "formatted_number",
  sequenceColumn: "number",
  tenantColumn: "organization_id",
  periodColumn: "issued_on",
  assignOn: "new.status = 'sent'",
},
```

```sql
update public.organizations
set invoice_number_format = 'INV-{yyyy}{mm}-{n}', invoice_number_reset = 'year'
where id = '6f1c0b1e-3c55-4a8e-9a43-1f2d3c4b5a69';
```

The counters are kept per period, so a tenant who changes its period starts a counter for the new one. `upgrade` keeps that counter in step: for an entity with `table`, `column` and `periodColumn` that sets `periodFrom`, or `formatFrom` with `period: "format"`, it adds an `after update` trigger to each tenant table those columns name, `bsb_sync_number_period_<entity>`. When an update changes the period a tenant numbers in, the trigger runs `sync_number_sequences(tenant, entity)` in the same transaction, so the new counter starts past the numbers the tenant already has in that period. Other updates, and a format change that keeps the period, leave the counters alone. The trigger compares the old and new rows, not a column list, so `periodFrom` and `formatFrom` can name generated columns.

Sync reads the period each tenant has chosen now, so it needs `periodColumn` (see [existing numbers](#existing-numbers)). Without `periodColumn`, `upgrade` adds no trigger, `sync_number_sequences` fails with `NUMBER_SYNC_UNSUPPORTED`, and a tenant who changes its period can repeat a number. Set `periodColumn` on such an entity.

`number_format(tenant, entity)` returns the format a tenant uses. Every number is still assigned in the document's transaction and never changes, and a format change only affects the numbers assigned after it.

## Previews and permissions [#previews-and-permissions]

`next_number` stays service-only. Called over the API it would commit a number in its own transaction, and a document that then fails to save leaves a gap. A signed-in user gets a number by saving the document: the trigger assigns it in the same transaction, and the table's own policies decide who may save it.

To show the number a document would get, such as on a send dialog, call `preview_number(tenant, entity, at)`. It returns the formatted next number in the tenant's format and period without assigning it, so a concurrent document can still take it first. It checks the block's `assign` permission in the tenant (`number_sequences.assign` by default; the owner role holds it through `*`), and refuses other callers with `NUMBER_FORBIDDEN`. Map `assign` to the key of the people who issue the documents:

```ts title="better-supabase.config.ts"
"numbering": {
  permissions: { assign: "invoices.write" },
  options: { entities: { invoice: { /* ... */ } } },
},
```

Server code calls the `numbering` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useNumbering } = defineDataHooks(
  "numbering",
  (db: AppDb) => db.numbering,
);
```

```ts
const next = await db.numbering.preview(organizationId, "invoice", issuedOn);
```

```tsx title="src/features/numbering/list.tsx"
"use client";

import { useNumbering } from "@/lib/supabase/hooks";

const rows = useNumbering({ limit: 50 });
```

When different people issue different documents, give an entity its own key with `permission`. `preview_number` then checks that key for the entity and `permissions.assign` for every entity without one, so a config without `permission` keeps working as before:

```ts title="better-supabase.config.ts"
"numbering": {
  permissions: { assign: "documents.write" },
  options: {
    entities: {
      invoice: { /* ... */ permission: "invoices.write" },
      quote: { /* ... */ },
    },
  },
},
```

| Action   | Default key               | Set by                                             | Checked by                                         |
| -------- | ------------------------- | -------------------------------------------------- | -------------------------------------------------- |
| `assign` | `number_sequences.assign` | `permissions.assign`, `entities.<name>.permission` | `preview_number`; the service role always holds it |

`upgrade` and doctor check an entity's key against the authorization provider like the block's own keys.

## Existing numbers [#existing-numbers]

`next_number` counts from `last_number`, so rows that got their number another way leave the counter behind: seed data, an import, a restore, or a table adopted with its documents already numbered. The next number then repeats one that exists. `sync_number_sequences` raises each counter to the highest number its table holds, per tenant and period, and never lowers one:

```sql
-- every entity with a table and column, for every tenant
select better_supabase.sync_number_sequences();

-- one tenant, one entity
select better_supabase.sync_number_sequences('6f1c0b1e-3c55-4a8e-9a43-1f2d3c4b5a69', 'invoice');
```

It reads the entities that set `table` and `column`, matches each value against `format` (`INV-{yyyy}-{n:5}` reads `INV-2026-00042` as number 42 in period `2026`) and skips values that do not match. A `year` period comes from `{yyyy}` or `{yy}`, and a `month` or fiscal period also needs `{mm}`. When the format holds no period, set `periodColumn` to a `date`, `timestamp` or `timestamptz` column of the table, such as `issued_at`, and the period comes from its date in UTC, as it does when the trigger assigns a number:

```ts title="better-supabase.config.ts"
"numbering": {
  options: {
    entities: {
      invoice: {
        period: "year",
        format: "{n}",
        table: "public.invoices",
        column: "number",
        tenantColumn: "organization_id",
        periodColumn: "issued_at",
      },
    },
  },
},
```

Without either, the function fails with `NUMBER_SYNC_UNSUPPORTED` for that entity.

With `formatFrom`, each row is read with its tenant's format, and the period comes from `periodColumn`, which such an entity needs unless its `period` is `none`. With `periodFrom`, the period comes from `periodColumn` in the period each tenant has chosen, and the entity always needs `periodColumn`. With `sequenceColumn`, the number comes from that column instead of the formatted value. It returns the number of counters it wrote. It is service-only, like `next_number`, and runs in the caller's transaction.

Run it after anything that writes numbered rows directly. After seeding, end `supabase/seed.sql` with `select better_supabase.sync_number_sequences();`, so `supabase db reset` leaves the counters past the seeded documents. After an import, call it in the same transaction as the import. After adopting tables whose documents already have numbers, run it once in a data migration.

## Hooks [#hooks]

`createNumbering` takes `hooks` around `next` and `preview`, to refuse or observe an
allocation (see [extending blocks](/docs/extending/blocks)). The sequences
table takes no app columns: its rows are counters the module owns.

## Over your own tables [#over-your-own-tables]

Map `tables.sequences` onto an existing table, for example `public.organization_number_sequences`, with columns `tenant`, `entity`, `period` and `lastNumber`. Your `entity` column may be an enum, such as `entity public.workflow_subject_type`: `next_number` casts the entity name to the column's type.

## Without the client [#without-the-client]

`db.numbering` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const numbers = createNumbering({ transport });
const next = await numbers.preview(organizationId, "invoice", issuedOn);
```

# Rank

> Fractional rank keys for ordered lists and boards, a move() that can change a row's group, and a rebalance job.

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

`rank` orders rows with fractional keys: short base-62 strings that sort in
byte order. Moving a card between two others writes one new key to one row,
so a drag on a board never renumbers the rest of the list. The same algorithm
runs in SQL (`rank_between`) and in TypeScript (`rankBetween`), and both are
tested against one set of vectors.

```bash
better-supabase add rank
```

```ts
import { createRank, rankBetween } from "better-supabase/blocks/rank";
```

## Declare a ranked subject [#declare-a-ranked-subject]

Give the subject a `rank` entry in `config.records`. `group` is optional: it
names the column a board groups by, such as a pipeline stage.

```ts title="better-supabase.config.ts"
subjects: {
  deal: {
    table: "public.deals",
    title: "name",
    permissions: { read: "deals.read", update: "deals.update" },
    rank: { column: "rank", group: "stage" },
  },
},
```

The rank column is `text collate "C"`, so Postgres compares it byte by byte,
the same way `rankBetween` does:

```sql
alter table public.deals add column rank text collate "C" not null default 'V';
create index on public.deals (organization_id, stage, rank);
```

## Keys [#keys]

| Function                      | Returns                                                                           |
| ----------------------------- | --------------------------------------------------------------------------------- |
| `rankBetween(previous, next)` | A key between two keys; `null` means the start or the end of the list             |
| `rankKeys(n)`                 | `n` evenly spaced keys, the shortest that fit                                     |
| `isRankKey(value)`            | Whether a value is a key: base-62 digits that don't end in `0`                    |
| `rankBetweenResult(...)`      | `rankBetween` as a `Result`, with the hint `RANK_KEY_INVALID` instead of throwing |

`rankBetween(null, null)` is `V`, the first key of an empty list. A key is
never empty and never ends in `0`, so there is always room for another key
between two of them.

## Move a row [#move-a-row]

`move` runs as the caller. It checks `can_subject` for the subject's `update`
permission, reads the neighbours' keys and writes the new key. With `group`, it
also sets the group column in the same update, so a state-machine trigger on
that column checks the stage change and rejects one the machine does not
allow.

Server code calls the `rank` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useRank } = defineDataHooks(
  "rank",
  (db: AppDb) => db.rank,
);
```

```ts
const key = await db.rank
  .move("deal", dealId, { previous: aboveId, next: belowId, group: "won" })
  .orThrow();
```

```tsx title="src/features/rank/list.tsx"
"use client";

import { useRank } from "@/lib/supabase/hooks";

const rows = useRank({ limit: 50 });
```

| Hint                     | When                                                   |
| ------------------------ | ------------------------------------------------------ |
| `RANK_FORBIDDEN`         | The caller can't update the row                        |
| `RANK_NEIGHBOR_MISMATCH` | A neighbour is in another tenant or group              |
| `RANK_NO_GROUP`          | `group` was given for a subject without a group column |
| `RANK_CONFLICT`          | The neighbours share a key; rebalance the group first  |
| `RANK_KEY_INVALID`       | A stored key is not a rank key                         |

## Rebalance [#rebalance]

Keys grow by about one character each time a row lands between the same two
neighbours. When a new key is longer than 24 characters and the `queue` module
is installed, `move` enqueues `rank.rebalance` with the subject type, tenant
and group. The handler calls `rebalance_ranks`, which rewrites the group's
keys to short, evenly spaced ones in the same order. It is service only.

```ts title="app/api/jobs/drain/route.ts"
export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: { "rank.rebalance": rank.job },
});
```

`rank.rebalance("deal", organizationId, "lead")` runs the same rewrite
directly and resolves to the number of rows it changed.

## Hooks [#hooks]

`createRank` takes `hooks` around `move` and `rebalance`; the `rank.rebalance`
job calls `rebalance` through them (see [extending blocks](/docs/extending/blocks)).

## Tags and custom fields [#tags-and-custom-fields]

Tag definitions and custom field definitions keep their order in a `rank`
column of their own and don't need this module. A new definition goes after
the last one. To reorder, pass the keys of the definitions it now sits
between:

```ts
await tags.reorder(tag.id, { previous: above?.rank, next: below?.rank });
```

`reorder` and `reorderDefinition` take the neighbours' keys, compute the key
with `rankBetween` and save it through `rank_tag` or `rank_field_definition`.

## Without the client [#without-the-client]

`db.rank` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const rank = createRank({ transport, service });

const key = await rank
  .move("deal", dealId, { previous: aboveId, next: belowId, group: "won" })
  .orThrow();
```

# Updated at

> createdAt and updatedAt set by a trigger in Postgres and by the repository, without remembering them.

Source: https://bettersupabase.com/docs/blocks/updated-at

The `updated-at` block installs the `track_updated_at` trigger, which sets
`updated_at` on every update, and a repository extension that fills
`createdAt` and `updatedAt` before the row is sent.

```bash
better-supabase add updated-at
```

`gen` marks every table that has the columns and installs the extension in
the generated schema, so `defineSupabase(schema)` stamps writes with no
`.use()` call. Rename the columns in the block's options:

```ts title="better-supabase.config.ts"
blocks: {
  "updated-at": { options: { createdAt: "inserted_at", updatedAt: "modified_at" } },
},
```

## What the extension writes [#what-the-extension-writes]

- Inserts get `createdAt` and `updatedAt`.
- Updates (including soft deletes from [trash](/docs/blocks/trash)) 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). In a runtime without Temporal and
without a `now` option, the extension leaves both columns to the column
default and the trigger.

A schema you write by hand installs the extension with `.use()`. On a
generated schema the same call replaces the installed one:

```ts
import { updatedAtExtension } from "better-supabase/blocks/updated-at";

const db = defineSupabase(schema, { now: () => clock.now() }).use(
  updatedAtExtension(),
);
```

> **Writes outside the app**
>
>
> SQL functions, dashboards and other services bypass the repository. The
> block's trigger covers them, so `updated_at` stays right either way.

# 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 `blocks.api-keys.options.prefix`, so scanners and support can tell your
keys apart. It is `bs` by default.

```bash
better-supabase 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 `blocks.api-keys.permissions`.

## Managing keys [#managing-keys]

Server code calls the `api-keys` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useApiKeys } = defineDataHooks(
  "apiKeys",
  (db: AppDb) => db.apiKeys,
);
```

```ts
const rows = await db.apiKeys.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/api-keys/list.tsx"
"use client";

import { useApiKeys } from "@/lib/supabase/hooks";

const rows = useApiKeys({ limit: 50 });
```

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

### Key kinds [#key-kinds]

A key's `kind` says what it is for. List the kinds in
`blocks.api-keys.options.kinds`, and a check constraint on the `kind` column
refuses any other. Without the option, `kind` takes any value. A key created
without a kind has none, and `rotate` copies it.

```ts title="better-supabase.config.ts"
blocks: {
  "api-keys": { options: { kinds: ["client", "admin", "export", "feed"] } },
},
```

```ts
const { token } = await keys
  .create({
    name: "Warehouse sync",
    organizationId,
    kind: "export",
    scopes: ["deals:read"],
  })
  .orThrow();
```

`list` and `verify` return the kind, so a settings page can group keys by
kind and a route can turn away a key of the wrong kind.

### In the browser [#in-the-browser]

`useApiKeys` lists an organization's keys newest first through the provider's client, with the
same rules as `list`. `create({ name, personal })` keeps the new key and
its token in `created` until `dismiss()`, the only time the token is shown,
and `revoke(id)` revokes a key; both load the list again and return a
`Result`. Without an organization the hook lists and creates the caller's
personal keys. The same hook runs in React DOM and React Native. It is a
registry item: install it with `npx shadcn add @better-supabase/use-api-keys`,
which writes `hooks/use-api-keys.ts` into the app.

```tsx title="components/api-keys-table.tsx"
import { useApiKeys } from "@/hooks/use-api-keys";

const { keys, created, create, revoke, dismiss } = useApiKeys({
  organizationId,
  schema: "api",
});
```

`onChanged` runs after a key was created or revoked, such as a server
action that completes an onboarding step. `createApiKeysController` from
`better-supabase/blocks/api-keys` is the same logic without React. Vue,
Svelte and Solid run it with `useBlockController` from `better-supabase/vue`,
`/svelte` and `/solid` (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)). The [API keys table recipe](/docs/registry/ui-recipes/api-keys-table)
shows it in the Next.js and Expo examples.

```vue title="ApiKeys.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createApiKeys,
  createApiKeysController,
  rpcTransport,
} from "better-supabase/blocks/api-keys";

const props = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createApiKeysController, {
  client: createApiKeys({ transport: rpcTransport(supabase), schema: "api" }),
  organizationId: props.organizationId,
});
</script>

<template>
  <p v-for="key in state.keys" :key="key.id">
    {{ key.name }}
    <button @click="actions.revoke(key.id)">Revoke</button>
  </p>
</template>
```

```svelte title="ApiKeys.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createApiKeys,
    createApiKeysController,
    rpcTransport,
  } from "better-supabase/blocks/api-keys";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const keys = useBlockController(createApiKeysController, {
    client: createApiKeys({ transport: rpcTransport(supabase), schema: "api" }),
    organizationId,
  });
</script>

<p>{$keys.keys?.length ?? 0} keys</p>
```

```tsx title="ApiKeys.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createApiKeys,
  createApiKeysController,
  rpcTransport,
} from "better-supabase/blocks/api-keys";

export function ApiKeys(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createApiKeysController, {
    client: createApiKeys({ transport: rpcTransport(supabase), schema: "api" }),
    organizationId: props.organizationId,
  });
  return <p>{state().keys?.length ?? 0} keys</p>;
}
```

## 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. A Bearer token that starts with
the key prefix and `_` counts as a key even when the rest is malformed, so
`bs_bad_key` answers `INVALID_API_KEY` like a revoked or unknown key instead
of failing JWT verification. 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 bsb = createHono(betterSupabase, {
  postgres,
  auth: { resolvers: [apiKeyResolver({ keys })] },
});

app.use(
  "/api/deals/*",
  bsb.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
(`blocks.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.

When your own code routes a Bearer token to either API key or JWT
verification, ask `keys.isApiKey(token)` instead of matching the prefix by
hand. It answers `true` for any token with the configured prefix and `_`,
well formed or not, and `keys.verify` answers `invalid` for one that doesn't
parse:

```ts
const token = authorization.replace(/^Bearer\s+/i, "");

if (keys.isApiKey(token)) {
  const checked = await keys.verify(token).orThrow();
  // checked.status is "ok", "invalid" or "rate_limited"
} else {
  // verify the JWT
}
```

### 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.list().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 `blocks.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
`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 |

`claims` adds claims of your own for each key, such as one read from its
kind or from an [extra column](#your-own-fields). It cannot set `sub`, `role`,
`aud` or `api_key`, or the claims the other options write; TypeScript refuses
those names, and the resolver drops them. With `fields`, the hook and
`serviceRoles` receive the key with its parsed columns.

```ts
apiKeyResolver({
  keys,
  serviceRoles: (key) => (key.kind === "admin" ? ["admin"] : ["integration"]),
  claims: (key) => ({ key_kind: key.kind ?? "client" }),
});
```

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

## Your own fields [#your-own-fields]

`blocks.api-keys.options.extraColumns` adds columns to the `api_keys` table,
keyed by column name with their SQL type. Pass `createApiKeys` a Standard Schema
for them as `fields`: `create` (`rotate` copies them) take them next to the block's own keys and
validate them (a bad value is a `validation` error), and `list`, `verify` return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createApiKeys({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.apiKeys` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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);
```

# Developer

> API keys, webhooks, flags and the blocks a platform exposes.

Source: https://bettersupabase.com/docs/blocks/catalog-developer

- [Outgoing webhooks](/docs/blocks/webhooks-out)
- [Incoming webhooks](/docs/blocks/webhooks-in)
- [API keys](/docs/blocks/api-keys)
- [Service principals](/docs/blocks/service-accounts)
- [Signing keys](/docs/blocks/signing-keys)
- [Shared signals](/docs/blocks/security-events)
- [OAuth server](/docs/blocks/oauth-server)
- [Vercel Marketplace](/docs/blocks/vercel-marketplace)
- [Incidents](/docs/blocks/incidents)

# Incidents

> Incidents with a severity, a status timeline, affected components and postmortems, and a public status page that anyone can read through one function that returns only public fields.

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

`incidents` is for running an outage and telling customers about it. You open
an incident with a severity and the components it affects, post timeline
updates as it moves from investigating to resolved, and write a postmortem
afterwards. Each organization can publish one status page: the public
components with their status and daily uptime, and the public incident
updates. Visitors read it without signing in.

```bash
better-supabase add incidents
```

```ts
import { createIncidents } from "better-supabase/blocks/incidents";
```

The module requires `memberships` and `access`. With
[notifications](/docs/blocks/notifications) the commander is notified when an
incident opens, with `outbox` it records the events, and with `audit` every
change is audited. The choices behind the anonymous surface are in
[ADR 0084](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0084-anonymous-status-page.md).

## Tables [#tables]

| Table                      | Holds                                                                                                    |
| -------------------------- | -------------------------------------------------------------------------------------------------------- |
| `status_components`        | The services a status page reports on: a name, a group, whether it is `public`, and its current `status` |
| `status_component_history` | One row per status a component held, with its start and end, for the uptime history                      |
| `incidents`                | The title, description, severity, status, whether it is public, the commander, and the start and end     |
| `incident_updates`         | The timeline: each update's status, text and whether it is public                                        |
| `incident_impacts`         | The components an incident affects and how                                                               |
| `incident_postmortems`     | One postmortem per incident: summary, impact, root cause, resolution and action items                    |
| `status_pages`             | One page per organization, with a slug unique across organizations                                       |

Members with `incidents.read` read every table. `anon` has no privileges on
any of them.

## Components [#components]

Server code calls the `incidents` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useIncidents } = defineDataHooks(
  "incidents",
  (db: AppDb) => db.incidents,
);
```

```ts
const api = await db.incidents
  .saveComponent(organizationId, { name: "API", group: "Platform" })
  .orThrow();
await db.incidents
  .saveComponent(organizationId, { name: "Primary database", public: false })
  .orThrow();
```

```tsx title="src/features/incidents/list.tsx"
"use client";

import { useIncidents } from "@/lib/supabase/hooks";

const rows = useIncidents({ limit: 50 });
```

A component is public unless `public` is `false`. Its status is never set by
hand: it is the worst impact of the open incidents on it (`maintenance`,
`degraded`, `partial_outage`, `major_outage`), and `operational` when none is
open. Every change is kept in the history the uptime is read from. Components
and the status page need `incidents.manage`.

## Run an incident [#run-an-incident]

```ts
const incident = await incidents
  .open(organizationId, {
    title: "Elevated API errors",
    severity: "sev2",
    public: true,
    commanderId,
    message: "We are looking into elevated error rates.",
    impacts: [{ componentId: api.id, impact: "partial_outage" }],
  })
  .orThrow();

await incidents
  .postUpdate(incident.id, {
    status: "identified",
    body: "A replica fell behind; failing over.",
    public: false,
  })
  .orThrow();
await incidents
  .postUpdate(incident.id, { status: "resolved", body: "Resolved." })
  .orThrow();
```

An incident starts `investigating` with severity `sev3` unless you pick one,
and is private unless `public` is `true`. `message` becomes the first
timeline update. Each `postUpdate` adds to the timeline and moves the
incident to the update's status (`investigating`, `identified`, `monitoring`
or `resolved`); resolving sets `resolvedAt` and returns its components to
`operational`. An update is public when the incident is, unless it says
otherwise. `setImpacts` replaces the affected components. Opening, updating,
posting and writing the postmortem need `incidents.write`.

## Postmortems [#postmortems]

```ts
await incidents
  .savePostmortem(incident.id, {
    summary: "Replica lag caused failed writes.",
    rootCause: "A replica on an undersized disk",
    actionItems: [{ title: "Alert on replica lag", ownerId }],
  })
  .orThrow();
await incidents.publishPostmortem(incident.id).orThrow();
```

A postmortem can be drafted at any time and is published once the incident
is resolved, with `incidents.manage`. The status page shows only its summary.

## Status page [#status-page]

```ts
await incidents
  .saveStatusPage(organizationId, {
    slug: "acme",
    title: "Acme status",
    published: true,
  })
  .orThrow();

const page = await incidents.publicStatusPage("acme", 30).orThrow();
```

`publicStatusPage(slug, days)` works with the publishable key, signed in or
not. It returns `undefined` unless the page is published. Otherwise it
returns the page title and description, the overall status, each public
component with its status, `uptimePercent` and one entry per day (1 to 90
days, default 90) with its worst status and minutes of outage, and the public
incidents that are open or resolved in the window. Each incident carries its
title, severity, status, start and end, the public components it affects, its
public updates (newest first) and the summary of a published postmortem.
Descriptions, commanders, private updates, private components, root causes,
action items and ids of organizations and members never appear.

`publicStatusPage` is the only function `anon` may call. The pgTAP test in
`supabase/tests/database/incidents.test.sql` checks that, and that a private
incident never shows on the page.

| Method                                  | Returns                                       |
| --------------------------------------- | --------------------------------------------- |
| `saveComponent(organizationId, input)`  | the component                                 |
| `deleteComponent(componentId)`          | its id                                        |
| `components(organizationId)`            | the components in order                       |
| `open(organizationId, input)`           | the incident                                  |
| `update(incidentId, changes)`           | the incident                                  |
| `postUpdate(incidentId, input)`         | the update                                    |
| `setImpacts(incidentId, impacts)`       | the incident                                  |
| `get(incidentId)`                       | the incident with its timeline and postmortem |
| `list(organizationId, options)`         | the incidents, newest first                   |
| `savePostmortem(incidentId, input)`     | the postmortem                                |
| `publishPostmortem(incidentId)`         | the published postmortem                      |
| `saveStatusPage(organizationId, input)` | the page                                      |
| `statusPage(organizationId)`            | the organization's page, or `undefined`       |
| `publicStatusPage(slug, days)`          | the public page, or `undefined`               |

## Errors [#errors]

| Hint                         | Cause                                                                                      |
| ---------------------------- | ------------------------------------------------------------------------------------------ |
| `INCIDENTS_FORBIDDEN`        | the caller lacks `incidents.read`, `.write` or `.manage`                                   |
| `INCIDENTS_INVALID`          | a field is invalid, such as a slug that isn't 2 to 63 lowercase letters, digits and dashes |
| `INCIDENT_NOT_FOUND`         | no incident with this id in an organization the caller reads                               |
| `STATUS_COMPONENT_NOT_FOUND` | no component with this id                                                                  |
| `POSTMORTEM_NOT_FOUND`       | the incident has no postmortem                                                             |
| `INCIDENT_NOT_RESOLVED`      | the postmortem of an unresolved incident can't be published                                |
| `STATUS_PAGE_SLUG_TAKEN`     | another organization uses this slug                                                        |

## Events [#events]

`incident.opened`, `incident.updated`, `incident.resolved`,
`postmortem.published` and `status_component.changed`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.incidents` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const incidents = createIncidents({ transport });

const api = await incidents
  .saveComponent(organizationId, { name: "API", group: "Platform" })
  .orThrow();
await incidents
  .saveComponent(organizationId, { name: "Primary database", public: false })
  .orThrow();
```

# OAuth server

> Storage for your own OAuth 2.1 authorization server, with hashed client secrets, PKCE-bound authorization codes, device codes, grants and a revocation list.

Source: https://bettersupabase.com/docs/blocks/oauth-server

`oauth-server` stores what an OAuth 2.1 authorization server needs:
registered clients (RFC 7591) with hashed secrets, one grant per user and
client, single-use authorization codes bound to a PKCE S256 challenge
(RFC 7636), device codes (RFC 8628) and a revocation list of access token
`jti`s. It issues no tokens. Sign them with
[signing keys](/docs/blocks/signing-keys) and accept them with
[`accessTokenResolver`](/docs/auth/access-tokens).

Use it when your app is the authorization server for its own clients, for
example a platform that lets third-party apps act for its users. When the
Supabase Auth OAuth server fits, use that instead; the
[OAuth consent page](/docs/auth/oauth-consent) covers it.

```bash
better-supabase add oauth-server
```

Server code calls the `oauth-server` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useOauthServer } = defineDataHooks(
  "oauthServer",
  (db: AppDb) => db.oauthServer,
);
```

```ts
const rows = await db.oauthServer.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/oauth-server/list.tsx"
"use client";

import { useOauthServer } from "@/lib/supabase/hooks";

const rows = useOauthServer({ limit: 50 });
```

Every function is service-role only, except `my_oauth_grants` and
`revoke_my_oauth_grant`, which a signed-in user calls for their own grants.
Clients can't read the tables. Secrets, codes and registration tokens are
generated in TypeScript and only their SHA-256 reaches the database.

## Clients [#clients]

`registerClient(metadata)` registers a client and returns its
`clientSecret` and `registrationAccessToken` once. A client with
`tokenEndpointAuthMethod: "none"` is public and gets no secret. Redirect URIs
must be absolute, carry no fragment, and use `https` or `http` on a loopback
host (RFC 9700 section 2.1). Metadata without a column of its own, such as
`logo_uri`, goes in `metadata`.

```ts
const { client, clientSecret, registrationAccessToken } = await oauth
  .registerClient({
    name: "Acme CLI",
    redirectUris: ["http://127.0.0.1:8976/callback"],
    grantTypes: ["authorization_code", "refresh_token"],
    tokenEndpointAuthMethod: "none",
    scope: "contacts:read contacts:write",
  })
  .orThrow();
```

`verifyClient(clientId, secret)` returns the client when the secret matches
(`null` as the secret for a public client), and
`verifyRegistrationToken(clientId, token)` checks the RFC 7592 token before
`readClient` or `updateClient`. `rotateSecret` returns a new secret, and
`deleteClient` removes the client with its grants and codes.

### Client ids [#client-ids]

`registerClient` generates a client id. To choose your own, such as `acme-cli`,
pass `{ clientId }` as the second argument. The database checks every id
against `clientIdPattern`, a block option. The default accepts
`[A-Za-z0-9._~-]{8,128}`, so existing clients keep working. The preset
`"slug"` accepts lower-case slugs (`^[a-z0-9][a-z0-9-]{2,63}$`), and any other
value is a regular expression:

```ts title="better-supabase.config.ts"
blocks: {
  "oauth-server": { options: { clientIdPattern: "slug" } },
},
```

## Authorization codes [#authorization-codes]

After the user consents, store the code with the request's PKCE challenge.
Only `S256` is accepted:

```ts
const { code } = await oauth
  .createAuthorizationCode({
    clientId,
    userId: user.id,
    redirectUri,
    scope: "contacts:read",
    codeChallenge,
    codeChallengeMethod: "S256",
  })
  .orThrow();
```

At the token endpoint, `redeemAuthorizationCode` hashes the code, computes
S256 of the `codeVerifier` and compares both with the client and redirect
URI in one locked row. It returns who to issue tokens for, or `null` for any
mismatch. A code presented a second time also revokes its grant, so tokens
issued from a stolen code stop working (RFC 9700 section 4.5). Codes live 60
seconds (`codeSeconds`).

### Resource indicators [#resource-indicators]

Pass `resource` (RFC 8707) to `createAuthorizationCode` with the APIs the
token may be used at. The code, the device code and the user's grant store
them. At the token endpoint, pass the request's `resource` to
`redeemAuthorizationCode`: it has to be a subset of the code's resources, and
anything wider returns `null` (the `invalid_target` case). The returned
`authorization.resource` is what you put in the token's `aud`; without a
`resource` in the exchange it is every resource of the code.

## Device codes [#device-codes]

`createDeviceCode(clientId, scope, resource?)` returns a `deviceCode` for the client
and a `userCode` such as `BDFG-HJKL` for the user. The verification page
reads the request with `deviceRequest(userCode)` and calls
`approveDeviceCode` or `denyDeviceCode` with the signed-in user. The client
polls `pollDeviceCode(deviceCode, clientId, resource?)`. A `resource` that is
not a subset of the device code's resources answers `invalid_grant`.

| Status                  | Token response (RFC 8628 section 3.5)                   |
| ----------------------- | ------------------------------------------------------- |
| `authorization_pending` | keep polling every `interval` seconds                   |
| `slow_down`             | polled too soon; `interval` grows by 5 seconds          |
| `access_denied`         | the user denied                                         |
| `expired_token`         | the code expired (`deviceCodeSeconds`, 600)             |
| `invalid_grant`         | unknown code, another client, or already exchanged      |
| `ok`                    | issue tokens for `authorization`; the code is now spent |

`deviceRequest(userCode)` returns `null` for a code that is unknown, expired
or already decided, so the verification page can show "code not found" for all
three. A client that polls before `interval` has passed gets `slow_down` and
the stored interval grows by 5 seconds up to 300, so honor the `interval` of
the last response.

## Grants and revocation [#grants-and-revocation]

A grant is a user's consent for a client. Approving a code or a device code
reuses the user's active grant for the client and widens its scopes. Put the
grant id in the tokens you issue as `grant_id`.

`revokeGrant(grantId)` ends every token and code issued under it. For the
RFC 7009 revocation endpoint, look up the token's `jti` and `grant_id`
claims, then call `revokeToken(jti, expiresAt, { grantId })` for one access
token, or `revokeGrant` when the client revokes a refresh token.
`revokeToken(jti, expiresAt)` puts one access token on the revocation list
until it would expire anyway. `isRevoked(jti, grantId)` checks both, and
treats an unknown grant as revoked, so deleting a client ends its tokens.
Pass the client to the resolver as `revocation`:

```ts
accessTokenResolver({ jwks, issuer, audience, revocation: oauth });
```

For a connected-apps page, `listGrants(userId)` returns a user's grants with
the client name. In the browser, the user calls `my_oauth_grants` and
`revoke_my_oauth_grant` over RPC for their own grants only; a read-only
support session can't revoke ([support sessions](/docs/blocks/sql#impersonation)).

`purge()` deletes expired codes, device codes and revocation entries; run it
from a daily job.

## Hono [#hono]

The [Hono example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/hono-api)
serves an authorization server on these calls. `POST /oauth/authorize` runs
behind `bsb.middleware({ allow: ["user"] })`, stores a code for the signed-in
user and returns the redirect. `POST /oauth/token` verifies the client
(`client_secret_basic`, `client_secret_post` or a public client), redeems the
code and signs an RFC 9068 access token (`typ: at+jwt`) with
[signing keys](/docs/blocks/signing-keys), carrying `grant_id` for
revocation. `GET /.well-known/jwks.json` publishes the keys.

```ts title="src/server.ts"
const app = bsb
  .app()
  .post("/oauth/token", (c) => token(c.req.raw))
  .get("/.well-known/jwks.json", () => jwks())
  .post("/oauth/authorize", bsb.middleware({ allow: ["user"] }), async (c) => {
    if (c.var.auth.kind !== "user") {
      return c.json({ error: "login_required" }, 401);
    }

    const params = new URLSearchParams(await c.req.text());
    const redirect = await authorize({ userId: c.var.auth.user.id, params });

    return c.json({ redirect: redirect.href });
  });
```

The token endpoint answers a refused request in the RFC 6749 format
(`{ "error": "invalid_grant" }`), which OAuth clients expect. Anything else,
such as a database error from an `.orThrow()`, reaches `bsb.onError`, which
`bsb.app()` installs, and answers as [Problem Details](/docs/auth/problems).
The [oRPC example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/orpc-api)
serves its router on Hono the same way, so both can share one app.

## One tenant shared by Hono and Next [#one-tenant-shared-by-hono-and-next]

The authorization server and your dashboard can run as two apps on one
database. Hono serves the token and authorize endpoints; Next.js serves the
consent page and the connected-apps page with `listGrants`. Both read the
same users, organizations and grants, so a grant the user revokes in the
dashboard ends the tokens Hono issued. Resource servers verify those tokens
with `accessTokenResolver` against the JWKS Hono publishes.
[Hono and Next.js](/docs/frameworks/hono-and-next) covers serving both from
one deployment.

## Events [#events]

| Event                                             | Data                            |
| ------------------------------------------------- | ------------------------------- |
| `oauth_client.registered`, `oauth_client.deleted` | `clientId`                      |
| `oauth_grant.created`, `oauth_grant.revoked`      | `grantId`, `userId`, `clientId` |

Each also writes an audit entry when the audit module is installed.

## Without the client [#without-the-client]

`db.oauthServer` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createOAuthServer,
  sqlTransport,
} from "better-supabase/blocks/oauth-server";

const oauth = createOAuthServer({ transport: sqlTransport(postgres.admin) });
```

# Shared signals

> An OpenID Shared Signals transmitter and receiver, with CAEP and RISC events as signed Security Event Tokens, pushed through outgoing webhooks or polled.

Source: https://bettersupabase.com/docs/blocks/security-events

`security-events` sends and receives security events between your app and
the identity providers, SaaS apps and security tools around it, per
[OpenID Shared Signals Framework 1.0](https://openid.net/specs/openid-sharedsignals-framework-1_0.html).
An event (a revoked session, a disabled account, a changed credential) is a
Security Event Token (SET, RFC 8417): a JWT signed with one of your
[signing keys](/docs/blocks/signing-keys). Receivers create streams through
a management API, and get SETs pushed to their endpoint (RFC 8935) or poll
for them (RFC 8936).

```bash
better-supabase add signing-keys webhooks-out security-events
```

```ts
import {
  createSsfReceiver,
  createSsfTransmitter,
  sqlTransport,
} from "better-supabase/blocks/security-events";
```

Every function is service-role only. Push delivery needs
[webhooks-out](/docs/blocks/webhooks-out); without it, streams can only
poll.

## Transmit [#transmit]

The transmitter takes a signer, the URL of its JWK Set, and an `authorize`
function that names the receiver behind a management request. Return
`null` to answer 401:

Server code calls the `security-events` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSecurityEvents } = defineDataHooks(
  "securityEvents",
  (db: AppDb) => db.securityEvents,
);
```

```ts
const rows = await db.securityEvents.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/security-events/list.tsx"
"use client";

import { useSecurityEvents } from "@/lib/supabase/hooks";

const rows = useSecurityEvents({ limit: 50 });
```

`authorize` returns `receiver`, a stable id such as the OAuth client id,
and optionally `memberships` and `audience`. A stream with a tenant gets only
that tenant's events; a stream without one gets every event. The `aud` of
its SETs is `audience`, or `receiver` when there is none.

Mount the routes under the issuer. `handler()` serves the well-known
metadata, `/ssf/stream`, `/ssf/status`, `/ssf/verify` and `/ssf/poll/:id`:

```ts title="app/ssf/[...path]/route.ts"
import { transmitter } from "@/lib/ssf";

const handle = transmitter.handler();
export {
  handle as DELETE,
  handle as GET,
  handle as PATCH,
  handle as POST,
  handle as PUT,
};
```

```ts title="app/.well-known/ssf-configuration/route.ts"
import { transmitter } from "@/lib/ssf";

export const GET = transmitter.handler();
```

```ts title="app/ssf/jwks/route.ts"
import { signingKeys } from "@/lib/signing-keys";

export const GET = () =>
  signingKeys.jwksResponse({ type: "ssf", id: "transmitter" }, { maxAge: 300 });
```

A receiver can only see and change the streams it created; another
receiver's stream answers 404.

### Emit events [#emit-events]

`emit` mints one SET per stream that delivers the event type and queues it.
Call it where the change happens, for example after you revoke a user's
sessions:

```ts
await transmitter
  .emit({
    tenant: organizationId,
    subject: { format: "email", email: user.email },
    event: {
      type: CAEP_EVENTS.sessionRevoked,
      claims: { event_timestamp: Math.floor(Date.now() / 1000) },
    },
  })
  .orThrow();
// { streams: 2, dispatched: 1, queued: 1, held: 0 }
```

`CAEP_EVENTS` and `RISC_EVENTS` hold the event type URIs of CAEP 1.0 and
RISC 1.0. A stream delivers the requested types that `eventsSupported`
lists, every CAEP and RISC type by default.

### Push and poll [#push-and-poll]

A push stream gets a webhooks-out endpoint with format `set`. Its SETs go
through the outbox like any other delivery, with retries, the dead-letter
queue and the delivery log. The `authorization_header` a receiver sends
goes into Vault under `ssf/<stream id>` and becomes the endpoint's
`credential_ref`, so configure the webhooks client with the same
credentials provider:

```ts title="lib/webhooks.ts"
export const webhooks = createWebhooks({
  transport: sqlTransport(postgres.admin),
  credentials: vaultCredentials({ transport: sqlTransport(postgres.admin) }),
});
```

A poll stream keeps its SETs in `ssf_events` until the receiver
acknowledges them. Run `purge()` from a daily job; it deletes unpolled SETs
older than the `retention` option (`7 days`).

### Stream status [#stream-status]

A paused stream holds its SETs, and enabling it sends them in order. A
disabled stream drops them. `setStatus` sends a `stream-updated` event
before it pauses or disables a stream, and after it enables one.
Verification requests send a `verification` event, at most once per
`minVerificationInterval` (30 seconds); a request inside that interval
answers 429 with `Retry-After`.

SETs carry no `exp`. A SET held longer than your
[JWKS grace](/docs/blocks/signing-keys#publish-the-jwks) after a key
rotation fails verification at the receiver, so resume or disable paused
streams before you retire a key.

## Receive [#receive]

The receiver verifies SETs against the transmitter's keys, drops a replayed
`(iss, jti)` through the `ssf_received` table, and calls the handler for
the event type:

```ts title="lib/ssf-receiver.ts"
import {
  CAEP_EVENTS,
  createSsfReceiver,
  sqlTransport,
} from "better-supabase/blocks/security-events";

export const receiver = createSsfReceiver({
  transport: sqlTransport(postgres.admin),
  issuer: "https://idp.example.com",
  audience: "https://app.example.com",
  authorization: `Bearer ${process.env.SSF_PUSH_TOKEN}`,
  handlers: {
    [CAEP_EVENTS.sessionRevoked]: async (set) => {
      await signOutEverywhere(set.subject);
    },
  },
});
```

The receiver reads the transmitter's metadata from
`/.well-known/ssf-configuration`, checks that it names the same `issuer`,
and fetches the keys from its `jwks_uri`. It caches both for
`cacheSeconds` (300), and refetches the keys for an unknown `kid` at most
every 30 seconds. Pass `jwks` to skip discovery.

A handler that throws leaves the SET unprocessed: the push endpoint answers
500 and a poll doesn't acknowledge it, so the transmitter sends it again.
An event without a handler is acknowledged; `"*"` handles the rest.

### Push endpoint [#push-endpoint]

```ts title="app/ssf/events/route.ts"
import { receiver } from "@/lib/ssf-receiver";

export const POST = receiver.pushHandler();
```

It answers 202 to a valid SET or a replay, and 400 with the RFC 8935
`err` and `description` otherwise. With `authorization` set, any other
`Authorization` header answers 401.

### Poll [#poll]

```ts
const result = await receiver
  .poll({ endpoint: streamConfig.delivery.endpoint_url, authorization: token })
  .orThrow();
// { received: 3, rejected: 0, failed: 0, moreAvailable: false }
```

`poll` requests until the transmitter has no more SETs, up to
`maxRequests` (10), and acknowledges each SET it processed in the next
request.

### Manage streams [#manage-streams]

`streams(authorization)` calls the transmitter's management API:

```ts
const api = receiver.streams(`Bearer ${accessToken}`);
const config = await api
  .create({
    method: "push",
    endpointUrl: "https://app.example.com/ssf/events",
    authorizationHeader: `Bearer ${process.env.SSF_PUSH_TOKEN}`,
    eventsRequested: [CAEP_EVENTS.sessionRevoked, CAEP_EVENTS.credentialChange],
  })
  .orThrow();
await api.verify(String(config.stream_id)).orThrow();
```

`create` fails with the hint `SSF_AUDIENCE_MISMATCH` when the new stream's
`aud` doesn't name this receiver.

## Events [#events]

| Type                        | When                                                     |
| --------------------------- | -------------------------------------------------------- |
| `ssf_stream.created`        | A receiver created a stream; `data` has `method`         |
| `ssf_stream.status_changed` | A stream's status changed; `data` has `status`, `reason` |
| `ssf_stream.deleted`        | A stream was deleted                                     |

Each carries `streamId`, `receiver` and `tenant`, and writes a `security`
audit entry when the audit block is installed.

## Without the client [#without-the-client]

`db.securityEvents` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/ssf.ts"
import {
  CAEP_EVENTS,
  createSsfTransmitter,
  sqlTransport,
} from "better-supabase/blocks/security-events";
import { vaultCredentials } from "better-supabase/credentials";

import { signingKeys } from "@/lib/signing-keys";

export const transmitter = createSsfTransmitter({
  transport: sqlTransport(postgres.admin),
  issuer: "https://app.example.com",
  jwksUri: "https://app.example.com/ssf/jwks",
  signer: signingKeys.signer(
    { type: "ssf", id: "transmitter" },
    { create: "ES256" },
  ),
  credentials: vaultCredentials({ transport: sqlTransport(postgres.admin) }),
  authorize: async (request) => {
    const client = await verifyAccessToken(request, { scope: "ssf.manage" });
    return client
      ? { receiver: client.id, tenant: client.organizationId }
      : null;
  },
});
```

# Service principals

> Non-human organization members that can't sign in, hold a role through a membership, and run organization-owned workflows and service webhook endpoints.

Source: https://bettersupabase.com/docs/blocks/service-accounts

`service-accounts` gives an organization members that aren't people: an
automation account, an integration, a nightly job. Each one is an
`auth.users` row that can't sign in and holds its role through a tenant
membership, so RLS, `member_can` and your authorization provider treat it
like any member with that role, and the audit log names it.

```bash
better-supabase add service-accounts
```

```ts
import { createServiceAccounts } from "better-supabase/blocks/service-accounts";
```

## Create one [#create-one]

`create(organizationId, name, role)` creates the user through the Auth admin
API, then registers it. The user is banned for about 100 years, has no
password, gets an address under `service-accounts.invalid` and carries
`app_metadata.bsb_service_principal`, so nobody can sign in as it. If the
registration is refused, the user is deleted again.

Server code calls the `service-accounts` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useServiceAccounts } = defineDataHooks(
  "serviceAccounts",
  (db: AppDb) => db.serviceAccounts,
);
```

```ts
const rows = await db.serviceAccounts.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/service-accounts/list.tsx"
"use client";

import { useServiceAccounts } from "@/lib/supabase/hooks";

const rows = useServiceAccounts({ limit: 50 });
```

Every function except `list` is service-role only. Pass `createdBy` when a
member asks for the change: that member needs `service_principals.manage`
and must be allowed to assign the role (`can_assign_as`), so nobody hands a
principal more than they hold. Without `createdBy` the service acts on its
own, for setup scripts and migrations.

| Method                           | What it does                                                                 |
| -------------------------------- | ---------------------------------------------------------------------------- |
| `create(org, name, role, opts?)` | Creates and registers a principal; `default: true` makes it the default      |
| `setRole(id, role, opts?)`       | Changes its role and its membership                                          |
| `disable(id, opts?)`             | Removes its membership and its default; the user row stays for the audit log |
| `defaultFor(org)`                | The organization's default principal, or `null`                              |
| `list(org)`                      | The organization's principals, through the `service_principals.read` policy  |

`register_service_principal` refuses a user without the
`bsb_service_principal` flag or without a ban, so an existing account never
becomes a principal.

## What runs as the default principal [#what-runs-as-the-default-principal]

Each organization has at most one default principal. Two features resolve
organization-owned work to it, so you don't need an `automation.runAs` hook
for the common case:

- **Workflow builder.** A version published by the service (owner
  `organization`) runs as the default principal. `workflow_run_principal`
  checks on every step that its role still holds every permission the
  version stored, and refuses the step with `WORKFLOW_PUBLISHER_REVOKED`
  otherwise. Without a `runAs` hook, `versions.runAs` returns
  `{ role: "authenticated", sub, tenant_id, bsb_permission_cap }`, the
  principal capped to the stored permissions. An `automation.runAs` hook
  still overrides it. See [Workflow builder](/docs/blocks/workflow-editor).
- **Outgoing webhooks.** A service endpoint (one without `created_by`) gets
  an event only when the default principal holds the event's read
  permission. Without a default principal, the endpoint's declared
  `permissions` decide, as before. See
  [Outgoing webhooks](/docs/blocks/webhooks-out).

Disabling the default principal stops both: workflow steps fail with "This
organization has no default service principal" and service endpoints fall
back to their declared permissions.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `service_principals`. `create` writes them from `fields`,
and `create` and `list` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    "service-accounts": { options: { extraColumns: { purpose: "text" } } },
  },
});
```

```ts
const client = createServiceAccounts({
  transport,
  admin: serviceClient.auth.admin,
  fields: v.object({ purpose: v.optional(v.string()) }),
});
await client.create(organizationId, "Zapier", "member", {
  fields: { purpose: "CRM sync" },
});
```

## Permissions [#permissions]

| Operation                 | Key                         | Default roles |
| ------------------------- | --------------------------- | ------------- |
| `service-accounts.read`   | `service_principals.read`   | admin, owner  |
| `service-accounts.manage` | `service_principals.manage` | admin, owner  |

Under the provider model, the membership row the module writes is what your
provider reads, so a provider that keeps roles elsewhere must give the
principal its role there too. With `blocks.memberships.mode: "custom"` the
module writes no membership at all. If you switch users off through
`blocks.permissions.disabled.user` with an active-row table (such as
`profiles`), add a row for each principal; a user without one is disabled.

## Events [#events]

`service_principal.created`, `service_principal.role_changed` and
`service_principal.disabled` go to the outbox and the audit log under the
`access` category. See [Events](/docs/extending/events).

## SQL [#sql]

| Function                                                                            | Who            |
| ----------------------------------------------------------------------------------- | -------------- |
| `register_service_principal(principal, tenant, name, role, is_default, created_by)` | `service_role` |
| `set_service_principal_role(principal, role, created_by)`                           | `service_role` |
| `disable_service_principal(principal, created_by)`                                  | `service_role` |
| `default_service_principal(tenant)`                                                 | `service_role` |
| `service_principals_list(tenant)`                                                   | members        |

## Without the client [#without-the-client]

`db.serviceAccounts` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="app/settings/automations/actions.ts"
"use server";

import {
  createServiceAccounts,
  sqlTransport,
} from "better-supabase/blocks/service-accounts";

const principals = createServiceAccounts({
  transport: sqlTransport(postgres.admin),
  admin: bsb.admin().$authAdmin,
});

export async function addAutomationAccount(organizationId: string) {
  const ctx = await bsb.context();
  return principals
    .create(organizationId, "Automations", "member", {
      default: true,
      createdBy: ctx.auth.claims.sub,
    })
    .orThrow();
}
```

# Signing keys

> Asymmetric keys per owner in Supabase Vault, with rotation that keeps old keys published until their tokens expire, a JWKS endpoint and a signer for JWTs and Ed25519 webhooks.

Source: https://bettersupabase.com/docs/blocks/signing-keys

`signing-keys` keeps Ed25519 and ES256 keys per owner: an organization, an
app, a stream of security events. The private key lives in
[Supabase Vault](https://supabase.com/docs/guides/database/vault), the public
key in a `signing_keys` row, and signing runs in TypeScript with WebCrypto.
Receivers check signatures against a JWK Set you publish, so they never hold
a secret.

```bash
better-supabase add signing-keys
```

```ts
import {
  createSigningKeys,
  sqlTransport,
} from "better-supabase/blocks/signing-keys";
```

Every function is service-role only. Clients can't read the table.

## Create and rotate [#create-and-rotate]

`create(owner, alg)` generates a key (`Ed25519` by default, or `ES256`),
stores the private key in Vault and makes it the owner's active key. The
`kid` is the RFC 7638 thumbprint of the public key. `rotate(owner)` does the
same with the active key's type, and the key it replaces is retired:

Server code calls the `signing-keys` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSigningKeys } = defineDataHooks(
  "signingKeys",
  (db: AppDb) => db.signingKeys,
);
```

```ts
const rows = await db.signingKeys.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/signing-keys/list.tsx"
"use client";

import { useSigningKeys } from "@/lib/supabase/hooks";

const rows = useSigningKeys({ limit: 50 });
```

| Method                 | What it does                                                          |
| ---------------------- | --------------------------------------------------------------------- |
| `create(owner, alg?)`  | Generates a key and makes it active; the previous key is retired      |
| `rotate(owner, alg?)`  | `create` with the active key's type                                   |
| `retire(kid)`          | Retires a key without a replacement                                   |
| `noteExp(kid, exp)`    | Keeps a key published until at least `exp`                            |
| `jwks(owner, opts?)`   | The owner's public keys still in use, as a JWK Set                    |
| `jwksResponse(owner)`  | `jwks` as an `application/jwk-set+json` response with `Cache-Control` |
| `signer(owner, opts?)` | Signs with the owner's active key                                     |
| `remove(owner)`        | Deletes every key of the owner and its Vault secrets                  |
| `purge(opts?)`         | Deletes retired keys that no token can still need                     |

### Owners [#owners]

An owner is any `{ type, id }` pair. Use one owner per tenant that needs its
own JWKS, such as `{ type: "environment", id: environment.id }`, so rotating
one tenant's key never touches another's. A platform with one authorization
server for every tenant uses the exported `PLATFORM_OWNER`
(`{ type: "platform", id: "default" }`).

An owner has at most one active key. `add_signing_key` takes an advisory
lock per owner, so two rotations at once leave one active key.

## Sign [#sign]

`signer(owner)` imports the active private key once and keeps it for
`cacheSeconds` (300). `sign(bytes)` returns the raw signature with its `kid`
and `alg`; `signJwt(claims, header?)` returns a compact JWS with `alg`, `kid`
and `typ: "JWT"` in the header:

```ts
const signer = signingKeys.signer(owner, { create: "Ed25519" });
const token = await signer
  .signJwt({
    iss: "https://app.example.com",
    sub: userId,
    exp: nowSeconds + 600,
  })
  .orThrow();
```

Without an active key, signing fails with `not_found` and the hint
`SIGNING_KEY_MISSING`, unless `create` names the type to generate. An
Ed25519 key signs with `alg: "EdDSA"` (RFC 8037), which most verifiers read;
set `ed25519Alg: "Ed25519"` for the fully specified name from RFC 9864.

### Token types [#token-types]

Name each kind of token you sign with `typ`, so a token of one kind can't be
replayed as another (RFC 8725 section 3.11). `jwtType("invite")` returns
`vnd.invite+jwt` and throws for a name outside lower-case letters, digits and
`-`. `signJwt` accepts `JWT` or a type ending in `+jwt` and fails with the
hint `SIGNING_TYP_INVALID` for any other value. Verify with the same type:

```ts
import { jwtType, verifyCompact } from "better-supabase/blocks/signing-keys";

const typ = jwtType("invite");
const token = await signer.signJwt({ sub: userId }, { typ }).orThrow();
await verifyCompact(token, jwks, { typ }); // throws JWS_TYP for another kind
```

A numeric `exp` claim is noted on the key, rounded up to the hour, so a
retired key stays in the JWKS until the last token it signed has expired.

## Publish the JWKS [#publish-the-jwks]

```ts title="app/.well-known/jwks.json/route.ts"
import { signingKeys } from "@/lib/signing-keys";

export const GET = () =>
  signingKeys.jwksResponse({ type: "app", id: "default" }, { maxAge: 300 });
```

The set has the active key and every retired key whose last `exp` plus the
grace period is still ahead. The grace defaults to the `jwksGrace` option
(`5 minutes`), which covers clock skew and cached JWKS responses; pass
`grace` to `jwks` or `purge` to change it for one call:

```ts title="better-supabase.config.ts"
blocks: {
  "signing-keys": { options: { jwksGrace: "15 minutes" } },
},
```

Receivers verify with any JOSE library, or with `verifyCompact` from this
entry, which checks the `alg` against the key and refuses `none`:

```ts
import { verifyCompact } from "better-supabase/blocks/signing-keys";

const { header, payload } = await verifyCompact(token, jwks, { typ: "JWT" });
```

Run `purge()` from a daily job to delete the retired keys that dropped out
of the set. Deleting a row deletes its Vault secret through a trigger.

## Ed25519 webhooks [#ed25519-webhooks]

[Outgoing webhooks](/docs/blocks/webhooks-out) can sign with an owner's key
instead of shared secrets (Standard Webhooks `v1a`). Pass the signer to
`standardWebhooksAsymmetric` and give receivers the `whpk_` public key:

```ts
import {
  standardWebhooksAsymmetric,
  webhookPublicKey,
} from "better-supabase/blocks/webhooks";

const asymmetricSigner = standardWebhooksAsymmetric({
  signer: signingKeys.signer({ type: "webhooks", id: "default" }),
});
const publicKey = webhookPublicKey(created.publicKey); // "whpk_…"
```

## Events [#events]

| Type                  | When                                          |
| --------------------- | --------------------------------------------- |
| `signing_key.created` | A key was created; `data` has `kid` and `alg` |
| `signing_key.retired` | A key was retired, by rotation or `retire`    |

Both carry `ownerType`, `ownerId` and `kid`, and write a `security` audit
entry when the audit block is installed.

## Without the client [#without-the-client]

`db.signingKeys` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/signing-keys.ts"
import {
  createSigningKeys,
  sqlTransport,
} from "better-supabase/blocks/signing-keys";

export const signingKeys = createSigningKeys({
  transport: sqlTransport(postgres.admin),
});

const owner = { type: "organization", id: organizationId };
const created = await signingKeys.create(owner).orThrow();
// { kid: "kPrK_qmx…", alg: "Ed25519", publicKey: { kty: "OKP", … }, retired: null }
```

# Vercel Marketplace

> Provider state for an integration listed on the Vercel Marketplace, installations, resources, plans, invoices and meter reports, with the Partner API, verified webhooks, the SSO exchange and usage reporting.

Source: https://bettersupabase.com/docs/blocks/vercel-marketplace

`vercel-marketplace` is for apps that sell a product through the [Vercel Marketplace](https://vercel.com/docs/integrations/create-integration/marketplace-product). It keeps what Vercel tells you, which team installed the integration, the resources they provisioned, the plans, Vercel's invoices and the quantities you reported. It serves the Partner API Vercel calls to install and provision, and gives you the server calls a provider needs: verify a webhook, exchange an SSO code and send usage.

```bash
better-supabase add vercel-marketplace
```

The module needs nothing else, and only the service role calls it. Every function is granted to `service_role`, so build the client on a service-role transport.

Server code calls the `vercel-marketplace` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useVercelMarketplace } = defineDataHooks(
  "vercelMarketplace",
  (db: AppDb) => db.vercelMarketplace,
);
```

```ts
const rows = await db.vercelMarketplace.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/vercel-marketplace/list.tsx"
"use client";

import { useVercelMarketplace } from "@/lib/supabase/hooks";

const rows = useVercelMarketplace({ limit: 50 });
```

Vercel hands out an access token for each installation when it installs the integration. The Partner API seals it in Vault, and invoices and usage reports read it from there. Pass `accessToken: (configurationId) => ...` to keep the tokens yourself instead.

## Partner API [#partner-api]

`partnerApi` returns one `Request` to `Response` handler for the routes Vercel calls on your integration: installations, resources, secret rotation and plans. Mount it under any prefix; it routes from the `/v1/` segment on. Each request carries an OIDC token Vercel signs. The handler verifies it against `https://marketplace.vercel.com/.well-known/jwks`, checks that the issuer is `https://marketplace.vercel.com`, that the audience is your client id and that the token has not expired, and that its `installation_id` claim is the installation in the path. A missing or unverifiable token gets a 401 and any other mismatch a 403, both as [Problem Details](/docs/auth/problems).

```ts title="app/api/vercel/[...path]/route.ts"
const partnerApi = marketplace.partnerApi({
  plans: [
    {
      id: "free",
      name: "Free",
      type: "subscription",
      scope: "resource",
      paymentMethodRequired: false,
      billing: { items: [] },
    },
  ],
  resources: {
    async provision({ installation, resource, environments }) {
      const tenant = await createOrganization(resource.name);
      const keys = await issueKeys(tenant, environments);

      return {
        tenant,
        tenantCreated: true,
        secrets: [
          {
            name: "APP_API_KEY",
            value: keys.production.secret,
            ref: keys.production.id,
            environmentOverrides: { preview: keys.preview.secret },
          },
        ],
      };
    },
    rotate: ({ resource }) => rotateKeys(resource.tenant),
    revoke: ({ previous }) => revokeKeys(previous),
  },
  organizations,
});

export { partnerApi as GET, partnerApi as POST, partnerApi as PUT };
export { partnerApi as PATCH, partnerApi as DELETE };
```

| Route                                                    | What the handler does                                                                                                                                                        |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUT /v1/installations/{id}`                             | Stores the installation with its account, scopes and accepted policies, seals `credentials.access_token` in Vault, then runs `install`, which may return a tenant to link.   |
| `GET`, `PATCH /v1/installations/{id}`                    | Returns or stores the installation's billing plan.                                                                                                                           |
| `DELETE /v1/installations/{id}`                          | Runs `deprovision` and removes the tenant for each resource, marks the installation removed, drops its sealed token and removes its tenant. Answers `{ "finalized": true }`. |
| `POST /v1/installations/{id}/resources`, `PUT .../{rid}` | Runs `provision`, stores the resource with its tenant and the secret refs, and answers 201 with the secrets.                                                                 |
| `GET`, `PATCH`, `DELETE .../resources/{rid}`             | Returns the resource, applies a change through `update`, or tears it down like an installation removal does.                                                                 |
| `POST .../resources/{rid}/secrets/rotate`                | Runs `rotate`, stores the new refs, then runs `revoke` with the previous ones. Without a `rotate` hook it answers 501.                                                       |
| `GET /v1/products/{pid}/plans`, `GET .../{id}/plans`     | Lists `plans`, or what the `plans` function returns for the product or installation.                                                                                         |

Secrets go to Vercel and never into the database. A secret's `ref`, the app's id for the key it handed out, stays in the block so the next rotation can pass it to `revoke`. `revoke` runs only after `rotate` resolved and the new refs are stored, so a failed rotation leaves the old keys working. `environmentOverrides` gives a secret a different value in `preview` or `development`, for an app that keeps one tenant environment per Vercel environment.

When a resource or installation goes away, `removeTenant` runs with the tenant and whether the integration created it. The default deletes it through `organizations` (the [organizations block](/docs/blocks/organizations) client) only when it was created by the integration and has no members, and does nothing without `organizations`. Pass your own `removeTenant` for other rules.

Errors answer as Problem Details. Internal messages stay hidden unless you pass `expose: true`, and `problem` rewrites the body like it does for the other block routes.

## Hono [#hono]

On [Hono](/docs/frameworks/hono), pass the raw request to the handler. Register the route before `bsb.middleware()` on the same prefix: Vercel sends its OIDC token as the bearer, which `partnerApi` verifies and Supabase Auth would refuse.

```ts title="src/server.ts"
const app = bsb
  .app()
  .all("/api/vercel/*", (c) => partnerApi(c.req.raw))
  .use("/api/*", bsb.middleware());
```

`bsb.app()` installs `bsb.onError`, so a `.orThrow()` that fails inside one of your hooks answers as Problem Details too. The [Hono example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/hono-api) provisions an organization per resource, hands out a tenant API key from the [API keys block](/docs/blocks/api-keys) as the secret, and revokes the previous key after a rotation.

## One tenant shared by Hono and Next [#one-tenant-shared-by-hono-and-next]

The Partner API and your dashboard can run as two apps on one database. Hono serves the routes Vercel calls; Next.js serves the dashboard users open from Vercel through [single sign-on](#single-sign-on). Both read the same organizations, so a resource the Partner API provisions is the tenant the dashboard shows, with no copy to keep in sync. Give both apps the same `VERCEL_MARKETPLACE_CLIENT_ID` and `VERCEL_MARKETPLACE_CLIENT_SECRET`. [Hono and Next.js](/docs/frameworks/hono-and-next) covers serving both from one deployment.

## Installations and resources [#installations-and-resources]

`upsertInstallation` records an installation by its configuration id, and a reinstall after a removal makes it active again. `linkTenant` links the installation to your own organization row. `upsertResource` provisions or updates a resource (the id Vercel uses is `externalId`), and `upsertPlan` keeps the plans your product offers. The Partner API calls these for you; call them yourself for an installation you create outside it.

```ts
await marketplace
  .linkTenant({ configurationId, tenant: organizationId, created: false })
  .orThrow();

await marketplace.upsertResource({
  configurationId,
  externalId: resourceId,
  name: "Production database",
  planId: "pro",
});
```

`resource` and `resources` read a resource or the installation's live ones, `deleteResource` marks one deleted and `removeInstallation` marks the installation removed. The rows stay for invoices and audit.

## Webhooks [#webhooks]

`handleWebhook` takes the `Request` Vercel sent. It checks the `x-vercel-signature` header, the HMAC-SHA1 of the raw body under your client secret, in constant time, and returns an error without touching the database when it does not match.

```ts title="app/api/vercel/webhook/route.ts"
export async function POST(request: Request) {
  const result = await marketplace.handleWebhook(request);

  return result.ok
    ? Response.json(result.data)
    : Response.json({ error: result.error.message }, { status: 400 });
}
```

| Event                                                           | What the block does                                                                             |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `integration-configuration.removed`                             | Marks the installation removed and its resources deleted. The rows stay for invoices and audit. |
| `marketplace.invoice.created`, `.paid`, `.notpaid`, `.refunded` | Stores the invoice with its state.                                                              |
| anything else                                                   | Returns `handled: false`.                                                                       |

An invoice event carries its creation time, and the database keeps the state of the newest event. Vercel can redeliver events out of order, and an older one never overwrites a newer state.

## Single sign-on [#single-sign-on]

When a user opens your product from the Vercel dashboard, Vercel redirects to your SSO route with a `code`. `exchangeSso` trades it for an `id_token`, verifies it with the same checks as the Partner API (signature, issuer, audience and expiry), and keeps the installation current.

```ts title="app/api/vercel/sso/route.ts"
const claims = await marketplace.exchangeSso({ code, state }).orThrow();
// claims.configurationId, claims.userEmail, claims.resourceId, claims.path
```

Pass `jwks` to verify against another key set, such as one inline in tests.

## Invoices and usage [#invoices-and-usage]

`submitInvoice` sends an invoice for a period, and `reportUsage` sends meter totals. Pass each meter's total for the billing period so far. `reportUsage` stores it (one row per installation, resource, meter and period), then sends what Vercel has not accepted yet with one request per billing period: `periodValue` is the total and `dayValue` the part not reported before. The reported amount moves only after Vercel answers 2xx, so a failed call sends the whole difference next time.

```ts
await marketplace.reportUsage({
  configurationId,
  periodStart: Temporal.Instant.from("2026-10-01T00:00:00Z"),
  periodEnd: Temporal.Instant.from("2026-11-01T00:00:00Z"),
  meters: [{ meterId: "requests", quantity: 12_000 }],
});
```

`usageRoute` runs that from a cron caller. It checks the bearer secret, reports what `usage` returns and answers 200 with the configuration ids it reported, or 502 when one failed. To feed a meter from the [usage block](/docs/blocks/usage), read the period's total there.

```ts title="app/api/cron/marketplace-usage/route.ts"
export const GET = marketplace.usageRoute({
  secret: process.env.CRON_SECRET,
  usage: async () => {
    const period = currentBillingPeriod();
    const installations = await activeInstallations();

    return Promise.all(
      installations.map(async (configurationId) => ({
        configurationId,
        ...period,
        meters: [
          {
            meterId: "requests",
            quantity: await usage.total("requests", period, configurationId),
          },
        ],
      })),
    );
  },
});
```

```json title="vercel.json"
{
  "crons": [{ "path": "/api/cron/marketplace-usage", "schedule": "0 1 * * *" }]
}
```

## Options and events [#options-and-events]

`blocks.vercel-marketplace.options.currency` (default `usd`) is the currency an invoice takes when the webhook names none.

The block writes `marketplace_installation.upserted`, `marketplace_installation.removed` and `marketplace_invoice.state_changed` to the [outbox](/docs/blocks/outbox) and the [audit log](/docs/blocks/audit) when they are installed.

## Without the client [#without-the-client]

`db.vercelMarketplace` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createVercelMarketplace } from "better-supabase/blocks/vercel-marketplace";

const marketplace = createVercelMarketplace({
  transport: serviceTransport,
  clientId: process.env.VERCEL_MARKETPLACE_CLIENT_ID,
  clientSecret: process.env.VERCEL_MARKETPLACE_CLIENT_SECRET,
});
```

# 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/queue#webhook-inbox) with the
endpoint's tenant, so it is acknowledged fast and processed with retries.

```bash
better-supabase 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
`blocks.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({ sql: 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"
blocks: {
  "webhooks-in": {
    on: {
      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({
  sql: 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,
`blocks.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. To keep endpoints in a table of your own, see
[Over your own tables](#over-your-own-tables).

### 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-bsb-endpoint-id` header:

```ts
import { createWebhookInbox, sqlTransport } from "better-supabase/blocks/queue";
import { INCOMING_ENDPOINT_HEADER } from "better-supabase/blocks/webhooks";

const inbox = createWebhookInbox({
  transport: sqlTransport(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>" }`.

### Sending deliveries to a queue [#sending-deliveries-to-a-queue]

`destination` decides where a verified delivery goes. The default,
`inboxDestination`, stores it in the webhook inbox as above.
`jobsDestination` enqueues it on a [jobs](/docs/blocks/queue) queue instead,
once per endpoint and message id, with the endpoint's tenant as the job's
context:

```ts
import * as v from "valibot";
import { createQueue } from "better-supabase/blocks/queue";
import {
  createIncomingWebhooks,
  jobsDestination,
} from "better-supabase/blocks/webhooks";

const jobs = createQueue({
  sql: postgres.admin,
  queues: { leads: v.object({ route: v.string(), body: v.unknown() }) },
});

export const hooks = createIncomingWebhooks({
  sql: postgres.admin,
  destination: jobsDestination(jobs, {
    queue: "leads",
    payload: (delivery) => ({
      route: String(delivery.endpoint.metadata["route"] ?? "default"),
      body: delivery.payload,
    }),
  }),
});
```

The queue key stays taken after the job completes, so a sender that retries
a delivery gets `200` and no second job. Without `payload` the job gets the
whole delivery: the endpoint's id, tenant and metadata, the source, the
message id, the type, the payload and the kept headers. Route on the
endpoint's `metadata` (set with `create` or `update`); `source` only labels
the inbox messages. The jobs destination needs the SQL or RPC queue backend:
the public pgmq backend has no `enqueueOnce`.

Any object with `apiVersion: 1`, a `name` and `deliver(delivery)` is a
destination. It returns the stored id and whether the delivery was there
already, which `receive` answers with `200`.
[`testIncomingDestination`](/docs/extending/conformance) checks that a
destination reports a repeated endpoint and message id as a duplicate.

## Over your own tables [#over-your-own-tables]

The module runs over an endpoint table your app already has. Map its table
and columns, and map an optional column to `null` when you don't want it:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    "webhooks-in": {
      tables: { endpoints: "public.integration_hooks" },
      columns: {
        endpoints: {
          tenant: "organization_id",
          tokenHash: "token_digest",
          previousSecretId: null,
          previousSecretExpiresAt: null,
          receiveCount: null,
          lastReceivedAt: null,
          lastStatus: null,
          subjectType: null,
          subjectId: null,
        },
      },
    },
  },
});
```

The module then skips its `create table`, adds the columns it needs that
your table lacks, and writes its functions, policies, indexes and the Vault
cleanup trigger over it. `id`, `tenant`, `name`, `tokenHash`, `verify` and
`enabled` are required, and so is `secretId` (or `secret` with
`secretStorage: "column"`). Each optional column mapped to `null` turns off
what needs it: without the previous-secret columns a rotation with
`graceSeconds` fails with `WEBHOOK_IN_NO_GRACE`, without the subject columns
`subjects` can't be configured, without `metadata` endpoints take no
metadata, and without the counters `receive` records nothing.

The webhook inbox runs over a table of yours the same way, through
`blocks.webhook-inbox` with the `messages` table:

```ts title="better-supabase.config.ts"
"webhook-inbox": {
  tables: { messages: "public.inbound_events" },
  columns: {
    messages: { messageId: "external_id", tenant: "organization_id", checkpoint: null },
  },
},
```

`id` (a `bigint`), `source`, `messageId`, `payload`, `status`, `attempts`,
`availableAt`, `lockedBy`, `lockedUntil` and `receivedAt` are required.
`eventType`, `headers`, `maxAttempts` (8 when missing), `lastError`,
`processedAt`, `memberships` and `checkpoint` are optional; one mapped to
`null` reads as the managed table's default. The functions stay in
`better_supabase`, and `receive_webhook` takes a lock per message before it
looks the message up, so your table needs no particular unique
constraint, though one on source, tenant and message id is a good guard.

## Hooks and transports [#hooks-and-transports]

`createIncomingWebhooks` takes the service connection as `sql`, which
`receive` uses. The management calls (`create`, `list`, `update`, `rotate`,
`rotateSecret`, `setEnabled`, `remove`) go through `transport` when you pass
one, such as the member's `sqlTransport(postgres.asUser(claims))`, so
`webhooks.manage` is checked for the caller, and through `sql` otherwise.
`hooks` runs your code around those calls (see
[extending blocks](/docs/extending/blocks#hooks)).

# 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 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/repository/permissions) 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:

Server code calls the `webhooks-out` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useWebhooksOut } = defineDataHooks(
  "webhooksOut",
  (db: AppDb) => db.webhooksOut,
);
```

```ts
const rows = await db.webhooksOut.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/webhooks-out/list.tsx"
"use client";

import { useWebhooksOut } from "@/lib/supabase/hooks";

const rows = useWebhooksOut({ limit: 50 });
```

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

### Ed25519 signatures [#ed25519-signatures]

An endpoint with `signing` set to `'v1a'` is signed with an Ed25519 key
instead of its secrets (Standard Webhooks `v1a`), so the receiver holds only
a public key and can't forge a delivery. Keep the key in the
[signing keys](/docs/blocks/signing-keys) block and pass a signer as
`asymmetricSigner`:

```ts title="lib/webhooks.ts"
import {
  createWebhooks,
  standardWebhooksAsymmetric,
  sqlTransport,
} from "better-supabase/blocks/webhooks";

export const webhooks = createWebhooks({
  transport: sqlTransport(postgres.admin),
  asymmetricSigner: standardWebhooksAsymmetric({
    signer: signingKeys.signer({ type: "webhooks", id: "default" }),
  }),
});
```

Show receivers `webhookPublicKey(jwk)`, the `whpk_` form that
`verifyWebhook` takes in place of a secret. A `v1a` endpoint needs no
secret. Without an `asymmetricSigner`, its deliveries are dead-lettered
instead of sent unsigned. `hmac: true` also adds the `v1` signatures from
the endpoint's secrets while receivers move over.

### Connectors [#connectors]

An endpoint's `format` picks the encoder that writes its requests, so the
same events can go to a log pipeline or a SIEM as well as to webhook
receivers. `cloudevents` is the default for new endpoints; set
`blocks.webhooks-out.options.defaultFormat` to change the column default and
the format of a row that has none. Four more are built in:

| `format`      | Writes                                                                         | Point the URL at            |
| ------------- | ------------------------------------------------------------------------------ | --------------------------- |
| `cloudevents` | CloudEvents over HTTP, one event or a batch, signed per Standard Webhooks      | The receiver                |
| `standard`    | The 0.8 body, `{ type, timestamp, data }` or an array of them, signed the same | A receiver written for 0.8  |
| `otlp-logs`   | OTLP/HTTP logs in JSON, a log record per event with the CloudEvents attributes | A collector's `/v1/logs`    |
| `splunk-hec`  | Splunk HTTP Event Collector events, the CloudEvent as `event`                  | `/services/collector/event` |
| `set`         | A Security Event Token pushed per RFC 8935, from `data.set`                    | A Shared Signals receiver   |

Endpoints are rows, so an app picks a format per endpoint by writing the
column, for example while receivers move from the 0.8 body to CloudEvents:

```sql
update better_supabase.webhook_endpoints set format = 'cloudevents' where id = $1;
```

Both `cloudevents` and `standard` put `transform`'s return value in `data`.

The [security-events](/docs/blocks/security-events) block creates the `set`
endpoints; the SET carries its own signature, and a 400 with `invalid_key`
retries while other RFC 8935 errors dead-letter.

`otlp-logs`, `splunk-hec` and `set` aren't signed. They authenticate with the
token in the endpoint's `credential_ref`, which the `credentials` option
resolves (`Authorization: Splunk <token>` for HEC, the token's own headers
otherwise). A ref outside the endpoint's organization, or a ref without a
`credentials` provider, dead-letters the delivery. A partial success from an
OTLP collector counts as delivered and keeps the rejection in the log, and an
HEC error code inside a 200 retries or dead-letters by its code.

```ts title="lib/webhooks.ts"
import {
  createWebhooks,
  otlpLogs,
  sqlTransport,
} from "better-supabase/blocks/webhooks";
import { vaultCredentials } from "better-supabase/credentials";

export const webhooks = createWebhooks({
  transport: sqlTransport(postgres.admin),
  credentials: vaultCredentials({ transport: sqlTransport(postgres.admin) }),
  encoders: [otlpLogs({ serviceName: "billing" })],
});
```

`encoders` replaces a built-in by name, with options as above, or adds a
`WebhookEncoder` for another format: `encode(events, endpoint)` returns the
body and its headers, `signed` says whether the Standard Webhooks headers go
on, `authorize(token)` turns the token into headers and `classify(response)`
reads a response the status alone doesn't explain.

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

### Who receives an event [#who-receives-an-event]

`publish` queues an event only for endpoints whose owner may read it. Pass
`subject` (the row the event is about, such as `invoices/42`) and
`readPermission`, the permission a reader of that row needs:

```ts
await webhooks
  .publish({
    type: "invoice.paid",
    data: { id: invoice.id },
    tenant: organizationId,
    subject: `invoices/${invoice.id}`,
    readPermission: "invoices.read",
  })
  .orThrow();
```

| Endpoint owner                                    | Gets the event when                                                       |
| ------------------------------------------------- | ------------------------------------------------------------------------- |
| A user (`created_by` is set)                      | The user holds `readPermission` (else `webhooks.read`) in the tenant      |
| A service principal (`created_by` is null)        | `readPermission` is unset, or `permissions` holds it, `*` or a `prefix.*` |
| A user who was deleted (`created_by` set to null) | As a service principal                                                    |

A service endpoint declares what it holds in its `permissions` column, for
example `{invoices.*}`. With the
[service-accounts](/docs/blocks/service-accounts) module installed, a
service endpoint gets an event only when the tenant's default principal
holds `readPermission` (else `webhooks.read`), and the declared
`permissions` apply only while the tenant has no default principal. The user check asks the
[authorization provider](/docs/extending/authorization-providers) when one is
configured, otherwise the [access contract](/docs/repository/permissions); without
either, user endpoints receive nothing.

The SQL check runs once, when the event is queued. For a check at send time,
such as row-level rules the provider doesn't know, pass `subscribers`, a
`SubscriberPolicy`. `deliver` calls it once per event with the candidate
endpoints (`{ principal, kind }`, where `principal` is the owner's user id;
for a service endpoint, the organization's default
[service principal](/docs/blocks/service-accounts), or the endpoint id
without one) and cancels the deliveries it leaves out. Each delivery is
checked against its own stored `subject`. A throw retries every delivery of
that event. Without the option the block uses
`authorization.delivery.subscribers`.

With `authorization` but no `delivery.subscribers`, the block fails closed
on records: it uses `subjectReaders(transport)`, so an event whose subject
is a [registered subject](/docs/blocks/sql#subject-registry)
(`subjects/<type>/<id>` or `<type>/<id>`) only reaches endpoints whose owner
or service principal holds the subject's `read` key, through
`can_subject_for`. A share link never counts. Events without a subject, or
about a type the registry doesn't list, keep the queue-time check.

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.

The sink publishes up to 1,000 events per call through
`better_supabase.publish_webhook_events(events jsonb)`, which runs
`publish_webhook_event` for each element. `webhooks.publishMany(inputs)`
calls it from a service transport and returns one `Result` per event, the
number of deliveries it queued or the error.

### Partitioned deliveries [#partitioned-deliveries]

`blocks.webhooks-out.options.partitioned` creates a new `webhook_deliveries`
table partitioned by month on `created_at`, with the primary key
`(id, created_at)`. A lock per event replaces the unique
`(endpoint, event)` index, and `purge_webhook_deliveries` drops every month
past the cutoff whose deliveries are all finished. Schedule
`better_supabase.maintain_partitions()` once a month. An existing table stays
as it is, and `better-supabase doctor` reports it as BS335.

## 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 a
[CloudEvents 1.0](/docs/standards/events) event in structured mode
(`content-type: application/cloudevents+json`). Receivers check it with
[`verifyWebhook`](/docs/standards/webhooks) or any Standard Webhooks library.
The delivery id is the `webhook-id` and the CloudEvent `id`, the same on every
retry.

```json
{
  "specversion": "1.0",
  "id": "8f6c1e0a-…",
  "source": "https://app.example.com/webhooks",
  "type": "invoice.paid",
  "subject": "invoices/42",
  "time": "2026-10-14T09:30:00Z",
  "datacontenttype": "application/json",
  "data": { "id": "42", "amount": 1200 }
}
```

| Attribute     | Value                                                                       |
| ------------- | --------------------------------------------------------------------------- |
| `source`      | `source`, else `/webhooks` on `appUrl`, else `urn:better-supabase:webhooks` |
| `type`        | The event type, after `typePrefix` and a dot when you set one               |
| `subject`     | The `subject` given to `publish`, left out without one                      |
| `time`        | When the event was queued                                                   |
| `dataschema`  | `dataschema`, a URI or a function of the delivery                           |
| `traceparent` | `traceparent(delivery)`, left out when it isn't a valid W3C trace context   |

An endpoint with `batch` set to `true` gets its claimed deliveries in one
request, as a CloudEvents batch (`application/cloudevents-batch+json`, a JSON
array of events). The `webhook-id` of a batch is `batch_` and a hash of its
delivery ids, and the deliveries succeed or fail together.

| 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 equal jitter (half the step plus a
random share of the other half, and never under a second) so failed
receivers aren't hit in bursts and a retry never fires at once. 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 |
| `asymmetricSigner`            | Signs `v1a` endpoints, usually `standardWebhooksAsymmetric({ signer })`                 |
| `encoders`                    | `WebhookEncoder`s for endpoint `format`s, such as `otlpLogs()` and `splunkHec()`        |
| `credentials`                 | The `CredentialProvider` that resolves an endpoint's `credential_ref`                   |
| `transform`                   | The CloudEvent `data` for a delivery                                                    |
| `subscribers`                 | A `SubscriberPolicy` that narrows each event's endpoints at send time                   |
| `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 `blocks.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`. When your app has its own, the block runs over them:
rename what differs and map what you don't want to `null`. This runs over 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({
  blocks: {
    "webhooks-out": {
      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"`, a secrets table of yours 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.

The `permissions`, `batch`, `signing`, `format` and `credentialRef` endpoint
columns and the `subject` delivery column are new in version 3 of the module.
On a table of yours the block adds the ones it lacks; map one to `null` to go
without it. Without them, service endpoints get only events published without `readPermission`,
nothing is sent as a batch, every endpoint signs with `v1` and gets
`defaultFormat`, no request carries a connector credential and deliveries
carry no `subject`.

An endpoints table of yours without a mapped `format` column needs an explicit
`defaultFormat`, since that one value then decides the body of every request.
`upgrade` refuses the config without it, and doctor reports it as
[BS344](/docs/cli/doctor#bs344). Use `standard` to keep the 0.8 body, or
`cloudevents` once your receivers read CloudEvents:

```ts title="better-supabase.config.ts"
"webhooks-out": {
  tables: { endpoints: "public.webhook_endpoints" },
  options: { defaultFormat: "standard" },
},
```

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 only
when the block runs over a table your app already has, 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 your tables
wherever they are.

| Option          | Default       | Meaning                                                                                                 |
| --------------- | ------------- | ------------------------------------------------------------------------------------------------------- |
| `secretStorage` | `vault`       | `vault`, or `column` (migration-only, over your own table)                                              |
| `allowHttp`     | `false`       | Allow `http:` endpoint URLs                                                                             |
| `defaultFormat` | `cloudevents` | The format of new endpoints and of rows without one; required over your table without a `format` column |
| `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, over your own table 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                |

## Hooks [#hooks]

`createWebhooks` takes `hooks` around its methods, such as a rule before
`publish` that returns a `DbError` to refuse the call; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.webhooksOut` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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 bsb.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();
}
```

# 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 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]

Server code calls the `announcements` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAnnouncements } = defineDataHooks(
  "announcements",
  (db: AppDb) => db.announcements,
);
```

```ts
const rows = await db.announcements.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/announcements/list.tsx"
"use client";

import { useAnnouncements } from "@/lib/supabase/hooks";

const rows = useAnnouncements({ limit: 50 });
```

`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. The hook is a registry item: install it with
`npx shadcn add @better-supabase/use-announcements`, which writes
`hooks/use-announcements.ts` into the app.

```tsx title="components/announcement-bar.tsx"
"use client";

import { useAnnouncements } from "@/hooks/use-announcements";

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
`blocks.announcements.options.topic` and `event`, and pass the same
`topic` to the hook (`null` loads once without Realtime).

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createAnnouncementsController` with its options; `auth` and `ports` come
from `app.use(betterSupabase(bsb))` unless you pass them. `state` is a
shallow ref of the snapshot and `actions` are the controller's actions. The
work starts on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createAnnouncements,
  createAnnouncementsController,
  rpcTransport,
} from "better-supabase/blocks/announcements";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(createAnnouncementsController, {
  client: createAnnouncements({ transport: rpcTransport(supabase) }),
  organizationId,
});
</script>

<template>
  <ul>
    <li v-for="item in state.items ?? []" :key="item.id">{{ item.title }}</li>
  </ul>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createAnnouncementsController` with its options; `auth` and `ports` come
from `setBetterSupabase(bsb)` unless you pass them. It returns a readable
store of the snapshot, read with `$`, with the actions on
`.actions`. The work starts on mount, and the controller is disposed with
the component.

```svelte title="Component.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createAnnouncements,
    createAnnouncementsController,
    rpcTransport,
  } from "better-supabase/blocks/announcements";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const announcements = useBlockController(createAnnouncementsController, {
    client: createAnnouncements({ transport: rpcTransport(supabase) }),
    organizationId,
  });
</script>

<ul>
  {#each $announcements.items ?? [] as item (item.id)}
    <li>{item.title}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createAnnouncementsController` with its options; `auth` and `ports` come
from `<BetterSupabaseProvider client={bsb}>` unless you pass them.
`state()` reads the snapshot through `from`, and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createAnnouncements,
  createAnnouncementsController,
  rpcTransport,
} from "better-supabase/blocks/announcements";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createAnnouncementsController, {
    client: createAnnouncements({ transport: rpcTransport(supabase) }),
    organizationId: props.organizationId,
  });
  return (
    <ul>
      <For each={state().items ?? []}>{(item) => <li>{item.title}</li>}</For>
    </ul>
  );
}
```

## 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                        |

## Your own fields [#your-own-fields]

`blocks.announcements.options.extraColumns` adds columns to the `announcements` table,
keyed by column name with their SQL type. Pass `createAnnouncements` a Standard Schema
for them as `fields`: `publish`, `update` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and `listActive`, `list` return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAnnouncements({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.announcements` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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);
```

# Growth

> Onboarding, waitlists and the blocks that bring people in.

Source: https://bettersupabase.com/docs/blocks/catalog-growth

- [Feature flags](/docs/blocks/flags)
- [Onboarding](/docs/blocks/onboarding)
- [Waitlist](/docs/blocks/waitlist)
- [Announcements](/docs/blocks/announcements)
- [Lead scoring](/docs/blocks/lead-scoring)
- [Scores](/docs/blocks/scores)

# Feature flags

> Feature flags per tenant, per user and per custom context such as a brand, with targeting rules, overrides and percentage rollouts, evaluated the same way in RLS, in an OpenFeature provider and in the Flags SDK.

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

The `flags` block stores feature flags in Postgres and evaluates them in
three places: `flag_enabled()` in policies and functions, an OpenFeature
provider, and a [Flags SDK](/docs/flags-sdk) adapter in the app. All of them
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 add flags   # adds tenant as well
```

| Table            | Holds                                                                                                                                |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `flags`          | `key`, `type`, `description`, `variants`, `default_variant`, `enabled`, `archived`, `rules`, `rollout_percentage`, `rollout_variant` |
| `flag_overrides` | A variant for one `organization_id`, one `user_id` or one custom context value (`context`, `context_key`)                            |

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`) or an archived one returns its
   default variant.
2. A user override, then a tenant override, then a custom context override
   in the order the contexts are declared.
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), `plans`
   (entitlement lookup keys, one match is enough) and `contexts` (a context
   name to its values, every one must match).
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`.

### Custom contexts [#custom-contexts]

A custom context targets flags by a value that isn't a tenant or a user,
such as the brand a white-label app serves. Declare each one with the table
and column that hold a tenant's value; `tenant` names the column with the
tenant id and defaults to `id`, for a column on the tenant table:

```ts title="better-supabase.config.ts"
blocks: {
  "flags": {
    options: {
      contexts: {
        brand: { table: "public.organizations", column: "brand_id" },
      },
    },
  },
},
```

Rules and overrides then name the context:

```sql
update better_supabase.flags
  set rules = '[{"variant": "on", "contexts": {"brand": ["acme"]}}]'
  where key = 'new_editor';

insert into better_supabase.flag_overrides (flag_key, context, context_key, variant)
  values ('new_editor', 'brand', 'globex', 'off');
```

`flag_evaluation(key, tenant, user, contexts)` takes the values as
`{"brand": "acme"}`. A declared context it leaves out is read from the
tenant, and only for a flag whose rules or overrides name a context, so
`flag_enabled()` in a policy targets brands without extra arguments. The
OpenFeature provider and the Flags SDK adapter never read the tenant table;
pass the values through `flagContext({ contexts })`, `contextClaims` or the
Flags SDK `identify`.

### Archiving a flag [#archiving-a-flag]

`upsert(key, { archived: true })` archives a flag: it serves its default
variant like a disabled flag, and Flags Explorer no longer lists it. Archive
a flag once no code reads it, and delete it later, so a deploy that still
checks it gets the default instead of an unknown flag.

### Managing flags from an admin page [#managing-flags-from-an-admin-page]

`createFlags` manages flags for platform staff with `flags.manage` (the
module's `manage` permission, checked with `is_platform()` when the
[access](/docs/repository/permissions) 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 `blocks.flags.api`
and `rpcTransport`:

Server code calls the `flags` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useFlags } = defineDataHooks(
  "flags",
  (db: AppDb) => db.flags,
);
```

```ts
import { createFlags, rpcTransport } from "better-supabase/blocks/flags";

await db.flags.upsert("new_editor", {
  rolloutPercentage: 25,
  rolloutVariant: "on",
});
await db.flags.override("new_editor", { organizationId }, "on");
await db.flags.override(
  "new_editor",
  { context: "brand", contextKey: "acme" },
  "on",
);
await db.flags.override("new_editor", { userId }, null); // removes it
const flags = await db.flags.list().orThrow();
```

```tsx title="src/features/flags/list.tsx"
"use client";

import { useFlags } from "@/lib/supabase/hooks";

const rows = useFlags({ limit: 50 });
```

`upsert` creates the flag or updates the fields you pass and keeps the rest.
Anyone else gets `FLAGS_FORBIDDEN`.

### Your own fields and hooks [#your-own-fields-and-hooks]

`blocks.flags.options.extraColumns` adds columns to the flags table, such as
an owner or a ticket. `upsert` writes them from `fields`, and `list`, `upsert`
and `flag_definitions()` return them under `fields`. Pass a Standard Schema
as `fields` to type them, parse them on reads and validate them on `upsert`,
and `hooks` to run code around a method (see
[extending blocks](/docs/extending/blocks)):

```ts
import { z } from "zod";

const admin = createFlags({
  transport: rpcTransport(supabase, { schema: "api" }),
  fields: z.object({ owner: z.string().email() }),
  hooks: {
    upsert: {
      after: (result, [key]) => console.info("flag saved", key, result.ok),
    },
  },
});

await admin.upsert("new_editor", { fields: { owner: "ana@acme.test" } });
```

### Owner and expiry [#owner-and-expiry]

Give every flag an owner and a date by which it should be gone, so release
flags don't pile up. Add the columns as extra columns:

```ts title="better-supabase.config.ts"
blocks: {
  "flags": {
    options: {
      extraColumns: { owner: "text", expires_at: "timestamptz" },
    },
  },
},
```

With an `expires_at` column, [`better-supabase doctor`](/docs/cli/doctor#bs336)
reads the live database and warns about each flag past it that isn't
archived (BS336), naming the owner when the table has one. Remove the
flag's checks from the code, then archive it, or move `expires_at` when the
flag stays. `expiredFlagsQuery(layout, installed)` from
`better-supabase/sql` returns the query doctor runs, for your own report.

## 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();
```

A row whose `type` is not one the table allows resolves to the default
with `PARSE_ERROR`, or `GENERAL` when the `errorCodes` map has no
`PARSE_ERROR`.

`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 `memberships` 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`; `definitions()` returns the cached list |
| `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                                                                                             |
| `createFlags(opts)`         | `list`, `upsert`, `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)).

## Without the client [#without-the-client]

`db.flags` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createFlags, rpcTransport } from "better-supabase/blocks/flags";

const admin = createFlags({
  transport: rpcTransport(supabase, { schema: "api" }),
});

await admin.upsert("new_editor", {
  rolloutPercentage: 25,
  rolloutVariant: "on",
});
await admin.override("new_editor", { organizationId }, "on");
await admin.override(
  "new_editor",
  { context: "brand", contextKey: "acme" },
  "on",
);
await admin.override("new_editor", { userId }, null); // removes it
const flags = await admin.list().orThrow();
```

# Lead scoring

> Score people and companies under a tenant-editable score model whose rules read the record, its segments, its email activity and its enrichment.

Source: https://bettersupabase.com/docs/blocks/lead-scoring

The `lead-scoring` block keeps a [score](/docs/blocks/scores) on every
[person](/docs/blocks/contacts), and optionally every company, under one score
model per organization. The model's rules read the record's own columns and
three sets of fields the block adds: the [segments](/docs/blocks/segments)
the record is in, its email activity from [mail-sync](/docs/blocks/mail-sync)
and its [enrichment](/docs/blocks/enrichment) status. A trigger marks a
record when it, its segments or its mail change, and a scheduled run scores
the marked ones.

```bash
better-supabase add lead-scoring   # adds tenant, access, parties and scores as well
```

Scoring one record needs `scores.compute`, as for any score. The block adds
no permissions of its own.

## Define the lead model [#define-the-lead-model]

The organization's lead model is a `scores` model with the key `lead` over
`person`. `syncFields` replaces a subject type's catalog, so sync the
record's columns and the block's fields as one catalog once per deploy, then
save the model:

Server code calls the `lead-scoring` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useLeadScoring } = defineDataHooks(
  "leadScoring",
  (db: AppDb) => db.leadScoring,
);
```

```ts
const rows = await db.leadScoring.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/lead-scoring/list.tsx"
"use client";

import { useLeadScoring } from "@/lib/supabase/hooks";

const rows = useLeadScoring({ limit: 50 });
```

`repliedOnce` and `inEnterpriseSegment` are filter trees over the fields
below, such as `mail.replied` greater than 0 or `segments` containing a
segment's id.

| Field                | Type        | What it holds                                   |
| -------------------- | ----------- | ----------------------------------------------- |
| `segments`           | multiselect | the ids of the segments the record is in        |
| `mail.sent`          | number      | emails sent to the record                       |
| `mail.opened`        | number      | emails it opened                                |
| `mail.clicked`       | number      | emails it clicked a link in                     |
| `mail.replied`       | number      | replies it sent                                 |
| `mail.lastSentAt`    | date        | when the last email went out                    |
| `mail.lastRepliedAt` | date        | when it last replied                            |
| `enrichment.status`  | select      | `matched` or `not_found`, from the last request |

The segments, mail and enrichment fields are empty when that block isn't
installed. Set `companyModel` to score companies too, under a second model
over `company`.

## Score [#score]

```ts
import {
  createLeadScoring,
  rpcTransport,
} from "better-supabase/blocks/lead-scoring";

const leads = createLeadScoring({
  transport: rpcTransport(supabase),
  tenant: organizationId,
});

const score = await leads.score({ type: "person", id: person.id }).orThrow();
// { score: 62, band: "hot", changed: true, breakdown: { rules, metrics } }
```

`score(subject)` scores one record now. `fields(subject)` returns the values
the model reads for it, which helps when a rule doesn't match the way you
expect.

`scoreDue()` runs as the service role and scores up to `limit` (100) records
that were marked since their last score or weren't scored for
`rescoreAfterHours`. It returns how many it scored. Call it from a cron
route:

```ts title="app/api/cron/lead-scores/route.ts"
export async function GET() {
  const scored = await blocks["lead-scoring"]
    .scoreDue({ limit: 200 })
    .orThrow();

  return Response.json({ scored });
}
```

A changed score emits `score.changed` from the scores block, so a workflow
can assign a hot lead or enroll it in a [sequence](/docs/blocks/sequences).

## Options [#options]

```ts title="better-supabase.config.ts"
blocks: {
  "lead-scoring": { options: { companyModel: "account-fit" } },
},
```

| Option              | Default  | What it does                                            |
| ------------------- | -------- | ------------------------------------------------------- |
| `personModel`       | `"lead"` | the key of the model people are scored under            |
| `companyModel`      | `""`     | the key of the model companies are scored under, if any |
| `rescoreAfterHours` | `24`     | hours after which an unchanged record is scored again   |

| Error code               | When                                                  |
| ------------------------ | ----------------------------------------------------- |
| `LEAD_SCORING_FORBIDDEN` | the caller lacks `scores.compute`                     |
| `LEAD_SCORING_NOT_FOUND` | there is no such record in the organization           |
| `LEAD_SCORING_SUBJECT`   | the subject type is not a person, or a scored company |

## Without the client [#without-the-client]

`db.leadScoring` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  createAutomationsAdmin,
  defineRuleFields,
} from "better-supabase/blocks/automations";
import { leadRuleFields } from "better-supabase/blocks/lead-scoring";
import { createScores } from "better-supabase/blocks/scores";

const columns = defineRuleFields("person", {
  lifecycle: { type: "select", options: ["lead", "customer"] },
});

await createAutomationsAdmin({ transport: serviceTransport })
  .syncFields({
    subjectType: "person",
    fields: [...columns.fields, ...leadRuleFields("person").fields],
  })
  .orThrow();

await createScores({ transport, tenant: organizationId })
  .saveModel({
    key: "lead",
    name: "Lead score",
    subjectType: "person",
    rules: [
      { id: "replied", conditions: repliedOnce, points: 30 },
      { id: "enterprise", conditions: inEnterpriseSegment, points: 20 },
    ],
    metrics: [
      {
        id: "opens",
        source: "field",
        field: "mail.opened",
        weight: 2,
        cap: 20,
      },
    ],
    bands: [
      { label: "hot", min: 50 },
      { label: "warm", min: 20 },
      { label: "cold", min: -1000 },
    ],
  })
  .orThrow();
```

# 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 add onboarding   # adds tenant, access and enrollments as well
```

Each checklist runs on [enrollments](/docs/blocks/enrollments) as the program
`onboarding:<checklist>`: the organization is enrolled in an organization
checklist, and each user, without an organization, in a user checklist. The
completed steps are `enrollment_steps` rows, so an admin page that lists
enrollments shows onboarding next to your own programs.

| 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({
  blocks: { 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 add onboarding` again after you change a
checklist's steps. An enrollment takes the new steps the next time a step of
it completes or resets, and its progress counts only the current steps.

## Progress [#progress]

`createOnboarding` reads and completes the steps of any checklist the module
knows. Each method takes the checklist first, so its step ids are typed:

Server code calls the `onboarding` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useOnboarding } = defineDataHooks(
  "onboarding",
  (db: AppDb) => db.onboarding,
);
```

```ts
const rows = await db.onboarding.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/onboarding/list.tsx"
"use client";

import { useOnboarding } from "@/lib/supabase/hooks";

const rows = useOnboarding({ limit: 50 });
```

A user checklist takes no organization: `onboarding.progress(profile)`.
`complete` returns `false` when the step was already done. With
[`defineBlocks`](/docs/blocks/create-blocks), `onboardingBlock` builds the
same client, and `hooks` run around its methods:

```ts title="lib/blocks.ts"
const onboarding = createOnboarding({
  transport,
  hooks: {
    complete: {
      after: (result, [checklist, step]) => {
        if (result.ok && result.data) track("step_done", checklist.id, step);
      },
    },
  },
});
```

The enrollments take no app columns: the steps live in `defineChecklist`.

## In React [#in-react]

`useOnboarding` loads a checklist through the browser client and loads it
again after `complete` or `reset`. The hook is a registry item: install it
with `npx shadcn add @better-supabase/use-onboarding`, which writes
`hooks/use-onboarding.ts` into the app.

```tsx title="components/getting-started.tsx"
"use client";

import { useOnboarding } from "@/hooks/use-onboarding";
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 `onboarding.progress(checklist)` instead; the hook
file starts with `"use client"`.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createOnboardingController` with its options, as
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)
explains; `auth` and `ports` come from
`app.use(betterSupabase(bsb))` unless you pass them. `state` is a shallow ref
of the snapshot and `actions` are the controller's actions. The work starts
on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createOnboarding,
  createOnboardingController,
  rpcTransport,
} from "better-supabase/blocks/onboarding";
import { gettingStarted } from "@/onboarding";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(createOnboardingController, {
  client: createOnboarding({ transport: rpcTransport(supabase) }),
  checklist: gettingStarted,
  organizationId,
});
</script>

<template>
  <ul>
    <li v-for="step in state.progress?.steps ?? []" :key="step.id">
      {{ step.title }}
    </li>
  </ul>
</template>
```

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createOnboardingController` with its options; `auth` and `ports` come from
`setBetterSupabase(bsb)` unless you pass them. It returns a readable
store of the snapshot, read with `$`, with the actions on `.actions`. The
work starts on mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createOnboarding,
    createOnboardingController,
    rpcTransport,
  } from "better-supabase/blocks/onboarding";
  import { gettingStarted } from "@/onboarding";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const onboarding = useBlockController(createOnboardingController, {
    client: createOnboarding({ transport: rpcTransport(supabase) }),
    checklist: gettingStarted,
    organizationId,
  });
</script>

<ul>
  {#each $onboarding.progress?.steps ?? [] as step (step.id)}
    <li>{step.title}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createOnboardingController` with its options; `auth` and `ports` come from
`<BetterSupabaseProvider client={bsb}>` unless you pass them. `state()` reads
the snapshot through `from`, and `actions` are the controller's actions. The
work starts on mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createOnboarding,
  createOnboardingController,
  rpcTransport,
} from "better-supabase/blocks/onboarding";
import { gettingStarted } from "@/onboarding";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createOnboardingController, {
    client: createOnboarding({ transport: rpcTransport(supabase) }),
    checklist: gettingStarted,
    organizationId: props.organizationId,
  });
  return (
    <ul>
      <For each={state().progress?.steps ?? []}>
        {(step) => <li>{step.title}</li>}
      </For>
    </ul>
  );
}
```

## 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`.

## Without the client [#without-the-client]

`db.onboarding` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="app/(app)/page.tsx"
import {
  createOnboarding,
  rpcTransport,
} from "better-supabase/blocks/onboarding";
import { gettingStarted } from "@/onboarding";

const onboarding = createOnboarding({ transport: rpcTransport(supabase) });

const progress = await onboarding
  .progress(gettingStarted, organizationId)
  .orThrow();
// { steps: [{ id, title, completed, completedAt, ... }], completed: 1, total: 3, done: false, next: { id: "billing", ... } }

await onboarding.complete(gettingStarted, "project", organizationId);
await onboarding.reset(gettingStarted, "project", organizationId);
```

# Scores

> Tenant-editable scoring models that add rule points and weighted metrics into a versioned score with bands on any record. Lead scoring and account health are models.

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

`scores` keeps scoring models per organization and the scores they give
records. A model names a subject type and adds up two kinds of points: rules,
each a filter tree over the [rules](/docs/blocks/automations) field catalog worth a
number of points when it matches, and metrics, each a number field of the
record (or its vote score from [reactions](/docs/blocks/reactions)) times a
weight, with an optional cap. Bands name ranges of the total, such as `hot`
from 50 and `warm` from 20.

Lead scoring and account health are two models, not two features: a `lead`
model over parties that rewards a qualified stage and company size, and a
`health` model that adds usage and subtracts open tickets.

Reading needs `scores.read`, changing models needs `scores.manage`, and
computing a score needs `scores.compute` or the service role. Admins get all
three and members get `scores.read`.

```bash
better-supabase add scores
```

The module needs `tenant`, `permissions` and `rules`. It reads vote scores when
[reactions](/docs/blocks/reactions) is installed, records `score_model.*`
events to the outbox and the audit log, and emits `score.changed`.

Server code calls the `scores` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useScores } = defineDataHooks(
  "scores",
  (db: AppDb) => db.scores,
);
```

```ts
import { createScores } from "better-supabase/blocks/scores";

await db.scores
  .saveModel({
    key: "lead",
    name: "Lead score",
    subjectType: "party",
    rules: [
      {
        id: "qualified",
        label: "Qualified",
        conditions: qualified,
        points: 20,
      },
      { id: "enterprise", conditions: bigRevenue, points: 30 },
    ],
    metrics: [
      { id: "size", source: "field", field: "employees", weight: 0.5, cap: 25 },
      { id: "votes", source: "votes", weight: 2 },
    ],
    bands: [
      { label: "hot", min: 50 },
      { label: "warm", min: 20 },
      { label: "cold", min: -1000 },
    ],
  })
  .orThrow();

const score = await db.scores
  .compute("lead", { type: "party", id: party.id }, party)
  .orThrow();
// { score: 81, band: "hot", changed: true, breakdown: { rules, metrics } }
```

```tsx title="src/features/scores/list.tsx"
"use client";

import { useScores } from "@/lib/supabase/hooks";

const rows = useScores({ limit: 50 });
```

`qualified` and `bigRevenue` are `FilterQuery` trees, the same ones the
[rule builder](/docs/blocks/automations#editor) edits. Sync the subject type's
fields with `createAutomationsAdmin().syncFields()` first; a model can only test
fields the catalog has, and a `field` metric must name a `number` field.

## Models [#models]

`saveModel` creates a model from `key`, `name` and `subjectType`, or with an
`id` changes the keys you pass. The key is unique in the organization and the
subject type can't change. Pass the `version` you read to fail with
`SCORES_MODEL_CONFLICT` when someone saved in between.

Changing rules, metrics or bands raises `version` and saves the definition, so
`model(id)` returns every version with who saved it. Renaming or turning a
model off keeps the version. `models()` lists models by key, `deleteModel(id)`
removes a model with its versions and scores, and `enabled: false` stops new
computations without losing the history.

| Option       | Default | What it limits                 |
| ------------ | ------- | ------------------------------ |
| `maxRules`   | 100     | The rules one model may hold   |
| `maxMetrics` | 50      | The metrics one model may hold |

A model holds at most 10 bands.

## Computing [#computing]

`compute(modelKey, subject, fields)` scores one record. `fields` holds the
record's values the way the rules block reads them (the same object
`rules.run` takes), and `timeZone` decides relative dates, UTC by default. The
total is the matched rules' points plus each metric's value times its weight,
capped at `cap` in both directions; an empty field counts as 0. The first band
whose `min` the total reaches names it.

A score that differs from the current one, or that comes from a newer model
version, becomes the record's current score and emits `score.changed` with
the previous score and band. An unchanged score returns the current row with
`changed: false`, so you can recompute on every change of the record without
growing the history. The old scores stay as history.

Compute from the place that changes the record: a Server Action after an
update, or an outbox sink on the record's events. With the service role you
can compute for any organization.

## Reading scores [#reading-scores]

| Method                              | Returns                                                   |
| ----------------------------------- | --------------------------------------------------------- |
| `list({ on })`                      | The record's current score under each model, by model key |
| `history(modelKey, subject)`        | The record's scores under one model, newest first         |
| `top(modelKey, { band, minScore })` | The highest current scores, for a "hot leads" list        |

Each score has its `breakdown`: every rule with whether it matched and its
points, and every metric with its value and points, so a UI can say why a
lead is hot.

`scoresBinding` loads `list({ on })` for a record page, and reloads on
`score.changed`. See [Record bindings](/docs/repository/record-bindings).

## Events [#events]

| Event                                      | Data                                                                                                                  |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `score_model.saved`, `score_model.deleted` | `organizationId`, `id`, `key`, `subjectType`, `version`                                                               |
| `score.changed`                            | `organizationId`, `modelId`, `modelKey`, `subjectType`, `subjectId`, `score`, `previousScore`, `band`, `previousBand` |

A rule on `score.changed` can route a lead that turns hot, or notify an
account owner when health drops a band.

## With ReUI [#with-reui]

There is no prebuilt scores UI. Show `band` as a `Badge` (`success-light` for
the top band) next to the record title, and list `breakdown.rules` and
`breakdown.metrics` in a `Popover` to explain the number. Edit a model's
rules with the rule builder recipe from the [rules page](/docs/blocks/automations#editor),
one filter tree per rule with a points input beside it.

## Why it works this way [#why-it-works-this-way]

Models are data, so an organization tunes its own scoring without a deploy,
and every version stays readable for the scores it produced. The record passes
its fields in, as with rules, so the module never reads your tables and works
for any subject type.

## Without the client [#without-the-client]

`db.scores` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createScores } from "better-supabase/blocks/scores";

const scores = createScores({ transport, tenant: organizationId });

await scores
  .saveModel({
    key: "lead",
    name: "Lead score",
    subjectType: "party",
    rules: [
      {
        id: "qualified",
        label: "Qualified",
        conditions: qualified,
        points: 20,
      },
      { id: "enterprise", conditions: bigRevenue, points: 30 },
    ],
    metrics: [
      { id: "size", source: "field", field: "employees", weight: 0.5, cap: 25 },
      { id: "votes", source: "votes", weight: 2 },
    ],
    bands: [
      { label: "hot", min: 50 },
      { label: "warm", min: 20 },
      { label: "cold", min: -1000 },
    ],
  })
  .orThrow();

const score = await scores
  .compute("lead", { type: "party", id: party.id }, party)
  .orThrow();
// { score: 81, band: "hot", changed: true, breakdown: { rules, metrics } }
```

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

Server code calls the `waitlist` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useWaitlist } = defineDataHooks(
  "waitlist",
  (db: AppDb) => db.waitlist,
);
```

```ts
const rows = await db.waitlist.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/waitlist/list.tsx"
"use client";

import { useWaitlist } from "@/lib/supabase/hooks";

const rows = useWaitlist({ limit: 50 });
```

`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 blocks.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 `blocks.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
`blocks.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                 |

## Your own columns [#your-own-columns]

Add columns to the waitlist entry, such as a company from the sign-up form, table with `blocks.waitlist.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`join` (only for a new address) validates and writes them, and `entries`, `approve` and `reject` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createWaitlist } from "better-supabase/blocks/waitlist";
import { z } from "zod";

const client = createWaitlist({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.waitlist` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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" }
}
```

# Access requests

> Members ask for a role in their organization, for a set time if they like. The request goes through approvals, the final approval grants the role, and the grant ends early or when it expires.

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

`access-requests` lets a member ask for a role (a viewer asking to be an admin for month end, a contractor asking to join). It files the request with the [approvals](/docs/blocks/approvals) module, so approvers, steps, delegation, notifications and expiry work as they do there. The final approval grants the role, and when the grant has a duration, `expire_access_grants()` puts the previous role back. Asking needs `access_requests.create`, reading other people's requests needs `access_requests.read`, and ending someone else's grant needs `access_requests.manage`.

```bash
better-supabase add access-requests
```

Server code calls the `access-requests` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAccessRequests } = defineDataHooks(
  "accessRequests",
  (db: AppDb) => db.accessRequests,
);
```

```ts
import { createAccessRequests } from "better-supabase/blocks/access-requests";
import { createApprovals } from "better-supabase/blocks/approvals";

const request = await db.accessRequests
  .request({
    organizationId,
    role: "admin",
    reason: "Month end",
    duration: Temporal.Duration.from({ days: 3 }),
  })
  .orThrow();

// An approver decides through approvals.
const approvals = createApprovals({ transport });
await approvals.approve(request.approvalId!).orThrow();
```

```tsx title="src/features/access-requests/list.tsx"
"use client";

import { useAccessRequests } from "@/lib/supabase/hooks";

const rows = useAccessRequests({ limit: 50 });
```

## Configure [#configure]

```ts title="better-supabase.config.ts"
blocks: {
  "access-requests": {
    options: {
      roles: ["admin", "billing"],
      maxDuration: "P7D",
      policy: { steps: [{ approvers: { role: "owner" } }], expiresIn: "P2D" },
    },
  },
},
```

| Option        | Default                                       | What it does                                                                                                       |
| ------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `roles`       | every role of the access model except `owner` | The roles a member may ask for (`ACCESS_ROLE_INVALID` otherwise)                                                   |
| `policy`      | one step: holders of `members.update_role`    | An [approvals policy](/docs/blocks/approvals#configure-policies) without `run`: steps, `expiresIn`, `selfApproval` |
| `maxDuration` | none                                          | An ISO 8601 duration that caps a grant and is the default when a request names none                                |
| `join`        | `false`                                       | Lets someone outside the organization ask to join with a role                                                      |

A member can have one pending request per organization (`ACCESS_REQUEST_PENDING`), can't ask for the role they hold (`ACCESS_ROLE_HELD`), and an owner can't ask for another role.

## Statuses [#statuses]

`pending`, `rejected`, `cancelled` and `expired` follow the approval request; cancel a request with `approvals.cancel(request.approvalId)`. `granted` means the role is held, and `ended` means it was given up, revoked or ran out.

`end(id)` ends a grant early, for the holder or a manager. Schedule `expire_access_grants()` to end grants past their `expiresAt`:

```sql
select cron.schedule('expire-access', '*/5 * * * *', 'select better_supabase.expire_access_grants()');
```

When a grant ends, the previous role comes back (or the membership goes, for someone who joined through the request), unless the role was changed in the meantime: a later change wins.

## The provider access model [#the-provider-access-model]

With the `provider` access model the module doesn't touch memberships. The authorization provider's adapter grants the role when it receives `access.request_approved` and takes it back on `access.ended`.

## Events and hooks [#events-and-hooks]

The module records `access.requested`, `access.request_approved` and `access.ended` (with `reason` set to `expired` or `revoked`), each with `organizationId`, `userId`, `role`, the request `id`, `approvalId` and `expiresAt`; they also go to the [audit log](/docs/blocks/audit). The approvals module records its own `approval.*` events for the request. The module calls `before_access_request`, `after_access_request`, `before_access_grant` and `after_access_grant`.

## With ReUI [#with-reui]

An admin page renders `list(organizationId, { status: "pending" })` in a ReUI data grid with approve and reject actions from the approvals client. A member's request form pairs a role select with a duration select and shows their requests from `list(organizationId, { mine: true })`.

## Your own fields [#your-own-fields]

`blocks.access-requests.options.extraColumns` adds columns to the `access_requests` table,
keyed by column name with their SQL type. Pass `createAccessRequests` a Standard Schema
for them as `fields`: `request` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and every read return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createAccessRequests({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.accessRequests` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createAccessRequests } from "better-supabase/blocks/access-requests";
import { createApprovals } from "better-supabase/blocks/approvals";

const access = createAccessRequests({ transport });
const request = await access
  .request({
    organizationId,
    role: "admin",
    reason: "Month end",
    duration: Temporal.Duration.from({ days: 3 }),
  })
  .orThrow();

// An approver decides through approvals.
const approvals = createApprovals({ transport });
await approvals.approve(request.approvalId!).orThrow();
```

# 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 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({
  blocks: { 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. `supportSessionId`, `impersonatedBy`
and `impersonationReason` fill the impersonation columns, such as for an
entry about a support session that the admin's own token records. Any
caller but the service role may only name itself as `impersonatedBy`, from
a token without an `act` claim; otherwise the claim's values are stored. 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
`blocks.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 `createAudit` to
type them:

Server code calls the `audit` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAudit } = defineDataHooks(
  "audit",
  (db: AppDb) => db.audit,
);
```

```ts
const rows = await db.audit.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/audit/list.tsx"
"use client";

import { useAudit } from "@/lib/supabase/hooks";

const rows = useAudit({ limit: 50 });
```

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                   |
| `read`          | decrypted or sensitive values, when a table opts in          |

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
blocks: { "flags": { audit: false } },
```

`options.auditCategory` on `organizations`, `organizations-suspension` and
`impersonation` still works and puts every entry of that module under one
category, but it is deprecated: map the shared categories with `values`
instead.

## Read audits [#read-audits]

A clinic or a regulated app has to show who looked at a record, not only who
changed it. Reads are audited where values are decrypted, so a query that
skips the definer path can't read the value either:

- an app table with [`auditReads: true`](/docs/repository/tables#encrypted-columns)
  writes `<type>.viewed` for each `<table>_reveal` call;
- [`custom-fields`](/docs/blocks/custom-fields#encrypted-fields) with
  `options.auditReads` writes `field_value.viewed` for each `readValues` call
  that returns an encrypted or sensitive value.

Each entry has the category `read`, the acting member and the tenant, and
names the rows in `metadata.ids`. One call writes one entry per tenant
whatever the number of rows, so listing a page of records costs one insert.
List them with `category: "read"`; [retention](#retention) purges them with
the other entries.

## 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 `memberships` or `partitionkey` extension, then `data.organizationId` |
| `actorId`                                                   | `data.actorId`                                                            |
| `metadata`                                                  | `data`                                                                    |
| `idempotencyKey`                                            | `source` and `id`, so a redelivered event is recorded once                |
| `supportSessionId`, `impersonatedBy`, `impersonationReason` | `data.sessionId`, `data.adminId` and `data.reason` of a `support.*` event |

`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),
});
```

The sink records up to 1,000 events per call through
`better_supabase.audit_event_many(events jsonb)`, which runs `audit_event`
for each element with the same checks. `audit.recordMany(events)` calls it
directly and returns one `Result` per event, so one bad event doesn't stop
the others.

`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]

`createAudit` 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 { createAudit, sqlTransport } from "better-supabase/blocks/audit";

const audit = createAudit({ 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();
```

### In the browser [#in-the-browser]

`useAuditLog` loads the newest page through the provider's client, as the caller, so the read policy
applies as it does for `list`. `setFilters({ actorId, eventType, search })`
narrows the table and loads the first page again (an empty value matches
every entry), and `loadMore()` appends the next, older page while
`hasMore` is `true`. The same hook runs in React DOM and React Native. It
is a registry item: install it with
`npx shadcn add @better-supabase/use-audit`, which writes
`hooks/use-audit.ts` into the app.

```tsx title="components/audit-log-table.tsx"
import { useAuditLog } from "@/hooks/use-audit";

const { entries, filters, hasMore, setFilters, loadMore } = useAuditLog({
  organizationId,
  schema: "api",
});
```

`createAuditLogController` from `better-supabase/blocks/audit` is the same
logic without React. Vue, Svelte and Solid run it with `useBlockController`
from `better-supabase/vue`, `/svelte` and `/solid`, passing a `client` from
`createAudit`, with `auth` from the provider (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)). The
[audit log table recipe](/docs/registry/ui-recipes/audit-log-table) shows it in the Next.js
and Expo examples.

```vue title="AuditLog.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createAudit,
  createAuditLogController,
  rpcTransport,
} from "better-supabase/blocks/audit";

const props = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createAuditLogController, {
  client: createAudit({ transport: rpcTransport(supabase), schema: "api" }),
  organizationId: props.organizationId,
});
</script>

<template>
  <p v-for="entry in state.entries" :key="entry.id">{{ entry.eventType }}</p>
  <button v-if="state.hasMore" @click="actions.loadMore()">Load more</button>
</template>
```

```svelte title="AuditLog.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createAudit,
    createAuditLogController,
    rpcTransport,
  } from "better-supabase/blocks/audit";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const audit = useBlockController(createAuditLogController, {
    client: createAudit({ transport: rpcTransport(supabase), schema: "api" }),
    organizationId,
  });
</script>

<p>{$audit.entries?.length ?? 0} entries</p>
```

```tsx title="AuditLog.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createAudit,
  createAuditLogController,
  rpcTransport,
} from "better-supabase/blocks/audit";

export function AuditLog(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createAuditLogController, {
    client: createAudit({ transport: rpcTransport(supabase), schema: "api" }),
    organizationId: props.organizationId,
  });
  return <p>{state().entries?.length ?? 0} entries</p>;
}
```

## Reverting changes [#reverting-changes]

`audit.revert(entryIds)` writes the old values of row changes back, newest
entry first, all or nothing. It runs as the caller, so the target table's
policies and grants decide whether each write is allowed, and the revert is
itself audited. The caller needs `audit.revert` in each entry's organization
(admins and owners have it by default).

```ts title="app/settings/audit/actions.ts"
const { reverted, undo } = await audit.revert([entry.id]).orThrow();
```

| Entry    | Revert                                                              |
| -------- | ------------------------------------------------------------------- |
| `update` | Restores the changed columns, if the row still holds the new values |
| `insert` | Deletes the row, if it still holds the inserted values              |
| `delete` | Inserts the row again with its old values, generated columns aside  |

| Hint                         | When                                                                 |
| ---------------------------- | -------------------------------------------------------------------- |
| `AUDIT_ENTRY_NOT_FOUND`      | An entry is missing or the caller lacks `audit.revert` in its tenant |
| `AUDIT_ENTRY_NOT_REVERTIBLE` | The entry is an event, or its table no longer exists                 |
| `AUDIT_ENTRY_REDACTED`       | A value the revert needs was redacted when it was logged             |
| `ROW_CHANGED_SINCE`          | The row changed after the entry, or a deleted row exists again       |
| `AUDIT_REVERT_FORBIDDEN`     | The table's policies hide the row from the caller                    |
| `AUDIT_REVERT_TOO_MANY`      | More than 1000 entries in one call                                   |

Reverting needs the row snapshots (`old` and `new` on the log, or in the
restricted table) and the access module. An adopted log without them gets no
`revert_audit_entries` function.

### Undo tokens [#undo-tokens]

Methods whose change has an inverse return an `undo` token: `relations.link`,
`relations.unlink` and `audit.revert`. The token is plain JSON, so a server
action can return it to a toast, and applying it runs the inverse as the
caller, with the same permission checks as any other write. A revert's token
names the entries the revert wrote, found by the correlation id it sets, so
applying it reverts the revert. An adopted log without a correlation id column
returns `undo: null`.

Apply a token with `createUndo` from `better-supabase/blocks/undo`, or keep a
stack of them in the browser with `useUndoStack`; see
[Undo](/docs/blocks/undo). Bulk actions return no token, because a row's
inverse depends on the action; revert their audit entries instead.

## 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 = bsb.handler(async (request, ctx) => {
  const audit = createAudit({
    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.
`purgeAuditLog` also takes a service-role transport instead of the SQL
connection, such as `rpcTransport(admin, { schema: "api" })` over the
wrappers `blocks.audit.api` writes.

With the `data-lifecycle` module, `purge_audit_log` also follows the
`audit` [retention policies and legal holds](/docs/blocks/data-lifecycle#retention-policies-and-legal-holds).

### Partitioned log [#partitioned-log]

`options.partitioned` creates a new managed log partitioned by month on
`occurred_at`, so a purge drops whole months instead of deleting rows:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { audit: { options: { partitioned: true } } },
});
```

The module creates this month's partition and the next three, plus a
default partition for anything outside them. Schedule
`better_supabase.maintain_partitions()` once a month to add the months
ahead. `purge_audit_log` drops every month past the cutoff, which is the
longest `audit` retention policy or `older_than`, and deletes the dropped
entries' restricted details. It deletes rows one by one instead while a
legal hold covers audit entries, when an `audit_retention` hook exists, or
for one tenant.

The option applies when the module creates the log. An existing log stays
as it is, and `better-supabase doctor` reports it as BS335; move its rows
into a partitioned copy yourself. An adopted log is never partitioned.

## Hash chain [#hash-chain]

`options.chain` links each tenant's entries in a hash chain: every entry
stores its position, the previous entry's hash and its own hash, a SHA-256
of the row and the previous hash. Changing, deleting or inserting an entry
afterwards breaks the chain from that point. `createAuditChain` checks it
and signs its heads:

```ts title="jobs/audit-checkpoint.ts"
import { createAuditChain, sqlTransport } from "better-supabase/blocks/audit";
import { createSigningKeys } from "better-supabase/blocks/signing-keys";

const signingKeys = createSigningKeys({
  transport: sqlTransport(postgres.admin),
});
const chain = createAuditChain({
  transport: sqlTransport(postgres.admin),
  signer: signingKeys.signer(
    { type: "app", id: "audit" },
    { create: "Ed25519" },
  ),
  issuer: "https://app.example.com",
});

await chain.checkpoint().orThrow();

const verdict = await chain.verify({ organizationId }).orThrow();
// { valid: true, checked: 1204, seq: 1204, hash: "9f2c…" }
// { valid: false, checked: 311, seq: 312, brokenAt: "8841", reason: "hash" }
```

| Method               | What it does                                                           |
| -------------------- | ---------------------------------------------------------------------- |
| `verify(range?)`     | Checks a tenant's entries in order, optionally between `from` and `to` |
| `heads()`            | Each tenant's latest entry that no checkpoint covers yet               |
| `checkpoint()`       | Signs every head as a JWS and records it in `audit_checkpoints`        |
| `checkpoints(opts?)` | A tenant's recorded checkpoints, newest first                          |

A verdict's `reason` is `hash` (an entry changed), `link` (an entry points
at the wrong predecessor), `gap` (an entry is missing) or `checkpoint` (a signed head no longer
matches). Without a `to`, `verify` checks up to the latest checkpoint, so
deleting the newest entries is caught once a checkpoint covers them. The checkpoint JWS carries `sub` (the
tenant), `seq`, `hash`, `entry` and `iat`; publish the signer's
[JWK Set](/docs/blocks/signing-keys) so an auditor can check the
checkpoints without database access. Run `checkpoint()` on a schedule:
an entry rewritten together with every later hash still fails against the
last signed head.

Every chain function is service-role only. The chain starts with the first
entry written after the option is on; older entries stay outside it. It
needs a managed log.

## Without the client [#without-the-client]

`db.audit` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const audit = createAudit<{ 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 }
```

# Organizations and identity

> Organizations, members, roles and the identity blocks an account needs.

Source: https://bettersupabase.com/docs/blocks/catalog-organizations

- [Organizations and invitations](/docs/blocks/organizations)
- [Memberships](/docs/blocks/memberships)
- [Profiles](/docs/blocks/profiles)
- [Audit log](/docs/blocks/audit)
- [Settings](/docs/blocks/settings)
- [Data lifecycle](/docs/blocks/data-lifecycle)
- [SSO](/docs/blocks/sso)
- [Teams](/docs/blocks/teams)
- [Metrics](/docs/blocks/metrics)
- [Created by](/docs/blocks/created-by)
- [Access requests](/docs/blocks/access-requests)
- [Identities](/docs/blocks/identities)
- [Custom domains](/docs/blocks/domains)
- [Controls](/docs/blocks/compliance)

# Controls

> Compliance frameworks and controls with evidence files and notes, recurring evidence tasks with assignees and an overdue state, versioned policies that members acknowledge, and a status rollup per framework.

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

`controls` keeps what an audit asks for in one place. You list the controls of
a framework such as SOC 2 or ISO 27001, attach evidence to each control, plan
the evidence that has to be collected again on a schedule, and publish
policies that every member acknowledges. Each control's status follows from
its evidence and tasks, and `posture` rolls it up per framework.

```bash
better-supabase add compliance
```

```ts
import { createCompliance } from "better-supabase/blocks/compliance";
```

The module requires `tenant`, `permissions` and `versions`. With
[attachments](/docs/blocks/attachments) a piece of evidence can be a file,
with [notifications](/docs/blocks/notifications) a missed task notifies its
assignee, with `outbox` it records the events, and with `audit` every change
is audited.

## Tables [#tables]

| Table                     | Holds                                                                                              |
| ------------------------- | -------------------------------------------------------------------------------------------------- |
| `control_frameworks`      | A framework per organization by `key`, with a name and version                                     |
| `controls`                | A control in a framework by `code`, with a title, an owner and whether it applies                  |
| `control_evidence`        | Evidence for a control: an attachment, a note or both, who collected it and until when it is valid |
| `evidence_tasks`          | Recurring evidence: a title, an interval in days, the next due date and an assignee                |
| `policies`                | A policy by `key` with an owner and its current published version                                  |
| `policy_acknowledgements` | Which member acknowledged which version of a policy, and when                                      |

Members with `controls.read` read every table.

## Frameworks and controls [#frameworks-and-controls]

Server code calls the `compliance` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCompliance } = defineDataHooks(
  "compliance",
  (db: AppDb) => db.compliance,
);
```

```ts
const soc2 = await db.compliance
  .saveFramework(organizationId, {
    key: "soc2",
    name: "SOC 2",
    version: "2017",
  })
  .orThrow();

const access = await db.compliance
  .saveControl({
    frameworkId: soc2.id,
    code: "CC6.1",
    title: "Logical access",
    ownerId,
  })
  .orThrow();
```

```tsx title="src/features/compliance/list.tsx"
"use client";

import { useCompliance } from "@/lib/supabase/hooks";

const rows = useCompliance({ limit: 50 });
```

Saving a framework with a key that exists, or a control with a code that
exists in the framework, updates it. A control with `applicable: false` is
out of scope. Frameworks, controls, evidence tasks and policies need
`controls.manage`.

A control's `status` is computed, never stored:

| Status           | When                                                               |
| ---------------- | ------------------------------------------------------------------ |
| `not_applicable` | the control doesn't apply                                          |
| `failing`        | an active evidence task is overdue, or all its evidence has lapsed |
| `passing`        | it has evidence that is still valid                                |
| `not_started`    | it has no evidence yet                                             |

## Evidence and tasks [#evidence-and-tasks]

```ts
await controls
  .addEvidence(access.id, {
    attachmentId,
    note: "Quarterly access review",
    validUntil: Temporal.PlainDate.from("2026-12-31"),
  })
  .orThrow();

const task = await controls
  .saveEvidenceTask(access.id, {
    title: "Review access",
    intervalDays: 90,
    assigneeId,
  })
  .orThrow();
```

Evidence is an attachment in the organization, a note, or both; adding it
needs `controls.collect`. A task is due on `dueOn` (default today) and is
`overdue` once that day has passed. Evidence added with `evidenceTaskId`
answers the task: its due date moves `intervalDays` past today, and the
evidence is valid until then unless `validUntil` says otherwise. A task's
assignee may collect for it without `controls.collect`.

Run `notifyOverdue` daily with the service role, for example from a
[job](/docs/blocks/queue). It records `evidence_task.missed` once for each
lapse and notifies the assignee, or the control's owner when the task has
none.

## Posture [#posture]

```ts
const [framework] = await controls.posture(organizationId).orThrow();
// { total, passing, failing, notStarted, notApplicable, overdueTasks }
```

`posture` returns one rollup per framework, or for one framework when you
pass its id.

## Policies [#policies]

```ts
const policy = await controls
  .savePolicy(organizationId, {
    key: "acceptable-use",
    title: "Acceptable use",
  })
  .orThrow();

await controls
  .publishPolicy(policy.id, { body: markdown }, "2026 edition")
  .orThrow();
await controls.acknowledgePolicy(policy.id).orThrow();
```

`publishPolicy` stores the content as a frozen [version](/docs/blocks/versions)
and makes it current. Members acknowledge the current version with
`controls.acknowledge`; publishing again asks everyone to acknowledge the new
version. `policies(organizationId, { pendingOnly: true })` lists the policies
waiting for the caller, and `acknowledgements(policyId)` lists every member
with the time they acknowledged the current version. An archived policy can't
be published or acknowledged.

| Method                                 | Returns                                      |
| -------------------------------------- | -------------------------------------------- |
| `saveFramework(organizationId, input)` | the framework                                |
| `frameworks(organizationId)`           | the frameworks                               |
| `saveControl(input)`                   | the control                                  |
| `getControl(controlId)`                | the control with its evidence and tasks      |
| `controls(organizationId, options)`    | the controls by framework and code           |
| `addEvidence(controlId, input)`        | the evidence                                 |
| `removeEvidence(evidenceId)`           | the removed evidence                         |
| `saveEvidenceTask(controlId, input)`   | the task                                     |
| `evidenceTasks(organizationId, opts)`  | the tasks, the earliest due first            |
| `posture(organizationId, frameworkId)` | one rollup per framework                     |
| `notifyOverdue()`                      | how many lapsed tasks it reported            |
| `savePolicy(organizationId, input)`    | the policy                                   |
| `publishPolicy(policyId, content)`     | the policy with its new version              |
| `archivePolicy(policyId)`              | the archived policy                          |
| `acknowledgePolicy(policyId)`          | the policy with the caller's acknowledgement |
| `policies(organizationId, options)`    | the policies with acknowledgement counts     |
| `acknowledgements(policyId)`           | every member and when they acknowledged      |

## Errors [#errors]

| Hint                          | Cause                                                                       |
| ----------------------------- | --------------------------------------------------------------------------- |
| `CONTROLS_FORBIDDEN`          | the caller lacks `controls.read`, `.manage`, `.collect` or `.acknowledge`   |
| `CONTROLS_INVALID`            | a field is invalid, or the attachment or a member isn't in the organization |
| `CONTROL_FRAMEWORK_NOT_FOUND` | no framework with this id                                                   |
| `CONTROL_NOT_FOUND`           | no control with this id                                                     |
| `CONTROL_EVIDENCE_NOT_FOUND`  | no evidence with this id                                                    |
| `EVIDENCE_TASK_NOT_FOUND`     | the evidence task isn't a task of this control                              |
| `POLICY_NOT_FOUND`            | no policy with this id                                                      |
| `POLICY_INVALID`              | the content isn't an object, or the owner isn't a member                    |
| `POLICY_ARCHIVED`             | the policy is archived and can't be published                               |
| `POLICY_NOT_PUBLISHED`        | the policy has no current version to acknowledge, or it is archived         |

## Events [#events]

`control.saved`, `control.evidence_added`, `evidence_task.missed`,
`policy.published` and `policy.acknowledged`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.compliance` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const controls = createCompliance({ transport });

const soc2 = await controls
  .saveFramework(organizationId, {
    key: "soc2",
    name: "SOC 2",
    version: "2017",
  })
  .orThrow();

const access = await controls
  .saveControl({
    frameworkId: soc2.id,
    code: "CC6.1",
    title: "Logical access",
    ownerId,
  })
  .orThrow();
```

# Created by

> Record who created, changed and deleted each row, in Postgres and in the repository.

Source: https://bettersupabase.com/docs/blocks/created-by

The `created-by` block installs the `track_actor` trigger, which reads
`auth.uid()` inside the database, and a repository extension that fills
`createdBy` and `updatedBy` from the request's actor.

```bash
better-supabase add created-by
```

`gen` marks every table that has the columns and installs the extension in
the generated schema. The actor comes from the connection:

```ts
const db = defineSupabase(schema).connect(supabase, {
  actor: { id: user.id, kind: "user" },
});
```

Rename the columns in the block's options (`createdBy`, `updatedBy` and
`impersonatedBy`, defaults `created_by`, `updated_by` and
`impersonated_by`).

## What the extension writes [#what-the-extension-writes]

- Inserts get `createdBy` and `updatedBy`.
- Updates get `updatedBy`. With [trash](/docs/blocks/trash), 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 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
  `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 `bsb.forContext(job.context)` (see
[jobs](/docs/blocks/queue#actor-and-tenant)), webhooks and scripts with
`bsb.actingAs(userId)`, and work no user owns with `bsb.admin()`, whose actor
is `service`.

To read the id from somewhere else, install the extension yourself with a
`resolve` function; it replaces the one the schema installed:

```ts
import { createdByExtension } from "better-supabase/blocks/created-by";

defineSupabase(schema).use(
  createdByExtension({ resolve: (context) => context.actor?.id }),
);
```

For a tamper-proof trail, the trigger and the [audit](/docs/blocks/audit)
block record the database user, whatever the app sends.

# 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 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 `upgrade`, and a table you map to another name in
`blocks.<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({
  blocks: {
    "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: {}`.

`omitPattern` leaves out columns by name, for tables you didn't list one by
one. `true` matches `token`, `secret`, `_hash`, `password`, `api_key` and
`license_key` anywhere in the name, ignoring case; a string is a Postgres
regular expression of your own. The match runs on the catalog when an
export runs, for listed and `autoTables` tables alike, and adds to each
table's `omit`. It is off by default:

```ts
options: {
  autoTables: {},
  omitPattern: true, // or "^(private|internal)_"
},
```

`doctor` warns about each exported column that matches the built-in pattern
and that no `omit` or `omitPattern` leaves out
([BS341](/docs/cli/doctor#bs341)).

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

Server code calls the `data-lifecycle` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDataLifecycle } = defineDataHooks(
  "dataLifecycle",
  (db: AppDb) => db.dataLifecycle,
);
```

```ts
const rows = await db.dataLifecycle.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/data-lifecycle/list.tsx"
"use client";

import { useDataLifecycle } from "@/lib/supabase/hooks";

const rows = useDataLifecycle({ limit: 50 });
```

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/queue) 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 `blocks.permissions.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 `blocks.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 a member holding the `cancelDelete` permission
(`organization.delete` unless `blocks.data-lifecycle.permissions.cancelDelete`
names another key), can cancel until the purge runs. The check goes through
`better_supabase.member_can_while_deleting()`, which every access model
renders: it asks the same question as `member_can()` without the
disabled-tenant check, so an authorization provider's roles keep working
while the organization waits. With `blocks.permissions.mode: "custom"`, where
the app writes the access functions itself, an owner can cancel instead.
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 (on its own table or yours), or the
row of the table `blocks.permissions.disabled.tenant` names, or the one in
`options.tenantRow` (`"schema.table.column"`, `false` to keep it), so a
tenant table of your own 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.

## Retention policies and legal holds [#retention-policies-and-legal-holds]

Each module's purge takes an `older_than` default. A retention policy
overrides it per kind of record, for every tenant, a plan's tenants or one
tenant, and a legal hold stops every purge from deleting what it covers.
Both live in `retention_policies` and `legal_holds`, and the functions are
service-role only:

```ts title="lib/retention.ts"
import {
  createRetention,
  sqlTransport,
} from "better-supabase/blocks/data-lifecycle";

const retention = createRetention({ transport: sqlTransport(postgres.admin) });

await retention.setPolicy("audit", "7 years").orThrow();
await retention.setPolicy("audit", "1 year", { plan: "free" }).orThrow();
await retention
  .setPolicy("outbox", Temporal.Duration.from({ days: 30 }), { organizationId })
  .orThrow();

const hold = await retention
  .placeHold({ organizationId, reason: "Case 2026-114" })
  .orThrow();
await retention.releaseHold(hold.id).orThrow();
```

| Kind                 | Purged by                  | Policies | Holds |
| -------------------- | -------------------------- | -------- | ----- |
| `audit`              | `purge_audit_log`          | yes      | yes   |
| `outbox`             | `purge_outbox`             | yes      | yes   |
| `webhook_deliveries` | `purge_webhook_deliveries` | yes      | yes   |
| `webhooks`           | `purge_webhooks`           | yes      | yes   |
| `inbound_events`     | `purge_inbound_events`     | yes      | yes   |
| `job_archive`        | `purge_job_archive`        | yes      | yes   |
| `usage`              | `purge_usage_history`      | yes      | yes   |
| `notifications`      | `purge_notifications`      | yes      | yes   |
| `workflow_runs`      | `purge_workflow_runs`      | yes      | yes   |
| `ai_chats`           | `purge_ai_chats`           | no       | yes   |
| `trash`              | `purge_expired_trash`      | no       | yes   |

A purge keeps a row for the tenant's own policy, then its plan's, then the
policy for every tenant, and falls back to its `older_than` argument; an
`audit_retention` hook still comes first for the audit log. Chats and trash
expire on their own date, so only holds apply to them. An app's own purges
can use any other kind through `better_supabase.retention_for(kind, tenant)`
and `is_held(kind, tenant)`.

A hold without `organizationId` covers every tenant, and one without `kind`
covers every kind. While a hold covers a tenant, `purge_organization` stops
with `ORGANIZATION_LEGAL_HOLD`, and the purger stops before it cancels
billing, revokes credentials or removes files. Placing and releasing a hold emit
`legal_hold.placed` and `legal_hold.released`. When `data-lifecycle` isn't
installed, each purge keeps its plain `older_than` and nothing is held.

## 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`                                                 |

## Hooks [#hooks]

`createDataLifecycle` takes `hooks`, keyed by method name, that run your code
before and after a call: refuse it with a `DbError`, rewrite its arguments
or observe the result (see [extending blocks](/docs/extending/blocks#hooks)).

## Without the client [#without-the-client]

`db.dataLifecycle` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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();
```

# Custom domains

> Hostnames an organization serves your app on, its own domains verified by a DNS TXT record and subdomains of your platform domain, with a lookup the proxy runs on every request.

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

`domains` lets each organization serve your app on its own hostnames: a custom domain such as `app.acme.com`, verified by a DNS TXT record, or a subdomain such as `acme.yourapp.com`, which is verified when added. Each organization can mark one verified hostname as primary, and your proxy looks up the organization behind a request's `Host` header with `resolve`.

Listing hostnames needs `domains.read`, and adding, removing or choosing the primary one needs `domains.manage`. Admins get both, and members get `domains.read`.

```bash
better-supabase add domains
```

The module needs `memberships` and `access`. With `reserved-slugs` installed, subdomains go through its slug check.

Server code calls the `domains` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDomains } = defineDataHooks(
  "domains",
  (db: AppDb) => db.domains,
);
```

```ts
import { createDomains } from "better-supabase/blocks/domains";

const hostname = await db.domains.add(organizationId, "app.acme.com").orThrow();
// hostname.record: {
//   type: "TXT",
//   name: "_better-supabase.app.acme.com",
//   value: "better-supabase-hostname-verification=3f1c...",
// }
```

```tsx title="src/features/domains/list.tsx"
"use client";

import { useDomains } from "@/lib/supabase/hooks";

const rows = useDomains({ limit: 50 });
```

Show the record to the organization's admin, who publishes it at their DNS provider and points the hostname at your app (a CNAME or the address your host gives you). The record stays on the hostname until it is verified, so the settings page can show it again.

## Verification [#verification]

`verify_hostname` is granted to the service role only. `createDomainsAdmin` looks up the TXT record over DNS-over-HTTPS (Cloudflare's resolver by default) and marks the hostname verified once the value is there. Pass `actorId` when a user asked for the check, so the database confirms they hold `domains.manage`.

```ts
import { createDomainsAdmin } from "better-supabase/blocks/domains";

const admin = createDomainsAdmin({ transport: serviceTransport });
const result = await admin.verify(hostnameId, { actorId: user.id });
// HOSTNAME_RECORD_MISSING until DNS has the record
```

Run it from a button on the settings page, or from a [job](/docs/blocks/queue) that retries until the record appears. Pass `resolver` to look up TXT records another way. Verification proves the organization controls the hostname; your host still has to serve it, so add the domain to your Vercel or Cloudflare project in the same step.

Only one organization can hold a verified hostname. Several can claim one while it is pending, and the first to verify it wins, so a pending claim can't block the real owner.

## Platform subdomains [#platform-subdomains]

Set `platformDomain` to your app's own domain, and one label below it becomes a subdomain that is verified when added:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { domains: { options: { platformDomain: "yourapp.com" } } },
});
```

`add(organizationId, "acme.yourapp.com")` returns a verified `subdomain` hostname. `yourapp.com` itself and deeper names such as `a.b.yourapp.com` are refused. `txtPrefix` (default `_better-supabase`) sets the record name's first label, such as `_yourapp-verify`.

## The proxy [#the-proxy]

`resolve(host)` returns the organization behind a verified hostname, or `undefined`. It is open to anon callers, since the proxy runs before sign-in, and it returns nothing a DNS lookup doesn't already show. It ignores the port and the case of the `Host` header.

```ts title="proxy.ts"
import { createClient } from "@supabase/supabase-js";
import { createDomains, rpcTransport } from "better-supabase/blocks/domains";
import { type NextRequest, NextResponse } from "next/server";

const domains = createDomains({
  transport: rpcTransport(
    createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    ),
  ),
});

export async function proxy(request: NextRequest) {
  const found = await domains.resolve(request.headers.get("host") ?? "");

  if (!found.ok || found.data === undefined) {
    return NextResponse.next();
  }

  const { organizationId, primary, primaryHostname } = found.data;

  if (!primary && primaryHostname !== undefined) {
    const url = request.nextUrl.clone();
    url.host = primaryHostname;
    url.port = "";

    return NextResponse.redirect(url, 308);
  }

  const url = request.nextUrl.clone();
  url.pathname = `/o/${organizationId}${url.pathname}`;

  return NextResponse.rewrite(url);
}
```

Cache the answer for a minute or so when every request goes through the proxy: a hostname changes owner only when it is removed and verified again.

## Errors [#errors]

| Hint                      | Cause                                                               |
| ------------------------- | ------------------------------------------------------------------- |
| `HOSTNAME_FORBIDDEN`      | the caller lacks `domains.manage` or `domains.read`                 |
| `HOSTNAME_INVALID`        | not a hostname, or more than one label below the platform domain    |
| `HOSTNAME_RESERVED`       | the platform domain itself, or a subdomain `reserved-slugs` refuses |
| `HOSTNAME_TAKEN`          | another organization has verified this hostname                     |
| `HOSTNAME_NOT_FOUND`      | no hostname the caller manages has this id                          |
| `HOSTNAME_NOT_VERIFIED`   | only a verified hostname can be primary                             |
| `HOSTNAME_RECORD_MISSING` | `verify` found no TXT record with the value yet                     |

## Events [#events]

The block records `hostname.added`, `hostname.verified`, `hostname.primary_set` and `hostname.removed` with `organizationId`, `hostnameId` and `hostname`, and writes them to the audit log under `configuration`. See [events](/docs/extending/events).

The block takes `hooks` around its methods, such as a rule on how many hostnames a plan may add; see [extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.domains` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createDomains } from "better-supabase/blocks/domains";

const domains = createDomains({ transport });
const hostname = await domains.add(organizationId, "app.acme.com").orThrow();
// hostname.record: {
//   type: "TXT",
//   name: "_better-supabase.app.acme.com",
//   value: "better-supabase-hostname-verification=3f1c...",
// }
```

# Identities

> Link an email address or a chat user to an app user or a contact, through a one-time code the signed-in user issues or a confirmed address.

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

`identities` maps an email address or a chat user (a Slack, Teams or WhatsApp id) to an app user or a contact. A bot or a mail webhook sees only the platform's id; this table says who that is. An identity with no row is anonymous.

```bash
better-supabase add identities
```

```ts
import {
  createIdentities,
  identityResolver,
} from "better-supabase/blocks/identities";
```

## How a row is created [#how-a-row-is-created]

A row comes from one of two places. Nobody inserts or updates `identity_links` directly.

1. **Link code.** The signed-in user calls `beginLink(kind, provider)`, which returns a one-time code that expires after 10 minutes. The user hands it to the bot (`/link 3F9A…`). The bot side calls `redeemLink(code, identity)` as the service role, and the row binds the identity the platform reported to the user who issued the code.
2. **Confirmed address.** A trigger on `auth.users` creates the user's email identity once `email_confirmed_at` is set, and moves it when the address changes. An unconfirmed address creates nothing. Run `backfillVerifiedEmails()` once for users confirmed before you installed the module.

Server code calls the `identities` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useIdentities } = defineDataHooks(
  "identities",
  (db: AppDb) => db.identities,
);
```

```ts
const rows = await db.identities.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/identities/list.tsx"
"use client";

import { useIdentities } from "@/lib/supabase/hooks";

const rows = useIdentities({ limit: 50 });
```

```ts title="lib/bot.ts"
import { redeemLink, sqlTransport } from "better-supabase/blocks/identities";
import { chatIdentityOf } from "better-supabase/chat-sdk";

const service = sqlTransport(postgres.asService());

bot.onSubscribedMessage(async (thread, message) => {
  const code = /^\/link\s+(\S+)/.exec(message.text)?.[1];
  if (!code) return;
  const linked = await redeemLink(
    service,
    code,
    chatIdentityOf(message.author, thread.adapter, teamId),
  );
  await thread.post(linked.ok ? "Linked." : "That code is invalid or expired.");
});
```

`redeemLink` trusts the identity it is given, so pass only one the platform authenticated: the author of a signed Slack event, or a sender your mail provider verified with DKIM. A code is refused once used, after it expires, when the user issued a newer one for the same kind and provider, and when the kind or provider differs. An identity linked to another user is refused with `IDENTITY_TAKEN`; that user unlinks it first.

## Resolving an identity [#resolving-an-identity]

`identityResolver(transport)` is a `PrincipalByIdentity`: it returns `{ kind: "user", userId }`, `{ kind: "contact", contactId }` or `null`. Give it a service transport and plug it into the server, or pass it to a chat helper directly.

```ts title="lib/server.ts"
import {
  identityResolver,
  sqlTransport,
} from "better-supabase/blocks/identities";
import { createServer, defineAuthorizationHooks } from "better-supabase/server";

export const bsb = createServer(betterSupabase, {
  authorization: defineAuthorizationHooks({
    apiVersion: 1,
    name: "app",
    identities: {
      principalByIdentity: identityResolver(sqlTransport(postgres.asService())),
    },
  }),
});
```

An email lookup is case-insensitive and also matches a row stored without a provider or workspace, so a verified address resolves whichever mail provider reports it. A database error rejects instead of answering `null`, so a linked author is never treated as anonymous during an outage. [Chat SDK identities](/docs/chat-sdk/identities) shows how bot jobs and buttons use the result.

## Calls [#calls]

| Method                       | Runs as        | SQL function               |
| ---------------------------- | -------------- | -------------------------- |
| `beginLink(kind, provider)`  | signed-in user | `begin_identity_link`      |
| `redeemLink(code, identity)` | service role   | `redeem_identity_link`     |
| `resolve(identity)`          | service role   | `principal_by_identity`    |
| `list()`                     | signed-in user | `my_identities`            |
| `unlink(id)`                 | signed-in user | `unlink_identity`          |
| `backfillVerifiedEmails()`   | service role   | `backfill_verified_emails` |

`beginLink` and `redeemLink` are also standalone functions that take the transport first.

## Tables and access [#tables-and-access]

| Table                 | Holds                                                                         | `authenticated`                |
| --------------------- | ----------------------------------------------------------------------------- | ------------------------------ |
| `identity_links`      | `kind`, `provider`, `value`, `workspace` and one of `user_id` or `contact_id` | reads and deletes its own rows |
| `identity_link_codes` | the SHA-256 of each code, its expiry and when it was used                     | none                           |

`anon` has no access to either table. `(kind, provider, value, workspace)` is unique, with nulls treated as equal. A check keeps exactly one of `user_id` and `contact_id` set. With the [inbox](/docs/blocks/inbox) installed, `contact_id` references its `contacts` table; the service role writes contact links directly, for example when it mirrors a channel contact.

## Options [#options]

| Option           | Default | Effect                                                                       |
| ---------------- | ------- | ---------------------------------------------------------------------------- |
| `codeMinutes`    | `10`    | How long a link code stays valid, clamped to 1 to 60                         |
| `verifiedEmails` | `true`  | `false` drops the `auth.users` trigger; `link_verified_email` stays callable |

```ts title="better-supabase.config.ts"
blocks: {
  "identities": { options: { codeMinutes: 5 } },
},
```

## Hooks [#hooks]

`createIdentities` takes `hooks`, keyed by method name, that run your code
before and after a call: refuse it with a `DbError`, rewrite its arguments
or observe the result (see [extending blocks](/docs/extending/blocks#hooks)).

## Without the client [#without-the-client]

`db.identities` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="app/settings/link-slack/actions.ts"
"use server";

import {
  createIdentities,
  sqlTransport,
} from "better-supabase/blocks/identities";

export async function linkSlack() {
  const ctx = await bsb.context();
  const identities = createIdentities({
    transport: sqlTransport(ctx.postgres),
  });
  const { code } = await identities.beginLink("chat", "slack").orThrow();
  return `Send "/link ${code}" to @acme-bot in Slack.`;
}
```

# Memberships

> Scope every query to the request's organization, on top of RLS.

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

The `memberships` block owns the memberships table and the
`tenant_ids_with()` checks the policies use. Its repository extension scopes
queries on organization tables to the request's organization and fills the
organization column on insert. `gen` installs the extension in the generated
schema; an app with its own tenancy turns it on by setting
`auth.organization.column` without the block.

```ts
const betterSupabase = defineSupabase(schema);
const db = betterSupabase.connect(supabase, { claims }); // or { tenant: organizationId }
```

For tables with the organization column:

- reads, updates and deletes get `organization_id = <organization>`,
  including includes and relation filters;
- inserts get the organization column filled in;
- writing a row for another organization, or moving a row to one, fails with
  `forbidden` before anything is sent. A numeric column matches when its text
  equals the organization id;
- an upsert that updates on conflict needs the organization column in its
  conflict target (`onConflict: ["organizationId", "kvk"]` or a unique
  constraint that includes it), so it cannot overwrite another
  organization's row. The SQL and SQLite executors also add
  `where <column> = excluded.<column>` to the update for every organization
  table, with or without the extension. PostgREST has no such clause, so on
  the default executor the table's RLS update policy is what stops a
  cross-organization upsert.

The organization it resolves becomes `db.$context.tenant`, so jobs, storage
and cache invalidation that receive `db.$context` use the same one.

## Where the organization comes from [#where-the-organization-comes-from]

`context.tenant` first, then the JWT claim. The claim defaults to
`auth.organization.claim` (`tenant_id`), then the same key in
`app_metadata`: a top-level claim from a custom access token hook, or
`app_metadata` set through the Auth admin API. Renaming the claim in the
config renames it for the extension, `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.

An organization from a URL or request body is only safe after you check it
against the user's memberships. Do that in a `resolve` function, or pass
`{ tenant }` to `betterSupabase.connect` once it is checked. Installing the
extension yourself replaces the one the schema installed:

```ts
import { membershipsExtension } from "better-supabase/blocks/memberships";

const betterSupabase = defineSupabase(schema).use(
  membershipsExtension({
    header: "X-Organization",
    resolve: (context) => checkedOrganization(context),
  }),
);
```

Pass your [claims type](/docs/auth#typed-claims) to check claim paths at
compile time. Only paths to string claims, up to three levels deep, are
accepted:

```ts
membershipsExtension<v.InferOutput<typeof Claims>>({
  claim: "app_metadata.tenant_id",
});
```

## Missing organization [#missing-organization]

A query on an organization table without an organization is left to RLS by
default. Set `auth.organization.onMissing: "error"` (or pass
`onMissing: "error"`) to fail it with `forbidden` instead, including a query
on another table whose includes or relation filters reach an organization
table. Admin tooling passes `{ allTenants: true }` per call:

```ts
await adminDb.customers.count({ allTenants: true });
```

## In the API document [#in-the-api-document]

[`defineApi`](/docs/specs) marks the organization column of every served
table `readOnly`, described as "Set from the request's tenant.", and drops it
from the required fields of request bodies. With `auth.organization.header`
(or the `header` option) set, each operation on an organization table has
that header as a parameter, "The tenant the request acts for.", required
when `onMissing` is `error`. The extension doesn't read the header itself;
`resolve` does.

## One level [#one-level]

The extension scopes queries to one column with one id. For an organization
with environments under it, make the environment the scope: put its id in the
claim or in `{ tenant }`, and let an
[authorization provider](/docs/extending/authorization-providers#nested-tenancy)
declare organization and environment as scopes with `parent`, so the policies
check permissions at the environment level. See
[nested tenancy](/docs/concepts/nested-tenancy) for the whole model.

> **Not a replacement for RLS**
>
>
> The extension makes queries correct and indexes usable; RLS makes them safe.
> Keep an organization policy on every table. `better-supabase doctor` checks
> both.

# Metrics

> Declare metrics with defineMetric, roll them up into metric_points or read small tables directly, and read series and KPIs with a previous-range comparison.

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

`metrics` turns a table into time series. A metric counts rows, or sums or
averages a column, per time bucket and per dimension, in the metric's time
zone. Large tables roll up into `metric_points`; small ones are read directly.
`series()` and `kpi()` return the shapes chart and stat-card components read,
and each read checks a permission in the tenant.

```bash
better-supabase add metrics
```

```ts
import {
  createMetrics,
  defineMetric,
  metricsSink,
  toChartData,
} from "better-supabase/blocks/metrics";
```

The module needs `memberships` and `access`. It works with `jobs` for the periodic
rollup and with [Usage](/docs/blocks/usage), whose events can be a metric's
source.

## Define metrics [#define-metrics]

Define each metric once, in a file both the config and the app import, so the
SQL and the client agree on keys and dimensions:

```ts title="src/metrics.ts"
import { defineMetric } from "better-supabase/blocks/metrics";

export const metrics = [
  defineMetric({
    key: "orders",
    table: "public.orders",
    measure: "count",
    timeColumn: "placed_at",
    dimensions: ["channel"],
    tz: "Europe/Amsterdam",
    rollup: ["hour", "day"],
  }),
  defineMetric({
    key: "revenue",
    table: "public.orders",
    measure: { sum: "total" },
    timeColumn: "placed_at",
    where: { status: "paid" },
    rollup: false,
    unit: "EUR",
  }),
  defineMetric({
    key: "api_calls",
    table: "better_supabase.usage_events",
    measure: { sum: "quantity" },
    timeColumn: "recorded_at",
    where: { meter: "api_calls" },
    permission: "metrics.manage",
  }),
] as const;
```

```ts title="better-supabase.config.ts"
import { metrics } from "./src/metrics";

blocks: {
  metrics: { options: { metrics, window: "3 days" } },
},
```

| Key              | Default           | What it sets                                                                  |
| ---------------- | ----------------- | ----------------------------------------------------------------------------- |
| `key`            | required          | The metric's name, lowercase with underscores                                 |
| `table`          | required          | The source table, as `table` or `schema.table`                                |
| `measure`        | required          | `"count"`, `{ sum: column }` or `{ avg: column }`                             |
| `timeColumn`     | required          | The timestamp that places a row in a bucket                                   |
| `dimensions`     | none              | Columns a read can filter or group by                                         |
| `tenant`         | `organization_id` | The tenant column                                                             |
| `tz`             | `UTC`             | The IANA time zone buckets start in                                           |
| `where`          | none              | Column to value; only matching rows count                                     |
| `includeDeleted` | `false`           | Counts rows of a subject table whose `deletedAt` column is set                |
| `rollup`         | `["day"]`         | The grains stored in `metric_points`, or `false` to read the table every time |
| `permission`     | `metrics.read`    | The key a read checks in the tenant                                           |
| `label`          | none              | A display name `definitions()` returns                                        |
| `unit`           | none              | A display unit such as `EUR`                                                  |

A metric over a table that a [`config.records`](/docs/blocks/sql#subject-registry)
entry with a `deletedAt` column points at counts live rows only: the module
adds `deletedAt is null` unless `where` names that column or the metric sets
`includeDeleted: true`.

`window` (default `3 days`) is how far back the periodic rollup
re-aggregates, so late and edited rows land in their buckets.

## Rollups [#rollups]

A stored grain answers that grain and every coarser one it divides: hourly
points sum into days, weeks and months, daily points into weeks and months.
A read at a grain no stored grain divides, such as `hour` for a daily metric,
reads the table.

`metrics_rollup` rebuilds the buckets of a window: it deletes the stored
points from the aligned start of the window to the aligned end and inserts
them again from the table, under an advisory lock per metric. It is service
only. Run it on a schedule with `jobs`:

```ts title="app/api/jobs/drain/route.ts"
export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: { "metrics.rollup": metrics.job },
});
```

To keep charts current between runs, relay the events that add rows to
`metricsSink`. It re-aggregates the buckets of each day the batch's events
fall on, per metric:

```ts
outbox.relay(
  "metrics",
  metricsSink(serviceMetrics, {
    on: { "order.placed": ["orders", "revenue"] },
  }),
);
```

The sink reads each event's `time`. When rows sit at another time than the
event, such as time logged for a past day, pass `time`, a function that
returns the timestamp to bucket by, or undefined to keep the event's own.
It may return several timestamps, for an event that moves a row from one
day to another; the sink rolls up each of their days and skips the days
between. [Time tracking](/docs/blocks/time-tracking#metrics) exports
`timeEntryMetricTime` for its entries, and
[orders](/docs/blocks/orders#metrics) exports `orderMetrics` with the events
that feed it.

Through [`createBlocks`](/docs/blocks/create-blocks#outbox-sinks), the
metrics block's `sink` option (`{ on, time }`) builds the same sink into
`sinks.metrics`.

## Read series and KPIs [#read-series-and-kpis]

`createMetrics({ transport, metrics })` types the metric keys and each
metric's dimensions from the definitions.

Server code calls the `metrics` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useMetrics } = defineDataHooks(
  "metrics",
  (db: AppDb) => db.metrics,
);
```

```ts
const range = {
  from: Temporal.Instant.from("2026-10-01T00:00:00Z"),
  until: Temporal.Instant.from("2026-11-01T00:00:00Z"),
};

const series = await db.metrics
  .series("orders", range, {
    organizationId,
    grain: "day",
    groupBy: "channel",
    compare: "previous",
  })
  .orThrow();

const revenue = await db.metrics
  .kpi("revenue", range, { organizationId, compare: "previous" })
  .orThrow();
// { metric: "revenue", value: 1840, count: 23, previous: 1600, change: 0.15, trend: "up" }
```

```tsx title="src/features/metrics/list.tsx"
"use client";

import { useMetrics } from "@/lib/supabase/hooks";

const rows = useMetrics({ limit: 50 });
```

A series has one point per bucket of `[from, until)`, with empty buckets as
zero (or `null` for an average over no rows). `compare: "previous"` reads the
range of the same length just before, bucket for bucket. `filter` keeps rows
whose dimensions equal the given values.

| Hint                       | When                                                        |
| -------------------------- | ----------------------------------------------------------- |
| `METRIC_FORBIDDEN`         | The caller lacks the metric's permission in the tenant      |
| `METRIC_UNKNOWN`           | No metric has this key                                      |
| `METRIC_DIMENSION_UNKNOWN` | A filter or `groupBy` names a column that isn't a dimension |
| `METRIC_GRAIN_INVALID`     | The grain isn't `hour`, `day`, `week` or `month`            |
| `METRIC_RANGE_INVALID`     | `from` is not before `until`                                |
| `METRIC_RANGE_TOO_LARGE`   | The series would have more than 1000 buckets                |

`definitions()` lists the declared metrics with their label, unit, measure,
dimensions, stored grains and time zone, for a metric picker.

## Usage meters [#usage-meters]

`usage` records each increment in `usage_events`, so a meter is a metric over
that table with `where: { meter }`, as `api_calls` above. Give it a stricter
`permission` when the numbers are internal.

## With ReUI [#with-reui]

`toChartData` turns a series into the rows Recharts reads: `date`, `value`,
`previous` with a comparison, and one key per group with `groupBy`. With the
ReUI (shadcn) chart components:

```tsx title="components/orders-chart.tsx"
"use client";

import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import type { MetricSeries } from "better-supabase/blocks/metrics";
import { toChartData } from "better-supabase/blocks/metrics";
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart";

const day = new Intl.DateTimeFormat("en", { month: "short", day: "numeric" });

export function OrdersChart({ series }: { series: MetricSeries }) {
  const data = toChartData(series, {
    format: (bucket) => day.format(new Date(bucket.epochMilliseconds)),
  });
  return (
    <ChartContainer
      config={{
        value: { label: "Orders", color: "var(--chart-1)" },
        previous: { label: "Previous period", color: "var(--chart-2)" },
      }}
    >
      <AreaChart data={[...data]}>
        <CartesianGrid vertical={false} />
        <XAxis dataKey="date" tickLine={false} axisLine={false} />
        <ChartTooltip content={<ChartTooltipContent />} />
        <Area dataKey="previous" stroke="var(--color-previous)" fill="none" />
        <Area
          dataKey="value"
          stroke="var(--color-value)"
          fill="var(--color-value)"
          fillOpacity={0.2}
        />
      </AreaChart>
    </ChartContainer>
  );
}
```

A `kpi()` result fills a stat card: `value`, and `change` with `trend` for
the badge.

## Hooks [#hooks]

`before_metric_rollup` and `after_metric_rollup` run around each metric's
rollup with its key and window. `createMetrics` takes `hooks` around its
methods, as every block does.

## Example [#example]

The Next.js example defines one metric in `src/metrics.ts`, customers counted
by `created_at` with a `status` dimension and `rollup: false`, and passes the
same array to `blocks.metrics.options.metrics` and to `createMetrics`.
The dashboard's New customers card reads three KPIs in one
`"use cache: private"` function with `bsb.cached()`: the last 30 days against
the 30 before, and the leads and active customers among them. See
`getCustomerCounts` in `apps/examples/nextjs/src/features/dashboard`.

## Without the client [#without-the-client]

`db.metrics` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const metrics = createMetrics({ transport, metrics: definitions });
const range = {
  from: Temporal.Instant.from("2026-10-01T00:00:00Z"),
  until: Temporal.Instant.from("2026-11-01T00:00:00Z"),
};

const series = await metrics
  .series("orders", range, {
    organizationId,
    grain: "day",
    groupBy: "channel",
    compare: "previous",
  })
  .orThrow();

const revenue = await metrics
  .kpi("revenue", range, { organizationId, compare: "previous" })
  .orThrow();
// { metric: "revenue", value: 1840, count: 23, previous: 1600, change: 0.15, trend: "up" }
```

# 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 add organizations invitations
```

Both modules need `tenant` (the memberships table) and `access` (the
[access contract](/docs/repository/permissions)), so `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 `blocks.memberships.columns.memberships.disabledAt`, see
[suspended memberships](/docs/repository/permissions#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
`blocks.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:

Server code calls the `organizations` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useOrganizations } = defineDataHooks(
  "organizations",
  (db: AppDb) => db.organizations,
);
```

```ts
const rows = await db.organizations.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/organizations/list.tsx"
"use client";

import { useOrganizations } from "@/lib/supabase/hooks";

const rows = useOrganizations({ limit: 50 });
```

`rpcTransport(ctx.supabase)` calls them over the Data API instead. Never add
`better_supabase` or a `blocks.<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: `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"
blocks: {
  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"
```

Invitations take their own columns from
`blocks.invitations.options.extraColumns`. `invite` writes them from
`fields`, every invitation the block returns carries them in `fields`, and
`invitationFields` types and checks them:

```ts
const organizations = createOrganizations({
  transport,
  invitationFields: z.object({ source: z.enum(["admin", "signup"]) }),
});
await organizations.invite({
  organizationId,
  email,
  role: "member",
  fields: { source: "admin" },
});
```

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 `blocks.permissions.activeTenant`:

| Source          | `switch_organization` writes                                         | `refresh` |
| --------------- | -------------------------------------------------------------------- | --------- |
| `claim`         | the claim (`auth.organization.claim`) 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.

### In the browser [#in-the-browser]

`useOrganizations` lists the caller's organizations and the active one, read
from the tenant claim (`auth.organization.claim`) of the provider's session.
`switchTo(id)` calls `switch_organization`, refreshes the session when the
result asks for it, and loads the list again; it returns a `Result` and keeps a
failure in `error`. The same hook runs in React DOM and React Native. It is a
registry item: install it with `npx shadcn add
@better-supabase/use-organizations`, which writes `hooks/use-organizations.ts`
into the app; the file also exports `useMembers` and `useInvitations`.

```tsx title="components/organization-switcher.tsx"
import { useOrganizations } from "@/hooks/use-organizations";

const { organizations, active, switching, switchTo } = useOrganizations({
  schema: "api",
});
```

`load` replaces `organizations.mine()`, such as with a server action that adds
the plan to each row, and `initial` and `activeId` take what a server already
rendered so the first load is skipped. `onSwitched` replaces the session
refresh, such as with a router refresh after it in a Next.js app.
`createOrganizationsController` from `better-supabase/blocks/organizations` is
the same logic without React. Vue, Svelte and Solid run it with
`useBlockController` from `better-supabase/vue`, `/svelte` and `/solid`, taking
the controller's options with `auth` from the provider
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)). The
[organization switcher recipe](/docs/registry/ui-recipes/org-switcher) shows it
in the Next.js and Expo examples.

```vue title="ActiveOrganization.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createOrganizations,
  createOrganizationsController,
  rpcTransport,
} from "better-supabase/blocks/organizations";

const supabase = useSupabase();
const organizations = createOrganizations({
  transport: rpcTransport(supabase),
});
const { state, actions } = useBlockController(createOrganizationsController, {
  client: organizations,
  load: () => organizations.mine().orThrow(),
  onSwitched: async () => {
    await supabase.auth.refreshSession();
  },
});
</script>

<template>
  <button
    v-for="organization in state.organizations"
    :key="organization.id"
    @click="actions.switchTo(organization.id)"
  >
    {{ organization.name }}
  </button>
</template>
```

```svelte title="ActiveOrganization.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createOrganizations,
    createOrganizationsController,
    rpcTransport,
  } from "better-supabase/blocks/organizations";

  const { supabase } = getBetterSupabase();
  const client = createOrganizations({ transport: rpcTransport(supabase) });
  const organizations = useBlockController(createOrganizationsController, {
    client,
    load: () => client.mine().orThrow(),
    onSwitched: async () => {
      await supabase.auth.refreshSession();
    },
  });
</script>

<p>{$organizations.activeId}</p>
```

```tsx title="ActiveOrganization.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createOrganizations,
  createOrganizationsController,
  rpcTransport,
} from "better-supabase/blocks/organizations";

export function ActiveOrganization() {
  const supabase = useSupabase();
  const client = createOrganizations({ transport: rpcTransport(supabase) });
  const { state } = useBlockController(createOrganizationsController, {
    client,
    load: () => client.mine().orThrow(),
    onSwitched: async () => {
      await supabase.auth.refreshSession();
    },
  });
  return <p>{state().activeId}</p>;
}
```

## Members and invitations in the browser [#members-and-invitations-in-the-browser]

`useMembers` and `useInvitations` from `@/hooks/use-organizations` call the
same functions as
`members`, `updateMemberRole`, `removeMember`, `invitations`, `invite` and
`revokeInvitation`, through the provider's client. Each takes the
organization and waits without one, and each write returns a `Result` and
keeps a failure in `error`. The same hooks run in React DOM and React
Native.

```tsx title="components/members-table.tsx"
import { useInvitations, useMembers } from "@/hooks/use-organizations";

const { members, pending, setRole, remove } = useMembers({
  organizationId,
  schema: "api",
});
const { invite, inviting, sent, clear } = useInvitations({
  organizationId,
  schema: "api",
});
```

`setRole(userId, role)` shows the new role at once and puts the old one
back when `update_member_role` refuses it; `pending` lists the members with
a write in flight. `remove(userId)` removes a member and loads the list
again. `invite({ email, role })` keeps the result in `sent` until `clear()`,
so a dialog can show the accept link from `sent.token`, and `revoke(id)`
revokes an open invitation.

`load` replaces the default list, such as with a server action that adds
names and avatars from your profiles table, and `initial` takes rows a
server already rendered so the first load is skipped. `onChanged` runs
after a change committed, such as a server action that refreshes the
member's session or completes an onboarding step. Both modules audit the
changes themselves. `createMembersController` and
`createInvitationsController` from `better-supabase/blocks/organizations`
are the same logic without React, and the `/vue`, `/svelte` and `/solid`
entries export `useMembers` and `useInvitations` over them. The
[members table](/docs/registry/ui-recipes/members-table) and
[invite dialog](/docs/registry/ui-recipes/invite-dialog) recipes show
them in the Next.js and Expo examples.

## Options [#options]

`blocks.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 `blocks.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.

`blocks.invitations.options` (`tokenStorage: "plain"` is migration-only: the
config accepts it only when the module runs over an invitations table your
app already has, 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 (your table) |
| `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           |
| `extraColumns`          | none       | column name to SQL type on a managed invitations table       |

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
`blocks.permissions.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 (`memberships` or `platform`), and neither kind of role is
assignable as the other. An adopted catalog maps `blocks.permissions.columns.roles.scope`
and names its values in `blocks.permissions.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 `blocks.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/repository/permissions#membership-roles-stored-as-ids).
With `through.where`, a `bsb_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]

The modules run over the tables you have: each skips the `create table` for
a table your schema already has and adds only the columns it lacks. Map the
names and set the optional columns you don't want to `null`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    permissions: { model: "catalog" },
    memberships: {
      tables: { memberships: "public.organization_users" },
      columns: { memberships: { tenant: "organization_id", role: "role_id" } },
    },
    organizations: {
      tables: { organizations: "public.organizations" },
      columns: { organizations: { createdBy: null, deletedAt: null } },
      options: { attributes: ["logo_url"] },
    },
    invitations: {
      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.
}
```

## Without the client [#without-the-client]

`db.organizations` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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 { bsb } from "@/lib/supabase/server";
import { sendInvitationEmail } from "@/lib/email";

export async function inviteMember(organizationId: string, email: string) {
  const ctx = await bsb.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" });
}
```

# 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 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:

Server code calls the `profiles` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useProfiles } = defineDataHooks(
  "profiles",
  (db: AppDb) => db.profiles,
);
```

```ts
import { createProfiles } from "better-supabase/blocks/profiles";

const row = await db.profiles.mine().orThrow();
await db.profiles.updateMine({ full_name: "Ada Lovelace" }).orThrow();
```

```tsx title="src/features/profiles/list.tsx"
"use client";

import { useProfiles } from "@/lib/supabase/hooks";

const rows = useProfiles({ limit: 50 });
```

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 table the block creates, the guard also sets `updated_at`. Over a
`profiles` table of your own, the guard neither checks nor writes the column:
`update_my_profile` sets it, and your own trigger keeps it current for other
writes.

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"
blocks: {
  permissions: { disabled: { user: "better_supabase.profiles.disabled_at" } },
  profiles: {},
},
```

## Options [#options]

`blocks.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, `bsb_profiles_platform_read`,
on a managed table and on yours, and pulls in the `permissions` 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]

When your schema already has a profiles table, the module skips its
`create table`, adds the columns it needs that your table lacks and writes
its functions, policies and triggers over it. Map the key and the columns you
have, and set the optional ones you don't want to `null`:

```ts title="better-supabase.config.ts"
blocks: {
  profiles: {
    tables: { profiles: "public.profiles" },
    columns: {
      profiles: {
        key: "user_id",
        fullName: null,
        avatar: null,
        avatarPath: "avatar_path",
        activeTenant: "active_organization_id",
        onboarding: null,
      },
    },
    callbacks: {
      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 `ownership: "custom"` the module
writes nothing and you provide `sync_profile` and `backfill_profiles`.

On your table, map `avatarPath` to `null` to go without the column; the
module adds it otherwise.

Avatar and logo uploads use the [`avatarBucket` and `organizationLogoBucket`
presets](/docs/platform/storage#avatars-and-organization-logos).

## Without the client [#without-the-client]

`db.profiles` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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();
```

# 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 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 `blocks.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]

`createSettings` takes the definition as `settings` and returns typed
`get`, `set` and `reset` methods for each scope:

Server code calls the `settings` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSettings } = defineDataHooks(
  "settings",
  (db: AppDb) => db.settings,
);
```

```ts
const rows = await db.settings.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/settings/list.tsx"
"use client";

import { useSettings } from "@/lib/supabase/hooks";

const rows = useSettings({ limit: 50 });
```

With [`defineBlocks`](/docs/blocks/create-blocks), pass the definition
through `using`:

```ts title="lib/blocks.ts"
settings: settingsBlock.using((options) =>
  createSettings({ ...options, settings }),
),
```

`hooks` run around every method, such as an audit line on
`setOrganization`:

```ts
const client = createSettings({
  settings,
  transport,
  hooks: {
    setOrganization: {
      after: (result, [organizationId, key]) => {
        if (result.ok) log.info("setting changed", { organizationId, key });
      },
    },
  },
});
```

The settings tables take no app columns: each value is typed by its key's
schema.

`setUser` 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 `blocks.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).

## Settings pages [#settings-pages]

`useSettings` is a registry item: `npx shadcn add
@better-supabase/use-settings` writes `hooks/use-settings.ts` into the app.
The hook edits one scope of the definition through the provider's client, so
a settings page can be
generated from it. `fields` lists the keys in declaration order, each with
a `kind` (`boolean`, `number`, `text`, `choice` or `json`), its `choices`,
and the `title` and `description` from the schema. They come from the
schema's [Standard JSON Schema](https://standardschema.dev): Zod implements
it, and `toStandardJsonSchema` from `@valibot/to-json-schema` adds it to a
Valibot schema. For a schema without it, the kind follows the default's
type. The same hook runs in React DOM and React Native.

```ts title="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({
  organization: {
    defaultRole: {
      schema: toStandardJsonSchema(
        v.pipe(v.picklist(["member", "admin"]), v.title("Default role")),
      ),
      default: "member",
    },
  },
});
```

```tsx title="components/settings-page.tsx"
import { useSettings } from "@/hooks/use-settings";

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

const { fields, values, saving, setField } = useSettings(settings, {
  scope: "organization",
  organizationId,
  schema: "api",
});
// fields: [{ key: "defaultRole", kind: "choice", choices: ["member", "admin"], ... }]
```

`set(key, value)` takes the key's typed input, and `setField(key, value)`
takes whatever a generated control produces; both validate with the key's
schema first and keep a failure in `error`. `reset(key)` goes back to the
default. `createSettingsController` and `settingFields` from
`better-supabase/blocks/settings` are the same logic without React. Vue,
Svelte and Solid run the controller with `useBlockController` from
`better-supabase/vue`, `/svelte` and `/solid` (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)),
passing the scope's `entries` and its client. The [settings layout recipe](/docs/registry/ui-recipes/settings-layout) shows it in
the Next.js and Expo examples.

```vue title="Preferences.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createSettings,
  createSettingsController,
  rpcTransport,
} from "better-supabase/blocks/settings";

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

const supabase = useSupabase();
const { state, actions } = useBlockController(createSettingsController, {
  entries: settings.schemas.user,
  user: createSettings({ settings, transport: rpcTransport(supabase) }),
});
</script>

<template>
  <label v-for="field in state.fields" :key="field.key">
    {{ field.title ?? field.key }}
    <input
      :value="state.values?.[field.key]"
      @change="actions.setField(field.key, $event.target.value)"
    />
  </label>
</template>
```

```svelte title="Preferences.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createSettings,
    createSettingsController,
    rpcTransport,
  } from "better-supabase/blocks/settings";

  import { settings } from "$lib/settings";

  const { supabase } = getBetterSupabase();
  const preferences = useBlockController(createSettingsController, {
    entries: settings.schemas.user,
    user: createSettings({ settings, transport: rpcTransport(supabase) }),
  });
</script>

{#each $preferences.fields as field}<p>{field.title ?? field.key}</p>{/each}
```

```tsx title="Preferences.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createSettings,
  createSettingsController,
  rpcTransport,
} from "better-supabase/blocks/settings";

import { settings } from "~/lib/settings";

export function Preferences() {
  const supabase = useSupabase();
  const { state } = useBlockController(createSettingsController, {
    entries: settings.schemas.user,
    user: createSettings({ settings, transport: rpcTransport(supabase) }),
  });
  return <p>{state().fields.length} settings</p>;
}
```

## 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 `blocks.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 = createSettings({
  settings: platformSettings,
  transport: sqlTransport(postgres.asUser(claims)),
});
await admin.setPlatform("invoiceFeePercent", 1.5).orThrow();
const fee = await admin.getPlatform("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({
  blocks: { settings: { options: { schemas: settings } } },
});
```

```bash
better-supabase add jsonb-schemas settings
```

The checks need the `jsonb-schemas` module, which installs `pg_jsonschema`.
Each one is named `bsb_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         |

## Without the client [#without-the-client]

`db.settings` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="app/settings/actions.ts"
import { createSettings, rpcTransport } from "better-supabase/blocks/settings";

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

const client = createSettings({
  settings,
  transport: rpcTransport(supabase, { schema: "api" }),
});

const theme = await client.getUser("theme").orThrow(); // "light" | "dark" | "system"
await client.setUser("theme", "dark").orThrow();
await client.resetUser("theme"); // back to the default

const all = await client.getOrganization(organizationId).orThrow();
await client.setOrganization(organizationId, "defaultRole", "viewer");
```

# 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 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 `blocks.sso.options.roleOrder`, highest first; `add` stops
without it. A memberships table that stores role ids reads them through
`blocks.memberships.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 `blocks.permissions.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                                                           |
| `scim_sync_log`              | One row per SCIM write, with `syncLog`                                                   |

| 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]

Server code calls the `sso` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSso } = defineDataHooks(
  "sso",
  (db: AppDb) => db.sso,
);
```

```ts
const rows = await db.sso.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/sso/list.tsx"
"use client";

import { useSso } from "@/lib/supabase/hooks";

const rows = useSso({ limit: 50 });
```

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 `blocks.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({
  blocks: {
    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.

### Another store [#another-store]

`transport` is short for `store: sqlScimStore(transport)`, which keeps users
and groups in the tables above. Pass your own `ScimStore` (`apiVersion: 1`,
with `list`, `get`, `save` and `delete` for users and groups) to keep them
somewhere else, such as a directory or a provider that relays them:

```ts title="app/scim/v2/[...path]/route.ts"
import type { ScimStore } from "better-supabase/blocks/sso";

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

const store: ScimStore = {
  apiVersion: 1,
  async list(type, tenant, query) {
    const page = await directory.search(type, tenant, query);
    // totalResults says the directory applied the filter and the paging.
    return ok({ resources: page.items, totalResults: page.total });
  },
  get: async (type, tenant, id) => ok(await directory.find(type, tenant, id)),
  async save(type, tenant, { id, resource, version }) {
    const saved = await directory.put(type, tenant, id, resource, version);
    return saved === "changed"
      ? err(dbError("stale", "Read it again"))
      : ok(saved);
  },
  delete: async (type, tenant, id) =>
    ok(await directory.remove(type, tenant, id)),
};

const handler = scimHandler({ store, keys, basePath: "/scim/v2" });
```

`save` gets the resource as the client sent it, or as a PATCH left it,
extension schemas included, so attributes such as the enterprise extension's
`department` reach the store unchanged. It gets the validated core
attributes as `data` as well; `sqlScimStore` stores only those. A store
returns resources without `meta` and `$ref`s, plus their `version`,
`created` and `lastModified`, and the handler adds the rest. Without
`totalResults`, `list` returns every resource of the tenant and the handler
filters and pages them. Errors are `DbError`s: `not_found` answers 404,
`stale` 412, `conflict` 409, and `invalid_input` or `validation` 400.

### The sync log [#the-sync-log]

`blocks.sso.options.syncLog: true` adds a `scim_sync_log` table with one row
per write: the operation (`create`, `replace`, `patch` or `delete`), the
resource, the status the handler answered, the error and the request id.
Pass `sqlScimSyncLog` as the handler's `log`:

```ts title="app/scim/v2/[...path]/route.ts"
import {
  scimHandler,
  sqlScimSyncLog,
  sqlTransport,
} from "better-supabase/blocks/sso";

const transport = sqlTransport(postgres.admin);
const handler = scimHandler({
  transport,
  keys: createApiKeys({ transport }),
  log: sqlScimSyncLog(transport),
  requestId: (request) => request.headers.get("x-request-id") ?? undefined,
});
```

The handler waits for the log but ignores its failures, so a log that is
down never changes an answer. `list_scim_sync_log(tenant, max)` reads the
latest rows for an admin page, and `purge_scim_sync_log(older_than, batch)`
deletes rows older than 90 days, or the `scim_sync_log` retention policy of
the [data lifecycle](/docs/blocks/data-lifecycle) block, for a periodic job.

## 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 `sqlScimStore`                     |
| `scim_log_sync`, `list_scim_sync_log`, `purge_scim_sync_log` | `service_role`                        | The sync log, with `syncLog`                          |

## Hooks [#hooks]

`createSso` takes `hooks` around its methods, such as a rule before
`removeDomain` that returns a `DbError` to refuse the call; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.sso` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```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);
```

# Teams

> Tenant teams and their members, shared with the inbox.

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

`teams` stores a tenant's teams and who is on them. Creating, changing, archiving and deleting a team needs `teams.manage`. `team_ids_of(user)` lists the teams a user belongs to.

```bash
better-supabase add teams
```

```ts
import { createTeams } from "better-supabase/blocks/teams";
```

| Table          | Holds                                                         |
| -------------- | ------------------------------------------------------------- |
| `teams`        | Name, slug, description and status                            |
| `team_members` | A user on a team, with a foreign key to the tenant membership |

`options.teamIdType` is `uuid` (the default) or `bigint`. The inbox needs `uuid`.

## Inbox [#inbox]

The inbox assigns a conversation to a `teams` row. Installing both drops `inbox_teams` and `inbox_team_members` after copying their rows. `create_inbox_team` and `set_inbox_team_member` call the teams functions.

## Over your own tables [#over-your-own-tables]

Map `tables.teams` and `tables.members` onto the app's tables. CentraKit's team ids are `bigint`, so set `teamIdType` to `bigint` and do not install the inbox module in that project.

## Your own columns [#your-own-columns]

Add columns to the team table with `blocks.teams.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`create` and `update` (the last argument) validates and writes them, and `create`, `update` and `list` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

Server code calls the `teams` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTeams } = defineDataHooks(
  "teams",
  (db: AppDb) => db.teams,
);
```

```ts
const rows = await db.teams.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/teams/list.tsx"
"use client";

import { useTeams } from "@/lib/supabase/hooks";

const rows = useTeams({ limit: 50 });
```

## Without the client [#without-the-client]

`db.teams` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createTeams } from "better-supabase/blocks/teams";
import { z } from "zod";

const client = createTeams({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

# Bookings

> Bookable resources with weekly hours, services with buffers, open slots computed in SQL, holds that expire, double bookings refused by an exclusion constraint, and public booking through a share link.

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

`bookings` lets customers book time on a resource: a person, a room or a
piece of equipment. Postgres computes the open slots, and an exclusion
constraint refuses two bookings that overlap on the same seat, so a race
between two customers can never double-book.

```bash
better-supabase add bookings
```

```ts
import { createBookings } from "better-supabase/blocks/bookings";
```

The module requires `memberships` and `access`, and installs the `btree_gist`
extension in the `extensions` schema for its exclusion constraint (see
[ADR 0023](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0023-bookings-btree-gist.md)).
With `jobs` a hold expires itself, with `share-links` and `rate-limit`
guests book through a link, with `calendar` confirmed bookings appear on the
resource's calendar, with `reminders` customers get reminders, and with
`outbox` its events are recorded. With [engagements](/docs/blocks/activities#bookings)
and `options.bookings`, a confirmed booking is logged as a meeting on the
booker's contact.

## Tables [#tables]

| Table                  | Holds                                                                                                                                                    |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `booking_resources`    | A bookable resource in an organization: `name`, `kind`, `capacity` (overlapping seats), an IANA `tz`, a `calendar_id`, a `host_user_id` and `check_busy` |
| `booking_availability` | Weekly hours: a `weekday` (1 is Monday) with a `starts_at` and `ends_at` time in the resource's zone                                                     |
| `booking_exceptions`   | A `during` range that closes the resource, or opens it outside its hours with `available`                                                                |
| `booking_services`     | What is booked: `duration`, `buffer_before`, `buffer_after`, the `resource_kinds` that serve it, `step` and `min_notice`                                 |
| `bookings`             | `resource_id`, `service_id`, `seat`, `during`, `status` (`hold`, `confirmed`, `cancelled`), `hold_expires_at`, customer                                  |

Each booking also stores `blocked`, its `during` widened by the service's
buffers. The constraint is on that range:

```sql
constraint bookings_no_overlap exclude using gist
  (resource_id with =, seat with =, blocked with &&)
  where (status <> 'cancelled')
```

A resource with a `capacity` of 3 has seats 1 to 3, and each seat takes one
booking at a time. Members read through the tables' policies, and a customer
always sees their own bookings. Every write goes through a function: resources,
hours and services need `bookings.manage`; members book with `bookings.create`
and read with `bookings.read`.

## Resources and services [#resources-and-services]

Server code calls the `bookings` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useBookings } = defineDataHooks(
  "bookings",
  (db: AppDb) => db.bookings,
);
```

```ts
const { id: resourceId } = await db.bookings
  .saveResource(organizationId, {
    name: "Ann",
    kind: "staff",
    tz: "Europe/Amsterdam",
  })
  .orThrow();

await db.bookings
  .setAvailability(resourceId, [
    { weekday: 1, start: "09:00", end: "17:00" },
    { weekday: 2, start: "09:00", end: "17:00" },
  ])
  .orThrow();

const { id: serviceId } = await db.bookings
  .saveService(organizationId, {
    name: "Haircut",
    duration: Temporal.Duration.from({ minutes: 30 }),
    bufferAfter: "PT15M",
    resourceKinds: ["staff"],
  })
  .orThrow();
```

```tsx title="src/features/bookings/list.tsx"
"use client";

import { useBookings } from "@/lib/supabase/hooks";

const rows = useBookings({ limit: 50 });
```

Hours are wall-clock times in the resource's zone, so 09:00 stays 09:00 across
a daylight saving change. `addException` closes a range (a holiday, a
dentist appointment) or, with `available: true`, opens one outside the weekly
hours. Durations take a `Temporal.Duration` or an ISO 8601 string.

## Slots [#slots]

`available_slots(service, range_start, range_end, tz)` is a SQL function, and
`availableSlots` calls it. It builds each resource's open windows from its
hours and exceptions, steps through them by the service's `step` (default its
duration), and keeps the slots where the slot plus its buffers fits and a seat
is free. Slots start no sooner than `min_notice` from now, and one call covers
at most 62 days.

```ts
const slots = await bookings
  .availableSlots(
    serviceId,
    {
      from: Temporal.Instant.from("2030-03-04T00:00:00Z"),
      until: Temporal.Instant.from("2030-03-11T00:00:00Z"),
    },
    { tz: "America/New_York" },
  )
  .orThrow();
// [{ resourceId, startsAt, endsAt, localStart, seats }, ...]
```

`localStart` is the start in the zone you pass, else the resource's zone.
`slotsByDay(slots)` groups them by local date for a day-by-day picker.

## Holds and confirmation [#holds-and-confirmation]

`hold` reserves a slot for the service's `holdFor` (default 10 minutes) while
the customer finishes, and `confirm` books it. `hold(..., { confirm: true })`
books in one step. A member books for themselves, with their email filled in;
a manager can book for any customer.

```ts
const held = await bookings
  .hold(serviceId, slot.resourceId, slot.startsAt, { name: "Bo" })
  .orThrow();

await bookings.confirm(held.id).orThrow();
await bookings.cancel(held.id, "Ill").orThrow();
```

The function checks the slot again before it inserts, and the constraint
settles a race: the second caller gets `BOOKING_SLOT_UNAVAILABLE`. Each new
booking also returns an `accessToken`, shown once and stored hashed, that
confirms or cancels it without signing in, for a link in a confirmation email.

With `jobs` installed, each hold enqueues a job on `bookings.expire-holds` that
runs when the hold ends. Add the handler to your drain route:

```ts
import { BOOKING_EXPIRE_QUEUE } from "better-supabase/blocks/bookings";

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: { [BOOKING_EXPIRE_QUEUE]: bookings.job },
});
```

`expireHolds()` (service only) cancels every hold whose time is up and records
`booking.expired`. A new hold on the same time also clears an expired hold
first, so a missed job never blocks a slot.

When a booking is confirmed and the `calendar` block is installed, it is copied
into the resource's `calendar_id` with the uid `booking-<id>`. With `reminders`,
a customer with a user id gets a reminder for each of the service's
`reminderOffsets` (minutes before the start, default one day). Cancelling
removes both.

| Method                                   | Does                                                                    |
| ---------------------------------------- | ----------------------------------------------------------------------- |
| `saveResource(organizationId, input)`    | Inserts a resource, or updates the one with `input.id`                  |
| `setAvailability(resourceId, hours)`     | Replaces the weekly hours                                               |
| `addException`, `deleteException`        | Closes or opens a range                                                 |
| `saveService(organizationId, input)`     | Inserts a service, or updates the one with `input.id`                   |
| `availableSlots(serviceId, range, opts)` | The open slots, optionally for one `resourceId` and in a `tz`           |
| `hold(serviceId, resourceId, at, ...)`   | Holds a slot, or books it with `confirm`                                |
| `confirm(id)`, `cancel(id, reason)`      | Confirms a live hold, or cancels a hold or booking                      |
| `list(organizationId, range, opts)`      | The bookings that overlap the range, as the caller's policies allow     |
| `expireHolds()`, `job`                   | Cancels expired holds; `job` is the handler for `bookings.expire-holds` |

## Recurring bookings [#recurring-bookings]

Turn on `recurring` to book a series: a weekly class, a standing appointment.
The option is off by default; it adds a `booking_series` table, a `series_id`
column on `bookings` and the series functions. Over a `bookings` table of
your own, the block adds `series_id` when the table lacks it, or uses the
column `columns.bookings.series` maps.

```ts title="better-supabase.config.ts"
blocks: {
  bookings: { options: { recurring: true } },
},
```

A rule has a `freq` (`daily` or `weekly`), an `interval` (1 to 52), the ISO
`weekdays` for a weekly rule (1 is Monday), and a `count` (at most 366), an
`until`, or both. Occurrences keep their local start time in the rule's `tz`
(the resource's zone by default), so a 09:00 class stays at 09:00 across a
daylight saving change. `parseRecurrence` reads the RRULE subset the series
supports (`FREQ`, `INTERVAL`, `BYDAY`, `COUNT`, `UNTIL`) and throws a
`RangeError` for anything else; `formatRecurrence` writes a rule back.

```ts
import { parseRecurrence } from "better-supabase/blocks/bookings";

const booked = await bookings
  .bookSeries(
    serviceId,
    resourceId,
    Temporal.Instant.from("2030-03-18T08:00:00Z"),
    parseRecurrence("FREQ=WEEKLY;BYDAY=MO,WE;COUNT=8"),
    { name: "Bo" },
  )
  .orThrow();

booked.series.id; // every occurrence carries it as seriesId
booked.skipped; // empty unless skipConflicts was set
```

Each occurrence is placed like a confirmed booking, through the same slot
check and the same `bookings_no_overlap` exclusion constraint, so a series can
never double-book a seat. When an occurrence is taken the whole series fails
with `BOOKING_SERIES_CONFLICT` and nothing is booked; pass
`{ skipConflicts: true }` to book the open occurrences and get the taken
starts back in `skipped`.

`reschedule(bookingId, startsAt)` moves one occurrence, or any single booking.
With `{ scope: "following" }` it moves this occurrence and every later live
one in the series by the same shift. Each move frees the booking's own seat
first, checks the new time is an open slot and lets the constraint settle a
race, so a move that collides fails with `BOOKING_SLOT_UNAVAILABLE` and leaves
every occurrence where it was. The calendar copy and the reminders move with
the booking, and each move records `booking.rescheduled`.

`cancelSeries(bookingId)` cancels this occurrence and the ones after it, and
ends the series; `{ scope: "one" }` cancels one occurrence. `series(seriesId)`
returns the series with its bookings.

| Method                                        | Does                                                                |
| --------------------------------------------- | ------------------------------------------------------------------- |
| `bookSeries(serviceId, resourceId, at, rule)` | Books every occurrence, or the open ones with `skipConflicts`       |
| `reschedule(bookingId, at, { scope })`        | Moves one booking, or it and the following occurrences              |
| `cancelSeries(bookingId, { scope, reason })`  | Cancels the following occurrences (default) or one; returns a count |
| `series(seriesId)`                            | The series and its bookings, as the caller's policies allow         |

To pay for classes with a pack of credits, see
[class credits](/docs/blocks/passes#class-credits).

## Appointment schedules [#appointment-schedules]

An appointment schedule is a page where others book time with a member. Build
it from a resource the member hosts: set `hostUserId` and `checkBusy`, and the
slots also skip every time the [calendar block](/docs/blocks/calendar#availability)
reports the host as busy (their own events, invitations they accepted, focus
time, out of office and approved leave). The resource's weekly hours stay the
bookable window; the host's working hours don't narrow it.

```ts
const { id: resourceId } = await bookings
  .saveResource(organizationId, {
    name: "Office hours with Ada",
    kind: "host",
    tz: "Europe/Amsterdam",
    hostUserId: ada,
    checkBusy: true,
    calendarId: adaCalendarId,
  })
  .orThrow();
```

A booking confirmed on that resource is copied into its calendar. The slots
don't count that copy as the host being busy, so one booking only closes its
own time, padded by the service's buffers. `checkBusy` needs the `calendar`
module; without it the flag has no effect. Share the service through a link
(below) for a public schedule.

Rooms work the same way without a host: give the room a resource with `kind:
"room"` and a `capacity`, and let services that need a room list `room` in
`resourceKinds`.

## Public booking [#public-booking]

With `share-links` installed, a share link to a service (`subjectType:
"booking_service"`) is a public booking page. The `public_*` functions are open
to `anon`, hash the token before the lookup, and refuse a revoked, expired or
unknown token, or a link whose organization is not the service's. A guest
must give a name and an email.

```ts
const link = await shareLinks
  .create(organizationId, "booking_service", serviceId, "view")
  .orThrow();

// On the public page, signed out:
const service = await bookings.public.service(token).orThrow();
const slots = await bookings.public.slots(token, range).orThrow();
const held = await bookings.public
  .hold(token, slot.resourceId, slot.startsAt, { name, email })
  .orThrow();
await bookings.public.confirm(held.id, held.accessToken).orThrow();
```

With `rate-limit` installed, every public call counts against the scope
`bookings.public`, keyed by the link and the first `x-forwarded-for` address.
The default is 30 calls a minute; set your own rule with
`better_supabase.set_rate_limit('bookings.public', 10)`. Over the limit a call
returns a `rate_limited` error with the seconds to wait.

## React [#react]

`useAvailableSlots` loads the open slots of a visible range and holds one. It
is typed structurally, so it needs no UI library. Pass `token` on a public page.
The hook is a registry item: install it with
`npx shadcn add @better-supabase/use-bookings`, which writes
`hooks/use-bookings.ts` into the app.

```tsx
"use client";

import { useAvailableSlots } from "@/hooks/use-bookings";

const slots = useAvailableSlots({ serviceId, range: { start, end } });

slots.days.get("2030-03-04")?.map((slot) => (
  <button key={String(slot.startsAt)} onClick={() => slots.hold(slot)}>
    {slot.localStart.toPlainTime().toString({ smallestUnit: "minute" })}
  </button>
));
```

`toEventCalendarBookings(bookings)` turns bookings into resource-view events
for the staff side: each sits on its resource's row, holds are read-only and
cancelled bookings are left out.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createAvailableSlotsController` with its options; the controller needs no
provider. `state` is a shallow ref of the snapshot and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createAvailableSlotsController,
  createBookings,
} from "better-supabase/blocks/bookings";
import { rpcTransport } from "better-supabase/blocks";

const { serviceId } = defineProps<{ serviceId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(
  createAvailableSlotsController,
  {
    client: createBookings({ transport: rpcTransport(supabase) }),
    serviceId,
    range: { start: new Date(), end: new Date(Date.now() + 7 * 86_400_000) },
  },
  false,
);
</script>

<template>
  <ul>
    <li v-for="slot in state.slots ?? []" :key="String(slot.startsAt)">
      {{ String(slot.startsAt) }}
    </li>
  </ul>
</template>
```

Call `actions.setRange(range)` when the visible range changes.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createAvailableSlotsController` with its options; the controller needs no
provider. It returns a readable store of the snapshot, read with
`$`, with the actions on `.actions`. The work starts on mount, and the
controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createAvailableSlotsController,
    createBookings,
  } from "better-supabase/blocks/bookings";
  import { rpcTransport } from "better-supabase/blocks";

  let { serviceId }: { serviceId: string } = $props();
  const { supabase } = getBetterSupabase();
  const slots = useBlockController(createAvailableSlotsController, {
    client: createBookings({ transport: rpcTransport(supabase) }),
    serviceId,
    range: { start: new Date(), end: new Date(Date.now() + 7 * 86_400_000) },
  }, false);
</script>

<ul>
  {#each $slots.slots ?? [] as slot (String(slot.startsAt))}
    <li>{String(slot.startsAt)}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createAvailableSlotsController` with its options; the controller needs no
provider. `state()` reads the snapshot through `from`, and `actions` are
the controller's actions. The work starts on mount, and the controller is
disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createAvailableSlotsController,
  createBookings,
} from "better-supabase/blocks/bookings";
import { rpcTransport } from "better-supabase/blocks";

export function Component(props: { serviceId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(
    createAvailableSlotsController,
    {
      client: createBookings({ transport: rpcTransport(supabase) }),
      serviceId: props.serviceId,
      range: { start: new Date(), end: new Date(Date.now() + 7 * 86_400_000) },
    },
    false,
  );
  return (
    <ul>
      <For each={state().slots ?? []}>
        {(slot) => <li>{String(slot.startsAt)}</li>}
      </For>
    </ul>
  );
}
```

## Hooks [#hooks]

`before_booking_reserve(tenant, service, resource, during, customer)` runs before a booking is
placed and can raise to refuse it. `after_booking_reserve(booking)`,
`after_booking_confirm(booking)` and `after_booking_cancel(booking)` run after
the change. Every occurrence of a series runs the same hooks. See [block hooks](/docs/extending/blocks#hooks).

## Errors [#errors]

| Hint                         | When                                                                                                             |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `BOOKING_FORBIDDEN`          | The caller lacks the permission, or the access token does not match                                              |
| `BOOKING_SLOT_UNAVAILABLE`   | The slot is outside the hours, too soon, not served by the resource's kind, or every seat is taken (`exclusion`) |
| `BOOKING_HOLD_EXPIRED`       | Confirming a hold whose time is up, or a cancelled booking                                                       |
| `BOOKING_STATUS_INVALID`     | A status other than `confirmed` or `cancelled`                                                                   |
| `BOOKING_NOT_FOUND`          | The booking does not exist                                                                                       |
| `BOOKING_RESOURCE_NOT_FOUND` | The resource does not exist, is inactive, or is in another organization                                          |
| `BOOKING_SERVICE_NOT_FOUND`  | The service does not exist or is inactive                                                                        |
| `BOOKING_TZ_INVALID`         | `tz` is not an IANA time zone                                                                                    |
| `BOOKING_RANGE_INVALID`      | The range is empty or longer than 62 days                                                                        |
| `BOOKING_LINK_INVALID`       | The share link is unknown, revoked, expired, or for another subject                                              |
| `BOOKING_CUSTOMER_INVALID`   | A guest booking without a name or a valid email                                                                  |
| `BOOKING_RATE_LIMITED`       | Too many public calls (`rate_limited`)                                                                           |
| `BOOKING_RULE_INVALID`       | A recurrence rule with an unknown `freq`, an `interval`, `weekdays` or `count` out of range, or no end           |
| `BOOKING_SERIES_CONFLICT`    | An occurrence of a series is not an open slot, or none is with `skipConflicts`                                   |
| `BOOKING_SCOPE_INVALID`      | A scope other than `one` or `following`                                                                          |

Availability is weekly hours plus exceptions; there is no RRULE for hours, and
no deposit through the billing block yet.

## With ReUI [#with-reui]

ReUI's Time Picker renders `useAvailableSlots`' slots, with taken times
disabled; the free example `c-time-picker-7` shows 30-minute slots with
booked times greyed out, and `c-calendar-27` pairs a date picker with time
slots. For the staff side, the Event Calendar's resource view renders
`toEventCalendarBookings`. The Pro blocks in the `application/schedule`
category build full booking pages, such as a Calendly-style month calendar
with a slot picker; their source is licensed per user, so copy them into your
app from [reui.io](https://reui.io).

## Your own fields [#your-own-fields]

`blocks.bookings.options.extraColumns` adds columns to the `bookings` table,
keyed by column name with their SQL type. Pass `createBookings` a Standard Schema
for them as `fields`: the customer object of `hold` and `public.hold` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and every booking read return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createBookings({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.bookings` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const bookings = createBookings({ transport, service: serviceTransport });

const { id: resourceId } = await bookings
  .saveResource(organizationId, {
    name: "Ann",
    kind: "staff",
    tz: "Europe/Amsterdam",
  })
  .orThrow();

await bookings
  .setAvailability(resourceId, [
    { weekday: 1, start: "09:00", end: "17:00" },
    { weekday: 2, start: "09:00", end: "17:00" },
  ])
  .orThrow();

const { id: serviceId } = await bookings
  .saveService(organizationId, {
    name: "Haircut",
    duration: Temporal.Duration.from({ minutes: 30 }),
    bufferAfter: "PT15M",
    resourceKinds: ["staff"],
  })
  .orThrow();
```

# Calendar

> Calendars and events with RFC 5545 recurrence, per-occurrence edits, attendees, iCalendar import and export, and an iCal feed through a share link.

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

`calendar` stores calendars and their events in Postgres. A recurring event is
one row with an RFC 5545 `RRULE`; `occurrences()` expands it in TypeScript with
the same `expandRule` the [reminders block](/docs/blocks/reminders) uses, so
both agree on every date. Editing one occurrence writes an override row, and
editing "this and following" splits the series in two.

```bash
better-supabase add calendar
```

The block's entities are `calendar_event` (the `calendar_events` table, its primary entity) and `calendar` (the `calendars` table). Installing it registers `calendar_event` as a subject, searchable by title, description and location when the search module is installed. Rename them with `blocks.calendar.names`, for example `{ calendar_event: "meeting" }`. `calendarEventInputSchema` validates what `upsert` takes.

```ts
import { createCalendar } from "better-supabase/blocks/calendar";
```

The module requires `memberships` and `access`. With `share-links` it serves an
iCal feed, with `reminders` each event gets a recurrence rule that reminders
can attach to, with `leave` approved leave counts as out of office, and with
`outbox` its events are recorded.

## Tables [#tables]

| Table                    | Holds                                                                                                                        |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `calendars`              | A named calendar in an organization, with an IANA `tz` and an optional owner subject (`owner_type`, `owner_id`)              |
| `calendar_events`        | `starts_at`, `ends_at` (exclusive), `all_day`, `tz`, `status`, `rrule`, `exdates`, an optional subject, and override columns |
| `calendar_attendees`     | An email per event, with an optional user id, a `role` and a `response`                                                      |
| `calendar_working_hours` | A member's weekly hours in an organization: a `weekday` with a `starts_at` and `ends_at` time in their `tz`                  |

An override has `recurrence_id` (the series row) and `original_start` (the
occurrence it replaces) and shares the series' `uid`, as `RECURRENCE-ID` does
in iCalendar. Members read through the tables' policies; every write goes
through a function that checks `calendar.write`, or `calendar.manage` to
create and delete calendars. Deleting a user keeps the events they created.

## Events and occurrences [#events-and-occurrences]

Server code calls the `calendar` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCalendar } = defineDataHooks(
  "calendar",
  (db: AppDb) => db.calendar,
);
```

```ts
const { id: calendarId } = await db.calendar
  .createCalendar(organizationId, "Team", { tz: "Europe/Amsterdam" })
  .orThrow();

await db.calendar
  .upsert(calendarId, {
    title: "Stand-up",
    startsAt: Temporal.Instant.from("2026-03-23T08:00:00Z"),
    endsAt: Temporal.Instant.from("2026-03-23T08:15:00Z"),
    tz: "Europe/Amsterdam",
    rrule: "FREQ=WEEKLY;BYDAY=MO",
  })
  .orThrow();

const week = await db.calendar
  .occurrences([calendarId], {
    from: Temporal.Instant.from("2026-03-30T00:00:00Z"),
    until: Temporal.Instant.from("2026-04-06T00:00:00Z"),
  })
  .orThrow();
```

```tsx title="src/features/calendar/list.tsx"
"use client";

import { useCalendar } from "@/lib/supabase/hooks";

const rows = useCalendar({ limit: 50 });
```

A series follows its `tz`: the stand-up stays at 09:00 in Amsterdam across
the daylight saving change, so its UTC time moves. Each occurrence has a
stable `key` (the series id and its original start), its `start` and `end`,
and the `event` row that applies, which is the override when there is one.

| Method                                | Does                                                                                     |
| ------------------------------------- | ---------------------------------------------------------------------------------------- |
| `upsert(calendarId, input)`           | Inserts an event, or updates the one with `input.id`; keys left out keep their value     |
| `events(calendarIds, range)`          | The rows that can fall in the range: single events, series and overrides                 |
| `occurrences(calendarIds, range)`     | The occurrences in the range, with EXDATEs removed and overrides applied                 |
| `editOccurrence(occurrence, changes)` | Changes one occurrence: an override for a series, an update for a single event           |
| `editFollowing(occurrence, changes)`  | Changes this occurrence and every later one, splitting the series unless it is the first |
| `cancelOccurrence(occurrence)`        | Adds an `EXDATE` and drops the occurrence's override, or deletes a single event          |
| `delete(eventId)`                     | Deletes an event with its overrides                                                      |
| `setAttendee`, `removeAttendee`       | Invites or removes an attendee by email                                                  |
| `respond(eventId, response)`          | The signed-in user answers: `accepted`, `declined`, `tentative` or `needs-action`        |

`editFollowing` ends the old series the second before the occurrence (its
`COUNT` becomes an `UNTIL`) and starts a new series there with the remaining
count and a new uid. EXDATEs and overrides after the split move to the new
series. `splitRule(rrule, dtstart, at)` computes both rules if you need them
yourself.

## Availability [#availability]

Each member can set weekly working hours, mark time as out of office or focus
time, and anyone in the organization with `calendar.read` can ask when a set of
members is busy. Postgres computes the answer, so a series' recurrence, an
override and an approved leave request all count without the client loading
every event.

```ts
import { commonFreeTime } from "better-supabase/blocks/calendar";

await calendar
  .setWorkingHours(organizationId, {
    tz: "Europe/Amsterdam",
    hours: [
      { weekday: 1, start: "09:00", end: "17:00" },
      { weekday: 3, start: "13:00", end: "17:00" },
    ],
  })
  .orThrow();

await calendar
  .upsert(calendarId, {
    title: "Deep work",
    kind: "focus_time",
    autoDecline: true,
    startsAt: Temporal.Instant.from("2030-03-06T08:00:00Z"),
    endsAt: Temporal.Instant.from("2030-03-06T10:00:00Z"),
  })
  .orThrow();

const day = {
  from: Temporal.Instant.from("2030-03-06T00:00:00Z"),
  until: Temporal.Instant.from("2030-03-07T00:00:00Z"),
};
const freeBusy = await calendar
  .freeBusy(organizationId, [ada, bob], day)
  .orThrow();
const free = commonFreeTime(freeBusy, day, {
  minimum: Temporal.Duration.from({ minutes: 30 }),
});
```

An event's `kind` is `event`, `out_of_office` or `focus_time`, and its
`transparency` is `opaque` or `transparent` (the iCalendar `TRANSP` property,
which `importIcs` and `exportIcs` map). A member is busy during:

- events on calendars they own (`owner_type` `user`), except transparent and
  cancelled ones;
- events they are invited to and haven't declined;
- approved leave, when the [leave block](/docs/blocks/leave) is installed: a
  full day, or the morning or afternoon half, in their working-hours zone.

`freeBusy` returns, per member, the merged `busy` ranges, the
`outOfOffice` and `focusTime` ranges on their own, and their `workingHours`
for the range when they set some. Ranges are clipped to the range you ask
for; one call covers at most 62 days and 100 members. `commonFreeTime` is a
pure function over that answer: it removes everyone's busy time and keeps the
time inside everyone's working hours (pass `workingHours: false` to skip that).

Recurring events expand in SQL with `calendar_rule_starts`, a PL/pgSQL port of
`expandRule`; an integration test runs both over the same rules, including
daylight saving changes, and expects the same instants.

### Auto-decline [#auto-decline]

An out-of-office block always declines new invitations that it covers. A
focus time block declines them when `autoDecline` is set. The decline happens
when the invitation is written, and when a block is saved it declines the
open invitations it already covers. Approved leave counts as out of office.
A recurring block, and an invitation to a recurring event, is never declined
automatically; the member answers those.

| Method                                     | Does                                                                    |
| ------------------------------------------ | ----------------------------------------------------------------------- |
| `setWorkingHours(organizationId, input)`   | Replaces a member's hours; managers pass `userId` to set someone else's |
| `workingHours(organizationId, userIds?)`   | The hours of the members that set them                                  |
| `freeBusy(organizationId, userIds, range)` | Busy, out-of-office, focus and working-hours ranges per member          |

To offer an appointment schedule (a link where others book time with a
member), use the [bookings block](/docs/blocks/bookings#appointment-schedules)
with the member as a resource's host: its slots then skip the times this
section reports as busy.

## iCalendar [#icalendar]

`importIcs` parses an RFC 5545 file and upserts its events by `UID`, and
`RECURRENCE-ID` events become overrides of their series. Importing the same
file again updates the events instead of duplicating them. `exportIcs` writes
the calendar back, with a `VTIMEZONE` for every zone its events use.

```ts
const summary = await calendar.importIcs(calendarId, text).orThrow();
// { created: 12, updated: 0, skipped: 1 }

const ics = await calendar
  .exportIcs({ id: calendarId, name: "Team", tz: "Europe/Amsterdam" })
  .orThrow();
```

The parser and writer are in the package (`parseIcs`, `toIcs`): they unfold
and fold lines at 75 octets, escape `TEXT`, and read `TZID`, UTC, floating and
all-day times, `DURATION`, `EXDATE`, `STATUS`, `TRANSP` and `ATTENDEE`. `VALARM` and
`VTODO` are skipped.

## iCal feed [#ical-feed]

With `share-links` installed, a share link to a calendar (`subjectType:
"calendar"`) works as a subscription URL. `calendar_feed(token)` is open to
`anon`, hashes the token before the lookup and returns nothing for a revoked,
expired or unknown token, or a link whose organization is not the calendar's.

```ts
const link = await shareLinks
  .create(organizationId, "calendar", calendarId, "view")
  .orThrow();
```

```ts title="app/calendars/[token]/route.ts"
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ token: string }> },
) {
  const { token } = await params;
  const ics = await calendar.feed(token).orThrow();
  if (ics === null) return new Response("Not found", { status: 404 });
  return new Response(ics, {
    headers: { "content-type": "text/calendar; charset=utf-8" },
  });
}
```

## React [#react]

`useCalendar` loads the occurrences of a visible range and returns them in the
shape an event calendar renders. The hook is a registry item: install it with
`npx shadcn add @better-supabase/use-calendar`, which writes
`hooks/use-calendar.ts` into the app. The hook is typed structurally, so it
needs no UI library.

```tsx
"use client";

import { useCalendar } from "@/hooks/use-calendar";

const { events, loading, editOccurrence, editFollowing, cancelOccurrence } =
  useCalendar({ calendarIds, range: { start, end } });
```

Each event's `id` is the occurrence key and its `data` is the occurrence, so
a drop or a resize maps back with `fromEventCalendarUpdate`.
`toEventCalendarResources` turns calendars into resources for a resource view;
by default each event's resource is its calendar, and `resourceOf` picks
another one, such as the event's subject.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs `createCalendarController`
with its options, as
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid) explains.
`state` is a shallow ref of the snapshot and `actions` are the controller's
actions. The work starts on mount, and the controller is disposed with the
component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createCalendar,
  createCalendarController,
} from "better-supabase/blocks/calendar";
import { rpcTransport } from "better-supabase/blocks";

const { calendarId } = defineProps<{ calendarId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(
  createCalendarController,
  {
    client: createCalendar({ transport: rpcTransport(supabase) }),
    calendarIds: [calendarId],
    range: { start: new Date(), end: new Date(Date.now() + 7 * 86_400_000) },
  },
  false,
);
</script>

<template>
  <ul>
    <li v-for="occurrence in state.occurrences ?? []" :key="occurrence.key">
      {{ String(occurrence.start) }}
    </li>
  </ul>
</template>
```

Call `actions.setRange(range)` when the visible range changes.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createCalendarController` with its options. It returns a readable store of the
snapshot, read with `$`, with the actions on `.actions`. The work starts on
mount, and the controller is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import {
    getBetterSupabase,
    useBlockController,
  } from "better-supabase/svelte";
  import {
    createCalendar,
    createCalendarController,
  } from "better-supabase/blocks/calendar";
  import { rpcTransport } from "better-supabase/blocks";

  let { calendarId }: { calendarId: string } = $props();
  const { supabase } = getBetterSupabase();
  const calendar = useBlockController(createCalendarController, {
    client: createCalendar({ transport: rpcTransport(supabase) }),
    calendarIds: [calendarId],
    range: { start: new Date(), end: new Date(Date.now() + 7 * 86_400_000) },
  }, false);
</script>

<ul>
  {#each $calendar.occurrences ?? [] as occurrence (occurrence.key)}
    <li>{String(occurrence.start)}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createCalendarController` with its options. `state()` reads the snapshot
through `from`, and `actions` are the controller's actions. The work starts on
mount, and the controller is disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createCalendar,
  createCalendarController,
} from "better-supabase/blocks/calendar";
import { rpcTransport } from "better-supabase/blocks";

export function Component(props: { calendarId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(
    createCalendarController,
    {
      client: createCalendar({ transport: rpcTransport(supabase) }),
      calendarIds: [props.calendarId],
      range: { start: new Date(), end: new Date(Date.now() + 7 * 86_400_000) },
    },
    false,
  );
  return (
    <ul>
      <For each={state().occurrences ?? []}>
        {(occurrence) => <li>{String(occurrence.start)}</li>}
      </For>
    </ul>
  );
}
```

## Hooks [#hooks]

`before_calendar_event_save(tenant, calendar, item)` runs before an insert or
update and can raise to refuse it; `after_calendar_event_save(event)` runs
after it. See [block hooks](/docs/extending/blocks#hooks).

## Errors [#errors]

| Hint                          | When                                                                                             |
| ----------------------------- | ------------------------------------------------------------------------------------------------ |
| `CALENDAR_FORBIDDEN`          | The caller lacks `calendar.write` or `calendar.manage`                                           |
| `CALENDAR_NOT_FOUND`          | The calendar does not exist                                                                      |
| `CALENDAR_EVENT_NOT_FOUND`    | The event does not exist in that calendar                                                        |
| `CALENDAR_EVENT_INVALID`      | A missing start or end, an end before the start, a bad status, or an override without a series   |
| `CALENDAR_TZ_INVALID`         | `tz` is not an IANA time zone                                                                    |
| `CALENDAR_HOURS_INVALID`      | Working hours that aren't `{ weekday 1-7, start HH:MM, end HH:MM }` with the end after the start |
| `CALENDAR_RANGE_INVALID`      | A free/busy range longer than 62 days, or no members or more than 100                            |
| `CALENDAR_SPLIT_INVALID`      | The split is at or before the series' first occurrence                                           |
| `CALENDAR_ATTENDEE_NOT_FOUND` | The caller is not invited to the event                                                           |
| `CALENDAR_RESPONSE_INVALID`   | The response is not one of the four values                                                       |

## With ReUI [#with-reui]

ReUI's Event Calendar renders `useCalendar`'s `events` directly: month, week,
day and agenda views, drag to move and resize, and resource views from
`toEventCalendarResources`. Install it with
`npx shadcn@latest add @reui/event-calendar`; the free examples
`c-event-calendar-1` to `c-event-calendar-5` show each view. The Pro blocks
in the `application/event-calendar` category build full scheduling screens on
it; their source is licensed per user, so copy them into your app from
[reui.io](https://reui.io).

## Your own fields [#your-own-fields]

`blocks.calendar.options.extraColumns` adds columns to the `calendar_events` table,
keyed by column name with their SQL type. Pass `createCalendar` a Standard Schema
for them as `fields`: `upsert` and the occurrence edits take them next to the block's own keys and
validate them (a bad value is a `validation` error), and `events` return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createCalendar({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.calendar` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const calendar = createCalendar({ transport });

const { id: calendarId } = await calendar
  .createCalendar(organizationId, "Team", { tz: "Europe/Amsterdam" })
  .orThrow();

await calendar
  .upsert(calendarId, {
    title: "Stand-up",
    startsAt: Temporal.Instant.from("2026-03-23T08:00:00Z"),
    endsAt: Temporal.Instant.from("2026-03-23T08:15:00Z"),
    tz: "Europe/Amsterdam",
    rrule: "FREQ=WEEKLY;BYDAY=MO",
  })
  .orThrow();

const week = await calendar
  .occurrences([calendarId], {
    from: Temporal.Instant.from("2026-03-30T00:00:00Z"),
    until: Temporal.Instant.from("2026-04-06T00:00:00Z"),
  })
  .orThrow();
```

# People and scheduling

> Employees, calendars, bookings and the blocks around them.

Source: https://bettersupabase.com/docs/blocks/catalog-people

- [Reminders](/docs/blocks/reminders)
- [Calendar](/docs/blocks/calendar)
- [Bookings](/docs/blocks/bookings)
- [Time tracking](/docs/blocks/time-tracking)
- [Hiring](/docs/blocks/hiring)
- [Employees](/docs/blocks/employees)
- [Leave](/docs/blocks/leave)
- [Passes](/docs/blocks/passes)

# Employees

> Employees with or without an account, a manager, team and status, an org chart, offboarding, and private details and contracts behind employees.view_sensitive.

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

`employees` keeps the people an organization employs. An employee has a name, an optional account (`userId`: contractors and interns often have none), a manager, a team, a job title, an employment type, a start and end date, and a status of `preboarding`, `active`, `on_leave` or `offboarded`. Managers form a tree: the database refuses a manager from another organization and a manager who reports to the employee. Private details and a contract history sit in their own tables, which only the employee and holders of `employees.view_sensitive` read.

Reading the directory needs `employees.read`, which members have. Creating, editing and offboarding needs `employees.write`, and the private details and contracts need `employees.view_sensitive`; admins have both. A manager without `employees.read` still reads the employees under them through `reportsOf` and `orgChart`, and every employee reads their own row, details and contracts.

```bash
better-supabase add employees
```

The module needs `memberships` and `access`. With `outbox` it records events, with `hiring` it makes employees from hired applications, and with `relations` it links them to the application they came from.

Server code calls the `employees` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useEmployees } = defineDataHooks(
  "employees",
  (db: AppDb) => db.employees,
);
```

```ts
import { createEmployees } from "better-supabase/blocks/employees";

const mia = await db.employees
  .create(organizationId, {
    name: "Mia de Vries",
    userId,
    jobTitle: "Designer",
    managerId: leadId,
    startDate: Temporal.PlainDate.from("2026-11-01"),
  })
  .orThrow();
```

```tsx title="src/features/employees/list.tsx"
"use client";

import { useEmployees } from "@/lib/supabase/hooks";

const rows = useEmployees({ limit: 50 });
```

The status defaults to `preboarding` before the start date and `active` from it. `employmentType` is one of `blocks.employees.options.employmentTypes` (`employee`, `contractor`, `intern` and `temporary` by default), and the first one is the default. A `userId` must be a member of the organization (`EMPLOYEE_USER_NOT_MEMBER`) and an employee only once (`EMPLOYEE_USER_TAKEN`).

Installing the block registers employees as the `employee` subject, so comments, files, tags and search attach to them. Set the page a search result links to on the entity:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { employees: { names: { employee: { url: "/people/{id}" } } } },
});
```

## Editing [#editing]

`update(changes)` applies a batch of `{ employeeId, field, value }` cells, each on its own, as an editable grid sends them. A change that fails comes back in `rejected` with its hint and the others stay. The fields are `name`, `email`, `userId`, `jobTitle`, `teamId`, `managerId`, `employmentType`, `status`, `startDate`, `endDate` and the extra columns by name; `null` clears one. `status` takes `preboarding`, `active` or `on_leave`; setting it on an offboarded employee rehires them and clears the end date.

```ts
const { accepted, rejected } = await employees
  .update([
    { employeeId: mia.id, field: "jobTitle", value: "Lead designer" },
    { employeeId: leadId, field: "managerId", value: mia.id },
  ])
  .orThrow();
// rejected[0].hint === "EMPLOYEE_MANAGER_CYCLE" when the lead manages Mia
```

`options.extraColumns` adds columns to the employees table. Pass a Standard Schema as `fields` to type them, parse them on reads and validate them on writes:

```ts
const employees = createEmployees({
  transport,
  fields: z.object({ cost_center: z.string() }),
});
```

## Time and leave [#time-and-leave]

With [time-tracking](/docs/blocks/time-tracking) or [leave](/docs/blocks/leave)
installed, time entries and leave requests carry an `employee_id` column.
A trigger fills it from the employee with the row's account when the row is
written, and a trigger on employees fills or clears it when an employee
gets, changes or loses an account. Upgrading an app that has both blocks
fills the column for the rows it already has. A member who isn't an
employee keeps `employee_id` null.

## Org chart [#org-chart]

`orgChart(organizationId)` returns the current employees under everyone without a manager, and `orgChart(organizationId, rootId)` the employees under one, each with its `depth` and number of `directReports`, in display order. `reportsOf(managerId)` returns the direct reports, and `{ indirect: true }` everyone under the manager. Offboarded employees are left out.

## Offboarding [#offboarding]

`offboard(employeeId, { endDate, reason })` sets the status to `offboarded`, the end date (today by default) and the reason, and moves the employee's reports to their manager. Offboarding again returns the employee unchanged.

## From a hired application [#from-a-hired-application]

With the [hiring](/docs/blocks/hiring) module, `fromApplication(applicationId)` makes the employee for a hired application, in `preboarding`. The name comes from the candidate's `title` column in `config.records`, and the job title, team and employment type from the job; pass overrides such as `{ startDate }` as the second argument. The employee's origin is the application, so calling it again returns the same employee, and with `relations` it gets a `created_from` relation to the application. Call it from an `application.hired` handler:

```ts
onBlockEvent(bsb, hiringEvents, "application.hired", async (event) => {
  await employees.fromApplication(event.data.applicationId).orThrow();
});
```

`create` takes an `origin` (`{ type, id }`) too, so other imports stay idempotent.

## Private details [#private-details]

`savePrivateDetails(employeeId, input)` saves the national id, IBAN, birth date, personal email, phone, address and emergency contacts; the keys it leaves out keep their value. `privateDetails(employeeId)` reads them. The employee edits their own details, and offboarding stamps `offboardedAt`, so an [anonymize rule](/docs/blocks/data-lifecycle) can clear them a retention period later:

```ts title="better-supabase.config.ts"
"data-lifecycle": {
  options: {
    anonymize: [
      {
        table: "better_supabase.employee_private_details",
        after: "2 years",
        from: "offboarded_at",
        set: {
          national_id: null,
          iban: null,
          address: null,
          emergency_contacts: { sql: "'[]'::jsonb" },
        },
        markedBy: "anonymized_at",
      },
    ],
  },
},
```

The three tables declare their tenant to `data-lifecycle`, so an organization export includes them and the purge deletes them. The employees row and the private details also declare the employee's user, so a user's own export includes them.

## Contracts [#contracts]

`addContract(employeeId, { startsOn, endsOn, hoursPerWeek, salary, salaryPeriod, note })` appends to the employee's contract history. `salary` is a `Money` value from [commerce](/docs/concepts/commerce), such as `money(5_400_000n, "EUR")`, which carries its currency and is sent as text so it stays exact past 2^53, and `salaryPeriod` is `hour`, `week`, `month` or `year`. A new contract starts after the latest one (`EMPLOYEE_CONTRACT_OVERLAP`), which ends the day before; rows are never edited. `contracts(employeeId)` returns them, the newest first. Adding one needs `employees.write` and `employees.view_sensitive`.

## Adopting a table [#adopting-a-table]

An app with its own employees table keeps it: the module runs over it, maps its columns, and adds the columns it lacks unless you map them to `null`: `email`, `jobTitle`, `team`, `employmentType`, `startDate`, `endDate`, `offboardReason`, the origin columns and the timestamps are optional, and the module leaves out what needs them. `privateDetails` and `contracts` can be `null` as whole tables.

## Events [#events]

| Event                                     | Data                                                                                                         |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `employee.created`                        | `organizationId`, `employeeId`, `subjectType`, `subjectId`, `name`, `userId`, `managerId`, `status`          |
| `employee.updated`, `employee.offboarded` | the same without the last three, with `fields` (the changed fields, or `contract`) or `endDate` and `reason` |
| `employee.manager_changed`                | the same, with `fromManagerId` and `toManagerId`; offboarding records one for each report that moves         |

## Hooks [#hooks]

`before_employee_create`, `after_employee_create`, `before_employee_update`, `after_employee_update`, `before_employee_offboard` and `after_employee_offboard` run in the same transaction as the change. See [extending blocks](/docs/extending/blocks).

## Errors [#errors]

| Hint                             | Cause                                                           |
| -------------------------------- | --------------------------------------------------------------- |
| `EMPLOYEE_FORBIDDEN`             | the caller lacks `employees.write`, or can't read the org chart |
| `EMPLOYEE_NOT_FOUND`             | no employee the caller can change or whose details they can see |
| `EMPLOYEE_ATTRS_INVALID`         | a missing name or a field with an invalid value                 |
| `EMPLOYEE_TYPE_INVALID`          | an employment type outside `options.employmentTypes`            |
| `EMPLOYEE_STATUS_INVALID`        | a status other than preboarding, active or on_leave             |
| `EMPLOYEE_USER_NOT_MEMBER`       | the user isn't a member of the organization                     |
| `EMPLOYEE_USER_TAKEN`            | the user is already an employee of the organization             |
| `EMPLOYEE_MANAGER_NOT_FOUND`     | the manager isn't an employee of the organization               |
| `EMPLOYEE_MANAGER_CYCLE`         | the manager reports to the employee                             |
| `EMPLOYEE_FIELD_UNKNOWN`         | a change names a field that can't be written                    |
| `EMPLOYEE_CHANGES_INVALID`       | changes isn't a list                                            |
| `EMPLOYEE_ORIGIN_NOT_FOUND`      | no application the caller can turn into an employee             |
| `EMPLOYEE_APPLICATION_NOT_HIRED` | the application isn't hired                                     |
| `EMPLOYEE_PRIVATE_INVALID`       | a private detail has an invalid value, such as a malformed IBAN |
| `EMPLOYEE_CONTRACT_INVALID`      | a contract without a start date, or with an invalid value       |
| `EMPLOYEE_CONTRACT_OVERLAP`      | the contract starts on or before the latest one                 |

## Without the client [#without-the-client]

`db.employees` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createEmployees } from "better-supabase/blocks/employees";

const employees = createEmployees({ transport });
const mia = await employees
  .create(organizationId, {
    name: "Mia de Vries",
    userId,
    jobTitle: "Designer",
    managerId: leadId,
    startDate: Temporal.PlainDate.from("2026-11-01"),
  })
  .orThrow();
```

# Hiring

> Jobs and applications on a pipeline per job, candidates from config.records, scorecards per stage, interviews, and hire, reject and withdraw events for the app to act on.

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

`hiring` is an applicant tracking system on the [pipelines](/docs/blocks/pipelines) foundation. A job is open or closed, each job has its own pipeline of stages, and an application moves a candidate through it, collecting interviews and scorecards on the way, until it ends hired, rejected or withdrawn. A candidate is any record in [`config.records`](/docs/blocks/sql#subject-registry), a [parties](/docs/blocks/contacts) person by default, so a candidate who applies twice is one record with two applications.

Reading needs `hiring.read`, moving applications, scheduling and scoring needs `hiring.write`, and jobs need `hiring.manage`. Admins get every permission and members none: give recruiters a role with `hiring.read` and `hiring.write`. An interviewer on an interview reads it and scores the application without those permissions.

```bash
better-supabase add hiring
```

The module needs `tenant`, `access`, `subjects` and `pipelines`. Installing it binds the `application` type in the pipelines module with one pipeline per job (`scope: "job_id"`):

| Category    | Outcome | Default stages                       |
| ----------- | ------- | ------------------------------------ |
| `active`    | open    | Applied, Screening, Interview, Offer |
| `hired`     | success | Hired                                |
| `rejected`  | failure | Rejected                             |
| `withdrawn` | failure | Withdrawn                            |

Each job's first application seeds its pipeline; an admin adds or renames stages per job with the pipelines client. The category sets the application's `state`.

Server code calls the `hiring` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useHiring } = defineDataHooks(
  "hiring",
  (db: AppDb) => db.hiring,
);
```

```ts
import { createHiring } from "better-supabase/blocks/hiring";

const job = await db.hiring
  .saveJob(organizationId, {
    title: "Backend engineer",
    location: "Utrecht",
    status: "open",
  })
  .orThrow();
const application = await db.hiring
  .apply(job.id, { id: personId }, { source: "referral" })
  .orThrow();
await db.hiring
  .scheduleInterview(application.id, {
    startsAt,
    endsAt,
    interviewerIds: [engineerId],
    kind: "technical",
  })
  .orThrow();
```

```tsx title="src/features/hiring/list.tsx"
"use client";

import { useHiring } from "@/lib/supabase/hooks";

const rows = useHiring({ limit: 50 });
```

## Your own fields [#your-own-fields]

`blocks.hiring.options.extraColumns` adds columns to the `queue` table,
keyed by column name with their SQL type. `saveJob` writes them from `fields` and returns them under `fields`. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const hiring = createHiring({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await hiring.saveJob(organizationId, {
  title: "Engineer",
  fields: { region: "emea" },
});
```

## Applications [#applications]

`apply(jobId, candidate, { source, ownerId })` adds a candidate to an open job (`HIRING_JOB_NOT_OPEN`); applying again returns the same application. The candidate must be in the organization and readable by the caller (`HIRING_CANDIDATE_FORBIDDEN`). Move it between stages with a stage update or [rank](/docs/blocks/rank) on a board, as any pipeline record.

`hire(id)`, `reject(id, reason)` and `withdraw(id)` move the application to its job pipeline's first stage of that category. Hiring stamps `hiredAt` and records `application.hired` with the candidate: call [`employees.fromApplication`](/docs/blocks/employees#from-a-hired-application) from that event, which makes the employee once and links it with a `created_from` [relation](/docs/blocks/relations), or create your own from an `after_application_hire` hook in the same transaction.

## Interviews and scorecards [#interviews-and-scorecards]

`scheduleInterview` records an interview with its interviewers, who can then read it. `score(id, { rating, recommendation, notes })` saves the caller's scorecard at the application's current stage: a rating from 1 to 4 and a recommendation from `strong_no` to `strong_yes`. Scoring again at the same stage replaces it. `detail(id)` returns the application with its scorecards, its interviews and the average rating.

## Events [#events]

| Event                                                                                       | Data                                                                                                             |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `job.opened`, `job.closed`                                                                  | `organizationId`, `jobId`, `title`                                                                               |
| `application.created`, `application.hired`, `application.rejected`, `application.retracted` | `organizationId`, `applicationId`, `subjectType`, `subjectId`, `jobId`, `candidateType`, `candidateId`, `reason` |
| `scorecard.submitted`, `interview.scheduled`                                                | the same, with `reviewerId` and `recommendation`, or `interviewId` and `startsAt`                                |

## Wire it to ReUI [#wire-it-to-reui]

The ReUI Recruiting Pipeline board reads its columns from the job's pipeline board and moves cards with `rank.move("application", id, { group: stageId })`. Show a card's match score from `detail(id).averageRating`, and its interview date from `interviews`.

## Errors [#errors]

| Hint                           | Cause                                                     |
| ------------------------------ | --------------------------------------------------------- |
| `HIRING_FORBIDDEN`             | the caller lacks `hiring.manage`                          |
| `HIRING_JOB_INVALID`           | a job needs a title and a status of draft, open or closed |
| `HIRING_JOB_NOT_FOUND`         | no such job the caller can change                         |
| `HIRING_JOB_NOT_OPEN`          | the job isn't open                                        |
| `HIRING_CANDIDATE_FORBIDDEN`   | the candidate is unknown or unreadable                    |
| `HIRING_APPLICATION_NOT_FOUND` | no application the caller can change or review            |
| `HIRING_OUTCOME_INVALID`       | close as hired, rejected or withdrawn                     |
| `HIRING_STAGE_MISSING`         | the job's pipeline has no stage of that category          |
| `HIRING_SCORECARD_INVALID`     | a rating from 1 to 4 and a recommendation                 |
| `HIRING_INTERVIEW_INVALID`     | an interview starts before it ends                        |

## Without the client [#without-the-client]

`db.hiring` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createHiring } from "better-supabase/blocks/hiring";

const hiring = createHiring({ transport });
const job = await hiring
  .saveJob(organizationId, {
    title: "Backend engineer",
    location: "Utrecht",
    status: "open",
  })
  .orThrow();
const application = await hiring
  .apply(job.id, { id: personId }, { source: "referral" })
  .orThrow();
await hiring
  .scheduleInterview(application.id, {
    startsAt,
    endsAt,
    interviewerIds: [engineerId],
    kind: "technical",
  })
  .orThrow();
```

# Leave

> Leave types in days or hours, requests approved through the approvals module, and balances on the ledger block per member and year with grants, accruals, adjustments and a year-end carryover.

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

`leave` keeps time off per organization: the types an organization offers (vacation, sick leave, a day off in lieu), the requests members file, and balances kept on the [ledger](/docs/blocks/ledger). A request opens an approval request in the [approvals](/docs/blocks/approvals) module, and the final approval books it.

Requesting leave needs `leave.request`, reading other members' requests and balances needs `leave.read`, approving needs `leave.approve` (the default approval step), and managing types, adjusting balances and closing a year needs `leave.manage`. Admins get every permission and members get `leave.request`.

```bash
better-supabase add leave
```

The module needs `tenant`, `permissions` and `approvals`.

Server code calls the `leave` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useLeave } = defineDataHooks(
  "leave",
  (db: AppDb) => db.leave,
);
```

```ts
import { createLeave } from "better-supabase/blocks/leave";

const vacation = await db.leave
  .saveType(organizationId, {
    key: "vacation",
    name: "Vacation",
    allowance: 25,
    carryoverMax: 5,
  })
  .orThrow();
await db.leave
  .accrue(organizationId, { typeId: vacation.id, period: 2026 })
  .orThrow();

const request = await db.leave
  .request(organizationId, {
    typeId: vacation.id,
    startsOn: Temporal.PlainDate.from("2026-10-19"),
    endsOn: Temporal.PlainDate.from("2026-10-23"),
  })
  .orThrow();
// request.amount: 5 (working days), request.status: "pending"
```

```tsx title="src/features/leave/list.tsx"
"use client";

import { useLeave } from "@/lib/supabase/hooks";

const rows = useLeave({ limit: 50 });
```

## Types [#types]

`saveType` creates a type, or updates the one with the same `key`. A type counts in `days` (the default) or `hours`, is paid or not, and is tracked against a balance unless `tracked: false` (sick leave, for example, often isn't). `allowance` is what `accrue` grants when it names no amount, and `carryoverMax` caps what `closePeriod` carries into the next year. `archiveType` hides a type from new requests and keeps its history, and `types` lists the active ones (`archived: true` for all).

## Requests [#requests]

`request` books working days between `startsOn` and `endsOn`, inclusive: the days in `workingDays` (Monday to Friday by default), so a request over a weekend counts five days for a full week. `partial: "morning"` or `"afternoon"` books half a day and needs one date. An hours type takes `hours` instead.

A request can't overlap another pending or approved request of the member (`LEAVE_OVERLAP`). For a tracked type it can't exceed the available balance (`LEAVE_BALANCE_EXCEEDED`) unless `allowNegative` is on. A request counts toward the year it starts in.

| Status      | Meaning                                                    |
| ----------- | ---------------------------------------------------------- |
| `pending`   | its approval request is open; it counts as `pending`       |
| `approved`  | the final step approved it; the ledger has a `taken` entry |
| `rejected`  | the approval request was rejected or expired               |
| `cancelled` | the requester or a manager cancelled it                    |

With the [employees block](/docs/blocks/employees) installed, every request
has an `employee_id` column: the employee behind the requester, kept in step
by triggers like time entries' (see [employees](/docs/blocks/employees#time-and-leave)).

Approvers decide with the approvals client, where the request's subject type is `leave_request`:

```ts
import { createApprovals } from "better-supabase/blocks/approvals";

const approvals = createApprovals({ transport });
await approvals.approve(request.approvalId!).orThrow();
```

`cancel(requestId)` cancels a pending request, or an approved one before the leave starts, for its requester, and any request for a member with `leave.manage`. It cancels a pending approval request with it. Cancelling an approved request writes an entry that gives the days back. `list` returns the caller's requests, earliest first, and `userId` or `everyone: true` read other members' with `leave.read`; `status`, `from` and `to` narrow it.

## Balances [#balances]

Each member has a ledger account per type and year (amounts in hundredths of the type's unit), and every change to a balance is a ledger transaction of kind `leave.grant`, `leave.accrual`, `leave.adjustment`, `leave.taken`, `leave.carryover` or `leave.expiry` against the type's pool or taken account. A carryover moves the amount between the two years' accounts. `balances(organizationId)` returns the caller's balance of each tracked type this year (pass `userId` and `period` for others), with `granted`, `taken`, `pending` and `available`, which is the ledger total less pending requests. `ledger` lists the entries behind it.

`adjust` writes a grant or a correction for one member. `accrue` grants an amount, or the type's allowance, to every member or to `userIds`; give it an `idempotencyKey` and a member gets that accrual once, so a monthly cron can call it safely. `closePeriod(organizationId, 2026)` carries each member's remaining balance, up to `carryoverMax`, into 2027 and expires the rest. Running it twice writes nothing the second time.

## Configure [#configure]

```ts title="better-supabase.config.ts"
blocks: {
  leave: {
    options: {
      workingDays: ["mon", "tue", "wed", "thu"],
      policy: { steps: [{ approvers: { role: "admin" } }], expiresIn: "P14D" },
    },
  },
},
```

`policy` is an [approvals policy](/docs/blocks/approvals) without `run`, since the final approval books the leave. `extraColumns` adds your own columns to requests, typed with the client's `fields` schema.

## Errors [#errors]

| Hint                      | Cause                                                                                               |
| ------------------------- | --------------------------------------------------------------------------------------------------- |
| `LEAVE_FORBIDDEN`         | the caller lacks the permission for this                                                            |
| `LEAVE_INVALID`           | dates out of order, a half day over several dates, missing hours, a zero amount, or no working days |
| `LEAVE_OVERLAP`           | the member has a pending or approved request on these dates                                         |
| `LEAVE_BALANCE_EXCEEDED`  | the request is more than the available balance                                                      |
| `LEAVE_TYPE_NOT_FOUND`    | no active type has this id                                                                          |
| `LEAVE_REQUEST_NOT_FOUND` | no request the caller can see has this id                                                           |
| `LEAVE_REQUEST_CLOSED`    | the request is already cancelled                                                                    |

## Events [#events]

The block records `leave.requested`, `leave.approved` and `leave.cancelled` with `organizationId`, `id`, `userId`, `typeId`, `startsOn`, `endsOn`, `amount` and `approvalId`, and `leave.adjusted` with `kind`, `amount` and `period` for grants and adjustments. See [events](/docs/extending/events).

The block takes `hooks` around its methods; see [extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.leave` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createLeave } from "better-supabase/blocks/leave";

const leave = createLeave({ transport });

const vacation = await leave
  .saveType(organizationId, {
    key: "vacation",
    name: "Vacation",
    allowance: 25,
    carryoverMax: 5,
  })
  .orThrow();
await leave
  .accrue(organizationId, { typeId: vacation.id, period: 2026 })
  .orThrow();

const request = await leave
  .request(organizationId, {
    typeId: vacation.id,
    startsOn: Temporal.PlainDate.from("2026-10-19"),
    endsOn: Temporal.PlainDate.from("2026-10-23"),
  })
  .orThrow();
// request.amount: 5 (working days), request.status: "pending"
```

# Passes

> Issue QR and NFC admission passes signed with your signing keys, check holders in at the door, count drop-ins against a resource's capacity with its bookings, and pay for classes with credits.

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

`passes` turns a booking into a ticket and a resource into something people
can walk into. You issue a pass, sign a token for it with the
[signing-keys](/docs/blocks/signing-keys) block, and put the token in a QR
code or on an NFC tag. At the door a scanner verifies the signature against
your JWKS and the database checks the pass in: once for a ticket, as often as
the pass allows otherwise, and only while there is a free seat.

```bash
better-supabase add passes
```

```ts
import { createPasses } from "better-supabase/blocks/passes";
```

The module requires `memberships` and `access`. With `bookings` a pass can be a
ticket for a booking or a pass for a resource, with `outbox` its events are
recorded, and with `audit` issuing and revoking are audited. Scans are
logged in the module's own table instead of the audit log. Signing needs the
`signing-keys` block in the app; the module itself never reads a key (see
[ADR 0073](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0073-signed-admission-passes.md)).

## Tables [#tables]

| Table            | Holds                                                                                                                                                                                                              |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `passes`         | One pass: `kind`, the holder (`holder_user_id`, `holder_name`, `holder_email`), the record, booking or resource it is for, `uses_allowed`, `uses`, the validity window, `status` and the `nonce` its token carries |
| `pass_check_ins` | Every scan: the pass, the resource and booking, the `gate`, the `method` (`qr`, `nfc` or `manual`), `admitted`, the `reason` when turned away                                                                      |

Both tables are read through the functions only. The holder reads their own
passes with the nonce; members with `passes.read` list the organization's.

## Issue a pass [#issue-a-pass]

Server code calls the `passes` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: usePasses } = defineDataHooks(
  "passes",
  (db: AppDb) => db.passes,
);
```

```ts
const ticket = await db.passes
  .issue({ bookingId, holderUserId: userId, holderName: "Jane" })
  .orThrow();
```

```tsx title="src/features/passes/list.tsx"
"use client";

import { usePasses } from "@/lib/supabase/hooks";

const rows = usePasses({ limit: 50 });
```

A pass for a booking is a ticket: it is valid until the booking ends and
admits once, unless `validUntil` or `usesAllowed` say otherwise. A pass for a
`resourceId` (a gym, a venue, a meeting room) admits as many times as
`usesAllowed` allows, or without a limit when it's missing. A pass can also
name any record in `config.records`, such as an event or a membership, with
`subject`. `kind` is free text such as `ticket`, `membership` or `visitor`.
Issuing needs `passes.manage`.

| Method                            | Returns                                                         |
| --------------------------------- | --------------------------------------------------------------- |
| `issue(input)`                    | the pass with its `nonce`                                       |
| `revoke(passId)`                  | the pass; its tokens stop admitting                             |
| `get(passId)`                     | the pass with its nonce, for the holder or a manager            |
| `list({ holderUserId, subject })` | the organization's passes, newest first                         |
| `mine()`                          | the caller's live passes with their nonces, for a wallet screen |
| `token(pass)`                     | the signed token for the QR code or NFC tag                     |
| `checkIn(token, options)`         | the scan, with `admitted`, the `reason` and the pass            |
| `checkInManually(passId)`         | the scan, for staff who looked the holder up                    |
| `checkIns(passId)`                | the pass's scans, newest first                                  |
| `occupancy(resourceId)`           | `capacity`, `booked`, `dropIns` and `free` seats right now      |

The [passes binding](/docs/repository/record-bindings) lists a record's passes on its
page, with `issue` and `revoke` writes.

## Sign the token [#sign-the-token]

Signing runs on the server with the organization's key:

```ts
const passes = createPasses({
  transport,
  tenant: organizationId,
  signer: signingKeys.signer(
    { type: "organization", id: organizationId },
    { create: "Ed25519" },
  ),
});

const pass = await passes.get(passId).orThrow();
const token = pass && (await passes.token(pass).orThrow());
```

The token is a compact JWS with `typ: "vnd.pass+jwt"` (`PASS_TOKEN_TYPE`), so
another kind of token can't be replayed as a pass. Its claims are the pass id
(`sub`), the nonce (`jti`), the organization (`org`), `kind`, the holder's
`name`, and `nbf` and `exp`. A token expires with the pass, or after
`tokenLifetime` (30 days) for a pass that runs longer; sign a new one when
the holder opens their wallet. The `exp` keeps the signing key in the JWKS
until the token expires, so rotating the key doesn't strand it.

Render the token with any QR library, or write it to an NFC tag as a text
record. The block doesn't ship one, so the app picks the library.

## Check in [#check-in]

```ts
const door = createPasses({
  transport,
  jwks: () => fetch(`${origin}/.well-known/jwks.json`).then((r) => r.json()),
});

const scan = await door
  .checkIn(scanned, { gate: "north", resourceId: roomId })
  .orThrow();

if (!scan.admitted) {
  showRejection(scan.reason);
}
```

`checkIn` verifies the signature and the `exp` first: a token that fails
comes back as an error (`PASS_TOKEN_INVALID` or `PASS_TOKEN_EXPIRED`) and
never reaches the database. The database then compares the nonce and decides,
under a row lock on the pass. Every scan is logged, and an admitted one uses
the pass once. Checking in needs `passes.check_in`.

| Reason                | Why the holder was turned away                           |
| --------------------- | -------------------------------------------------------- |
| `invalid`             | the token's nonce doesn't match the pass                 |
| `revoked`             | the pass was revoked                                     |
| `not_yet_valid`       | `validFrom` is still ahead                               |
| `expired`             | `validUntil` has passed                                  |
| `used_up`             | every use is spent                                       |
| `booking_cancelled`   | the ticket's booking was cancelled, or its hold lapsed   |
| `booking_unconfirmed` | the ticket's booking is still a hold                     |
| `outside_window`      | the booking hasn't opened for check-in yet, or has ended |
| `wrong_resource`      | the door belongs to another resource than the pass       |
| `full`                | every seat of the resource is taken                      |

A ticket opens `checkInOpens` (one hour) before its booking starts. A pass
for a resource without a booking is a drop-in: it is admitted only while the
confirmed bookings, live holds and drop-ins on the resource are fewer than its
`capacity`. A drop-in counts for `dropInLength` (two hours) after it was
admitted. An advisory lock per resource keeps two scans at once from taking
the last seat twice.

```ts title="better-supabase.config.ts"
blocks: {
  "passes": { options: { checkInOpens: "30 minutes", dropInLength: "90 minutes" } },
},
```

## Class credits [#class-credits]

Turn on `credits` to sell a pack of classes: a pass with `usesAllowed` and no
booking of its own pays one credit for each class its holder books. The
option is off by default and needs the `bookings` block.

```ts title="better-supabase.config.ts"
blocks: {
  "passes": { options: { credits: true, creditRefundBefore: "12 hours" } },
},
```

```ts
const pack = await passes
  .issue({ holderUserId: userId, resourceId, usesAllowed: 10 })
  .orThrow();

const booked = await passes
  .bookWithCredit(pack.id, { serviceId, resourceId, startsAt })
  .orThrow();

booked.creditsLeft; // 9
```

`bookWithCredit` books a confirmed booking for the holder, through the
bookings block's slot check and exclusion constraint, and takes one credit in
the same transaction. Credits post on the `ledger` block, which the
`credits` option installs: each pass has an account that takes `-1` for a
booking and `+1` for a refund against the organization's pool, and the pass's
`uses` is their running total, so `credits(passId)` returns the whole
history, newest first. Bind `uses: { ledger: "<instance>" }` to keep credits
on a ledger instance of their own. The
holder books with their own pass; a member with `passes.manage` can book for
them.

Cancelling the booking at least `creditRefundBefore` (default 12 hours)
before it starts gives the credit back once; a later cancellation keeps it.
Rescheduling keeps the credit with the booking. At the door, issue a ticket
for the booking and scan that: scanning the pack itself counts as another use.

| Method                          | Does                                                  |
| ------------------------------- | ----------------------------------------------------- |
| `bookWithCredit(passId, input)` | Books a class and takes a credit; returns the booking |
| `credits(passId)`               | The pass's credit ledger, newest first                |

## Errors [#errors]

| Hint                      | Cause                                                       |
| ------------------------- | ----------------------------------------------------------- |
| `PASS_FORBIDDEN`          | the caller lacks `passes.read` or `passes.manage`           |
| `PASS_NOT_FOUND`          | no pass with this id that the caller manages or checks in   |
| `PASS_SUBJECT_INVALID`    | only one of the record type and id was passed               |
| `PASS_SUBJECT_NOT_FOUND`  | the record doesn't exist in the organization                |
| `PASS_HOLDER_NOT_FOUND`   | no user with `holderUserId`                                 |
| `PASS_BOOKINGS_MISSING`   | a booking or resource was named without the bookings block  |
| `PASS_BOOKING_NOT_FOUND`  | the booking doesn't exist in the organization               |
| `PASS_BOOKING_CANCELLED`  | the booking is cancelled                                    |
| `PASS_RESOURCE_MISMATCH`  | `resourceId` isn't the booking's resource                   |
| `PASS_RESOURCE_NOT_FOUND` | the resource doesn't exist in the organization              |
| `PASS_VALIDITY_INVALID`   | `validUntil` isn't after `validFrom` and in the future      |
| `PASS_METHOD_INVALID`     | the method isn't `qr`, `nfc` or `manual`                    |
| `PASS_SIGNER_MISSING`     | `token` was called on a client without `signer`             |
| `PASS_JWKS_MISSING`       | `checkIn` was called on a client without `jwks`             |
| `PASS_TOKEN_INVALID`      | the token isn't a pass token signed by a key in the JWKS    |
| `PASS_TOKEN_EXPIRED`      | the token's `exp` has passed, or its `nbf` is still ahead   |
| `PASS_REVOKED`            | booking with a revoked pack                                 |
| `PASS_NOT_CREDITS`        | the pass has no `usesAllowed`, or is a ticket for a booking |
| `PASS_WRONG_RESOURCE`     | the pack is for another resource than the class             |
| `PASS_NOT_VALID`          | the class starts outside the pack's validity window         |
| `PASS_CREDITS_USED_UP`    | every credit of the pack is used                            |

## Events [#events]

`pass.issued` and `pass.revoked`, `pass.admitted` and `pass.rejected` for
every scan, and `pass.credit_used` and `pass.credit_refunded` for class
credits. The [events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.passes` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const passes = createPasses({ transport, tenant: organizationId });

const ticket = await passes
  .issue({ bookingId, holderUserId: userId, holderName: "Jane" })
  .orThrow();
```

# Reminders

> RFC 5545 recurrence and reminders, expanded in TypeScript and delivered through notifications.

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

`reminders` stores a recurrence rule, a reminder (who, how, how long before) and one delivery row per occurrence. Expansion runs in the app with `expandRule`, on `Temporal.ZonedDateTime`, so a local time stays put across a daylight-saving change. `list_reminder_schedule` returns each reminder with its rule. `planReminderDeliveries` expands a horizon and returns one delayed job per occurrence, with a dedupe key of reminder id and occurrence. `deliverReminder` calls `mark_reminder_sent`, which records `reminder.sent`, then your notify function.

```bash
better-supabase add reminders
```

The block's entity is `reminder` (the `reminders` table). It isn't registered as a subject. Rename it with `blocks.reminders.names`.

```ts
import {
  createReminders,
  planReminderDeliveries,
} from "better-supabase/blocks/reminders";
```

`options.ruleFormat` is `rrule` (the default) or `columns`. The columns format maps `frequency`, `interval_count`, `week_days`, `month_day`, `month_weekday` and `month_ordinal`.

`reminders.upsert(subject, offsetMinutes)` sets a reminder for the caller, `reminders.list(subject)` returns the caller's reminders on that subject, and `reminders.remove(id)` deletes one with its delivery history. All three need `reminders.manage`. The subject is a `RecordRef`, such as `{ type: "task", id: taskId }`, and `upsert` and `list` act in the block's `tenant`, an organization id or a function that returns the current one. `createBlocks` and `createBlockClient` pass the request's tenant; a call without one fails with `invalid_request` and the hint `TENANT_REQUIRED`. The [calendar](/docs/blocks/calendar) block mirrors each event into a rule, so reminders on a `calendar_event` fire.

`expandRule` covers `FREQ`, `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY` with ordinals, `BYMONTHDAY`, `BYMONTH`, `BYSETPOS` and `WKST`. The pin is `SPEC_PINS.rfc5545`.

## Delivery [#delivery]

`reminders.deliver(payload)` marks one occurrence sent and, when this call
recorded it, calls `notifyDelivery`, which defaults to sending the
`notification` type through the `notifier` port. It returns `ok(true)` for the first
delivery and `ok(false)` for a replay, which records no second
`reminder.sent` and sends nothing. `reminders.job` is the same call as a job
handler, for the `reminders.deliver` queue (`REMINDER_DELIVERY_QUEUE`):

Server code calls the `reminders` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useReminders } = defineDataHooks(
  "reminders",
  (db: AppDb) => db.reminders,
);
```

```ts
const rows = await db.reminders.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/reminders/list.tsx"
"use client";

import { useReminders } from "@/lib/supabase/hooks";

const rows = useReminders({ limit: 50 });
```

Before it records the occurrence, `deliver` checks the reminder's user with
`audience`, a `SubscriberPolicy`. A user it leaves out still has the
occurrence recorded but gets no notification, and a throw fails the call
with the hint `REMINDER_AUDIENCE_FAILED` so the job retries. Without the
option it uses `authorization.delivery.audience`; with `authorization` and
no such policy, it uses `subjectReaders`, so a user who lost read access to
the subject stops getting its reminders:

```ts
createReminders({ ...context, authorization: bsb.authorization });
```

`reminder.sent` carries `organizationId`, `id`, `subjectType`, `subjectId`
and `userId`, so a [watcher sink](/docs/blocks/notifications#watchers) can
route it like any other subject event.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `reminders`. `upsert` writes them (its last argument) from `fields`,
and `upsert` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { reminders: { options: { extraColumns: { channel: "text" } } } },
});
```

```ts
const client = createReminders({
  transport,
  tenant: organizationId,
  fields: v.object({ channel: v.optional(v.picklist(["email", "push"])) }),
});
await client.upsert({ type: "task", id: taskId }, 30, { channel: "push" });
```

## Over your own tables [#over-your-own-tables]

To run over tables you already have, map `tables.rules`, `tables.reminders` and `tables.deliveries`. Collection-level defaults, such as `agenda_collection_notification_reminders`, are reminders whose subject is the collection.

## Without the client [#without-the-client]

`db.reminders` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const { reminders } = createBlocks(
  { transport, service },
  {
    notifications: notificationsBlock,
    reminders: remindersBlock.with({ notification: "reminder.due" }),
  },
);

export const GET = jobs.drainRoute({
  secret: process.env.CRON_SECRET,
  handlers: { [REMINDER_DELIVERY_QUEUE]: reminders.job },
});
```

# Time tracking

> Timers and logged time on any record, one running timer per member and no overlapping entries enforced by Postgres, weekly timesheets approved through the approvals module, locked weeks, and a weekly grid shape for a data grid.

Source: https://bettersupabase.com/docs/blocks/time-tracking

`time-tracking` stores time entries and weekly timesheets per organization. An entry belongs to a member, can name the record it was spent on (a project, a ticket, a client), and is billable or not, with an hourly rate. An exclusion constraint keeps one member's entries from overlapping, which also means one running timer per member. A member submits a week as a timesheet, the [approvals](/docs/blocks/approvals) module routes it to an approver, and an approved week is locked.

Tracking your own time needs `time_tracking.track`, reading other members' entries and timesheets needs `time_tracking.read`, approving needs `time_tracking.approve` (the default approval step), and logging time for someone else, editing their entries and reopening an approved week needs `time_tracking.manage`. Admins get every permission and members get `time_tracking.track`.

```bash
better-supabase add time-tracking
```

The module needs `tenant`, `permissions` and `approvals`, and creates the `btree_gist` extension for its exclusion constraint.

Server code calls the `time-tracking` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTimeTracking } = defineDataHooks(
  "timeTracking",
  (db: AppDb) => db.timeTracking,
);
```

```ts
import { money } from "better-supabase/commerce";
import { createTimeTracking } from "better-supabase/blocks/time-tracking";

await db.timeTracking
  .start({
    subject: { type: "project", id: projectId },
    billable: true,
    rate: money(12_000n, "EUR"),
  })
  .orThrow();
const entry = await db.timeTracking.stop().orThrow();

const week = Temporal.PlainDate.from("2026-10-05");
const sheet = await db.timeTracking.timesheet(week).orThrow();
// sheet.totals: { seconds, billableSeconds, amounts: [money(48_000n, "EUR")] }
await db.timeTracking.submit(week, "Week 41").orThrow();
```

```tsx title="src/features/time-tracking/list.tsx"
"use client";

import { useTimeTracking } from "@/lib/supabase/hooks";

const rows = useTimeTracking({ limit: 50 });
```

Every call acts in the block's `tenant`, an organization id or a function that returns the current one. `createBlocks` and `createBlockClient` pass the request's tenant; a call without one fails with `invalid_request` and the hint `TENANT_REQUIRED`.

## Timers and entries [#timers-and-entries]

`start` starts a timer for the caller. When a timer already runs, it stops first and is recorded, so a member switches tasks with one call. `stop` ends the running timer (`TIME_NO_TIMER` when none runs) and `running` returns it, or `undefined`.

`log` records a finished entry with `startedAt` and `endedAt`. Its end must come after its start (`TIME_ENTRY_INVALID`), and it can't overlap another of the member's entries (`TIME_ENTRY_OVERLAP`). Passing `userId` logs time for another member of the organization, which needs `time_tracking.manage` (`TIME_USER_INVALID` when they aren't a member). `update(entryId, patch)` changes the times, the subject, `billable`, `rate` or `note`; `null` clears the subject, rate or note. `remove` deletes an entry. A member edits their own entries; anyone else's needs `time_tracking.manage`.

A rate is a `Money` value from `better-supabase/commerce`: an hourly amount in minor units of its currency, so `money(12_000n, "EUR")` is 120 euros an hour. The client returns `CURRENCY_INVALID` before any database call for a code that isn't ISO 4217, and the table keeps `rate` and `rate_currency` set together (`TIME_RATE_INVALID`). The functions return `rate` as text, as every block amount is ([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)).

An entry's `subject` is a `RecordRef`, `{ type, id }`, or none; an entry without one returns `subject: undefined`. The SQL functions take it as `subject_type` and `subject_id` and fail with `TIME_SUBJECT_INVALID` when a caller passes only one of them. With a subject, the caller must be able to read the record, through its registration in [`config.records`](/docs/blocks/sql#subject-registry) or the block's `on`. Entries stay when their record is deleted, so billed time keeps its history.

With the [employees block](/docs/blocks/employees) installed, every entry
has an `employee_id` column: the employee of the organization behind the
entry's member, or null for a member who isn't one. Triggers set it when an
entry is written and when an employee's account changes, so payroll and
HR reports join time to employees without matching users by hand.

`list` returns the caller's entries, newest first. `from` and `to` select the entries that overlap that range, `subject` selects one record's time, and `userId` or `everyone: true` read other members' entries with `time_tracking.read`.

## Timesheets and approvals [#timesheets-and-approvals]

A week starts on Monday in the module's `timeZone`. `timesheet(week)` returns the caller's week (or another member's with `userId` and `time_tracking.read`) with the entries that start in it and its totals: `seconds` counts every entry and running ones up to now, `billableSeconds` the billable ones, and `amounts` adds billable hours times their rate, rounded to minor units, with one `Money` per currency ordered by code. A week passed as any day is read as the week that contains it.

| Status      | Meaning                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| `open`      | never submitted, or reopened                                            |
| `submitted` | an approval request for it is pending                                   |
| `approved`  | the final approval step approved it                                     |
| `rejected`  | the approval request was rejected; the member can edit and submit again |

`submit(week, note)` opens an approval request for the week, with the timesheet as its subject (`timesheet`). It fails with `TIMESHEET_TIMER_RUNNING` while a timer in the week still runs, `TIMESHEET_SUBMITTED` while a request is pending and `TIMESHEET_APPROVED` once approved. Approvers decide with the approvals client, and the final approval marks the timesheet approved:

```ts
import { createApprovals } from "better-supabase/blocks/approvals";

const approvals = createApprovals({ transport });
await approvals.approve(sheet.approvalId!).orThrow();
```

A week is locked while its request is pending and once it is approved: adding, changing or deleting an entry that starts in it fails with `TIME_PERIOD_LOCKED`, also for direct table writes. A rejection unlocks it. `reopen(timesheetId)` unlocks an approved week (`TIMESHEET_NOT_APPROVED` otherwise) and needs `time_tracking.manage`. `timesheets()` lists the caller's timesheets, newest week first, filtered by `status` and paged with `before`.

## Configure [#configure]

```ts title="better-supabase.config.ts"
blocks: {
  "time-tracking": {
    options: {
      timeZone: "Europe/Amsterdam",
      policy: { steps: [{ approvers: { role: "admin" } }], expiresIn: "P7D" },
    },
  },
},
```

| Option     | Default                                      | What it does                                                                                                                      |
| ---------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `timeZone` | `UTC`                                        | The IANA time zone weeks and days start in                                                                                        |
| `policy`   | one step: holders of `time_tracking.approve` | An [approvals policy](/docs/blocks/approvals#configure-policies) without `run`, because the final approval is what locks the week |

Errors carry these hints: `TIME_FORBIDDEN`, `TIME_SUBJECT_INVALID`, `TIME_ENTRY_INVALID`, `TIME_ENTRY_OVERLAP`, `TIME_ENTRY_NOT_FOUND`, `TIME_USER_INVALID`, `TIME_NO_TIMER`, `TIME_PERIOD_LOCKED`, `TIMESHEET_NOT_FOUND`, `TIMESHEET_TIMER_RUNNING`, `TIMESHEET_SUBMITTED`, `TIMESHEET_APPROVED` and `TIMESHEET_NOT_APPROVED`.

## Events and hooks [#events-and-hooks]

The module records `time_entry.started`, `time_entry.recorded` (with `seconds`, `billable`, `rate` as text and `rateCurrency`), `time_entry.updated` and `time_entry.deleted`, each with the entry's `subjectType`, `subjectId` and `startedAt`, so an [outbox](/docs/blocks/outbox) consumer can add up hours per record or feed billable hours to a usage meter. It records `timesheet.submitted`, `timesheet.approved` and `timesheet.reopened` with the `week` and `approvalId`, and audits them. It calls `before_time_entry_save`, `after_time_entry_save`, `before_timesheet_submit` and `after_timesheet_submit`.

## Metrics [#metrics]

Every entry has a `seconds` column, generated from `ended_at - started_at` and null while the timer runs. `timeTrackingMetrics()` defines `time_tracked` over it for the [metrics](/docs/blocks/metrics) block: the seconds of finished entries per day they start in, filtered or grouped by `billable`, `user_id` and `subject_type`. Running timers count once they stop.

```ts title="better-supabase.config.ts"
import { timeTrackingMetrics } from "better-supabase/blocks/time-tracking";

blocks: {
  "time-tracking": {},
  metrics: { options: { metrics: timeTrackingMetrics({ tz: "Europe/Amsterdam" }) } },
},
```

Time is often logged for a past day, so the metrics sink buckets entry events by the entry's `startedAt` instead of when the event happened. Pass `TIME_TRACKING_METRIC_EVENTS` and `timeEntryMetricTime` to the metrics block's `sink` option:

```ts title="app/api/outbox/route.ts"
import {
  TIME_TRACKING_METRIC_EVENTS,
  timeEntryMetricTime,
  timeTrackingMetrics,
} from "better-supabase/blocks/time-tracking";

const app = createBlocks(
  { transport: service },
  {
    metrics: metricsBlock
      .with({
        sink: { on: TIME_TRACKING_METRIC_EVENTS, time: timeEntryMetricTime },
      })
      .using((options) =>
        createMetrics({ ...options, metrics: timeTrackingMetrics() }),
      ),
  },
);
```

`time_entry.updated` carries `previousStartedAt`, and `timeEntryMetricTime` returns it next to `startedAt`, so an update that moves an entry to another day rolls up both days in the same relay. Over a time entries table of your own, map `seconds` to a column of yours, or map it to `null` and define the metric yourself.

## With ReUI [#with-reui]

`toWeeklyGrid` turns a week's entries into the rows of ReUI's weekly Data Grid (`data-grid-base-4`): one row per record and billable flag, a column per day from Monday, and totals per row, day and week. It splits entries that cross midnight in the time zone, and counts a running entry up to now.

```tsx
import { toWeeklyGrid } from "better-supabase/blocks/time-tracking";

import { blockConfig } from "@/lib/supabase/blocks.generated";

const sheet = await time.timesheet(week).orThrow();
const grid = toWeeklyGrid(sheet.entries, {
  week,
  // The module's timeZone option, as `better-supabase gen` wrote it.
  timeZone: blockConfig["time-tracking"].options.timeZone,
});
// columns: a subject column, then grid.days (header day.date, footer day.seconds), then a total column
// <DataGrid data={grid.rows}>: row.days[i] seconds per day, row.seconds for the total
```

Pass `rowKey` to group rows differently, for example by subject only. The header can show `sheet.status` as a badge with a submit button that calls `submit`, disabled while a timer runs, and a timer button that calls `start` and `stop` and shows `running`.

## Your own columns [#your-own-columns]

Add columns to the time entry table with `blocks.time-tracking.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`start`, `log` and `update` validates and writes them, and every method that returns an entry return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createTimeTracking } from "better-supabase/blocks/time-tracking";
import { z } from "zod";

const client = createTimeTracking({
  transport,
  tenant: organizationId,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.timeTracking` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { money } from "better-supabase/commerce";
import { createTimeTracking } from "better-supabase/blocks/time-tracking";

const time = createTimeTracking({ transport, tenant: organizationId });
await time
  .start({
    subject: { type: "project", id: projectId },
    billable: true,
    rate: money(12_000n, "EUR"),
  })
  .orThrow();
const entry = await time.stop().orThrow();

const week = Temporal.PlainDate.from("2026-10-05");
const sheet = await time.timesheet(week).orThrow();
// sheet.totals: { seconds, billableSeconds, amounts: [money(48_000n, "EUR")] }
await time.submit(week, "Week 41").orThrow();
```

# Composing blocks

> Where the glue between blocks lives, from record pages and lists to default automations between blocks and forms with custom fields.

Source: https://bettersupabase.com/docs/blocks/composing-blocks

Blocks work together through three layers. Knowing which layer a piece of
glue belongs to tells you where to write it and what `gen` writes for you.

| Layer                    | Owner           | What it holds                                                               |
| ------------------------ | --------------- | --------------------------------------------------------------------------- |
| Library logic            | better-supabase | record bindings, record lists, the timeline, My work, actions, `recordForm` |
| One file per record type | `gen`           | the record type that names the blocks bound to it                           |
| UI                       | your app        | recipes copied into the app and rendered with your shadcn components        |

## Reads: record bindings [#reads-record-bindings]

A record binding is one block's part of a record, such as its comments, files
or versions, with a `load`, an optional `count` and a permission, named by the
block's key in the config. With `gen.recordTypes`, `gen` writes one record type
per type in `config.records` with the blocks bound to it. Everything that shows
more than one block on a record reads bindings:

- a [record page](/docs/repository/record-bindings) loads each binding on its own, so a
  failing or hidden one doesn't take the page down;
- a [record list](/docs/repository/record-lists) fills columns from a binding's
  `count` or rows, one query per page;
- the timeline merges the bindings of one record, newest first;
- My work collects what waits on the caller across the installed blocks.

## Writes: default automations [#writes-default-automations]

A block that turns its records into another block's ships a
[default automation](/docs/blocks/automations#default-automations): an
automation row whose trigger is its outbox event and whose action is a
conversion action of the target block. The inbox ships "Open a task for each
new conversation" (`tasks.from_conversation`), hiring ships "Preboard each
hired candidate" (`employees.from_application`) and quotes ships "Order each
accepted quote" (`orders.from_quote`). An organization turns them off or
edits them like its own automations. Each conversion creates the record once
and keeps the lineage as a `created_from` relation.

## Forms: entity schema and custom fields [#forms-entity-schema-and-custom-fields]

[`recordForm`](/docs/blocks/entity-forms) builds one form from a block's
entity schema and the organization's custom fields, and `useRecordForm`
holds its state. The form recipe renders it.

## Which layer? [#which-layer]

- Data that belongs to one record and shows on its page or list: a record binding.
- A record that should exist because another one changed: an automation with a conversion action.
- A form over a block entity with tenant fields: `recordForm` and the recipe.
- Markup of any kind: a [recipe](/docs/registry/ui-recipes), never the library.

The decision is ADR 0064; ADR 0093 replaces its sections with record bindings and its flows with default automations.

# createBlocks

> Build several blocks from one set of options and the generated block config, and wire them on the ports they provide and consume, from better-supabase/blocks.

Source: https://bettersupabase.com/docs/blocks/create-blocks

`defineBlocks` and `createBlocks` build the blocks you list with one
transport and set of error mappers, and wire them on their ports: each block
that consumes a port, such as ai-schedules and reminders consuming `notifier`,
gets it from the block that provides it, here `notifications`. Every block
exports its manifest from its subpath (`organizationsBlock` from
`better-supabase/blocks/organizations`), so only the blocks you list end up
in the bundle.

Each block also reads the settings its SQL half has in the `blocks` config:
its schema, its API schema and the options both halves share, such as
`attachments.requireScan`. `better-supabase gen` writes them to
`blocks.generated.ts`, and `defineBlocks` takes that file's `blockConfig`, so
you set each of them once, in `better-supabase.config.ts`.

```ts title="src/lib/blocks.ts"
import {
  type RpcClient,
  defineBlocks,
  rpcTransport,
} from "better-supabase/blocks";
import { auditBlock } from "better-supabase/blocks/audit";
import {
  createNotifications,
  notificationsBlock,
} from "better-supabase/blocks/notifications";
import { organizationsBlock } from "better-supabase/blocks/organizations";

import { blockConfig } from "./supabase/blocks.generated";

export const blockSet = defineBlocks(blockConfig, {
  organizations: organizationsBlock,
  audit: auditBlock,
  notifications: notificationsBlock.using((options) =>
    createNotifications({ ...options, types, render }),
  ),
});

export function blocks(supabase: RpcClient) {
  return blockSet.create({ transport: rpcTransport(supabase) });
}

const { organizations, audit, notifications } = blocks(supabase);
```

The result has one key per block with a client, typed from its creator,
plus `sinks` and `close()`. A block with only SQL, such as `membershipsBlock`, is
skipped.

## Block instances on db [#block-instances-on-db]

Register the set on the definition with `.blocks()` and every `Db` carries
each instance under its key in camel case: `db.comments`, `db.leadScoring`.
`gen` writes the `blocks` map in `blocks.generated.ts` with every installed
instance's manifest, so you list only the blocks that take app definitions:

```ts title="src/lib/supabase/index.ts"
import { defineSupabase } from "better-supabase";
import { defineBlocks } from "better-supabase/blocks";
import { createNotifications } from "better-supabase/blocks/notifications";

import { blockConfig, blocks } from "./blocks.generated.ts";
import { schema } from "./generated.ts";

const blockSet = defineBlocks(blockConfig, {
  ...blocks,
  notifications: blocks.notifications.using((options) =>
    createNotifications({ ...options, types, render }),
  ),
});

export const betterSupabase = defineSupabase(schema).blocks(blockSet);
```

```ts title="src/features/notes/notes-actions.ts"
export const addNote = bsb.action(
  { input: NoteInput, requireOrganization: true },
  async ({ customerId, text }, { db }) =>
    db.comments.add({ type: "customer", id: customerId }, text),
);
```

Each client runs through the `Db`'s executor, so it acts as the same caller
(RLS and each function's checks apply), and works in the `Db`'s organization
(`db.$context.tenant`). It is built the first time you read it on that `Db`,
so `db.$with({ tenant })` and every request get their own. The browser client
of `createClient` gets the same properties on `bsb.db`.

When a table already holds the property, such as an app `tasks` table next to
the `tasks` block, the table keeps `db.tasks` and the instance is on
`db.$blocks.tasks`. `db.$blocks` holds every instance. `db.$api(fn, args)`
calls a function in the API schema that has no typed client, such as
`flag_enabled`, and resolves to its result.

## The generated config [#the-generated-config]

`gen` writes `blocks.generated.ts` next to `generated.ts` when the config
installs blocks. It holds one entry per installed block, keyed by the
block's name:

```ts title="src/lib/supabase/blocks.generated.ts"
export const blockConfig = {
  attachments: {
    schema: "better_supabase",
    api: "api",
    options: { requireScan: true },
  },
  notifications: {
    schema: "better_supabase",
    api: "api",
    options: { topic: "notifications:{userId}" },
  },
} as const satisfies GeneratedBlocks;
```

`schema` and `api` resolve `blocks.<block>.schema` and `.api` over
`database.sql.schema` and `database.sql.api`. `options` holds the options marked as shared in
the block's manifest, with the module's default when the config leaves one
out. Each block's creator gets the API schema when there is one (the
wrappers there are what the caller may run), otherwise the module schema,
plus the shared options. A creator you call yourself takes the same values
from `generatedOptions`:

```ts
import { generatedOptions } from "better-supabase/blocks";

const attachments = createAttachments({
  ...generatedOptions(blockConfig.attachments),
  transport,
  storage: supabase.storage,
});
```

The realtime controllers take their topic from the same file. The
notifications topic is a template, which `fillTopic` fills in:
`fillTopic(blockConfig.notifications.options.topic, { userId })`.

| Option shared with TypeScript | Read by                                    |
| ----------------------------- | ------------------------------------------ |
| `attachments.requireScan`     | `createAttachments`                        |
| `bulk.limit`                  | `createBulk`                               |
| `notifications.topic`         | the notifications feed controller and hook |
| `announcements.topic`         | the announcements controller and hook      |
| `inbox.topic`                 | the inbox controllers and hooks            |
| `reactions.topic`             | the reactions controller and hook          |
| `ai-chat.topic`, `listTopic`  | the chat controllers and hooks             |
| `time-tracking.timeZone`      | `toWeeklyGrid`                             |
| `waitlist.codeField`          | `waitlistHook`                             |
| `webhooks-out.allowHttp`      | `publicUrl`, the webhook URL check         |

## Options [#options]

| Option        | What it does                                                                                         |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| `transport`   | Calls as the caller: `dbTransport(ctx.db)`, `rpcTransport(supabase)` or `sqlTransport(ctx.postgres)` |
| `service`     | Calls as the service role, for the blocks with service-only calls                                    |
| `config`      | `blockConfig` from `blocks.generated.ts`; `defineBlocks` passes it, `createBlocks` takes it here     |
| `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-keys 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`            |
| `hooks`       | Hooks around each block's methods, keyed like the blocks (see [Hooks](#hooks))                       |
| `tenant`      | The organization the subject methods act in: an id, or a function that returns the current one       |

## Tenants [#tenants]

The blocks whose methods work on a subject (comments, attachments, tags,
reactions, relations, versions, sla, share links, custom fields, reminders,
time tracking and approvals) act in one organization, which they read from
the `memberships` option instead of taking an `organizationId` argument. Pass an
id, or a function that returns the current one, such as
`() => ctx.tenant`; the function runs on every call. A call without a tenant
fails with `invalid_request` and the hint `TENANT_REQUIRED` before it reaches
the database. The SQL functions still check that the caller belongs to that
organization.

Each of these methods takes the record it works on as a `RecordRef`,
`{ type, id }`, and its rows carry it as `subject`. Batch methods such as
`comments.counts(subjects)` and `reactions.summary(subjects)` return a record
keyed `type:id`, which `subjectKey(subject)` from `better-supabase/blocks`
builds.

## One block per request [#one-block-per-request]

`createBlockClient(db, block, options?)` builds one block for a request. It
calls through `dbTransport(db)` and acts in the `Db`'s tenant
(`db.$context.tenant`), so a server action or route handler needs no wrapper
that passes the organization:

```ts
import { createBlockClient } from "better-supabase/blocks";
import { commentsBlock } from "better-supabase/blocks/comments";

import { blockConfig } from "@/lib/supabase/blocks.generated";
import { bsb } from "@/lib/supabase/server";

const ctx = await bsb.context();
const comments = createBlockClient(ctx.db, commentsBlock, {
  config: blockConfig,
});
const thread = await comments
  .list({ type: "customer", id: customerId })
  .orThrow();
```

`blockSet.client(db, key, options?)` does the same for a block of a set
built with `defineBlocks`, with the set's config, so
`blockSet.client(ctx.db, "comments")` returns the same client typed from the
set. The options are those of `createBlocks` without `audit`, plus `hooks`
for the one block: `transport` replaces `dbTransport(db)`, and `tenant`
replaces the `Db`'s tenant, for a job that works in an organization other
than the caller's.

## A block's own options [#a-blocks-own-options]

`with` sets options one block gets on top of the shared ones, such as the
notification type ai-schedules sends:

```ts
defineBlocks(blockConfig, {
  aiTasks: aiSchedulesBlock.with({ notification: "ai_task.finished", run }),
  notifications: notificationsBlock,
}).create({ transport, service });
```

`using` builds a block with your own call to its creator, which keeps the
creator's type parameters, such as the notification `types` or the metric
definitions, or passes what the creator needs from your code:
`settingsBlock.using((options) => createSettings({ ...options, settings }))`.
Settings from an earlier `with` still reach the creator and the block's
ports.

## Transports [#transports]

A transport decides how a block's functions are called and as whom. All
three reject with the raw database error, so a block maps the same codes and
hints whichever one you pick.

| Transport                            | Calls through                                    | Use it when                                                      |
| ------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------- |
| `dbTransport(db, { schema })`        | the executor of a `Db` (`db.$executor`)          | the blocks should run wherever `ctx.db` runs                     |
| `rpcTransport(supabase, { schema })` | a supabase-js client over the Data API           | you hold a supabase-js client, such as in the browser            |
| `sqlTransport(client)`               | a `SqlClient`, such as `postgres.asUser(claims)` | you call over Postgres as a fixed role, such as `postgres.admin` |

`dbTransport` follows the `Db` it gets. `ctx.db` runs over the Data API in a
normal request, and over direct Postgres as the target user in a
[support session](/docs/auth/impersonation) or for an API key, so blocks
built on `dbTransport(ctx.db)` need no second code path for those requests:

```ts title="src/lib/blocks.ts"
import {
  type DbTransportSource,
  defineBlocks,
  dbTransport,
} from "better-supabase/blocks";
import { organizationsBlock } from "better-supabase/blocks/organizations";

import { blockConfig } from "./supabase/blocks.generated";

const blockSet = defineBlocks(blockConfig, {
  organizations: organizationsBlock,
});

export function blocksFor(db: DbTransportSource) {
  return blockSet.create({ transport: dbTransport(db) });
}

const { organizations } = blocksFor(ctx.db);
```

Each block calls the schema from its generated entry: the API schema that
`blocks.<block>.api` (or `database.sql.api`) writes wrappers into, or the module
schema without one. Over the Data API that schema has to be exposed; over
Postgres the same wrappers work. A transport's own `schema` option sends
every call there instead. The executor needs `rpc()`: the built-in PostgREST
and Postgres executors have it.

### Renamed blocks [#renamed-blocks]

When the config renames the tenant (`blocks.memberships.names.organization`),
a block's entities (`blocks.<name>.names`) or its functions
(`blocks.<name>.functions`), the SQL writes the block functions under other
names. `gen` writes those names into each block's `names` entry in
`blocks.generated.ts`, and `defineBlocks` wraps that block's transport with
`blockNames(names)`, so the clients call the renamed functions with no change
to `lib/blocks.ts`. Rows that a function returns whole get their renamed
tenant columns (`workspace_id`) back under the names the clients read
(`organization_id`).

A renamed entity also renames the TypeScript side. For each block whose
entities the config renames, `gen` writes the block and its types under the
new names, so you import `tasksBlock` and `Task` instead of `tasksBlock`
and `WorkItem`:

```ts title="src/lib/supabase/blocks.generated.ts"
export const tasksBlock = renameBlock(tasksBlock, blockConfig["tasks"].names);
export type Task<F extends object = Record<never, never>> = RenamedType<
  WorkItem<F>,
  WorkItemsRenames
>;
export type TasksEvents = RenamedEvents<TasksEvents, WorkItemsRenames>;
```

`renameBlock` renames the client's methods (`createWorkItem` to `createTask`),
the keys of their arguments and results (`workItemId` to `taskId`) and the
block's event types (`work_item.created` to `task.created`). The block keeps
its name, SQL and ports, and hooks see the renamed client. The tenant rename
stays in SQL: payloads and client types keep `organizationId`. See
[Typed blocks](/docs/blocks/typed-blocks) for the config key.

A creator you call yourself takes the same middleware:

```ts
import {
  blockNames,
  generatedOptions,
  wrapTransport,
} from "better-supabase/blocks";
import { createOrganizations } from "better-supabase/blocks/organizations";

import { blockConfig } from "./supabase/blocks.generated";

const entry = blockConfig.organizations;
const organizations = createOrganizations({
  ...generatedOptions(entry),
  transport: entry.names
    ? wrapTransport(transport, blockNames(entry.names))
    : transport,
});
```

## Ports [#ports]

A block's manifest says which ports it provides and consumes. `createBlocks`
hands each consumed port to the block from the one block in the set that
provides it, never by the key you gave it, and refuses two providers of one
port or a missing `required` one.

| Port          | Provided by     | Consumed by                                                              |
| ------------- | --------------- | ------------------------------------------------------------------------ |
| `notifier`    | `notifications` | ai-schedules (with `notification`), reminders (with `notification`)      |
| `files`       | `ai-files`      | knowledge                                                                |
| `attachments` | `attachments`   | drive, which downloads files through it                                  |
| `workflows`   | `workflows`     | workflow-editor, which writes schedule triggers' schedules through it    |
| `mailer`      | `mail-sync`     | sequences, which sends email steps through it                            |
| `sink`        | search, metrics | none: each one lands in `sinks`, keyed like the block (see Outbox sinks) |

With a `notification` type, ai-schedules sends it 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. Reminders send theirs for each
delivered occurrence to the reminder's owner and the subject's watchers,
keyed by `reminder:<id>:<occurrenceAt>`, with the data
`{ reminderId, occurrenceAt }`; `reminders.job` then delivers through it.

```ts
const { aiTasks, reminders } = defineBlocks(blockConfig, {
  aiTasks: aiSchedulesBlock.with({ notification: "ai_task.finished", run }),
  reminders: remindersBlock.with({ notification: "reminder.due" }),
  notifications: notificationsBlock.using((options) =>
    createNotifications({ ...options, types, render }),
  ),
}).create({ transport, service });
```

Some pairs wire in SQL instead, when both modules are installed: approvals
move the state machine, the bulk block gets a `trash.trash` action, and the
search block drops soft-deleted rows. The blocks have nothing to wire for
those.

## Outbox sinks [#outbox-sinks]

`sinks` holds the outbox consumers the blocks provide. `searchBlock` provides
[`searchSink`](/docs/blocks/search): a trashed or purged subject leaves the
index and a restored one is read again. `metricsBlock` with a `sink` option
provides [`metricsSink`](/docs/blocks/metrics), which rolls up the listed
metrics for each event. Build these blocks with the service transport, and
relay the sinks from your cron route:

```ts title="app/api/outbox/route.ts"
import "server-only";

import { defineBlocks } from "better-supabase/blocks";
import { metricsBlock } from "better-supabase/blocks/metrics";
import { searchBlock } from "better-supabase/blocks/search";

import { outbox } from "@/lib/outbox";
import { blockConfig } from "@/lib/supabase/blocks.generated";

const app = defineBlocks(blockConfig, {
  search: searchBlock,
  metrics: metricsBlock.with({
    sink: { on: { "order.placed": ["orders", "revenue"] } },
  }),
}).create({ transport: service });

export const GET = outbox.relayRoute({
  secret: process.env.CRON_SECRET,
  consumers: { ...app.sinks },
});
```

## Hooks [#hooks]

`hooks`, keyed like the blocks and then by 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. Ports are built
from the hooked block, so a `send` hook also runs when ai-schedules notifies. See
[Extending blocks](/docs/extending/blocks#hooks).

```ts
export const app = defineBlocks(blockConfig, {
  notifications: notificationsBlock.using((options) =>
    createNotifications({ ...options, types }),
  ),
}).create({
  transport,
  hooks: { notifications: { send: { before: refuseDuringQuietHours } } },
});
```

## Typed events [#typed-events]

`defineBlocks` also takes your own events
([`defineEvents`](/docs/extending/events#app-events)) after the blocks, and
types every event they can emit. `on` listens with that typing, and `create`
builds the blocks with the generated config:

```ts title="src/lib/blocks.ts"
import { defineBlocks, defineEvents, event } from "better-supabase/blocks";

import { blockConfig } from "./supabase/blocks.generated";

const appEvents = defineEvents({
  "invoice.sent": event<{ readonly invoiceId: string }>(),
});

export const app = defineBlocks(
  blockConfig,
  { organizations: organizationsBlock, comments: commentsBlock },
  appEvents,
);

app.on(betterSupabase, "comment.*", (event) => {
  console.log(event.data.subjectType, event.data.subjectId);
});

const { organizations, comments } = app.create({ transport });
```

## 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 = createAudit({ transport: service });

export const app = defineBlocks(blockConfig, {
  organizations: organizationsBlock,
}).create({
  transport: service,
  events: betterSupabase.events,
  audit: log.sink(),
});
```

# Entity forms

> Build a create or edit form from a block's entity schema and the organization's custom fields, and validate both together.

Source: https://bettersupabase.com/docs/blocks/entity-forms

`recordForm(schema, options)` from `better-supabase/records` turns a block's
entity schema, such as `workItemInputSchema`, into a list of form fields and
adds the organization's [custom fields](/docs/blocks/custom-fields) after
them. Its `validate` turns the text from the inputs into typed values,
validates the entity part with the schema and checks the required custom
fields, then returns the entity input and the custom values apart. The
`useRecordForm` hook from `better-supabase/react` holds the values and the
errors in a component. Neither ships markup: the
[entity form recipe](/docs/registry/ui-recipes/entity-form) renders them
with your shadcn components.

## Build the fields [#build-the-fields]

```ts
import { workItemInputSchema } from "better-supabase/blocks/tasks";
import { recordForm } from "better-supabase/records";

const fields = await db.customFields.listDefinitions("work_item").orThrow();

const form = recordForm(workItemInputSchema, {
  fields: ["title", "priority", "dueAt"],
  labels: { dueAt: "Due" },
  customFields: fields.map((field) => ({
    key: field.key,
    label: field.label,
    type: field.type,
    required: false,
    sensitive: field.sensitive,
  })),
  timeZone: "Europe/Amsterdam",
});
```

Each entry of `form.fields` has a `key`, a `label` (from `labels`, or the key
in words: `dueAt` becomes `Due at`), an `input`, `required`, a `source` of
`entity` or `custom`, and the bounds the schema sets (`minimum`, `maximum`,
`minLength`, `maxLength`) and its enum `options`. Without `fields`, the form
shows every field that isn't read-only, in schema order.

| Schema kind                            | `input`                           |
| -------------------------------------- | --------------------------------- |
| `string`, `uuid`                       | `text`                            |
| `integer`, `bigint`                    | `integer`                         |
| `number`                               | `number`                          |
| `boolean`                              | `bool`                            |
| `date`, `plainDate`                    | `date`                            |
| `datetime`, `instant`, `plainDateTime` | `datetime`                        |
| `enum`                                 | `option`, or `multi` for an array |
| `json`, `custom`, `unknown`            | `json`                            |

A custom field's `input` follows its type, the same way the
[forms block](/docs/blocks/forms) maps it; an unknown type is `text`.

## Validate [#validate]

`validate(values)` takes one flat record keyed by field key, as the inputs
hold it. Blank values are left out. Text becomes the value the schema
expects: a number for `integer` and `number`, a `bigint`, `true` or `false`
for `on`, `true` and `false`, a `Temporal.Instant` for an instant (text
without an offset is read in `timeZone`, UTC by default), a
`Temporal.PlainDate` or `PlainDateTime`, and parsed JSON. Text that doesn't
parse stays text, so the schema reports it.

```ts
const result = await form.validate({
  title: "Ship it",
  priority: "2",
  dueAt: "2026-10-22T10:00",
  region: "eu",
});

if (result.ok) {
  await db.$blocks.tasks.create(tenant, result.input);
  // result.custom is { region: "eu" }
} else {
  // result.errors is { title?: string, region?: string, ... }
}
```

Errors are keyed by the first segment of each issue's path, with the first
message per field; an issue without a path is under `""`.

## Use it in a component [#use-it-in-a-component]

`useRecordForm(form, { initial, onSubmit })` returns `values`, `errors`,
`pending`, `set(key, value)`, `submit(event?)` and `reset()`. `submit`
prevents the browser's submit, validates, and calls `onSubmit` with the
split values only when they pass:

```tsx
"use client";

import { useRecordForm } from "better-supabase/react";

const state = useRecordForm(form, {
  initial: { title: "", priority: "0" },
  onSubmit: ({ input, custom }) => create.run({ ...input, custom }),
});

<form onSubmit={(event) => void state.submit(event)}>
  <Input
    value={String(state.values["title"] ?? "")}
    onChange={(event) => state.set("title", event.target.value)}
  />
  {state.errors["title"] ? (
    <FieldError>{state.errors["title"]}</FieldError>
  ) : null}
</form>;
```

The example's create dialog for work items is the full version: it renders
the custom fields after the built-in ones and saves them with
`customFields.setValue` in the same Server Action.

# Your own schemas

> Keep your own declarative schema files next to the block files. The CLI detects your tables, and each block runs over the ones you already have and creates the rest.

Source: https://bettersupabase.com/docs/blocks/own-schemas

You can keep writing your own declarative schema files in `supabase/schemas`
and install blocks next to them. There is no setting for it: the CLI reads
which tables your files create, and per table, a block whose table you
already declare runs over yours instead of creating its own. The tables you
don't have, the block creates as usual. `tables.<name>` entries do the same
for the [app tables](/docs/repository/tables#enriching-a-table-you-already-have):
over a table of yours, they add only what the scope needs.

## What the CLI detects [#what-the-cli-detects]

Before it renders anything, `add` and `upgrade` collect the tables that are
yours:

- every table created by a file in `supabase/schemas`, or by a migration,
  that better-supabase didn't write (block files carry a `-- @bsb-block`
  line, and module migrations end in `_better_supabase_<name>.sql`);
- every table in the saved schema snapshot (`database.source.snapshot`, see
  [`gen --save-snapshot`](/docs/cli/gen)) that no block file or module
  migration created. This covers tables that exist in the database but in
  none of your files.

For each table it also keeps the columns your files declare and any trigger
that already keeps `updated_at` current. A block table counts as yours when
one of these tables is at the block's default name, or at the name
`blocks.<name>.tables` maps it to. A table you create only by hand in the
dashboard, outside both your files and the snapshot, isn't seen; save a
snapshot after you create it.

Most block tables can run over yours. A few belong to the block alone, and a
table of yours with that name stops `upgrade` with an error that names it.

## What a block does over your table [#what-a-block-does-over-your-table]

Over a table of yours, a block:

- skips its `create table` and adds only the columns it needs that your
  table lacks, each with `add column if not exists`, nullable unless the
  column has a default, and without `primary key` or `unique`;
- leaves out every statement that would change your table's data rules or
  access: check and other constraints, `alter column` and `revoke`. It
  never recreates your table and never revokes your grants;
- writes its functions, row level security, grants, policies, triggers and
  indexes over your table;
- casts what it writes to your column's type where a column may be an enum or
  a domain, such as a `status` column of your own enum type, through
  `better_supabase.as_column_type`, and compares as text. Other text columns
  the block writes a parameter into must take a text value; doctor reports
  an enum there as [BS342](/docs/cli/doctor#bs342). The
  [Block SQL page](/docs/blocks/sql#over-your-own-tables) lists the columns
  that may differ.

Map your own names with `tables` and `columns`, and set an optional column to
`null` to turn its feature off:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    audit: {
      tables: { log: "public.activity_log" },
      columns: { log: { tenant: "workspace_id" } },
    },
  },
});
```

### Names on your tables [#names-on-your-tables]

The policies and triggers a block adds to your table start with `bsb_`, so
they never replace or drop one of yours, even one with the same name. A
policy is named `bsb_<table>_<command>` (`bsb_tickets_select`), with the rest
of the block's own name after it when the block has more than one policy for
that command. A trigger is named `bsb_<name>` (`bsb_updated_at`). A
`drop policy` for a policy the block doesn't create there is left out, so
your policies stay.

When your table already has a trigger that keeps `updated_at` current (one
that calls `set_updated_at`, `moddatetime` or a function that sets
`new.updated_at`), the block adds no `bsb_updated_at` trigger, and doctor
reports it as [BS348](/docs/cli/doctor#bs348). Postgres combines permissive
policies with `or`, so the block adds no `bsb_` policy for a command one of
your permissive policies already allows for the block's roles: with `true`,
the block's condition, the block's condition without some of its `and`
operands, or one of those as an `or` operand of your condition. A comment in the block's file names your policy, and the
`drop policy if exists` before it removes the copy an earlier install left. A
`bsb_` policy whose condition is wider than yours stays, because it can open
rows your policies keep closed; [BS347](/docs/cli/doctor#bs347) reports it.
On tenant tables, a restrictive `bsb_tenant_scope` policy keeps every row
inside the caller's organizations, so your own permissive policy can't widen
access beyond them.

## Enriching a table with tables.name [#enriching-a-table-with-tablesname]

A `tables.<name>` entry over a table your schema already creates enriches it
instead of creating it. In the app tables file it adds, each with
`add column if not exists`, only what the table lacks:

- `id` (a unique `uuid` with a default, or a `bigint` identity with
  `id: "bigint"`), unique rather than the primary key your table may have;
- the scope column, `organization_id` (the tenant column) or `user_id` in
  `user` scope, nullable;
- `created_at` and `updated_at`, unless `timestamps: false`;
- the columns of the entry that your table lacks, nullable unless they have a
  default.

Then it writes the scope's row level security, grants and policies as for a
table it creates, with two differences. The policies are named
`bsb_<table>_<command>`, and your own grants stay: the file adds the grants
the scope needs instead of revoking first. In `tenant` scope it also adds the
restrictive `bsb_tenant_scope` policy. Last, the scope column gets a not-null
check that is `not valid`, added once:

```sql
alter table "public"."projects" add column if not exists "organization_id" uuid;
-- inside a do block that skips it when the constraint exists
alter table "public"."projects" add constraint projects_organization_id_not_null
  check ("organization_id" is not null) not valid;
```

[App tables](/docs/repository/tables#enriching-a-table-you-already-have)
shows the full file.

## Why the check is not valid [#why-the-check-is-not-valid]

The `not valid` check is deliberate. Postgres checks it on every insert and
update but not against the rows already in the table, so:

- a new row must name its owner right away;
- an existing row without an owner matches none of the scope's policies, so
  only the service role reads or writes it;
- an update to such a row fails the check until its scope column is filled.

pg-delta keeps the clause when it writes the migration: a check that is
`not valid` in the schema files becomes `add constraint ... not valid` with
no `validate constraint`, so the migration doesn't scan your existing rows.
Your schema files keep `not valid`. Validating is a step on the live database
only, and it comes after the backfill.

## Fill the owner with db backfill [#fill-the-owner-with-db-backfill]

[`better-supabase db backfill`](/docs/cli/db#backfill) fills the scope column
of the existing rows in batches, locking only the rows of one batch at a
time, so the app keeps writing while it runs. Copy the owner from a table a
foreign key points at, or write one value:

```bash
pnpm better-supabase db backfill projects.organization_id --from clients.organization_id via client_id
pnpm better-supabase db backfill projects.organization_id --value "'0b7c...'::uuid" --dry-run
```

It touches only rows where the column is null, so you can run it again until
nothing is left. Then run `better-supabase doctor --fix`: it validates the
check ([BS236](/docs/cli/doctor#bs236)), which scans the table without
blocking reads or writes.

## What doctor reports [#what-doctor-reports]

| Code                            | What it means                                                                                                                    |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| [BS236](/docs/cli/doctor#bs236) | a `not valid` check that no row fails any more; `doctor --fix` validates it                                                      |
| [BS237](/docs/cli/doctor#bs237) | an enriched table keeps permissive policies or grants of its own beyond what its scope grants                                    |
| [BS238](/docs/cli/doctor#bs238) | an enriched table has rows without an owner; it names the `db backfill` command, and after it `doctor --fix` validates the check |
| [BS342](/docs/cli/doctor#bs342) | a column of your table has a type the block can't write into                                                                     |
| [BS347](/docs/cli/doctor#bs347) | a block's `bsb_` policy can let a user reach rows your own policies keep closed                                                  |
| [BS348](/docs/cli/doctor#bs348) | your table keeps its own `updated_at` trigger, so the block adds none                                                            |

BS236 and BS238 need a database, so a saved snapshot skips them.

## Block files are protected [#block-files-are-protected]

Each block file carries a `-- @bsb-hash sha256:...` line over its SQL. When
the SQL no longer matches, the file was edited by hand, and `upgrade` leaves
it alone and exits 1 naming it. Put the change in your own schema file or a
[SQL hook](/docs/extending/events#sql-hooks) instead, or pass `--force` to
overwrite it. When you remove a block from the config, `upgrade` removes the
files no installed block writes any more, unless they were edited by hand.
Only files with a block marker count, so your own files are never removed.

## Steps for an existing app [#steps-for-an-existing-app]

1. Move the schema into `supabase/schemas` on pg-delta, as
   [Existing apps](/docs/migration/existing-apps#move-the-schema-into-files)
   describes, or save a snapshot with `better-supabase gen --save-snapshot`
   when you keep migrations only.
2. When you come from 0.14, run `better-supabase codemod 0.15`. It removes
   `ownership: "adopt"` from the config and keeps `tables` and `columns`; a
   config that still sets it fails to load.
3. Add the blocks, map your names with `tables` and `columns`
   (`better-supabase add --adopt <block> --tables <schema.table>` proposes
   them), and add `tables.<name>` entries for the tables of your own that
   need a scope.
4. Run `better-supabase upgrade` and read the comment it writes over each of
   your tables (it says the table is the app's and which columns it adds),
   then write the migration with `supabase db schema declarative sync`.
5. Apply the migration, then run `better-supabase db backfill` for each scope
   column BS238 reports, and `better-supabase doctor --fix` to validate the
   checks.
6. Run `better-supabase doctor` and work through BS237, BS347 and BS348.

# Block SQL

> The SQL a block installs into your declarative schema. The files are idempotent, and you commit them with the app.

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

`better-supabase add` writes a block's SQL 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. `add` renders the new modules next to the ones `blocks`
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.

`add` also lists the new modules in `blocks` of your config file, in
the form it already uses: `tags: {}` in an object, `"tags"` in a list. It
edits the object literal you pass to `defineConfig()` and leaves the rest of
the file as it is. When `blocks` is not a literal it can follow, such as
a variable or a spread, it changes nothing and prints the entries to add
instead. `--dry-run` says what it would add.

```bash
better-supabase add audit queue pgtap
supabase db schema declarative sync -f better_supabase_block
better-supabase add --adopt
better-supabase blocks list --installed
```

After the SQL, `add` wires the clients of blocks that have one into
`lib/blocks.ts` and prints the registry command for your framework's glue.

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 `add` and `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.
`add --adopt` refuses to write the data migration while a module extension is
missing from every earlier migration; run `upgrade`, 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.blocks` 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 `database.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 add --adopt` 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. `add` and `upgrade` 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`
(`bsb_audit_forget_dropped`) or `rls-guard` (`bsb_ensure_rls`). Their data
files repeat the event triggers (`drop event trigger if exists`, then
`create event trigger`), so the `add --adopt` 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.

## Commands [#commands]

| Command                     | What it does                                                      | Side effects  |
| --------------------------- | ----------------------------------------------------------------- | ------------- |
| `add <block...>`            | Writes the module files and config entries, and wires the clients | Writes files. |
| `add --adopt`               | Writes the data migration from `supabase/better-supabase-data`    | Writes files. |
| `upgrade`                   | Writes the upgrade steps, then every module file again            | Writes files. |
| `remove <block...>`         | Takes blocks out of the config and deletes their module files     | Writes files. |
| `blocks list --installed`   | Lists the installed blocks with their versions                    | None.         |
| `blocks view <block> --sql` | Prints the SQL `add` would write for one block                    | None.         |

[`init`](/docs/cli/init#add) describes `add`, `remove` and `upgrade` and
their flags.

## Workflows [#workflows]

`database.sql.workflow` says how the modules reach a migration. There are three:

| Workflow     | What `add` and `upgrade` write                       | How the migration is made                                |
| ------------ | ---------------------------------------------------- | -------------------------------------------------------- |
| `pg-delta`   | Module files in `supabase/schemas` (the default)     | `supabase db schema declarative sync -f <name>`          |
| `migra`      | Module files in `supabase/schemas`                   | `supabase db diff -f <name>`, with the stack stopped     |
| `migrations` | One migration, `<stamp>_better_supabase_modules.sql` | The CLI writes it; apply it with `supabase migration up` |

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: { sql: { workflow: "migrations" } },
  blocks: ["audit", "queue"],
});
```

When `database.sql.workflow` is unset, the CLI reads `supabase/config.toml`: pg-delta
when `[experimental.pgdelta] enabled = true`, migra when that key is `false`,
when `[db.migrations] schema_paths` is set or when `supabase/schemas` already
holds files without pg-delta, and pg-delta otherwise. `migrations` is never
detected, so an app picks it in the config.
`better-supabase init --workflow <name>` writes the key.

### pg-delta (Declarative Schemas 2.0) [#pg-delta-declarative-schemas-20]

pg-delta is the Supabase CLI's diff engine (2.119 or later) and the default
for projects that `supabase init` creates. It 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`; `better-supabase init`, `add` and `upgrade` add
it to an existing `config.toml` under this workflow.

### migra (Declarative Schemas 1.0) [#migra-declarative-schemas-10]

The legacy engine reads `supabase/schemas` while the stack is stopped. The
CLI and doctor name its command, `supabase db diff -f better_supabase_block`.
When `supabase/config.toml` lists `[db.migrations] schema_paths`, migra reads
only the files those entries match, so `add` names any module file no
entry matches and prints the lines to add before the schemas that call their
functions. Without `workflow: "migra"` in the config,
[doctor](/docs/cli/doctor#bs316) reports BS316. 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.

### Migrations only [#migrations-only]

This workflow is for an app without declarative schemas. `add` and `upgrade`
write nothing to `database.sql.dir`. `add` and `upgrade` write one migration that
holds, in order, the extensions, the upgrade steps, each module's schema with
its `@bsb-block` line, the realtime topic policies and the data statements.
Every statement can run again, so each new migration carries all the modules,
not only the changes. A command writes a new one only when the statements
differ from the newest `*_better_supabase_modules.sql`.

- `upgrade --check` and [doctor BS304](/docs/cli/doctor#bs304) compare
  against that newest migration.
- `blocks list --installed`, `upgrade` and doctor read the installed versions from its
  `@bsb-block` lines.
- `add --adopt` writes nothing, because the rows are already in the migration.
- `gen` writes the read-sets module into `<stamp>_better_supabase_read_sets.sql`.
- The pgTAP files still go to `database.sql.testsDir`.

## 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; `token_scope_allows(scope)` checks a delegated token's OAuth scope in a policy                                                                                                                                                                                                                           |
| `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 `auth.organization.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/repository/permissions)                                                                                                                                                                                                                                                            |
| `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/queue`](/docs/blocks/queue)                                                                                                                                                                                                                                                                        |
| `idempotency`        | Stores `Idempotency-Key` results so retried requests replay the first response                                                                                                                                                                                                                                                                                                                                                                                                     |
| `webhook-inbox`      | Stores verified webhooks once and hands them to a worker; it can run over your own table                                                                                                                                                                                                                                                                                                                                                                                           |
| `realtime-tables`    | Payload-free change broadcasts for [live queries](/docs/frontend/live-queries), registered from `database.realtime.tables`, and `broadcast_topic()` for [topic triggers](/docs/platform/realtime#row-changes)                                                                                                                                                                                                                                                                      |
| `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), `last_sign_ins(user_ids)` reads many users' last sign-in at once for the service role, and `list_my_sessions()` and `end_my_session(id)` list and end the caller's own sessions. 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)                                                                                                                                                                                                                                                                                                          |
| `impersonation`      | Support sessions: `start_support_session(target, reason)` checks `is_platform` and a second admin's approval, 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)                                                                                                                                                                |
| `messaging`          | Conversations, members, threaded messages with edits, pins and resolution, mentions, read markers, typing over Realtime, scheduled sends through jobs, and files and reactions on messages, for channels, direct messages and record conversations. See [Messaging](/docs/blocks/messaging)                                                                                                                                                                                        |
| `channels`           | Named channels on messaging: public ones the whole tenant can browse and join, private ones for their members, a topic, a purpose and an optional linked record. See [Channels](/docs/blocks/channels)                                                                                                                                                                                                                                                                             |
| `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 or a jobs queue, and it can run over your endpoints table. See                                                                                                                                                                                                              |
| `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()`; it can run over your secrets table. 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-schedules`       | 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-schedules)                                                                                                                                                                                                                                                                                                          |
| `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-keys`            | 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-keys)                                                                                                                                                                                                                                                                                                     |
| `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 as its messaging conversation, with mentions, resolve, pins and tasks, `create_comment` and `list_comments`. See [Comments](/docs/blocks/comments)                                                                                                                                                                                                                                                                                                 |
| `activity`           | An activity feed built from outbox events, joined with a subject's audit entries, notification deliveries and workflow runs, `record_activity` and `list_activity`. See [Activity](/docs/blocks/timeline)                                                                                                                                                                                                                                                                          |
| `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`         | Checklists per user or organization run as enrollment programs, `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`       | 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-editor`    | 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-editor)                                                                                                                                             |
| `numbering`          | Gapless numbers per tenant, entity and period. See [Number sequences](/docs/blocks/numbering).                                                                                                                                                                                                                                                                                                                                                                                     |
| `teams`              | Teams and members. The inbox assigns conversations here. See [Teams](/docs/blocks/teams).                                                                                                                                                                                                                                                                                                                                                                                          |
| `tags`               | Tag definitions and per-subject applications. See [Tags](/docs/blocks/tags).                                                                                                                                                                                                                                                                                                                                                                                                       |
| `state-machine`      | Allowed status transitions, history and `status.changed`. See [State machine](/docs/blocks/state-machine).                                                                                                                                                                                                                                                                                                                                                                         |
| `custom-fields`      | Definitions and typed values, with a permission check for sensitive values. See [Custom fields](/docs/blocks/custom-fields).                                                                                                                                                                                                                                                                                                                                                       |
| `share-links`        | Hashed share links, views and per-user state. See [Share links](/docs/blocks/share-links).                                                                                                                                                                                                                                                                                                                                                                                         |
| `reminders`          | RFC 5545 recurrence and reminder delivery. See [Reminders](/docs/blocks/reminders).                                                                                                                                                                                                                                                                                                                                                                                                |
| `imports`            | CSV import runs. See [Imports](/docs/blocks/imports).                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `subjects`           | The app-wide subject registry from `config.records`: `subject_title`, `subject_url`, `subject_titles`, `can_subject` and `can_subject_for`. See [Subject registry](#subject-registry) and [Subjects](/docs/repository/record-types).                                                                                                                                                                                                                                               |
| `rank`               | Fractional rank keys for subjects with a `rank` column: `rank_between`, `move` and the `rank.rebalance` job. See [Rank](/docs/blocks/rank).                                                                                                                                                                                                                                                                                                                                        |
| `saved-views`        | Named list views per user, team or organization, with a default per slot and rank order. See [Saved views](/docs/blocks/saved-views).                                                                                                                                                                                                                                                                                                                                              |
| `relations`          | Typed links between subjects with inverse names, cycle checks, one-target kinds, ancestors, descendants and rollups. See [Relations](/docs/blocks/relations).                                                                                                                                                                                                                                                                                                                      |
| `bulk`               | One action over many rows with a result per row, and background runs on the jobs queue as the user who started them. See [Bulk actions](/docs/blocks/bulk).                                                                                                                                                                                                                                                                                                                        |
| `search`             | A `search_documents` index kept up by triggers from each `search` entry in `config.records`; `search(q)` as the caller with websearch, prefix and trigram matching and `can_subject`, hybrid ranking with embeddings, and recents and favorites from share-links. See [Search](/docs/blocks/search).                                                                                                                                                                               |
| `metrics`            | Metrics declared with `defineMetric`: `metric_points` rollups per grain and time zone, `metric_series` and `metric_value` checked against a permission in the tenant, and a `metrics.rollup` job. See [Metrics](/docs/blocks/metrics).                                                                                                                                                                                                                                             |
| `calendar`           | Calendars, events with RFC 5545 recurrence, overrides and attendees, an iCalendar import and an iCal feed through a share link. See [Calendar](/docs/blocks/calendar).                                                                                                                                                                                                                                                                                                             |
| `bookings`           | Bookable resources with weekly hours and exceptions, services with buffers, `available_slots` in SQL, holds that expire, an exclusion constraint against double bookings (needs `btree_gist`) and public booking through a share link. With `recurring`, a weekly or daily rule books a series one occurrence at a time. See [Bookings](/docs/blocks/bookings).                                                                                                                    |
| `versions`           | Numbered snapshots of a subject's content with JSON Patch diffs, restore, auto and frozen snapshots, and retention. See [Versions](/docs/blocks/versions).                                                                                                                                                                                                                                                                                                                         |
| `trash`              | Soft delete through each subject's `deletedAt` column, with children trashed in one batch, restore with unique checks and purge after a retention period. See [Trash](/docs/blocks/trash).                                                                                                                                                                                                                                                                                         |
| `reactions`          | Emoji reactions and up or down votes on any subject, comments and inbox messages, with a vote score per subject, custom emoji per organization and a realtime topic per organization. See [Reactions](/docs/blocks/reactions).                                                                                                                                                                                                                                                     |
| `approvals`          | Approval requests with steps per subject type and action, approvers by permission, role, team or user, delegation, expiry and reminders; the final approval runs a state-machine transition or a job. See [Approvals](/docs/blocks/approvals).                                                                                                                                                                                                                                     |
| `access-requests`    | Members ask for a role, for a set time if they like; the request goes through approvals, the final approval grants the role, and the grant ends early or when it expires. See [Access requests](/docs/blocks/access-requests).                                                                                                                                                                                                                                                     |
| `documents`          | Yjs documents on any subject: an update log, a compacted snapshot and a private Realtime topic per document for edits and presence; `better-supabase/yjs` connects a `Y.Doc`. See [Documents](/docs/blocks/documents).                                                                                                                                                                                                                                                             |
| `catalog`            | Products, options, variants, prices in bigint minor units per currency and price list, categories, collections and an opt-in public storefront. See [Catalog](/docs/blocks/catalog).                                                                                                                                                                                                                                                                                               |
| `inventory`          | Locations, stock levels per variant and location posted to the ledger, reservations with expiry, transfers and purchase orders with partial receiving, over catalog variants. See [Inventory](/docs/blocks/inventory).                                                                                                                                                                                                                                                             |
| `drive`              | Folders and files per organization, with grants to users, teams or roles inherited down the tree, a name-conflict policy for moves and renames, and the SQL hooks and events around them. It renders ahead of `comments`, so `drive_node` can be a subject. See [Drive](/docs/blocks/drive).                                                                                                                                                                                       |
| `drive-files`        | The part of `drive` that needs attachments: uploads and revisions through versions, restrictive policies on the attachment rows and `storage.objects`, the bin through trash, stars and recent items through share-links' user state, folder links and the purge. Installed with `drive`.                                                                                                                                                                                          |
| `orders`             | Shipping rates, carts owned by the signed-in (or anonymous) user, orders with lines, payments, fulfillments, returns and refunds over catalog variants; checkout reserves inventory and numbers orders and invoices. With `kitchen`, lines move through preparation states at stations and broadcast each change. See [Orders](/docs/blocks/orders).                                                                                                                               |
| `promotions`         | Percentage, fixed and free-shipping promotions, automatic or by code, scoped to the order, products, categories or collections, with usage and per-customer limits, stacking and priority; orders prices them into carts and redeems them at checkout. See [Promotions](/docs/blocks/promotions).                                                                                                                                                                                  |
| `forms`              | Forms with numbered versions (steps and show or skip logic), a JSON Schema 2020-12 per version that `pg_jsonschema` checks on every submission, drafts, public links through share-links with a rate limit and a bot-check hook, and handlers that turn a submission into a record. See [Forms](/docs/blocks/forms).                                                                                                                                                               |
| `signatures`         | Simple electronic signatures on a frozen version from the versions module: click, typed or drawn signatures in parallel or in order, a hash that detects edits after signing, and an approval step a signer can decide. See [Signatures](/docs/blocks/signatures).                                                                                                                                                                                                                 |
| `time-tracking`      | Timers and logged time on any record with one running timer per member and no overlaps, weekly timesheets approved through approvals, and weeks that lock while pending or approved. See [Time tracking](/docs/blocks/time-tracking).                                                                                                                                                                                                                                              |
| `identities`         | Links an email address or a chat user to an app user or a contact, through a one-time code or a confirmed address. See [Identities](/docs/blocks/identities).                                                                                                                                                                                                                                                                                                                      |
| `service-accounts`   | Organization members that aren't people: `auth.users` rows that can't sign in, with a role through a membership. Organization-owned workflow versions and service webhook endpoints run as the default one. See [Service principals](/docs/blocks/service-accounts).                                                                                                                                                                                                               |
| `signing-keys`       | Ed25519 and ES256 keys per owner, private keys in Vault, one active key per owner, retired keys published until their tokens expire, and a JWK Set per owner. See [Signing keys](/docs/blocks/signing-keys).                                                                                                                                                                                                                                                                       |
| `security-events`    | OpenID Shared Signals streams per receiver, push delivery through a webhooks-out endpoint with format `set`, a poll queue, SETs held while a stream is paused, and the receiver replay table. See [Shared signals](/docs/blocks/security-events).                                                                                                                                                                                                                                  |
| `oauth-server`       | OAuth client registry with hashed secrets and registration tokens, authorization codes bound to an S256 PKCE challenge, device codes, grants per user and client, and a revocation list keyed by `jti` and grant. It stores and verifies; your authorization server signs the tokens. See [OAuth server](/docs/blocks/oauth-server).                                                                                                                                               |
| `domains`            | Hostnames per organization: custom domains verified by a DNS TXT record, subdomains of `platformDomain` verified when added, one primary per organization, and `resolve_hostname` for the proxy. See [Custom domains](/docs/blocks/domains).                                                                                                                                                                                                                                       |
| `reviews`            | Ratings and reviews of catalog products and registered subjects: one per user and subject, a verified flag from paid orders, moderation before or after publishing through the moderation module, the organization's reply and a per-subject aggregate public storefronts can read. See [Reviews](/docs/blocks/reviews).                                                                                                                                                           |
| `moderation`         | Reports on any subject for configured reasons, a review queue, decisions (approve, hide, remove, restore, ban) with a log, user bans and an automatic hide threshold. Hides and removes messages, comments included, and reviews moderate through it. See [Moderation](/docs/blocks/moderation).                                                                                                                                                                                   |
| `enrollments`        | Programs of ordered steps per organization and enrollments of any subject in them, with a step snapshot, completed steps, completion and cancel. Onboarding checklists run on it. See [Enrollments](/docs/blocks/enrollments).                                                                                                                                                                                                                                                     |
| `courses`            | Courses of modules and lessons in rank order, quiz lessons graded by `forms`, per-lesson progress, completion and certificates. Each course runs as an enrollment program, so learners enroll through `enrollments`. See [Courses](/docs/blocks/courses).                                                                                                                                                                                                                          |
| `leave`              | Leave types in days or hours, requests that go through approvals, and balances per member and year on the ledger with grants, accruals, adjustments and a year-end carryover. See [Leave](/docs/blocks/leave).                                                                                                                                                                                                                                                                     |
| `rules`              | Tenant-editable rules on a subject type: a filter tree over a field catalog the app syncs, tested in rank order when a trigger event happens, `{ kind, config }` actions the app runs, saved versions, an evaluation log and `rule_evaluate`, which gives the same answer as `evaluateFilterQuery`. See [Rules](/docs/blocks/automations).                                                                                                                                         |
| `vercel-marketplace` | Marketplace installations per configuration id with their access token sealed in Vault, provisioned resources with their tenant and secret refs, plans, invoices kept by event time and the meter totals reported to Vercel per period. Service role only. See [Vercel Marketplace](/docs/blocks/vercel-marketplace).                                                                                                                                                              |
| `ledger`             | Double-entry accounts per organization in a currency or a custom unit, append-only transactions whose entries sum to zero per unit, running balances and holds. Credits, loyalty points, gift cards and trust balances are account kinds; inventory, leave and invoices post to it. See [Ledger](/docs/blocks/ledger).                                                                                                                                                             |
| `assets`             | A location hierarchy (site, building, unit) and an asset registry with tags, status, coordinates and assignment to members or records. Optional PostGIS stores points and areas as geography; booking resources and work items can name an asset. See [Assets and locations](/docs/blocks/assets).                                                                                                                                                                                 |
| `scores`             | Tenant-editable scoring models on a subject type: rules from the rules field catalog worth points, weighted metrics from number fields or vote scores, bands, saved versions, and the current score and history per record with a breakdown. Lead and health scoring are models. See [Scores](/docs/blocks/scores).                                                                                                                                                                |
| `guests`             | People outside the organization, invited by email, who read, comment on and upload to the records they are granted, and see the invoices and bookings of the party they act for. Guests are never members; comments, attachments, invoices and bookings let them in through `ctx.scope.guest`. See [Guests](/docs/blocks/guests).                                                                                                                                                  |
| `passes`             | Admission passes on QR codes and NFC tags, signed with the signing-keys block: a ticket for a booking or a pass with a number of uses for a resource. Every scan is logged, and drop-ins count against the resource's capacity with its bookings. With `credits`, a pass pays for class bookings and refunds the credit on an early cancellation. See [Passes](/docs/blocks/passes).                                                                                               |
| `billable-time`      | An hourly rate card per organization, member and record, a preview of unbilled time, and `invoice_from_time`, which bills it as lines on a draft invoice. Billed entries lock until the invoice is voided. See [Billable time](/docs/blocks/billable-time).                                                                                                                                                                                                                        |
| `leases`             | Units from the assets block let to parties, with rent per period posted on the ledger once (a charge row and a ledger key per period), deposits held through ledger holds until termination, renewals, and maintenance requests on the unit. See [Leases](/docs/blocks/leases).                                                                                                                                                                                                    |
| `expenses`           | Expense reports with lines in categories, receipts as attachments on the lines, spend policies (a limit per category and a receipt threshold) checked on submit, approval through the approvals block, and the approved total and its reimbursement posted on the ledger. See [Expenses](/docs/blocks/expenses).                                                                                                                                                                   |
| `donations`          | Gifts from donors to funds and campaigns posted on the ledger, pledges charged once per period through a payments provider, and numbered tax receipts per gift or per donor and year. See [Donations](/docs/blocks/donations).                                                                                                                                                                                                                                                     |
| `dispatch`           | Field service work orders at asset locations, scheduled into technicians' slots without overlaps, worked from scheduled through en route and on site to done, with day routes of ordered stops and the parts each job used. See [Dispatch](/docs/blocks/dispatch).                                                                                                                                                                                                                 |
| `shipments`          | Shipments with packages and addresses, labels bought through a versioned carrier interface, and a tracking timeline that records each carrier event once; the status follows the latest event. See [Shipments](/docs/blocks/shipments).                                                                                                                                                                                                                                            |
| `contracts`          | Contracts with a registered counterparty from draft through review and signature (frozen as a version and signed with the signatures block) to active, expired or terminated. Values are bigint minor units; renewal terms schedule the owner's reminder before the notice deadline, and `run_contract_renewals` renews or expires contracts past their end date. Obligations carry due dates and owners.                                                                          |
| `controls`           | Compliance frameworks and controls, evidence (attachments or notes) with a validity date, recurring evidence tasks with an assignee and an overdue state, a computed control status and a rollup per framework, and policies published as versions that members acknowledge.                                                                                                                                                                                                       |
| `incidents`          | Incidents with a severity, a status timeline, affected components and a postmortem. Component status follows the open incidents and keeps an uptime history. `public_status_page(slug)` is the one function anon may call: it returns only the public components, incidents and updates of a published page.                                                                                                                                                                       |
| `templates`          | Versioned templates of a type that create records, system starters kept by key and forked per organization, a default per type and language, the applications each template made, and the workflow-editor workflows each new record starts. See [Templates](/docs/blocks/templates).                                                                                                                                                                                               |
| `pipelines`          | Tenant-editable pipelines for registered subjects: ordered stages whose categories map to open, success or failure, a stage history with time in stage, board totals per currency, and a status column set from the stage. See [Pipelines](/docs/blocks/pipelines).                                                                                                                                                                                                                |
| `parties`            | Companies and people an organization works with: lifecycle stages, owners, who works where with a role and a primary contact, domain and email dedupe, merges and keyset-paged search. See [Parties](/docs/blocks/contacts).                                                                                                                                                                                                                                                       |
| `engagements`        | Calls, emails, meetings, notes and tasks logged on one or more registered records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks. With `recordings`, calls and meetings keep recordings and transcripts with speaker segments, and with `bookings`, confirmed bookings become meetings. See [Engagements](/docs/blocks/activities).                                                                                       |
| `mail-sync`          | Gmail, Outlook and custom mailboxes synced per member through their own OAuth grant: threads and messages matched to the people and companies they name, logged as email engagements, private or shared mailboxes, and the mail members send. See [Mail sync](/docs/blocks/mail-sync).                                                                                                                                                                                             |
| `segments`           | Saved filter trees over one registered record type whose matching records are kept in Postgres, refreshed on demand or on a schedule, with an event for each record that enters or leaves. See [Segments](/docs/blocks/segments).                                                                                                                                                                                                                                                  |
| `assignment`         | Queues that pick an owner for a new record by round robin or weighted round robin among their members, within a territory filter, with a log of every pick. See [Assignment](/docs/blocks/assignment).                                                                                                                                                                                                                                                                             |
| `enrichment`         | Company and person profiles from People Data Labs or a custom provider, stored per record and provider, filling only empty columns, with a backlog of records never or long ago enriched. See [Enrichment](/docs/blocks/enrichment).                                                                                                                                                                                                                                               |
| `sequences`          | Multi-step outreach: email, wait and task steps that run per recipient on an enrollment program, sent from the recipient's mailbox through mail-sync, with a reply ending the run, retries for failed sends and counts per sequence. See [Sequences](/docs/blocks/sequences).                                                                                                                                                                                                      |
| `lead-scoring`       | Scores people, and optionally companies, under a score model whose rules read the record, its segments, its mail activity and its enrichment, and rescores the records that changed. See [Lead scoring](/docs/blocks/lead-scoring).                                                                                                                                                                                                                                                |
| `posts`              | Forum and feedback boards: posts with up and down votes, roadmap statuses, pins, locks and merged duplicates, replies through comments, and reports, hiding and bans through moderation. See [Posts](/docs/blocks/posts).                                                                                                                                                                                                                                                          |
| `content`            | Content entries per organization: typed pages and posts with slugs, locales and translation groups, scheduled publishing on a block timer, publishing gated by approvals, and a public read by slug for anon. See [Content](/docs/blocks/content).                                                                                                                                                                                                                                 |
| `forecasts`          | Forecast snapshots per period and currency, with a copy of the deals behind each number and a deal-by-deal comparison of two snapshots. See [Forecasts](/docs/blocks/forecasts).                                                                                                                                                                                                                                                                                                   |
| `field-history`      | A trigger per tracked table that records the column, old and new value, author and time of each change, read with the record's permission. See [Field history](/docs/blocks/field-history).                                                                                                                                                                                                                                                                                        |
| `collections`        | Favorites (a member's ranked stars on any registered record) and lists (named, static collections of one record type for a user, a team or the organization), whose entries stay hidden unless the caller can read the record. See [Collections](/docs/blocks/collections).                                                                                                                                                                                                        |
| `deals`              | Deals on pipelines: amounts in minor units, a company and contacts, a deal team, line items, forecast calls with attainment, close-date slips, a next step, locking and batched edits with per-change rejections. See [Deals](/docs/blocks/deals).                                                                                                                                                                                                                                 |
| `tasks`              | Issues and tasks on pipelines: identifiers, parents without cycles, several assignees, priority 0 to 4, estimates, start, end and due dates, locking, projects, iterations, batched edits and a progress tree. See [Work items](/docs/blocks/tasks).                                                                                                                                                                                                                               |
| `quotes`             | Quotes with line items and totals, a revision chain, a public link the customer accepts, rejects or asks changes through, expiry, and quotes from deals. See [Quotes](/docs/blocks/quotes).                                                                                                                                                                                                                                                                                        |
| `invoices`           | Invoices an organization sends its own customers: drafts with lines, a gapless number on issue, payments recorded on the ledger, credit notes, void and write-off, overdue and aging per currency, and invoices from accepted quotes. See [Invoices](/docs/blocks/invoices).                                                                                                                                                                                                       |
| `sla`                | Service levels on any registered record: first response, next response and resolution clocks from a policy per type and priority, business hours, pauses while the record waits, and warnings and breaches a job records. See [Service levels](/docs/blocks/sla).                                                                                                                                                                                                                  |
| `targets`            | Goals per period for a member, a team or the organization, with progress the database computes for won deals, completed work items and paid invoices, or a value the app keeps. See [Targets](/docs/blocks/goals).                                                                                                                                                                                                                                                                 |
| `hiring`             | Jobs and applications on a pipeline per job, candidates from config.records, scorecards per stage, interviews, and hire, reject and withdraw events for the app to act on. See [Hiring](/docs/blocks/hiring).                                                                                                                                                                                                                                                                      |
| `employees`          | Employees with or without an account, a manager, team, job title, employment type and status, an org chart that refuses cycles, offboarding, employees from hired applications, and private details and a contract history behind employees.view_sensitive. See [Employees](/docs/blocks/employees).                                                                                                                                                                               |
| `field-encryption`   | Per-owner data keys in Vault that app table columns and custom fields encrypt with, `drop_field_key` to shred an owner's values, and no searchable equality. See [Field encryption](/docs/blocks/field-encryption).                                                                                                                                                                                                                                                                |
| `rls-guard`          | 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 `memberships` and `access`, so adding one
pulls those in as well. They read the active tenant from the source in
`blocks.permissions.activeTenant`: by default the tenant the server resolved for the
request, then the claim named by `auth.organization.claim` (`tenant_id` by default).
The roles come from `blocks.permissions.roles`. Change modules through `blocks` (see
[below](#existing-tables-managed-and-custom)) rather than by editing
the files: `upgrade` 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`),
`add tenant` stops, because two hooks would write that claim. Pass
`--allow-hook-overlap` to write it anyway,
for example to keep the helpers while you migrate. Modules that need `tenant`,
such as `invitations`, are written without `--allow-hook-overlap`, 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 `auth.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.

## Scope and naming [#scope-and-naming]

A block's rows belong to a tenant by default. `database.sql.owner` sets that for
every block, and `blocks.<name>.scope` for one block.

| Scope              | Rows belong to                                      | Checks                                                       |
| ------------------ | --------------------------------------------------- | ------------------------------------------------------------ |
| `tenant` (default) | a tenant, through the `memberships` module          | the access contract: `can`, `tenant_ids_with`, `is_platform` |
| `user`             | the signed-in user, whose id the scope column holds | the scope column is the caller's id                          |
| `none`             | one shared space                                    | any signed-in user                                           |

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: { sql: { owner: "user" } },
  blocks: { tags: {}, comments: {}, notifications: {} },
});
```

Outside `tenant` scope a block needs neither the `memberships` module nor the
access contract, so `add` leaves them out, and its scope column is
nullable: a user's id by default (`default auth.uid()`) in `user` scope, null
in `none` scope. The block's functions take the same arguments in every
scope. In `user` scope they replace the scope id a signed-in caller passes
with the caller's own id, and in `none` scope with null, so one user can't
write into another's rows. Blocks that need tenants (organizations,
invitations, billing and most business blocks) run only in `tenant` scope,
and `add` refuses them in another one; set their `scope` to `tenant` to
keep them per tenant while the rest of the app runs per user.
`memberships` and `access` always run in `tenant` scope.

| Runs in       | Blocks                                                                                                               |
| ------------- | -------------------------------------------------------------------------------------------------------------------- |
| every scope   | `tags`, `comments`, and the blocks without tenant data (`notifications`, `profiles`, `jobs`, `outbox`, `audit`, ...) |
| `tenant` only | every other block that requires `memberships` or `access`, and the blocks that require one of them                   |

In `tenant` scope each table a client can reach also gets a restrictive
`bsb_tenant_scope` policy, and so does each tenant table in `tables`. It
keeps rows to the caller's organizations and to what the block's own
policies grant (an invitee's invitation, a guest's granted invoice, a
user's own rows). Postgres combines permissive policies with `or`, so a
policy you add to a block table, even `using (true)`, can widen access
within the caller's organizations but never to another organization's
rows.

`blocks.memberships.names.organization` renames the tenant concept in the SQL
the blocks write:
`workspace` turns the `organizations` table into `workspaces`,
`organization_id` into `workspace_id`, `create_organization` into
`create_workspace` and `member_organization_ids` into `member_workspace_ids`.

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    memberships: { names: { organization: "workspace" } },
    organizations: {},
  },
});
```

`blocks.<name>.functions` renames single functions next to `tables` and
`columns`. Its keys are the default names, or the names after the tenant
rename; an unknown function is an error.

```ts title="better-supabase.config.ts"
blocks: {
  tags: { functions: { create_tag: "make_tag" } },
},
```

`blocks.<name>.names` renames a block's own entities. A string is the new
singular noun; an object takes it as `name`, plus `plural` when the English
plural is wrong, `url` for the page of one row and `subject: false` to keep
the entity out of the subject registry. A bare string for `entities` renames
the block's primary entity. `work_item: "task"` turns the `work_items` table
into `tasks`, `work_item_id` into `task_id`, `create_work_item` into
`create_task`, `work_item.created` into `task.created` and the
`work_items.read` permission into `tasks.read`:

```ts title="better-supabase.config.ts"
blocks: {
  "tasks": { entities: { work_item: { name: "task", url: "/tasks/{id}" } } },
  contacts: { entities: { company: "account" } },
},
```

An entity rename reaches the TypeScript side too: method names, argument and
result keys, event types and the generated types. See
[Typed blocks](/docs/blocks/typed-blocks).

Every block that owns tables names its nouns this way, from `quotes`
(`quote`) and `orders` (`order`, `cart`) to `ai-chat` (`chat`, `project`) and
`hiring` (`job`, `application`, `interview`). Quotes, invoices, orders,
bookings and forms are also record types, so comments, attachments, tags and
activity can point at them. Chats, AI documents and knowledge documents stay
plain entities: only their owner or scope reads them, and a record type would
open them to every member with the block's read permission. JSON payload keys
the clients read (`orderId`, `promotionId`), the `/memories` path and ledger
account keys stored in rows (`invoices:cash:<code>`) keep their names.

You can rename the entities of a block you already installed. Change
`blocks.<name>.names`, then run `better-supabase upgrade`. It reads the
names from each file's `@bsb-names` line, compares the old and new module
SQL and writes guarded `alter ... rename` statements for the tables, columns,
indexes, policies, triggers and functions, so the rows stay where they are.
It also moves the permission keys in a role and permission catalog and the
stored `subject_type` values (comments, attachments, activity) to the new
names. Each statement checks that the old name still exists, so running it
twice changes nothing. Three things don't move: number sequence keys, outbox
events already recorded and your own hook functions (`before_work_item_create`),
so rename those yourself. When the two versions of a module don't pair up
object by object, `upgrade` stops with `config_invalid` and names the
module.

Some names stay as they are, so the TypeScript clients and event consumers
work under any tenant name:

- Function parameters, such as `organization` in `list_members`.
- Event types and payload keys (`organization.created`, `organizationId`)
  and permission keys.
- The access contract (`can`, `tenant_ids_with`, `is_platform`).
  `blocks.permissions.functions` keeps its meaning: the templates of the `custom`
  model.
- Tables and columns of your own that a block maps by name.

A tenant name that would rename a table or column to a name a block already
uses is refused. `team`, for example, can't run with `teams`, `profiles` or the
other blocks with a `team_id` column.

`better-supabase gen` writes the names to `blocks.generated.ts`, and
`defineBlocks` makes the block clients call the renamed functions; see
[renamed blocks](/docs/blocks/create-blocks#renamed-blocks).

## Existing tables: managed and custom [#existing-tables-managed-and-custom]

Each module has a contract: the functions policies, other modules and the
TypeScript side call. `blocks.<module>.ownership` decides what stands behind it.

| Ownership           | The module writes                                                                                       |
| ------------------- | ------------------------------------------------------------------------------------------------------- |
| `managed` (default) | its tables, policies and functions, and over a table your schema already has, only the columns it lacks |
| `custom`            | nothing: you write the contract functions, and [doctor](/docs/cli/doctor#bs307) checks them             |

`better-supabase blocks list --installed` marks custom modules, and `blocks view --sql <module>` shows
the signatures a custom module must provide. Not every module supports
`custom`; `memberships` and `access` do.

### Over your own tables [#over-your-own-tables]

A managed block decides per table whether to create it. The CLI reads the
tables your app already has: your declarative schema files, or the schema
snapshot or live database for an app on migrations. When one of them is at the
block's default name, or at the name `blocks.<name>.tables` maps, the block
skips its `create table` for it. It adds only the columns it needs that your
table lacks, with `add column if not exists` (nullable unless the column has a
default), and writes its functions, policies, triggers and indexes over your
table. Tables the app doesn't have are created as usual. Most block tables can
run this way, and an optional column can still map to `null` to turn its
feature off.

There is no setting for this. An `ownership: "adopt"` left from an older
version fails config validation until you delete it or run
[`better-supabase codemod`](/docs/cli/codemod). The block's policies and
triggers on your table are named `bsb_<table>_<command>` and `bsb_<name>`,
and it never recreates your table or revokes your grants.
[Your own schemas](/docs/blocks/own-schemas) explains what the CLI detects,
the names, the doctor findings and how block files are protected.

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({
  blocks: {
    memberships: {
      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                                                                                                                                                                      |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ownership`   | `managed` or `custom`                                                                                                                                                             |
| `schema`      | the schema of the module's functions and managed tables (`database.sql.schema`, then `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/repository/permissions)                                                                               |
| `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 (`database.sql.api` by default; see below), or `false` for none                                       |

[`better-supabase add --adopt <block> --tables <schema.table>`](/docs/cli/presets#adopting-tables)
proposes the `tables` and `columns` of a block entry from the tables in
the database and reports what can't map, and
[`add --preset <code> --adopt`](/docs/cli/presets#adopting-existing-tables)
does the same for every block of a preset that has tables under its default
names.

A column of your table keeps the type the module writes, which is `text` for
names, kinds and subject types. A few of those may be an enum or a domain
instead, because the module casts what it writes through
`better_supabase.as_column_type` and compares as text: status columns, the
tags `kind` and `subjectType` and the numbering `entity`. Any other text
column the module writes a parameter or computed value into must take a text
value: `upgrade` and `add --adopt` refuse an enum there when the schema files
declare it, and doctor reports it as [BS342](/docs/cli/doctor#bs342). A
column the module only writes string literals into may be an enum whose
labels include those literals.

`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"
blocks: {
  settings: { api: "api" },
  notifications: {
    api: { schema: "api", functions: ["list_notifications", "notification_counts"] },
  },
},
```

To give every module the same API schema, set `database.sql.api` once. A module that
sets its own `api` keeps it, and `api: false` leaves a module without entry
points. `database.sql.schema` works the same way for the module schema.

```ts title="better-supabase.config.ts"
sql: {
  api: "api",
},
blocks: {
  settings: {},
  notifications: {},
  queue: { api: false },
},
```

`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 `upgrade` 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 = createSettings({
  settings,
  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 an ownership a module doesn't support, stops
`add` and `upgrade` with the valid names. Each file's header records the
module version and mode (`-- @bsb-block tenant@2 managed`), and the data migration
records them in `better_supabase.blocks`. An option a module doesn't read
stops `add` as well, so a misspelled option can't fall back to its default
without notice. Only `blocks.permissions` 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 `bsb_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 bsb_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`, `tenant_path` and `tenant_function`. `audit_settings(table)`
returns a table's settings.

### Writes the secret key makes for a member [#writes-the-secret-key-makes-for-a-member]

`server.admin({}, { as: userId })` sends the `x-bsb-actor` header on every
request. `better_supabase.acting_member()` returns that user for a service
caller (the `service_role` key or a direct admin connection) and null for
anyone else, so a signed-in user cannot name another user by sending the
header. The audit trigger and `audit_event()` record the acting member as the
actor when `auth.uid()` is null, and `track_actor()` uses it for `created_by`
and `updated_by`. Only `service_role` may execute `acting_member()`; the audit
and actor triggers run as their owner, so they call it for every writer. Your
own function that calls it must be `security definer` too.

A child table without a tenant column can still record its tenant.
`tenant_path => 'group_id -> directory_groups.organization_id'` reads
`organization_id` from the `directory_groups` row that `group_id` references;
each further `-> table.column` continues from there. `audit()` resolves every
hop through a single-column foreign key and fails when a hop has none.
`tenant_function => 'public.tenant_of(jsonb)'::regprocedure` instead names a
function that receives the row as `jsonb` and returns the tenant. The trigger
tries the row's own tenant column first, then the path, then the function.

When `ignore` is not empty, `audit()` installs two triggers: `bsb_audit` for
inserts and deletes, and `bsb_audit_update` with a `WHEN` clause that skips an
update where only ignored columns changed, so a write such as `last_run_at`
never starts the trigger. Calling `audit()` again without `ignore`, or
`unaudit()`, removes `bsb_audit_update`.

Each `audit()` call replaces every setting on the trigger: the last call
wins, and arguments it leaves out go back to their defaults. A second
`audit('public.customers')` therefore clears the app's `ignore` and
`redact`. Code that registers a table the app may already audit, such as another
package's migration, reads `audit_settings(table)` first
and passes the app's `ignore` and `redact` back in, or leaves the call to the
app. The pgTAP file `upgrade` writes for each audited table checks the
settings of the last call in the schema files and migrations, so it fails
when a function or seed replaces them at run time. A later call in a
migration changes what that file expects, so review the generated test in
the diff.

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 `memberships` 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 => '{}'
);
```

The impersonation columns come from the caller's `act` claim. An event
about impersonation that the admin's own token records, such as a support
session starting or ending, passes them as `support_session`,
`impersonated_by` and `impersonation_reason`. The service role may pass any
values; another caller only itself as `impersonated_by`, and only from a
token without an `act` claim, whose values win otherwise:

```sql
select better_supabase.audit_event(
  event_type => 'support.started',
  category => 'security',
  support_session => session_id,
  impersonated_by => auth.uid(),
  impersonation_reason => reason
);
```

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 (`blocks.audit.options`):

| Option                | Default                    | What it does                                                                                                                                                                                                                                                                                      |
| --------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tenantColumn`        | `auth.organization.column` | 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/repository/permissions))                                                                                                                                          |
| `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                       | only over a log of your own: its values for `scope`, `actorKind`, `outcome`, `source` and `category`, see below                                                                                                                                                                                   |
| `metadataColumns`     | none                       | only over a log of your own: its 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                                                                                                                                                                                      |

`add` and `upgrade` read the `audit(...)` calls and `bsb_audit` triggers in
your schema files and migrations, and write a pgTAP file per audited table to `database.sql.testsDir`
(`supabase/tests/900_better_supabase_audit_public_customers.test.sql`). It
checks that the table has the `bsb_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 `upgrade --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 `upgrade`. 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 (`bsb_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 `add --adopt` 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 keeps it: map `blocks.audit.tables.log` and
`columns.log` to your names, and `null` the columns you don't want (the row
snapshots or `supportSession`, for example). The module then writes into your
table and never creates it; it adds the columns it needs that your table
lacks.

When your 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 `createAudit`) 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({
  blocks: {
    audit: {
      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; on a log of your own the module writes the
ones that `columns.log` and `columns.restricted` don't map to `null`.

### 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                                                                         | TypeScript                                   | Deletes                                                                                                            |
| -------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `purge_audit_log(older_than => '1 year')`                                        | `purgeAuditLog(source, options)`             | `audit_events` entries older than `older_than`, or than the tenant's own interval                                  |
| `purge_webhooks(older_than => '30 days')`                                        | `inbox.purge()`, `purgeWebhookInbox(source)` | processed messages; `include_dead => true` also deletes dead ones                                                  |
| `purge_job_archive(queue, older_than => '7 days', dead_older_than => '30 days')` | `jobs.purge()`                               | completed jobs, and dead jobs after their own retention (`pgmq.a_<queue>`, or `job_messages` on the table backend) |
| `purge_idempotency_keys()`                                                       | `idempotency.purge()`                        | expired idempotency keys                                                                                           |
| `purge_webhook_deliveries(older_than => '30 days')`                              | `webhooks.purge()`                           | succeeded and canceled outgoing deliveries; `include_dead => true` also deletes dead ones                          |
| `purge_rate_limits()`                                                            | `rateLimit.purge()`                          | counters whose window has ended or whose rule was removed                                                          |
| `purge_usage_history(older_than => '400 days')`                                  | `usage.purgeHistory()`                       | usage history entries                                                                                              |
| `purge_usage_events(older_than => '30 days')`                                    | `usage.purgeEvents()`                        | idempotency keys of recorded usage                                                                                 |

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 `blocks.audit.callbacks` 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,
});
```

Each TypeScript method takes the same options in camel case (`olderThan` in
seconds or as an interval, `includeDead`, `batch`) and returns how many rows
it deleted as a `Result`. Each object also has a `purgeJob(options)` that
returns a job handler, so the purges can run on the [jobs](/docs/blocks/queue)
scheduler instead of pg_cron. They work over a service SQL connection or a
service-role transport, so an app without a direct connection can schedule
them too:

```ts
const jobs = createQueue({
  sql: postgres.admin,
  queues: { purge: v.object({}) },
});

await jobs.schedule("retention", "0 4 * * *", "purge", {}).orThrow();

await jobs.work("purge", async (payload, job, signal) => {
  await idempotency.purgeJob()(payload, job, signal);
  await jobs.purgeJob({ deadOlderThan: "90 days" })(payload, job, signal);
});
```

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]

`impersonation` records who viewed the app as whom, for
[support mode](/docs/auth/impersonation). It needs `permissions` 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. Under the `provider` access model, a target holds platform
permissions when a row in `invitations.options.platformRoles.table` names them,
and, with `platformRoles.through.where`, only when that row's role matches the
condition, so a roles table that also holds tenant roles counts only its
platform ones. `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`. `expire_support_sessions()`,
for the service role only, ends every running session past its expiry with
`ended_by` set to `expired` and returns them, so each one gets its
`support.ended` entry. `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`, `SUPPORT_REASON_REQUIRED` or
`SUPPORT_APPROVAL_REQUIRED` in the `hint`.

A start needs a second admin by default (`options.approval: "distinct"`).
The admin asks with `request_support_session(target, reason, read_only,
metadata, tenant)`, which runs the same checks as a start and stores a row in
`support_requests`. Another admin approves it with
`approve_support_request(request_id)`. `start_support_session` then uses the
newest approved, unused request for the same admin, target and tenant (a
read-only approval never starts a writable session) and records its id and
approver in the `support.started` audit entry. A request is open for
`approvalTtl` and starts one session.

The requester never approves their own request: `approve_support_request`
refuses it with `SUPPORT_SELF_APPROVAL`, and the
`support_requests_distinct_approver` check constraint refuses the row even
for the service role. Clients can read their own requests but can't insert
or update the table. Without an authorization provider, an approver needs
`is_platform('support.start')` (rename it with
`blocks["impersonation"].permissions.approve`). With one that sets
[`isApprover`](/docs/extending/authorization-providers#approvals), the module
calls it with `{kind}` `'support-session'` and `{subject}` the request id
instead. The service role may pass `admin_id`, `approved_by` and
`approver_id` for decisions the server already made; `approved_by` still
can't be the admin. `support_request(request_id)` returns a request to the
service role, its requester and its approvers, and
`approved_support_request(target, tenant, read_only)` the request a start
would use. Errors carry `SUPPORT_REQUEST_MISSING`, `SUPPORT_REQUEST_DECIDED`
(another admin approved it) or `SUPPORT_REQUEST_CLOSED` (expired or used).

A read-only session (`read_only` true, the default) is refused by every
`security definer` write granted to `authenticated` or `anon`, with
`ACTOR_READ_ONLY` and SQLSTATE `42501`. The same refusal covers a support
`act` unless `read_only` is `false`, an agent `act` with `read_only: true`,
an impersonation `act` or one of another `kind`, and an `act` that has
`session_id` and no `kind`. Reads are unchanged.
These functions stay callable so the session can be opened and closed:
`start_support_session`, `end_support_session` and `expire_support_sessions`.
`check_request` stays callable too: it counts the request for the rate limit
and runs before reads.
With an authorization provider, set `authorization.functions.actorMayWrite`
and the writes call that instead of the built-in check.

| Option                 | Default    | What it does                                                             |
| ---------------------- | ---------- | ------------------------------------------------------------------------ |
| `maxTtl`               | `4 hours`  | the longest session `start_support_session` accepts                      |
| `requireReason`        | `true`     | refuses a start or request with an empty or blank reason                 |
| `approval`             | `distinct` | `none` starts without a second admin's approval                          |
| `approvalTtl`          | `1 hour`   | how long a request stays open for approval and a start                   |
| `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["impersonation"].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. The module runs over an existing sessions
table: the `memberships`, `readOnly`, `endedBy` and `metadata` columns are
optional. It still creates `support_requests` unless `tables.requests` names
your own table; `tables.requests: null` needs `options.approval: "none"`.

```ts title="better-supabase.config.ts"
blocks: {
  "impersonation": {
    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 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 `add --adopt`
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({
  blocks: { "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/queue` 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/queue";

const limits = createRateLimit({ sql: 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({ transport: rpcTransport(adminClient) })`. It calls
`check_rate_limit(scope, key, max_requests, period)`, which returns the same
decision as JSON; with `blocks.rate-limit.api` set, pass the API schema
as `rpcTransport(adminClient, { schema: "api" })`. The `hooks` option runs
around `check` and `purge`, and the `purgeJob` handler calls `purge` through
them. `createBlocks` builds it from its shared `transport`, like the other
blocks.

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.

### Limits as middleware [#limits-as-middleware]

The [Hono](/docs/frameworks/hono#rate-limits) and
[oRPC](/docs/frameworks/orpc#rate-limits) adapters wrap `check` in a
`rateLimit()` middleware. Responses then carry the `RateLimit` field of
draft-ietf-httpapi-ratelimit-headers (`SPEC_PINS.ratelimitHeaders`), and
`RateLimit-Policy` when the route gives a `rule`. A refused request gets a
429 with `Retry-After`. Elsewhere, `rateLimitHeader()` and
`rateLimitPolicyHeader()` from `better-supabase/server` build the two fields.

## Enforcing jsonb shapes [#enforcing-jsonb-shapes]

Give a `json` entry a `schema` and `better-supabase 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({
  gen: {
    json: {
      "customers.metadata": {
        import: "./lib/metadata.ts#CustomerMetadata",
        schema: CustomerMetadata,
      },
    },
  },
  blocks: ["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 `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.

## Subject registry [#subject-registry]

Tags, comments, attachments, reminders and the other modules that attach to
your rows take the record types they attach to from `on`. Declare the types
once in `config.records`, and every block that you don't give its own `on`
uses the registry.

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  records: {
    task: {
      table: "public.tasks",
      idType: "uuid",
      title: "title",
      url: "/projects/{project_id}/tasks/{id}",
      permissions: { read: "tasks.read", update: "tasks.update" },
      rank: { column: "rank", group: "status" },
    },
    customer: { table: "public.customers", title: "name" },
  },
  blocks: ["memberships", "permissions", "subjects", "tags", "comments"],
});
```

| Key           | Default           | What it sets                                                                            |
| ------------- | ----------------- | --------------------------------------------------------------------------------------- |
| `table`       | required          | The table, as `table` or `schema.table`                                                 |
| `id`          | `id`              | The id column                                                                           |
| `idType`      | compared as text  | The id column's type (`uuid`, `bigint`), so lookups use its index                       |
| `tenant`      | `organization_id` | The tenant column, or `false` for a subject without one                                 |
| `title`       | none              | The column `subject_title` returns                                                      |
| `url`         | none              | A path template whose `{column}` placeholders `subject_url` fills from the row          |
| `permissions` | none              | Action to permission key; `read` is the key modules check before they attach to the row |
| `scope`       | none              | A record scope to check the keys in, for subjects without a tenant                      |
| `cascade`     | none              | Delete a module's rows when the subject row is deleted                                  |
| `deletedAt`   | none              | A soft-delete column; rows where it is set count as gone                                |
| `search`      | none              | `title` and `body` columns for search                                                   |
| `rank`        | none              | The rank column and an optional group column (such as `status`) that `move` uses        |

A block's `on` works three ways:

- unset: the block uses every registered type. Blocks that need a tenant
  skip the types with `tenant: false`.
- a list of names, `on: ["task"]`: the block uses those registered types, and
  an unknown name fails `gen` (doctor reports it as BS346).
- an object: each entry merges onto the registered one, and the block's
  keys win. A type the registry lacks needs its own `table`.

`webhooks-in` never falls back to the registry, because its subjects are the
tables that inbound events write to.

Subject types are lowercase letters, digits and underscores. The
`notifications` block's own `on` keys may also contain hyphens
(`time-off-request`), because its `subject_type` column takes any text.

The `subjects` module installs four functions in the `better_supabase`
schema. Each runs as the caller, so the subject table's own policies decide
what is visible:

| Function                                          | Returns                                                                                                                                                                                                          |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subject_types()`                                 | The registered types                                                                                                                                                                                             |
| `subject_title(subject_type, id)`                 | The `title` column, or null when the row is hidden, deleted or the type has no title                                                                                                                             |
| `subject_url(subject_type, id)`                   | The `url` template filled from the row, or null                                                                                                                                                                  |
| `subject_titles(refs)`                            | A `jsonb` array of `{ type, id, title, url }` for up to 500 `{ type, id }` refs, as the caller; hidden, deleted and unknown refs are left out ([Subjects](/docs/repository/record-types))                        |
| `can_subject(subject_type, id, action)`           | True when the caller can see the row and holds the action's key from `permissions` in the row's tenant                                                                                                           |
| `can_subject_for(user, subject_type, id, action)` | The same for another user, service role only: the row exists and `can_user` grants the action's key; false for an action without a key or a provider that can't answer for others, and a share link never counts |

An action without a key in `permissions` checks only that the caller can see
the row. The service role passes every key check.

With a registry, `gen` also emits the types in `database.types.ts`:

```ts
import {
  type RecordRef,
  type SubjectType,
  subjectTypes,
} from "./lib/supabase/schema";

const ref: RecordRef<SubjectType> = { type: "task", id: taskId };
```

Doctor reports a registered table or column the database lacks (BS331) and a
registered table without row level security (BS332).

### Merging subject records [#merging-subject-records]

When two rows of a subject table are merged, one call moves every module's
rows from the dropped record to the kept one:

```sql
perform better_supabase.merge_subject_refs('public.customers', keep::text, drop::text);
```

Each module that stores rows per subject installs a handler, and
`merge_subject_refs` runs them all:

| Module          | What moves                                                                                 |
| --------------- | ------------------------------------------------------------------------------------------ |
| `comments`      | Comments; the activity feed stays on the dropped record                                    |
| `attachments`   | Attachments                                                                                |
| `reactions`     | Reactions, except one the same person already left on the kept record with the same emoji  |
| `tags`          | Taggings, except a tag the kept record already has                                         |
| `relations`     | Links on either end; a link that becomes a self-link or a duplicate is deleted             |
| `guests`        | Guest grants, except a grant the guest already has on the kept record                      |
| `passes`        | Passes issued for the record                                                               |
| `billable-time` | Rates on the record, except a rate the kept record already has for the same member and day |
| `leases`        | Leases of the party                                                                        |
| `donations`     | Gifts, pledges and receipts of the donor                                                   |
| `dispatch`      | Work orders opened for the record                                                          |
| `shipments`     | Shipments sent for the record                                                              |
| `contracts`     | Contracts with the record as their counterparty                                            |

The function isn't granted to any API role. Call it from your own `security
definer` function that has already checked the caller, as the parties
`merge_companies` and `merge_people` functions do. Merging doesn't re-check a
relation kind's `single` or `acyclic` rule.

To move your own tables in the same call, add a function with the signature
`(subject_table text, keep text, drop text)`, owned by the role that owns the
migrations, and give it the comment `better-supabase:subject-merge`. The
dispatcher skips marked functions another role owns.

```sql
create function app.deals_subject_merge(subject_table text, keep text, drop text)
returns void language plpgsql security definer set search_path = '' as $$
begin
  if subject_table = 'better_supabase.companies' then
    update app.deals set company_id = keep::uuid where company_id = drop::uuid;
  end if;
end;
$$;
revoke execute on function app.deals_subject_merge(text, text, text) from public, anon, authenticated;
comment on function app.deals_subject_merge(text, text, text) is 'better-supabase:subject-merge';
```

## RLS on every new table [#rls-on-every-new-table]

`better-supabase add rls-guard` 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({
  database: { sql: { testsDir: "supabase/tests" } },
  blocks: ["audit", "queue", "invitations"],
});
```

`better-supabase upgrade` rewrites those files after an upgrade, and
`upgrade --check` fails in CI when a file is out of date. Use
`blocks view --sql <module>` to copy a module into a hand-written migration instead.
`add`, `upgrade` and `add --adopt` take `--dry-run` to show what they would write, and
`--tests-dir <dir>` overrides `database.sql.testsDir` for the pgTAP module.

| Option                  | Default                                                        |
| ----------------------- | -------------------------------------------------------------- |
| `database.sql.workflow` | detected from `supabase/config.toml` ([Workflows](#workflows)) |
| `database.sql.dir`      | `supabase/schemas`                                             |
| `database.sql.prefix`   | `900_better_supabase`                                          |
| `database.sql.testsDir` | `supabase/tests`                                               |
| `database.sql.schema`   | `better_supabase`, for every module that sets no `schema`      |
| `database.sql.api`      | unset; the Data API schema for every module that sets no `api` |
| `blocks`                | `[]`                                                           |

## 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 upgrade
```

It reads the version from each file's `@bsb-block` 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. `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; `upgrade --check` keeps them current, and `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: `upgrade` renames it, and
doctor reports the old name in your SQL before you upgrade. A module declares
these moves in `NAMES.migrations` (the version, the table, its old name and
the old column names), and `upgrade` renders them after your table and
entity renames, so the step renames the right table under any config. 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 `add` and `upgrade` use, for build scripts
and tests that write the files themselves. `moduleLayout(config)` returns the
paths, `renderBlocks` returns the files with their managed headers (dependencies
included), and `resolveBlocks` lists the modules a set of names pulls in.

```ts title="scripts/write-block.ts"
import { resolveConfig } from "better-supabase/config";
import { moduleLayout, renderBlocks, resolveBlocks } from "better-supabase/sql";

import config from "../better-supabase.config.ts";

const layout = moduleLayout(resolveConfig(config, process.cwd()));
const modules = resolveBlocks(["audit", "queue"], layout).map((m) => m.name);
const files = renderBlocks(["audit", "queue"], 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
`blocks.<module>.permissions` and whether a tenant or a platform check asks for
it. Under `blocks.permissions.model: 'provider'`, `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(
  blocks,
  blockNames,
)) {
  if (!scopeOnly.has(key)) console.error(`${module}.${action}: ${key}`);
}
```

# Typed blocks

> Rename a block's entities once in the config, and get typed config keys, renamed clients, subjects, name constants and input schemas from better-supabase gen.

Source: https://bettersupabase.com/docs/blocks/typed-blocks

You name a block's entities once, in `blocks.<name>.names`, and the SQL,
the clients, the events, the permissions and the generated types all use
that name. The config keys are typed from the installed blocks, so a wrong
entity, column or function name fails in your editor instead of in
`better-supabase gen`.

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  blocks: {
    memberships: { names: { organization: "workspace" } },
    tasks: { names: { work_item: { name: "task", url: "/tasks/{id}" } } },
  },
});
```

With this config the SQL writes a `tasks` table with a `workspace_id`
column, `create_task` and `task.created`, and `gen` writes `tasksBlock`,
`Task` and `TasksEvents` into `blocks.generated.ts`. The tenant rename stays
in SQL: event payloads and client types keep `organizationId`, so code that
reads them works under any tenant name.

## Typed config keys [#typed-config-keys]

`defineConfig` returns the config with its literal types, and the `blocks`
key is typed from each block's names:

| Key                               | Checked against                                           |
| --------------------------------- | --------------------------------------------------------- |
| `blocks.<name>`                   | the installable blocks                                    |
| `blocks.<name>.names.<entity>`    | the block's own entities, such as `work_item` for `tasks` |
| `entities.<entity>.url`           | `{column}` placeholders from the entity's columns         |
| `blocks.<name>.tables`, `columns` | the block's tables and the columns of each one            |
| `blocks.<name>.functions`         | the block's function names                                |
| `blocks.<name>.permissions`       | the block's actions                                       |
| `blocks.<name>.hooks.functions`   | the `before_*` and `after_*` hooks the block calls        |
| `blocks.<name>.options`           | the options schema in the block's manifest                |

The package ships the same keys as JSON Schema, in
`better-supabase/schemas/config-v1.json`, for editors that read it.

## Subjects [#subjects]

A block's entities register as subjects on their own: `deals` registers
`deal`, `catalog` registers `product` and `product_variant`, `tasks`
registers `work_item` under its final name. The collaboration blocks register
`comment`, `engagement`, `document` and `calendar_event`. Comments,
attachments, reactions and the other subject blocks then accept them with no
entry in `subjects`.
`url` sets the page `subject_url` returns, and `subject: false` keeps an
entity out of the registry:

```ts title="better-supabase.config.ts"
blocks: {
  catalog: { entities: { product: { url: "/admin/products/{id}" } } },
  deals: { entities: { deal: { subject: false } } },
},
```

A `subjects` entry with the name of a block entity is an error, because the
block already registers it.

## Renamed clients [#renamed-clients]

For each block whose entities the config renames, `gen` writes the block
under its new names with `renameBlock`. List that one in `defineBlocks`:

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

import { blockConfig, tasksBlock } from "./supabase/blocks.generated";

export const blockSet = defineBlocks(blockConfig, { tasks: tasksBlock });

const { tasks } = blockSet.create({ transport: rpcTransport(supabase) });
const task = (await tasks.create(workspaceId, { title: "Ship it" })).orThrow();
await tasks.update([{ taskId: task.id, field: "title", value: "Shipped" }]);
```

Method names that contain the noun (`saveWorkItem` to `saveTask`), argument
and result keys (`workItemId` to `taskId`) and event types follow the rename,
and hooks keyed under `tasks` see the renamed client. See
[Renamed blocks](/docs/blocks/create-blocks#renamed-blocks) for the
transport side.

## Name constants [#name-constants]

`blocks.generated.ts` also exports the names the SQL uses, so code that
checks a permission, filters events or builds a link uses a constant instead
of a string:

| Export          | What it holds                                                                          |
| --------------- | -------------------------------------------------------------------------------------- |
| `permissions`   | Permission keys by block and action: `permissions["tasks"].write`                      |
| `PermissionKey` | The union of every permission key, for `permission` options                            |
| `RoleName`      | The roles `blocks.permissions.roles` defines                                           |
| `BlockEvents`   | Every installed block's events, type to data                                           |
| `blockEvents`   | The same events as a value, for `onBlockEvent`, `useBlockEvent` and the sinks          |
| `urls`          | A function per entity with a `url`: `urls.task({ id })`                                |
| `topics`        | The realtime topics with their placeholders filled: `topics.notifications({ userId })` |
| `paths`         | Object paths per bucket, from `buckets.<name>.path`                                    |

```ts title="app/tasks/actions.ts"
"use server";

import * as v from "valibot";

import { bsb } from "@/lib/bsb";
import { permissions } from "@/lib/supabase/blocks.generated";

export const renameTask = bsb.blockAction(({ db }) => db.tasks, "update", {
  input: v.object({ taskId: v.string(), title: v.string() }),
  permission: permissions["tasks"].write,
  args: ({ taskId, title }) => [[{ taskId, field: "title", value: title }]],
});
```

`urls.task({ id: task.id })` builds the link to the same row.

## Input schemas [#input-schemas]

The entity blocks (catalog, deals, employees, parties and tasks) and the
collaboration blocks with an input object (comments, attachments, engagements
and calendar) export a Standard Schema for each input, such as
`workItemInputSchema` or `commentInputSchema`. It
validates without a validation library and converts to JSON Schema. A
renamed block carries its schemas under the new names, with renamed
property names and titles: `tasksBlock.schemas.task.input`.

The [zod and valibot generators](/docs/cli/gen#schemas-for-functions-and-blocks)
write the same inputs in your validation library, named after the final
noun: `taskInput`, `taskChange` and `taskListFilter`.

## Helpers built on these types [#helpers-built-on-these-types]

| Helper                                                               | From                                              | What it does                                       |
| -------------------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
| [`blockQueries`](/docs/frontend/query#block-queries)                 | `better-supabase/query`                           | TanStack Query options for every block method      |
| [`useBlockQuery`](/docs/frontend/block-controllers#any-block-method) | `better-supabase/react`, `vue`, `svelte`, `solid` | Calls any block method from a component            |
| [`blockAction`](/docs/frameworks/next#block-actions)                 | `createNext`                                      | A server action that calls one block method        |
| [`router`](/docs/frameworks/orpc#serve-the-api)                      | `createOrpc`                                      | oRPC procedures for every table and block instance |
| [`nuqs`](/docs/cli/gen#search-params-for-nuqs)                       | `better-supabase/config`                          | Search-param parsers for each table list           |
| [`factories`](/docs/testing#factories)                               | `better-supabase/config`                          | Test rows with valid defaults for each table       |

# Using blocks

> Install a block, name an instance and call it through the client.

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

- [createBlocks](/docs/blocks/create-blocks)
- [Typed blocks](/docs/blocks/typed-blocks)
- [SQL modules](/docs/blocks/sql)
- [Composing blocks](/docs/blocks/composing-blocks)
- [Entity forms](/docs/blocks/entity-forms)

# Approvals

> Approval requests with steps per subject type and action, approvers by permission, role, team or user, delegation, expiry and reminders. The final approval runs a state-machine transition or a job.

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

`approvals` files a request for an action on a subject (send an invoice, publish a page), asks each step's approvers in turn, and runs what the policy unlocks once the last step is approved. Requests snapshot their policy, so a config change leaves pending requests alone. Filing a request needs `approvals.request` and read access to the subject, reading other people's requests needs `approvals.read`, and cancelling someone else's request needs `approvals.manage`. Who may decide a step comes from the policy, not from a permission key.

With the [drive](/docs/blocks/drive#labels-and-approvals) installed, requests on `drive_node` follow the node's grants: listing them needs read access to the node, and filing one needs write access. `drive.requestApproval` files them for one revision of a file.

```bash
better-supabase add approvals
```

Server code calls the `approvals` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useApprovals } = defineDataHooks(
  "approvals",
  (db: AppDb) => db.approvals,
);
```

```ts
import { createApprovals } from "better-supabase/blocks/approvals";

const invoice = { type: "invoice", id: invoiceId };

const request = await db.approvals
  .request(invoice, { action: "send", reason: "Q3 invoice" })
  .orThrow();
const waiting = await db.approvals.list({ assigned: true }).orThrow();
const onInvoice = await db.approvals
  .list({ subject: invoice, status: null })
  .orThrow();
await db.approvals.approve(request.id, "Looks right").orThrow();
```

```tsx title="src/features/approvals/list.tsx"
"use client";

import { useApprovals } from "@/lib/supabase/hooks";

const rows = useApprovals({ limit: 50 });
```

`request(subject, input)` takes the subject as a `RecordRef` (`{ type, id }`), and `request` and `list` act in the block's `tenant`, an organization id or a function that returns the current one. `createBlocks` and `createBlockClient` pass the request's tenant; a call without one fails with `invalid_request` and the hint `TENANT_REQUIRED`. Each request carries `subject` and `organizationId`.

## Configure policies [#configure-policies]

`options.policies` maps a subject type, then an action, to its steps. The subject type comes from [`config.records`](/docs/blocks/sql#subject-registry), which decides who can read the subject.

```ts title="better-supabase.config.ts"
blocks: {
  approvals: {
    options: {
      policies: {
        invoice: {
          send: {
            steps: [
              { approvers: { permission: "invoices.approve" }, required: 2 },
              { approvers: { users: ["8b0f6c1e-0000-4000-8000-000000000001"] }, required: "all" },
            ],
            expiresIn: "P7D",
            run: { transition: "sent" },
          },
        },
      },
    },
  },
},
```

| Option              | Default | What it does                                                                                                                                       |
| ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `steps[].approvers` | none    | `permission`, `role` (membership role), `team` (a [teams](/docs/blocks/teams) id) or `users`; any match may decide                                 |
| `steps[].required`  | `1`     | Approvals the step needs; `"all"` with `users` means every listed user                                                                             |
| `expiresIn`         | none    | An ISO 8601 duration such as `P7D`; `expire_approvals()` closes the request after it                                                               |
| `selfApproval`      | `false` | Whether the requester may decide a step                                                                                                            |
| `run`               | none    | `{ transition: "<status>" }` through the [state machine](/docs/blocks/state-machine), or `{ queue: "<queue>" }` through [jobs](/docs/blocks/queue) |

A subject can have one pending request per action (`APPROVAL_PENDING`). A rejection on any step closes the request. An approver decides a step once, and `delegate(id, userId)` hands their place on the step to another member of the organization, who is then asked.

Policies are fixed per subject type and action. To pick one by the record's values, such as the finance chain for an expense over 500, give each chain its own action and let a tenant-edited [rule](/docs/blocks/automations#actions) choose it: `approvalsAction` from `better-supabase/blocks/automations` files the request for the action in the matching rule's `config.action`.

## Run the approved action [#run-the-approved-action]

With `run: { transition }`, the final approval calls `transition()` for the subject. Mark the edge with `requiresApproval` on the machine so nobody can skip the request:

```ts title="better-supabase.config.ts"
"state-machine": {
  options: {
    machines: {
      invoice: { table: "public.invoices", column: "status", initial: "draft", transitions: { draft: ["sent"] }, requiresApproval: { draft: ["sent"] } },
    },
  },
},
```

With `run: { queue }`, the final approval enqueues a job with `requestId`, `organizationId`, `subjectType`, `subjectId`, `action` and the request's `payload`. Without `run`, check `isApproved(subject, action)` or handle `approval.approved`. [Access requests](/docs/blocks/access-requests) file their requests here with their own policy, and the final approval grants the role. [Time tracking](/docs/blocks/time-tracking) files submitted timesheets the same way, and the final approval locks the week.

## Tool calls and support sessions [#tool-calls-and-support-sessions]

When the [AI chat](/docs/blocks/ai-chat) or impersonation module is
installed next to approvals, its waiting requests become approval requests
too, so one approvals inbox lists them:

| Module        | `subjectType`      | `subjectId`                 | `action` | Who decides                                                                                      |
| ------------- | ------------------ | --------------------------- | -------- | ------------------------------------------------------------------------------------------------ |
| ai-chat       | `ai_tool_approval` | the tool call's approval id | `run`    | the chat's owner, or the users the provider's `isApprover` allows for kind `ai-tool`             |
| impersonation | `support_request`  | the support request id      | `start`  | platform staff with the approve permission, or the provider's `isApprover` for `support-session` |

The module that filed the request keeps deciding who approves it, so the
policy's approvers, roles and delegation don't apply (`delegate` refuses with
`APPROVAL_DELEGATE_INVALID`). A decision syncs both ways: approving or
rejecting the request with `approve` or `reject` decides the tool call or the
support request, and `decide_ai_tool_approval` or `approve_support_request`
closes the request. A rejected support request can no longer start a
session. Only the requester, the eligible approvers and the service role read
these requests; `approvals.read` alone doesn't show them. A support request
without a tenant stays in `support_requests` only, because every approval
request belongs to an organization.

## Approvers from your authorization provider [#approvers-from-your-authorization-provider]

Under the `provider` access model, a provider that sets
`authorization.functions.isApprover` can name approvers the policy doesn't:
`approval_eligible` also allows a user the template accepts for kind
`<subject type>.<action>` (for example `refund.approve`), the subject id
and the request's tenant. The requester never counts, even with
`selfApproval`. The policy's own approvers still decide as well. See
[Authorization providers](/docs/extending/authorization-providers).

Server code can ask the same question through the hooks' `approverOf`. Pass
it with the user the transport runs as, and `approve`, `reject` and
`delegate` refuse with `APPROVAL_FORBIDDEN` unless both the SQL check and
the hook allow the caller:

```ts
const approvals = createApprovals({
  transport: sqlTransport(postgres.asUser(ctx.auth.claims)),
  authorization: { approverOf: hooks.approverOf },
  approver: { userId: ctx.auth.claims.sub, claims: ctx.auth.claims },
});
```

## Delegation and out-of-office [#delegation-and-out-of-office]

`delegate(id, userId)` hands the caller's place on the current step of one
request to another member of the organization. It never outlives the
request. This block does not store out-of-office covers, where one user
approves for another until a date.

When your authorization provider keeps covers, let it own them: its
`isApprover` answers for the cover, and `approval_eligible` passes the
request's `requested_by` as `{requester}` so a cover never lets the
requester's own delegator approve. Every module that waits for an approval
then honours the same covers. Configure covers in the provider only, and
don't mirror them with `delegate` calls. Without such a provider, call
`delegate` per request. Under the `provider` model, `delegate` checks that
the delegate is a member through the provider's `memberIdsFor` when the
provider sets it. See
[Authorization providers](/docs/extending/authorization-providers#out-of-office-covers).

## Resume a workflow when a request is decided [#resume-a-workflow-when-a-request-is-decided]

A request's id is a random uuid, so it works as the token a paused workflow
waits on: only `decide_approval`, after its approver check, can end the
wait. Call `wakeOnApproval` from your outbox consumer, and it resumes the
run waiting on the request with `{ approved, status, requestId }` for
`approval.approved`, `approval.rejected`, `approval.expired` and
`approval.cancelled`. See
[Workflow SDK](/docs/blocks/workflow-sdk#approvals-that-expire).

```ts title="app/api/outbox/route.ts"
import { wakeOnApproval } from "better-supabase/workflow-sdk";

for (const event of events) await wakeOnApproval(event);
```

## Expiry and reminders [#expiry-and-reminders]

Schedule both functions with pg_cron or the [jobs](/docs/blocks/queue) drain. `remind_approvals(after)` asks the current step's approvers again for requests pending longer than `after` (default one day), once per `after`.

```sql
select cron.schedule('expire-approvals', '*/15 * * * *', 'select better_supabase.expire_approvals()');
select cron.schedule('remind-approvals', '0 9 * * *', 'select better_supabase.remind_approvals()');
```

## Notifications, events and hooks [#notifications-events-and-hooks]

With the [notifications](/docs/blocks/notifications) module, the current step's approvers get `approval.requested` (and `approval.reminder`), a delegate gets `approval.delegated`, and the requester gets `approval.approved`, `approval.rejected` or `approval.expired`. Approvers named by permission are reached through the notification's permission audience.

The module records `approval.requested`, `approval.approved`, `approval.rejected`, `approval.cancelled`, `approval.expired` and `approval.delegated`, each with `organizationId`, `subjectType`, `subjectId`, the request `id`, `action`, `status` and `step`. Every event but `approval.expired` also goes to the [audit log](/docs/blocks/audit). It calls `before_approval_request`, `after_approval_request`, `before_approval_decide` and `after_approval_decide`.

## With ReUI [#with-reui]

An approvals inbox renders `list({ assigned: true })` in a ReUI data grid with approve and reject actions. A request sheet shows `get(id)`: the steps as a stepper with the current `step` highlighted, and `decisions` as a timeline. Show the approve buttons only when `canDecide` is true.

## Your own fields [#your-own-fields]

`blocks.approvals.options.extraColumns` adds columns to the `approval_requests` table,
keyed by column name with their SQL type. Pass `createApprovals` a Standard Schema
for them as `fields`: `request` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and every request read return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createApprovals({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.approvals` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createApprovals } from "better-supabase/blocks/approvals";

const approvals = createApprovals({ transport, tenant: organizationId });
const invoice = { type: "invoice", id: invoiceId };

const request = await approvals
  .request(invoice, { action: "send", reason: "Q3 invoice" })
  .orThrow();
const waiting = await approvals.list({ assigned: true }).orThrow();
const onInvoice = await approvals
  .list({ subject: invoice, status: null })
  .orThrow();
await approvals.approve(request.id, "Looks right").orThrow();
```

# Assets and locations

> A location hierarchy (site, building, unit) and an asset registry with tags, status, coordinates and assignment to members or records. Optional PostGIS stores points and areas as geography; booking resources and work items can name an asset.

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

`assets` keeps two registries per organization. Locations form a tree, such
as a site with buildings and units inside it, and assets (a laptop, a van, a
meeting room, a machine) sit at a location, carry a tag and a serial number,
and are assigned to a member or to a record such as a party.

Reading needs `assets.read`, creating and changing locations and assets needs
`assets.manage`, and assigning needs `assets.assign`. Admins get all three and
members get `assets.read`. A member also reads the assets assigned to them
without `assets.read`.

```bash
better-supabase add assets
```

The module needs `memberships` and `access`. It works with
[bookings](/docs/blocks/bookings) and [work items](/docs/blocks/tasks)
when they are installed, and records `asset.*` events to the outbox and the
audit log.

Server code calls the `assets` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useAssets } = defineDataHooks(
  "assets",
  (db: AppDb) => db.assets,
);
```

```ts
import { createAssets } from "better-supabase/blocks/assets";

const site = await db.assets
  .createLocation({
    kind: "site",
    name: "Amsterdam office",
    code: "AMS",
    point: { latitude: 52.37, longitude: 4.89 },
  })
  .orThrow();

const laptop = await db.assets
  .createAsset({
    kind: "laptop",
    name: "MacBook Pro 14",
    locationId: site.id,
    tag: "IT-0042",
    serialNumber: "C02XYZ",
  })
  .orThrow();

await db.assets
  .assign(laptop.id, { type: "member", id: userId }, { note: "New hire" })
  .orThrow();
```

```tsx title="src/features/assets/list.tsx"
"use client";

import { useAssets } from "@/lib/supabase/hooks";

const rows = useAssets({ limit: 50 });
```

## Locations [#locations]

A location has a free-text `kind` (`site`, `building`, `floor`, `unit`), a
`name`, an optional `code` that is unique in the organization, an `address`
object and an optional `point`. `parentId` places it in the tree: the parent
must belong to the same organization, and a move that would put a location
inside one of its own sublocations fails with `ASSETS_LOCATION_CYCLE`.

`location(id)` returns the location with its `path` from the top, and how
many open sublocations and assets in service it holds. `locations()` lists the
top locations, and `locations({ parentId })` the children of one.
`archiveLocation(id)` archives a location once it has no open sublocations
and no assets in service.

## Assets [#assets]

An asset has a `kind`, a `name`, an optional `locationId`, a `tag` that is
unique in the organization, a `serialNumber`, a `point` and a `status`:
`active`, `maintenance` or `retired`. Retiring an asset ends its assignment,
and a retired asset can't be assigned again.

`assets(filter)` lists assets newest first, filtered by location (with
`includeSublocations` for everything under it), kind, status, assignee or a
search on name, tag and serial number, and pages with `before`. `asset(id)`
adds the location path and the booking resources and open work items that
name the asset.

## Assignment [#assignment]

`assign(assetId, assignee)` assigns an asset to a member
(`{ type: "member", id: userId }`) or to any record (`{ type, id }`), and
ends the previous assignment. A member must be able to read the
organization's assets. `release(assetId)` ends the assignment, and
`assignments(assetId)` lists the history newest first.

`assignedTo(subject)` lists the assets a member or record holds; the
[assets binding](/docs/repository/record-bindings) renders them on the record page. The
`before_asset_assign` and `after_asset_assign` hooks run around an
assignment.

## Coordinates and PostGIS [#coordinates-and-postgis]

Locations and assets store `latitude` and `longitude`. `near({ latitude,
longitude, radiusMeters })` returns the assets and locations within the
radius, nearest first, each with `distanceMeters`. Without PostGIS this is a
haversine distance on the stored coordinates.

The `postgis` option installs the `postgis` extension in the `extensions`
schema and adds geography columns: a generated `point` on locations and
assets, and an `area` on locations (a GeoJSON polygon or multipolygon, stored
as a multipolygon). Both get GiST indexes, `near` uses `ST_DWithin`, and
`locationsAt(point)` returns the locations whose area contains a point.
Without the option, `locationsAt` fails with `ASSETS_POSTGIS_REQUIRED`.

```ts
blocks: {
  assets: { options: { postgis: true } },
}
```

## Bookings and work items [#bookings-and-work-items]

With [bookings](/docs/blocks/bookings) installed, booking resources get an
`asset_id` column, so a meeting room or vehicle you book is an asset. With
[work items](/docs/blocks/tasks) installed, work items get one for the
asset a job is about. `link({ type: "booking_resource" | "work_item", id },
assetId)` sets it, and `null` clears it. Locations and assets are registered
subjects, so [relations](/docs/blocks/relations), comments and attachments
work on them.

## Errors [#errors]

| Hint                           | Cause                                                     |
| ------------------------------ | --------------------------------------------------------- |
| `ASSETS_FORBIDDEN`             | the caller lacks the permission for this                  |
| `ASSETS_LOCATION_NOT_FOUND`    | no location with this id in the organization              |
| `ASSETS_NOT_FOUND`             | no asset with this id in the organization                 |
| `ASSETS_CODE_TAKEN`            | a location with this code already exists                  |
| `ASSETS_TAG_TAKEN`             | an asset with this tag already exists                     |
| `ASSETS_LOCATION_CYCLE`        | the parent is one of the location's own sublocations      |
| `ASSETS_LOCATION_IN_USE`       | the location still holds open sublocations or assets      |
| `ASSETS_RETIRED`               | the asset is retired                                      |
| `ASSETS_ASSIGNEE_INVALID`      | the assignee isn't valid, or the member can't read assets |
| `ASSETS_POINT_INVALID`         | a latitude or longitude is missing or out of range        |
| `ASSETS_RADIUS_INVALID`        | the radius isn't a positive number of meters              |
| `ASSETS_PATCH_INVALID`         | the patch names a key that can't be changed               |
| `ASSETS_POSTGIS_REQUIRED`      | `locationsAt` needs the `postgis` option                  |
| `ASSETS_LINK_UNSUPPORTED`      | the link target isn't a booking resource or a work item   |
| `ASSETS_LINK_TARGET_NOT_FOUND` | no booking resource or work item with this id             |

## Events [#events]

`asset_location.created`, `asset_location.updated` and
`asset_location.archived`, and `asset.created`, `asset.updated`,
`asset.assigned` and `asset.released`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.assets` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createAssets } from "better-supabase/blocks/assets";

const assets = createAssets({ transport, tenant: organizationId });

const site = await assets
  .createLocation({
    kind: "site",
    name: "Amsterdam office",
    code: "AMS",
    point: { latitude: 52.37, longitude: 4.89 },
  })
  .orThrow();

const laptop = await assets
  .createAsset({
    kind: "laptop",
    name: "MacBook Pro 14",
    locationId: site.id,
    tag: "IT-0042",
    serialNumber: "C02XYZ",
  })
  .orThrow();

await assets
  .assign(laptop.id, { type: "member", id: userId }, { note: "New hire" })
  .orThrow();
```

# Bulk actions

> Run one action over many rows with a result per row, in one call up to a limit and as a background run on the jobs queue beyond it, as the user who started it.

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

`bulk` runs one action over many rows: tag 40 tasks, move 300 deals to
`won`, put 2,000 issues under an epic. Up to a limit (500 by default) the
rows run in one call and the answer lists a result per row. Beyond it, the
rows go into a background run on the [jobs](/docs/blocks/queue) queue `bulk`,
which broadcasts its progress on a Realtime topic.

```bash
better-supabase add bulk
```

```ts
import { createBulk } from "better-supabase/blocks/bulk";
```

## Actions [#actions]

Each block that is installed adds its action:

| Action                    | Block                                       | `args`                                  |
| ------------------------- | ------------------------------------------- | --------------------------------------- |
| `tags.set`                | [Tags](/docs/blocks/tags)                   | `{ add?: tagIds, remove?: tagIds }`     |
| `stateMachine.transition` | [State machine](/docs/blocks/state-machine) | `{ to, reason? }`                       |
| `relations.link`          | [Relations](/docs/blocks/relations)         | `{ kind, targetType, targetId, data? }` |
| `trash.trash`             | [Trash](/docs/blocks/trash)                 | none (`{}`)                             |
| `collections.add`         | [Collections](/docs/blocks/collections)     | `{ collectionId }`                      |
| `appTables.set`           | [App tables](/docs/repository/tables)       | `{ values }`                            |

`appTables.set` sets columns on the selected rows of any app table that is a
subject. `values` is keyed by database column name, and each value is cast
to the column's type:

```ts
await bulk
  .apply({
    organizationId,
    subjectType: "task",
    rows: selectedIds,
    action: "appTables.set",
    args: { values: { due_on: "2026-11-02" } },
  })
  .orThrow();
```

It runs `app_table_set` as the caller, so row level security and column
grants decide what changes. A row fails with `APP_TABLE_SET_EMPTY` when
`values` is empty, `APP_TABLE_SET_COLUMN` for a column the table doesn't
let it set (an id, the tenant column or a timestamp), `APP_TABLE_SET_SUBJECT`
for a subject type that isn't an app table, and `APP_TABLE_SET_NOT_FOUND`
when the caller can't see the row.

An app adds its own actions in `options.actions`, each a function that
takes `(tenant, subject_type, subject_id, args jsonb)` and raises with a
hint when a row fails:

```ts title="better-supabase.config.ts"
blocks: {
  "bulk": {
    options: {
      limit: 500,
      actions: { "tasks.archive": "app.archive_task" },
    },
  },
},
```

Every row runs through the action's own checks, as the caller: `tags.set`
calls `tag` and `untag`, so a row the user can't tag fails with
`TAG_SUBJECT_FORBIDDEN` while the others go through. Running a bulk action
also needs `bulk.run`, which the default member role has.

## Run rows now [#run-rows-now]

Server code calls the `bulk` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useBulk } = defineDataHooks(
  "bulk",
  (db: AppDb) => db.bulk,
);
```

```ts
const result = await db.bulk
  .run({
    organizationId,
    subjectType: "task",
    rows: selectedIds,
    action: "tags.set",
    args: { add: [urgentTagId] },
  })
  .orThrow();

result.failed; // rows that raised
result.results; // [{ id, ok, error: { code, message, sqlstate } | null }]
```

```tsx title="src/features/bulk/list.tsx"
"use client";

import { useBulk } from "@/lib/supabase/hooks";

const rows = useBulk({ limit: 50 });
```

Each row runs in its own savepoint, so a failing row leaves the others
applied. Pass `atomic: true` to roll every row back on the first failure;
the call then fails with that row's error. More rows than the limit fail
with `BULK_TOO_MANY`.

`rows` also takes a function that resolves the ids, such as the ids a list
query's filter tree matches, so a "select all 2,000 results" action reads
them on the server:

```ts
await bulk.apply({
  organizationId,
  subjectType: "task",
  rows: () => matchingTaskIds(filterTree),
  action: "stateMachine.transition",
  args: { to: "done" },
});
```

`apply` runs the rows now when they fit the limit and starts a background
run otherwise; it resolves to `{ mode: "now", ...result }` or
`{ mode: "background", id, total, topic }`. Pass the module's `limit` to
`createBulk` when you changed it.

## Background runs [#background-runs]

`start` stores the run in `bulk_runs` and enqueues a job on the queue
`bulk`. Process it with the handler:

```ts
jobs.work({ handlers: { bulk: bulk.handler() } });
```

The handler calls `bulk_step` with the service role until the run is done.
Each step takes the next rows (200 by default), switches to the user who
started the run (their claims and the `authenticated` role), runs the rows,
switches back, and saves the results. The rows run with the same
permissions and policies as a call from that user, and a run whose user
lost `bulk.run` stops as `failed`. A step that dies is retried by the queue
and resumes where the last saved step ended.

After each step the run broadcasts `progress` on its topic,
`bulk:<run id>`, with `position`, `total`, `succeeded` and `failed`. The
topic is private, so add a policy on `realtime.messages` that lets the user
who started the run read it. `get(id)` reads a run with its results; only
the user who started it can read it. A finished run records
`bulk_run.completed`.

| Hint                  | When                                               |
| --------------------- | -------------------------------------------------- |
| `BULK_FORBIDDEN`      | The caller lacks `bulk.run`                        |
| `BULK_ACTION_UNKNOWN` | The action is not installed or configured          |
| `BULK_TOO_MANY`       | More rows than the limit, or than 100,000 in a run |
| `BULK_NO_JOBS`        | A background run without the jobs module           |
| `BULK_RUN_NOT_FOUND`  | `bulk_step` for a run that doesn't exist           |

The SQL functions are `bulk_apply`, `start_bulk`, `bulk_run` and
`bulk_step` (service role only).

## Undo [#undo]

A bulk call returns no undo token, because the inverse of a row depends on
what the row held before. With the [audit](/docs/blocks/audit) block on the
subject table, `audit.revert` writes the old values back.

## With ReUI [#with-reui]

- Data Grid: row selection feeds `rows`, and the selection bar runs `apply`
  with an action per button.
- A toast subscribes to the run's topic and shows
  `position` of `total`, then lists the failed rows from `get`.

The free `c-data-grid-*` examples show row selection; the Pro data grid
blocks show a bulk action bar. Pro blocks are licensed per user, so copy
them from [reui.io](https://reui.io).

## Without the client [#without-the-client]

`db.bulk` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const bulk = createBulk({ transport });

const result = await bulk
  .run({
    organizationId,
    subjectType: "task",
    rows: selectedIds,
    action: "tags.set",
    args: { add: [urgentTagId] },
  })
  .orThrow();

result.failed; // rows that raised
result.results; // [{ id, ok, error: { code, message, sqlstate } | null }]
```

# Work

> Tasks, goals and the blocks that track work on a record.

Source: https://bettersupabase.com/docs/blocks/catalog-work

- [Tags](/docs/blocks/tags)
- [State machine](/docs/blocks/state-machine)
- [Custom fields](/docs/blocks/custom-fields)
- [Saved views](/docs/blocks/saved-views)
- [Relations](/docs/blocks/relations)
- [Bulk actions](/docs/blocks/bulk)
- [Undo](/docs/blocks/undo)
- [Search](/docs/blocks/search)
- [Approvals](/docs/blocks/approvals)
- [Assets and locations](/docs/blocks/assets)
- [Pipelines](/docs/blocks/pipelines)
- [Collections](/docs/blocks/collections)
- [Work items](/docs/blocks/tasks)
- [Service levels](/docs/blocks/sla)
- [Dispatch](/docs/blocks/dispatch)

# Collections

> Favorites (a member's starred records, ranked for a sidebar), saved items (a member's records to come back to, with a note and a reminder) and lists (named, static collections of one record type owned by a user, a team or the organization) on any registered record.

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

`collections` keeps two kinds of hand-picked record sets. Favorites are a member's own stars: any record registered in [`config.records`](/docs/blocks/sql#subject-registry) can be starred, and the stars keep a rank so a sidebar shows them in the order the member drags them into. Lists are named, static collections of one record type, such as "Key accounts" or "Conference leads": a member adds records by hand or in bulk, and the list keeps them until someone removes them.

A list is the static counterpart of a [saved view](/docs/blocks/saved-views). A saved view stores a filter, so its rows change as the data changes; a list stores the records themselves. Like a saved view, a list belongs to one user, a team (with the [teams](/docs/blocks/teams) module) or the whole organization.

Reading needs `collections.read`, and starring, creating personal lists and changing entries need `collections.write`. Creating, renaming or archiving a team or organization list needs `collections.share`. Admins get every permission, and members get `collections.read` and `collections.write`.

```bash
better-supabase add collections
```

The module needs `tenant`, `permissions` and `subjects`, and uses `teams` for team lists and `outbox` for its events.

Server code calls the `collections` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCollections } = defineDataHooks(
  "collections",
  (db: AppDb) => db.collections,
);
```

```ts
import { createCollections } from "better-supabase/blocks/collections";

await db.collections
  .star(organizationId, { type: "deal", id: dealId })
  .orThrow();
const stars = await db.collections.starred(organizationId).orThrow();

const keyAccounts = await db.collections
  .create(organizationId, {
    name: "Key accounts",
    subjectType: "company",
    scope: "team",
    teamId,
  })
  .orThrow();
await db.collections.add(keyAccounts.id, [acmeId, globexId]).orThrow();
```

```tsx title="src/features/collections/list.tsx"
"use client";

import { useCollections } from "@/lib/supabase/hooks";

const rows = useCollections({ limit: 50 });
```

## Favorites [#favorites]

| Method                                     | Does                                                                         |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `star(organizationId, subject)`            | Stars a record at the end of the caller's favorites; a starred one stays put |
| `unstar(subject)`                          | Removes the star; `false` when there was none                                |
| `starred(organizationId, { type })`        | The caller's stars in rank order, with each record's `title` and `url`       |
| `reorderStar(subject, { previous, next })` | Moves a star between its neighbours' rank keys                               |

Stars are private: each member reads and changes only their own. The record must belong to the organization and the caller must be able to read it (`FAVORITE_SUBJECT_FORBIDDEN`). `title` and `url` come from the record's `title` and `url` in `config.records`.

These stars are separate from the [search](/docs/blocks/search) module's starred results, which only the command palette reads.

## Lists [#lists]

`create(organizationId, { name, subjectType, scope, teamId, description })` makes a list of one subject type, which must be a registered type with a tenant column (`COLLECTION_SUBJECT_TYPE_UNKNOWN`). The scope defaults to `user`; a `team` list needs a team the caller belongs to.

| Method                                                   | Does                                                                                                        |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `add(id, subjectIds)`                                    | Adds up to 1000 records to the end, in the order given; ids already in the list are skipped                 |
| `remove(id, subjectIds)`                                 | Removes records; resolves to how many were removed                                                          |
| `reorder(id, subjectId, neighbors)`                      | Moves an entry between its neighbours' rank keys                                                            |
| `entries(id, { limit, cursor })`                         | A page of entries in rank order (default 50, at most 200) with `rank`, `addedBy`, `addedAt`, `title`, `url` |
| `listsOf(subject)`                                       | The active lists the caller sees that hold a record, for a record's "In lists" panel                        |
| `list(organizationId, { subjectType, includeArchived })` | The lists the caller sees, by name                                                                          |
| `rename(id, { name, description })`                      | Changes the name or the description (`null` clears it)                                                      |
| `archive(id, archived?)`                                 | Archives a list, or restores it with `false`; an archived list keeps its entries but takes no changes       |

`add` checks every record before it writes one: a record that is unknown, in another organization or unreadable fails the whole call (`COLLECTION_SUBJECT_FORBIDDEN`). `entries` returns `next` while there may be more; pass it as `cursor`.

Who sees a list follows its scope: the owner, the team's members, or every member with `collections.read`. Anyone who sees a list and holds `collections.write` changes its entries. An entry is visible only when the caller can also read its record through the record table's own policies, so a shared list never shows a record to someone who can't open it.

When a record type sets `cascade: true` in `config.records`, deleting a record deletes its entries and its stars.

## Save for later [#save-for-later]

Saved items are a member's own "come back to this" list: a record, an
optional note and an optional reminder. Like stars they are private, and the
record must be readable in the organization (`SAVED_ITEM_SUBJECT_FORBIDDEN`).
A message or a conversation from the [messaging](/docs/blocks/messaging) block
is a record too, so a chat message can be saved.

```ts
const item = await collections
  .saveForLater(
    organizationId,
    { type: "message", id: messageId },
    {
      note: "Reply after the board meeting",
      remindAt: Temporal.Now.instant().add({ hours: 4 }),
    },
  )
  .orThrow();

const open = await collections.saved(organizationId).orThrow();
await collections.completeSaved(item.id).orThrow();
```

| Method                                                | Does                                                                                                                       |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `saveForLater(organizationId, subject, input)`        | Saves a record with `note` (at most 1000 characters) and `remindAt`; saving it again replaces both and reopens it          |
| `saved(organizationId, { completed, limit, cursor })` | The caller's open items, newest first (default 50, at most 200), with `title` and `url`; `completed: true` lists done ones |
| `completeSaved(id, completed?)`                       | Marks an item done, or open again with `false`; `false` when it isn't the caller's                                         |
| `removeSaved(id)`                                     | Deletes an item; `false` when it isn't the caller's                                                                        |
| `remindDue({ limit })`                                | Service role: claims the open items whose reminder is due and notifies each member once                                    |

`remindAt` must be in the future. Run `remindDue()` from a cron or a
[jobs](/docs/blocks/queue) schedule with the service role. It claims up to
`limit` due items (default 100, at most 500), records `saved_item.reminded`
for each, and with the [notifications](/docs/blocks/notifications) block sends
the `notification` type through the `notifier` port to the member who saved
it. `createBlocks` hands the port over:

```ts
const { collections } = createBlocks(
  { transport, service },
  {
    notifications: notificationsBlock,
    collections: collectionsBlock.with({ notification: "saved_item.reminder" }),
  },
);

await collections.remindDue().orThrow();
```

A claimed item is not reminded again until it is saved with a new reminder.
The reminders block's recurrence rules stay for repeating schedules on a
record; a saved item's reminder is one-off and belongs to one member. When a
record type sets `cascade: true`, deleting the record deletes its saved items.

## Saved views and bulk actions [#saved-views-and-bulk-actions]

A saved view can show only the records of a list: store the list's id as `state.collectionId`, and pass `collections.where(id)` as the list query's `where`. With the collections module installed, `save` checks that the list exists in the organization and is shared or the caller's own (`SAVED_VIEW_COLLECTION_NOT_FOUND`).

```ts
const view = defaultView(
  await views.list(organizationId, "companies").orThrow(),
);
const where = view?.state.collectionId
  ? await collections.where(view.state.collectionId).orThrow()
  : undefined;
const page = await companiesList.run(db, query, { where }).orThrow();
```

`where` reads up to 500 ids (`max` raises it to 1000) and returns `{ id: { in: ids } }`; pass `column` when the list's rows point at the record through another column. `subjectIds(id)` returns the same ids.

With the [bulk](/docs/blocks/bulk) module installed, `collections.add` is a built-in action, so a multi-select adds many records at once with a result per row:

```ts
await bulk
  .run({
    organizationId,
    subjectType: "person",
    rows: selectedIds,
    action: "collections.add",
    args: { collectionId: list.id },
  })
  .orThrow();
```

## Events [#events]

| Event                                                | Data                                                                                      |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `collection.created`                                 | `organizationId`, `collectionId`, `subjectType`, `name`, `scope`                          |
| `collection.archived`                                | `organizationId`, `collectionId`, `subjectType`, `name`                                   |
| `collection.entry_added`, `collection.entry_removed` | `organizationId`, `collectionId`, `subjectType`, `subjectId`                              |
| `saved_item.reminded`                                | `organizationId`, `savedItemId`, `userId`, `subjectType`, `subjectId`, `note`, `remindAt` |

Stars and saved items record no other events: they are a member's own
bookmarks.

## Hooks [#hooks]

The module calls `before_collection_create(tenant, subject_type text, scope text)`, `after_collection_create(collection uuid)`, `before_collection_add(collection uuid, subject_ids text[])` and `after_collection_add(collection uuid, added text[])`. A `before_` hook raises to refuse the write. The TypeScript client takes `hooks` for its methods (see [extending blocks](/docs/extending/blocks)).

## Wire it to ReUI [#wire-it-to-reui]

The ReUI Contacts Directory (`solution-crm-3`) has a multi-select toolbar with an "Add to list" action. Fill its list picker from `list(organizationId, { subjectType: "person" })`, offer "New list" through `create`, and run the action through `bulk.run` with `collections.add` so each row reports its own result; a picker without the bulk module calls `add(listId, selectedIds)`. A "Lists" filter on the directory stores the chosen list as a saved view's `state.collectionId`.

The Pipeline Dashboard's deal cards carry a favorite star. Read `starred(organizationId, { type: "deal" })` once into a set of ids, toggle a card's star with `star` or `unstar`, and render the sidebar's "Favorites" group from the same rows, using `title` and `url`. Dragging a favorite calls `reorderStar` with the keys of its new neighbours.

A message's action menu gets "Save for later" with a reminder picker (in an
hour, tomorrow, next week), which calls `saveForLater` with the message as
the subject. A "Saved" page lists `saved(organizationId)` with each item's
`title`, `url`, note and reminder time, a checkbox that calls `completeSaved`
and a "Done" tab over `saved(organizationId, { completed: true })`.

## Errors [#errors]

| Hint                               | Cause                                                                        |
| ---------------------------------- | ---------------------------------------------------------------------------- |
| `COLLECTION_FORBIDDEN`             | the caller lacks the permission or can't change this list                    |
| `COLLECTION_NOT_FOUND`             | no list the caller sees                                                      |
| `COLLECTION_INVALID`               | an unknown scope, a team list without a team, or no signed-in user           |
| `COLLECTION_NAME_REQUIRED`         | the name is empty                                                            |
| `COLLECTION_SUBJECT_TYPE_UNKNOWN`  | not a registered subject type with a tenant                                  |
| `COLLECTION_SUBJECT_TYPE_MISMATCH` | a bulk run's subject type isn't the list's                                   |
| `COLLECTION_SUBJECT_FORBIDDEN`     | a record is unknown, in another organization or unreadable                   |
| `COLLECTION_ARCHIVED`              | the list is archived                                                         |
| `COLLECTION_TOO_MANY`              | more than 1000 ids in one call                                               |
| `COLLECTION_RANK_INVALID`          | not a rank key                                                               |
| `FAVORITE_FORBIDDEN`               | the caller lacks `collections.read` in the organization                      |
| `FAVORITE_SUBJECT_FORBIDDEN`       | the record is unknown, in another organization or unreadable                 |
| `FAVORITE_INVALID`                 | no signed-in user                                                            |
| `FAVORITE_RANK_INVALID`            | not a rank key                                                               |
| `SAVED_ITEM_FORBIDDEN`             | the caller lacks `collections.read`, or `remindDue` without the service role |
| `SAVED_ITEM_SUBJECT_FORBIDDEN`     | the record is unknown, in another organization or unreadable                 |
| `SAVED_ITEM_INVALID`               | no signed-in user, a note over 1000 characters or a past reminder            |
| `RANK_KEY_INVALID`                 | the neighbours passed to `reorder` or `reorderStar` aren't rank keys         |

## Your own fields [#your-own-fields]

`blocks.collections.options.extraColumns` adds columns to the `subject_collections` table,
keyed by column name with their SQL type. Pass `createCollections` a Standard Schema
for them as `fields`: `create`, `rename` take them next to the block's own keys and
validate them (a bad value is a `validation` error), and `create`, `rename`, `list` return them
parsed. A column the module already uses is refused. See
[your own fields](/docs/extending/blocks#your-own-fields).

```ts
const client = createCollections({
  transport,
  fields: z.object({ priority: z.number().int().positive().nullable() }),
});
```

## Without the client [#without-the-client]

`db.collections` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createCollections } from "better-supabase/blocks/collections";

const collections = createCollections({ transport });

await collections.star(organizationId, { type: "deal", id: dealId }).orThrow();
const stars = await collections.starred(organizationId).orThrow();

const keyAccounts = await collections
  .create(organizationId, {
    name: "Key accounts",
    subjectType: "company",
    scope: "team",
    teamId,
  })
  .orThrow();
await collections.add(keyAccounts.id, [acmeId, globexId]).orThrow();
```

# Custom fields

> Field definitions, options and typed values, with a permission check for sensitive values.

Source: https://bettersupabase.com/docs/blocks/custom-fields

`custom-fields` stores definitions (`entity`, `key`, `label`, `type`, `required`, `sensitive`, `permission`) and one typed value per subject.

```bash
better-supabase add custom-fields
```

Server code calls the `custom-fields` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCustomFields } = defineDataHooks(
  "customFields",
  (db: AppDb) => db.customFields,
);
```

```ts
import {
  CUSTOM_FIELD_TYPES,
  createCustomFields,
} from "better-supabase/blocks/custom-fields";

const customer = { type: "customer", id: customerId };

const tier = await db.customFields
  .saveDefinition("customer", "tier", "Tier", "option")
  .orThrow();
await db.customFields.setValue(customer, tier.id, "gold").orThrow();
const values = await db.customFields.readValues(customer).orThrow();
```

```tsx title="src/features/custom-fields/list.tsx"
"use client";

import { useCustomFields } from "@/lib/supabase/hooks";

const rows = useCustomFields({ limit: 50 });
```

Definitions belong to an entity (`listDefinitions(entity)`,
`saveDefinition(entity, key, label, type, sensitive?)`), and values to a
subject, a `RecordRef` (`{ type, id }`): `readValues(subject)` and
`setValue(subject, definitionId, value)`. Every call acts in the block's
`tenant`, which `createBlocks` and `createBlockClient` pass from the request;
a call without one fails with `invalid_request` and the hint
`TENANT_REQUIRED`.

## Types [#types]

`CUSTOM_FIELD_TYPES` lists the types a definition takes. A value is stored as text whatever the type; the type tells the form and the UI what it holds. The names match the [app table formats](/docs/repository/tables), so a field and a column that hold the same thing use the same word.

| Type                                     | Holds                         | Forms field type          |
| ---------------------------------------- | ----------------------------- | ------------------------- |
| `text`, `number`, `date`, `bool`, `json` | the value                     | the same name             |
| `option`                                 | one option value              | `option`                  |
| `multi_option`                           | a JSON array of option values | `multi`                   |
| `email`, `url`                           | an address                    | `email`, `url`            |
| `user`                                   | a user id                     | `text`                    |
| `rating`                                 | 1 to 5                        | `integer`, bounded 1 to 5 |
| `currency`                               | an integer of minor units     | `integer`                 |

A [form](/docs/blocks/forms) field that names a `customField` answers with the forms field type in the last column, and a form field's own `min`, `max` or `options` win over the definition's. The check on `type` is named `bsb_field_definitions_type`; `upgrade` drops the inline check an older install created and adds the named one.

A sensitive value is returned only when `can` passes for the field's `permission`, or `custom_fields.sensitive.read` when the field names none. A provider implements field-level grants behind that key.

## Encrypted fields [#encrypted-fields]

With the [`field-encryption`](/docs/blocks/field-encryption) block
installed, `encryptDefinition(id)` stores a `text`, `email` or `url` field's
values as ciphertext under the tenant's data key, and encrypts the values it
already has. It needs `custom_fields.manage`, and there is no way back.
`listDefinitions` reports `encrypted` on each definition. `setValue` and
`readValues` work as before: the definer functions encrypt on write and
decrypt for callers that pass `custom_fields.read` (and the sensitive
permission for a sensitive field).

`options.auditReads: true` writes one `field_value.viewed` entry with the
category `read` to the [audit log](/docs/blocks/audit#read-audits) for each
`readValues` call that returns an encrypted or sensitive value, with the
subject and the value ids. It needs the `audit` block.

```ts title="better-supabase.config.ts"
blocks: {
  "field-encryption": {},
  "custom-fields": { options: { auditReads: true } },
},
```

`options.layout` is `normalized` (the default) or `inline`. Inline layout stores the definition columns on the value row and skips the definitions table, which is how `asset_custom_attributes` is adopted.

## Over your own tables [#over-your-own-tables]

To run over tables you already have, map `tables.definitions`, `tables.options` and `tables.values`. Set a column to `null` when the existing table does not have it. For the inline layout, map `values.definition` to `null` and set `layout` to `inline`.

## Order [#order]

Field definitions of an entity are ordered by `rank`, a fractional key (see [Rank](/docs/blocks/rank)). A new definition goes after the last one, and `reorderDefinition(id, { previous, next })` moves it between the definitions with those keys. `listDefinitions(entity)` returns an entity's definitions in that order, each with its `rank` key, for callers with `custom_fields.read`. Version 2 of the module replaced the integer `position` column with `rank`: the upgrade backfills keys in the old order and drops `position`. The inline layout has no rank column.

## Without the client [#without-the-client]

`db.customFields` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import {
  CUSTOM_FIELD_TYPES,
  createCustomFields,
} from "better-supabase/blocks/custom-fields";

const fields = createCustomFields({ transport, tenant: organizationId });
const customer = { type: "customer", id: customerId };

const tier = await fields
  .saveDefinition("customer", "tier", "Tier", "option")
  .orThrow();
await fields.setValue(customer, tier.id, "gold").orThrow();
const values = await fields.readValues(customer).orThrow();
```

# Dispatch

> Schedule field service work orders into technicians' slots on a board, move them from scheduled to done, plan each technician's day as a route, and record the parts each job used.

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

`dispatch` is the field service desk. A dispatcher creates a work order at an
asset location or an address, puts it in a technician's slot on the schedule
board, and plans the order the technician drives to their jobs. The
technician opens their day plan, marks each job en route, on site and done,
and records the parts they used.

```bash
better-supabase add dispatch
```

```ts
import { createDispatch } from "better-supabase/blocks/dispatch";
```

The module requires `memberships` and `access`. With `assets` a work order and a
part can name an asset location and an asset, with `bookings` a work order
can take its slot from a booking, and with `catalog` and `inventory` a part
can name the product variant and the stock location it came from. With
`outbox` every change records an event, and with `audit` the writes are
audited. Technicians are the organization's members who hold
`dispatch.work`; members hold it by default.

## Tables [#tables]

| Table                  | Holds                                                                                                                                                                                          |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `work_orders`          | One job: `title`, `priority`, `status`, the `technician_id` and the slot (`starts_at`, `ends_at`), the `location_id`, `asset_id`, `booking_id`, record or `address`, and the time of each step |
| `dispatch_routes`      | A technician's plan for one day                                                                                                                                                                |
| `dispatch_route_stops` | The work orders of a route, by `position`                                                                                                                                                      |
| `work_order_parts`     | The parts a job used: `name`, `quantity`, `unit`, and the `asset_id`, `variant_id` or inventory `location_id`                                                                                  |

Every table is read through the functions only. Readers with `dispatch.read`
see every work order, and a technician with `dispatch.work` sees and works
their own. `blocks.dispatch.names` renames the tables and functions.

## Create and schedule work orders [#create-and-schedule-work-orders]

Server code calls the `dispatch` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useDispatch } = defineDataHooks(
  "dispatch",
  (db: AppDb) => db.dispatch,
);
```

```ts
const order = await db.dispatch
  .create({
    title: "Service the boiler",
    priority: "high",
    locationId,
    assetId,
  })
  .orThrow();

await db.dispatch
  .schedule(order.id, {
    technicianId,
    startsAt: Temporal.Instant.from("2030-01-07T09:00:00Z"),
  })
  .orThrow();
```

```tsx title="src/features/dispatch/list.tsx"
"use client";

import { useDispatch } from "@/lib/supabase/hooks";

const rows = useDispatch({ limit: 50 });
```

A work order without a slot waits in the backlog (`unscheduled`). Pass
`technicianId` and `startsAt` to `create`, or a `bookingId` to take the slot
from a booking, to schedule it at once. A slot without `endsAt` lasts
`defaultDuration` (one hour). The database refuses a slot that overlaps
another scheduled, en route or on site work order of the same technician,
under an advisory lock per technician, so two dispatchers can't book the same
hour. Creating, changing and scheduling need `dispatch.manage`.

| Method                                                 | Returns                                                                                              |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `create(input)`                                        | the work order, `unscheduled` or `scheduled`                                                         |
| `update(id, patch)`                                    | the work order with a new title, description, priority or place                                      |
| `schedule(id, slot)`                                   | the work order in the technician's slot; moving it to another technician drops it from the old route |
| `unschedule(id)`                                       | the work order back in the backlog and off its route                                                 |
| `setStatus(id, status, note)`                          | the work order after the step, with `note` as the completion note or the reason                      |
| `get(id)`                                              | the work order, or `null`                                                                            |
| `list({ status, technicianId, subject, from, until })` | the organization's work orders, newest first                                                         |
| `board({ from, to, technicianIds })`                   | the schedule board                                                                                   |
| `technicianDay(day, technicianId)`                     | the technician's day plan                                                                            |
| `planRoute(technicianId, day, ids)`                    | the route with its stops in order                                                                    |
| `recordPart(workOrderId, input)`                       | the recorded part                                                                                    |
| `parts(workOrderId)`                                   | the work order's parts, oldest first                                                                 |
| `removePart(partId)`                                   | `true` when the part was removed                                                                     |

The [dispatch binding](/docs/repository/record-bindings) lists a record's work orders on
its page, with `create` and `cancel` writes.

## The status lifecycle [#the-status-lifecycle]

| From                    | To          | Who                            |
| ----------------------- | ----------- | ------------------------------ |
| `scheduled`             | `en_route`  | the technician or a dispatcher |
| `scheduled`, `en_route` | `on_site`   | the technician or a dispatcher |
| `on_site`               | `done`      | the technician or a dispatcher |
| anything not finished   | `cancelled` | a dispatcher                   |

Each step stamps its time (`enRouteAt`, `arrivedAt`, `completedAt`,
`cancelledAt`) and records `work_order.status_changed` with `from` and `to`.
Setting the status a work order already has changes nothing.

## The board and the day plan [#the-board-and-the-day-plan]

```ts
const board = await dispatch
  .board({
    from: Temporal.PlainDate.from("2030-01-07"),
    to: Temporal.PlainDate.from("2030-01-11"),
  })
  .orThrow();

const day = await dispatch
  .technicianDay(Temporal.PlainDate.from("2030-01-07"))
  .orThrow();
```

The board reads 1 to 31 days in the block's `timeZone` (UTC by default). It
returns the scheduled work orders with their `day`, the technicians (the ones
you pass, else the ones with work), and the backlog, most urgent first.

`planRoute` sets the order a technician drives to their work orders on a
day; each must be a live work order of theirs. `technicianDay` returns the
route's stops in that order, then the day's other work orders by start.
`technicianId` defaults to the caller, so a technician opens their own plan.
A dispatcher plans any route; a technician plans their own.

```ts title="better-supabase.config.ts"
blocks: {
  dispatch: { options: { timeZone: "Europe/Amsterdam", defaultDuration: "90 minutes" } },
},
```

## Parts [#parts]

```ts
await dispatch
  .recordPart(order.id, {
    name: "Gasket",
    quantity: 2,
    unit: "pc",
    variantId,
    locationId,
  })
  .orThrow();
```

A part is free text, an asset, or a catalog variant taken from an inventory
location. Recording a part moves no stock: book the movement in the
[inventory](/docs/blocks/inventory) block when the job is done. The
technician on the work order and dispatchers record parts, and the one who
recorded a part can remove it.

## UI recipes [#ui-recipes]

The [`dispatch-board`](/docs/registry/ui-recipes/dispatch-board) UI recipe renders the board
as a kanban with the backlog as the first column and a column per
technician, and [`technician-day`](/docs/registry/ui-recipes/technician-day) renders a technician's route as a list with
the status buttons. Both are in the Next.js example.

## Errors [#errors]

| Hint                                    | Cause                                                                           |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `DISPATCH_FORBIDDEN`                    | the caller lacks `dispatch.read` or `dispatch.manage`, or can't plan this route |
| `DISPATCH_NOT_FOUND`                    | no work order with this id that the caller manages or works                     |
| `DISPATCH_INVALID`                      | the title isn't 1 to 200 characters, or the priority is unknown                 |
| `DISPATCH_SUBJECT_INVALID`              | only one of the record type and id was passed                                   |
| `DISPATCH_SUBJECT_NOT_FOUND`            | the record doesn't exist in the organization                                    |
| `DISPATCH_TECHNICIAN_REQUIRED`          | a slot was passed without a technician                                          |
| `DISPATCH_TECHNICIAN_INVALID`           | the technician isn't a member with `dispatch.work`                              |
| `DISPATCH_SLOT_INVALID`                 | the slot doesn't end after it starts                                            |
| `DISPATCH_SLOT_TAKEN`                   | the technician already has a work order in that slot                            |
| `DISPATCH_STATUS_INVALID`               | the work order has started, or is cancelled                                     |
| `DISPATCH_TRANSITION_INVALID`           | the step isn't in the lifecycle                                                 |
| `DISPATCH_RANGE_INVALID`                | the board spans more than 31 days                                               |
| `DISPATCH_ROUTE_INVALID`                | a stop repeats, or isn't a live work order of the technician                    |
| `DISPATCH_PART_INVALID`                 | the part has no name or a quantity of zero or less                              |
| `DISPATCH_LOCATION_NOT_FOUND`           | the location doesn't exist in the organization                                  |
| `DISPATCH_ASSET_NOT_FOUND`              | the asset doesn't exist in the organization                                     |
| `DISPATCH_BOOKING_NOT_FOUND`            | the booking doesn't exist in the organization                                   |
| `DISPATCH_VARIANT_NOT_FOUND`            | the product variant doesn't exist in the organization                           |
| `DISPATCH_INVENTORY_LOCATION_NOT_FOUND` | the inventory location doesn't exist in the organization                        |
| `DISPATCH_ASSETS_MISSING`               | a location or asset was named without the assets block                          |
| `DISPATCH_BOOKINGS_MISSING`             | a booking was named without the bookings block                                  |
| `DISPATCH_CATALOG_MISSING`              | a variant was named without the catalog block                                   |
| `DISPATCH_INVENTORY_MISSING`            | an inventory location was named without the inventory block                     |

## Events [#events]

`work_order.created`, `work_order.updated`, `work_order.scheduled`,
`work_order.unscheduled`, `work_order.status_changed`,
`work_order.part_used` and `dispatch_route.planned`. The
[events page](/docs/extending/events) lists their payloads.

## Without the client [#without-the-client]

`db.dispatch` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const dispatch = createDispatch({ transport, tenant: organizationId });

const order = await dispatch
  .create({
    title: "Service the boiler",
    priority: "high",
    locationId,
    assetId,
  })
  .orThrow();

await dispatch
  .schedule(order.id, {
    technicianId,
    startsAt: Temporal.Instant.from("2030-01-07T09:00:00Z"),
  })
  .orThrow();
```

# Pipelines

> Tenant-editable pipelines for any record type, with ordered stages whose categories map to open, success or failure, a stage history with time in stage, board totals per currency, and a status the stage sets through the state machine.

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

`pipelines` puts any record your app registers in [`config.records`](/docs/blocks/sql#subject-registry) on a pipeline the organization edits: deals, quotes, job applications, tickets, tasks. Each organization can keep several pipelines per record type, each with its own stages, and every stage has a category your config declares. A category maps to one of three fixed outcomes, `open`, `success` or `failure`, so reports and policies read the outcome and never a stage name.

The module writes a history row every time a record changes stage, keeps the time it entered its current stage, totals each board column per currency, and can set a status column from the stage category, so `won` on a deal still runs through the [state machine](/docs/blocks/state-machine).

Reading pipelines and boards needs `pipelines.read`, and creating, renaming, reordering or archiving pipelines and stages needs `pipelines.manage`. Admins get both and members get `pipelines.read`. Moving a record is an update of its own table, so your table's policies decide who may move it.

```bash
better-supabase add pipelines
```

The module needs `tenant`, `permissions` and `subjects`.

## Bind a record type [#bind-a-record-type]

Register the record in `config.records`, add a `stage_id` and a `pipeline_id` column (both `uuid`) to its table, and bind it in the module's options. `categories` is required: it names the categories stages of this type can have and maps each to an outcome. `stages` is the template the first pipeline of an organization starts with.

```ts title="better-supabase.config.ts"
subjects: {
  deal: {
    table: "public.deals",
    idType: "uuid",
    rank: { column: "rank", group: "stage_id" },
  },
},
blocks: {
  pipelines: {
    on: {
      deal: {
        categories: { open: "open", won: "success", lost: "failure" },
        enteredAt: "stage_entered_at",
        amount: "amount",
        currency: "currency",
        probability: "probability",
        owner: "owner_id",
        status: { column: "outcome", values: { open: "open", won: "won", lost: "lost" } },
        stages: [
          { key: "qualified", name: "Qualified", category: "open", probability: 20 },
          { key: "proposal", name: "Proposal", category: "open", probability: 55 },
          { key: "won", name: "Closed won", category: "won", probability: 100 },
          { key: "lost", name: "Closed lost", category: "lost", probability: 0 },
        ],
      },
    },
  },
},
```

| Key           | Meaning                                                                                                      |
| ------------- | ------------------------------------------------------------------------------------------------------------ |
| `categories`  | Each category of this type and its outcome: `open`, `success` or `failure`. At least one is `open`           |
| `stage`       | The stage column, `stage_id` by default                                                                      |
| `pipeline`    | The pipeline column, `pipeline_id` by default                                                                |
| `enteredAt`   | A `timestamptz` column the module sets whenever the stage changes                                            |
| `amount`      | A `bigint` column of minor units the board totals. Needs `currency`, a `char(3)` column                      |
| `probability` | A record's own win probability (0 to 100), which overrides the stage's in weighted totals                    |
| `owner`       | A user id column. The owner watches the record and `board` can filter by it                                  |
| `scope`       | A column whose value picks one pipeline per scope, such as `job_id` for a pipeline per job                   |
| `status`      | A status column and the value each category sets in it                                                       |
| `stages`      | The first pipeline's stages: `key`, `name`, `category` and `probability`. Without it, one stage per category |

An insert without a pipeline lands in the organization's default pipeline for the type and scope, created from `stages` the first time. An insert without a stage lands in the first open stage. The first pipeline an admin creates for a type and scope becomes its default.

## Manage pipelines and stages [#manage-pipelines-and-stages]

Server code calls the `pipelines` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: usePipelines } = defineDataHooks(
  "pipelines",
  (db: AppDb) => db.pipelines,
);
```

```ts
import { createPipelines } from "better-supabase/blocks/pipelines";

const renewals = await db.pipelines
  .upsert(organizationId, {
    subjectType: "deal",
    key: "renewals",
    name: "Renewals",
  })
  .orThrow();
await db.pipelines
  .saveStage(renewals.id, {
    key: "due",
    name: "Renewal due",
    category: "open",
    probability: 70,
    rottingAfterDays: 14,
    wipLimit: 25,
  })
  .orThrow();
const [deals] = await db.pipelines.list(organizationId, "deal").orThrow();
```

```tsx title="src/features/pipelines/list.tsx"
"use client";

import { usePipelines } from "@/lib/supabase/hooks";

const rows = usePipelines({ limit: 50 });
```

`saveStage` adds a stage at the end, or changes one when you pass its `id`. Its category must be one the type declares (`PIPELINE_CATEGORY_UNKNOWN`), and a stage that holds records keeps its outcome (`PIPELINE_STAGE_IN_USE`), so a won stage never turns open under its deals. `reorderStage(stageId, { previous, next })` moves a stage between two rank keys. `archiveStage` and `archive` refuse a stage or pipeline that still holds records, and a pipeline keeps at least one open stage (`PIPELINE_LAST_OPEN_STAGE`).

Without transitions, a record moves from any stage to any other. `setTransitions(pipelineId, [{ from, to }])` limits the moves to the pairs you list, and an empty list allows every move again (`PIPELINE_TRANSITION_INVALID` for any other move).

## Move records [#move-records]

A record changes stage when its stage column changes. An update from your app, a bulk action or a board drag all go through the same trigger, which refuses a stage of another pipeline, an archived stage, or a stage of another organization (`PIPELINE_STAGE_INVALID`), checks the transitions, closes the previous history row and opens a new one.

On a board, move the card with [rank](/docs/blocks/rank) and the stage id as the group, so the rank key and the stage change in one update:

```ts
import { createRank } from "better-supabase/blocks/rank";

const rank = createRank({ transport });
await rank
  .move("deal", dealId, {
    previous: aboveId,
    next: belowId,
    group: proposalStageId,
  })
  .orThrow();
```

With `status` set, a stage in a mapped category also writes the status column in a separate update, so a [state machine](/docs/blocks/state-machine) on that column checks the change and records `status.changed`. Moving a deal to `Closed won` sets `outcome` to `won`, and moving it back to an open stage sets `open` again.

## Board, stats and health [#board-stats-and-health]

```ts
const columns = await pipelines
  .board("deal", pipelineId, { owner: userId })
  .orThrow();
// [{ stageId, name, outcome, count, overLimit, totals: [{ currency, amount, weighted }] }]

const stats = await pipelines
  .stats(pipelineId, { since: quarterStart, until: quarterEnd })
  .orThrow();
// { won, lost, winRate, stages: [{ stageId, entered, averageSeconds }] }

const stuck = (await pipelines.health(pipelineId).orThrow()).filter(
  (row) => row.rotting,
);
```

`board` returns every active stage in order with the number of records the caller can see and, per currency, the total amount and the amount weighted by probability (the record's own, else the stage's). `amount` and `weighted` are `Money` values from [commerce](/docs/concepts/commerce), sent as text so they stay exact past 2^53, and currencies are never added together. A currency that isn't an ISO 4217 code makes `board` fail, so keep the `currency` column to ISO codes. `overLimit` is true when a stage holds more records than its WIP limit. All three reads run as the caller, so the record table's policies decide what they count.

`stats` reads the stage history in a time window: how many records entered each stage, how long the ones that left stayed on average, and the win rate (records that entered a success stage over those that entered a success or failure stage). `health` lists the open records with the time they entered their stage, their days in stage and whether they sit longer than the stage's `rottingAfterDays`.

## Watching [#watching]

With the [notifications](/docs/blocks/notifications) module installed, the owner column's user watches the record at the `participating` level when the record is created or reassigned, and a level the user already chose stays. Every stage change notifies the record's watchers with a `pipeline.stage_changed` notification. Anyone else watches a record with the notifications client's `subscribe({ subject: { type: "deal", id } })`.

## Events [#events]

| Event                                                       | When                            | Data                                                                                                          |
| ----------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `pipeline.stage_changed`                                    | a record moved to another stage | `organizationId`, `subjectType`, `subjectId`, `pipelineId`, `fromStageId`, `toStageId`, `category`, `outcome` |
| `pipeline.created`, `pipeline.updated`, `pipeline.archived` | an admin changed a pipeline     | `organizationId`, `pipelineId`, `subjectType`, `key`                                                          |
| `pipeline_stage.saved`, `pipeline_stage.archived`           | an admin changed a stage        | `organizationId`, `pipelineId`, `stageId`, `key`, `category`                                                  |

`pipeline.stage_changed` carries the record as its subject, so the [activity feed](/docs/blocks/timeline) shows it on the record's timeline. Pipeline and stage changes are written to the audit log as configuration changes.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `pipelines`. `upsert` writes them from `fields`,
and `upsert` and `list` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { pipelines: { options: { extraColumns: { team: "text" } } } },
});
```

```ts
const client = createPipelines({
  transport,
  fields: v.object({ team: v.optional(v.string()) }),
});
await client.upsert(organizationId, {
  subjectType: "deal",
  key: "enterprise",
  name: "Enterprise",
  fields: { team: "sales-emea" },
});
```

## Hooks [#hooks]

The module calls `before_pipeline_save`, `after_pipeline_save`, `before_pipeline_stage_save` and `after_pipeline_stage_save` around admin changes, and `before_pipeline_stage_change(subject_type text, subject_id text, from_stage uuid, to_stage uuid)` and `after_pipeline_stage_change(subject_type text, subject_id text, to_stage uuid)` inside the trigger. A `before_` hook raises to refuse the move. The TypeScript client takes `hooks` for its methods (see [extending blocks](/docs/extending/blocks)).

## Wire it to ReUI [#wire-it-to-reui]

The ReUI Kanban commits a finished drag through `onValueCommit(value, meta)`. Call `rank.move` with the card's new neighbours in `meta.overContainer` and the column's stage id as the group, and restore `meta.previousValue` when the result is an error. Read the column headers from `board`, and the count and totals of each column from its entry.

## Errors [#errors]

| Hint                           | Cause                                                                   |
| ------------------------------ | ----------------------------------------------------------------------- |
| `PIPELINE_FORBIDDEN`           | the caller lacks `pipelines.manage`                                     |
| `PIPELINE_SUBJECT_UNKNOWN`     | the subject type has no binding in `on`                                 |
| `PIPELINE_NOT_FOUND`           | no such pipeline                                                        |
| `PIPELINE_KEY_TAKEN`           | another pipeline of the type and scope has the key                      |
| `PIPELINE_IN_USE`              | records are still in the pipeline                                       |
| `PIPELINE_CATEGORY_UNKNOWN`    | the type declares no such category                                      |
| `PIPELINE_STAGE_NOT_FOUND`     | no such stage in the pipeline                                           |
| `PIPELINE_STAGE_KEY_TAKEN`     | another stage of the pipeline has the key                               |
| `PIPELINE_STAGE_IN_USE`        | records are in the stage, so it can't be archived or change its outcome |
| `PIPELINE_LAST_OPEN_STAGE`     | archiving it would leave the pipeline without an open stage             |
| `PIPELINE_STAGE_INVALID`       | the stage isn't an active stage of the record's pipeline                |
| `PIPELINE_TRANSITION_INVALID`  | the pipeline's transitions don't allow the move                         |
| `PIPELINE_TRANSITIONS_INVALID` | a transition names a stage of another pipeline                          |
| `PIPELINE_RANK_INVALID`        | not a rank key                                                          |

## Without the client [#without-the-client]

`db.pipelines` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createPipelines } from "better-supabase/blocks/pipelines";

const pipelines = createPipelines({ transport });
const renewals = await pipelines
  .upsert(organizationId, {
    subjectType: "deal",
    key: "renewals",
    name: "Renewals",
  })
  .orThrow();
await pipelines
  .saveStage(renewals.id, {
    key: "due",
    name: "Renewal due",
    category: "open",
    probability: 70,
    rottingAfterDays: 14,
    wipLimit: 25,
  })
  .orThrow();
const [deals] = await pipelines.list(organizationId, "deal").orThrow();
```

# Relations

> Typed links between subjects (blocks, parent, relates or your own kinds) with inverse names, cycle checks, one-target kinds, ancestors, descendants, rollups and undo tokens.

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

`relations` links two records of any subject type: a task that blocks
another, an issue under an epic, a person who reports to a manager. Each kind
of link is declared once, with its inverse name, whether it may form a
cycle, and whether a record can have more than one target. Links carry a
small `data` object, such as a Gantt dependency's type and lag.

```bash
better-supabase add relations
```

```ts
import {
  createRelations,
  toGanttTasks,
  toTreeItems,
} from "better-supabase/blocks/relations";
```

## Kinds [#kinds]

Without `kinds`, the module declares four:

```ts title="better-supabase.config.ts"
blocks: {
  relations: {
    options: {
      kinds: {
        blocks: { inverse: "blocked_by", acyclic: true },
        parent: { inverse: "children", cardinality: "one", acyclic: true },
        relates: { symmetric: true },
        created_from: { inverse: "converted_to", cardinality: "one", acyclic: true },
      },
    },
  },
},
```

| Option        | What it does                                                                                     |
| ------------- | ------------------------------------------------------------------------------------------------ |
| `inverse`     | The kind's name from the target's side. `link` accepts it and stores the link from the other end |
| `acyclic`     | Refuses a link whose target already reaches its source through links of the same kind            |
| `cardinality` | `one`: a source has at most one target; a new link replaces the old one                          |
| `symmetric`   | The link reads the same from both ends and is stored once                                        |

A link reads "source kind target": `link("parent", issue, epic)` makes the
epic the issue's parent, and `link("blocks", design, build)` means design
blocks build. The cycle check runs a recursive query under an advisory lock
per organization and kind, so two concurrent links can't close a cycle
between them.

The subjects come from `config.records`, or from `on` with the
same shape. Both ends must exist in the organization and be readable by the
caller, and linking needs `relations.manage`. A subject with `cascade: true`
deletes its links when its row is deleted.

## Link and unlink [#link-and-unlink]

Server code calls the `relations` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useRelations } = defineDataHooks(
  "relations",
  (db: AppDb) => db.relations,
);
```

```ts
const result = await db.relations
  .link(
    { type: "task", id: designId },
    { type: "task", id: buildId },
    { kind: "blocks", data: { type: "FS", lag: 2 } },
  )
  .orThrow();

await db.relations.unlink(
  { type: "task", id: buildId },
  { type: "task", id: designId },
  { kind: "blocked_by" },
);
```

```tsx title="src/features/relations/list.tsx"
"use client";

import { useRelations } from "@/lib/supabase/hooks";

const rows = useRelations({ limit: 50 });
```

The ends are `RecordRef`s, `{ type, id }`, and every call acts in the
block's `tenant`, an organization id or a function that returns the current
one. `createBlocks` and `createBlockClient` pass the request's tenant; a call
without one fails with `invalid_request` and the hint `TENANT_REQUIRED`.

`link` resolves to the stored link, `created: false` when it already existed
(its data is updated when you pass `data`), and `replaced` with the old
target when a one-target kind moved. Both methods return an `undo` token
that reverses the change, including putting a replaced parent back. See
[Undo tokens](/docs/blocks/audit#undo-tokens).

| Hint                         | When                                                     |
| ---------------------------- | -------------------------------------------------------- |
| `RELATION_FORBIDDEN`         | The caller lacks `relations.manage`                      |
| `RELATION_SUBJECT_FORBIDDEN` | An end is missing, in another organization or unreadable |
| `RELATION_KIND_UNKNOWN`      | The kind is not declared                                 |
| `RELATION_SELF`              | Both ends are the same record                            |
| `RELATION_CYCLE`             | The link would close a cycle of an acyclic kind          |
| `RELATION_INVALID`           | `data` is not an object                                  |

## Read links [#read-links]

| Method                       | Returns                                                                          |
| ---------------------------- | -------------------------------------------------------------------------------- |
| `of(subject, kind?)`         | A record's links in both directions, each named from its side (`blocked_by`)     |
| `edges(kind)`                | Every link of a kind, up to 10,000, for a chart or a tree                        |
| `ancestors(subject, kind)`   | The records above a subject (its parent's parents), with their depth             |
| `descendants(subject, kind)` | The records below it                                                             |
| `rollup(subject, input)`     | `count`, `sum`, `avg`, `min` or `max` of a column over the descendants of a type |

Reads run as the caller and need `relations.read`, which the default member
role has. `rollup` also reads the subject table as the caller, so its
policies decide which rows count:

```ts
const points = await relations
  .rollup(
    { type: "issue", id: epicId },
    { kind: "parent", of: "issue", agg: "sum", column: "estimate" },
  )
  .orThrow();
```

The SQL functions are `link`, `unlink`, `relations_of`, `relations_by_kind`,
`ancestors`, `descendants`, `converted_into` and `relation_rollup`. Walks
stop at 100 levels.

## Conversions [#conversions]

When one record turns into another (a deal into a quote, a quote into an
order and an invoice, an application into an employee), link the new record
to its origin with `created_from`. A record has one origin, and the link
can't close a cycle. Check `convertedInto` before you create the new record,
so converting the same deal twice returns the first quote:

```ts
const existing = await relations
  .convertedInto({ type: "deal", id: dealId }, "quote")
  .orThrow();
const quoteId = existing ?? (await createQuoteFrom(dealId));
if (existing === undefined) {
  await relations
    .link(
      { type: "quote", id: quoteId },
      { type: "deal", id: dealId },
      { kind: "created_from" },
    )
    .orThrow();
}

const { origins, conversions } = await relations
  .lineage({ type: "quote", id: quoteId })
  .orThrow();
// origins: the deal; conversions: the orders and invoices made from the quote
```

`lineage` reads `ancestors` and `descendants` of `created_from`, nearest
first, for a breadcrumb such as deal, quote, order, invoice. An app that sets
its own `kinds` adds `created_from` to use them.

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `relations`. `link` writes them from `fields`,
and `link`, `of` and `edges` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { relations: { options: { extraColumns: { weight: "integer" } } } },
});
```

```ts
const client = createRelations({
  transport,
  fields: v.object({ weight: v.optional(v.number()) }),
});
await client.link(
  { type: "task", id: a },
  { type: "task", id: b },
  { kind: "blocks", fields: { weight: 2 } },
);
```

## Events [#events]

`link` and `unlink` record `subject.linked` and `subject.unlinked` with the
kind and both ends, and call the `before_relation_link` and
`after_relation_link` hooks.

## Over your own tables [#over-your-own-tables]

Map `tables.relations` onto an existing table with the same columns, and the
module runs over it. Tables that hold one kind each, such as a sub-issue
table or a reporting-line table, can't stand in for `relations` yet.

## With ReUI [#with-reui]

- Gantt: `toGanttTasks(tasks, await relations.edges("blocks"))` adds
  each task's `dependencies` with the type and lag from the link's `data`.
- Tree: `toTreeItems(items, await relations.edges("parent"))` returns
  the flat `{ [id]: { name, children } }` map a tree view takes, for
  sub-issues or a reporting line.
- Data Grid: a "Blocked by" column from `of(task, "blocked_by")`, and
  an undo toast from the `undo` token.

The free `c-gantt-*` and `c-tree-*` examples show both views. ReUI's Tempo
and Roster templates show sub-issues and reporting trees; Pro blocks are
licensed per user, so copy them from [reui.io](https://reui.io).

## Without the client [#without-the-client]

`db.relations` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const relations = createRelations({ transport, tenant: organizationId });

const result = await relations
  .link(
    { type: "task", id: designId },
    { type: "task", id: buildId },
    { kind: "blocks", data: { type: "FS", lag: 2 } },
  )
  .orThrow();

await relations.unlink(
  { type: "task", id: buildId },
  { type: "task", id: designId },
  { kind: "blocked_by" },
);
```

# Saved views

> Named list views per user, team or organization, with the filter tree, sort, columns, grouping and density checked against the list definition, personal defaults, duplicates, and useSavedViews for nuqs.

Source: https://bettersupabase.com/docs/blocks/saved-views

`saved-views` stores named views of a list: a filter tree, a sort, the
visible columns, the grouping and the density. A view belongs to one user,
one team or the whole organization, and each of those can have a default
view that opens first. The client checks a view's query against the list's
`defineListQuery` definition before it saves it, so a stored view always
parses.

```bash
better-supabase add saved-views
```

```ts
import { createSavedViews } from "better-supabase/blocks/saved-views";
import { useSavedViews } from "@/hooks/use-saved-views";
```

## Scopes and permissions [#scopes-and-permissions]

| Scope    | Who sees it                              | Who can change it                      |
| -------- | ---------------------------------------- | -------------------------------------- |
| `user`   | The owner                                | The owner                              |
| `team`   | Members of the team (the `teams` module) | Members of the team with `views.share` |
| `tenant` | Everyone in the organization             | Anyone with `views.share`              |

Reading any view needs `views.read`. The default roles give members
`views.read` and admins `views.*`. Without the `teams` module installed, team
views can't be created and are never visible.

## Save and list views [#save-and-list-views]

Pass the list definitions to `lists` so `upsert` checks each view's `query`
with the list's own `parse`. An invalid view returns a `validation` error
with the issues, prefixed with `query`, and makes no call.

Server code calls the `saved-views` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSavedViews } = defineDataHooks(
  "savedViews",
  (db: AppDb) => db.savedViews,
);
```

```ts
const rows = await db.savedViews.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/saved-views/list.tsx"
"use client";

import { useSavedViews } from "@/lib/supabase/hooks";

const rows = useSavedViews({ limit: 50 });
```

`state.query` never stores `page` or `after`, so a view opens on the first
page. `savedViewStateSchema(tasksList)` returns the JSON Schema of the whole
state, with `query` taken from the list's `jsonSchema`, for API docs and
forms.

| Method                    | What it does                                                        |
| ------------------------- | ------------------------------------------------------------------- |
| `upsert(input)`           | Adds a view, or updates `input.id`; resolves to its id and rank key |
| `list(organizationId, l)` | The caller's views of list `l`, in rank order                       |
| `reorder(id, neighbors)`  | Moves a view between its neighbours' rank keys                      |
| `setDefault(id, on)`      | Makes a view the default of its slot, clearing the slot's other one |
| `setPreference(id, p)`    | Sets the caller's own default, hidden and pinned flags on a view    |
| `duplicate(id, input)`    | Copies a view the caller sees into a new view                       |
| `delete(id)`              | Deletes a view                                                      |

`defaultView(views)` picks the view to open: the caller's preferred view,
then the user's default, a team default, the organization's, and then the
first view. It skips views the caller hid.

## Personal preferences [#personal-preferences]

Changing a shared view's slot default needs `views.share`. A member who only
reads a team or organization view can still make it their own default, hide
it from their menu or pin it. `setPreference` stores those flags in
`saved_view_preferences`, one row per view and user, and needs only that the
caller sees the view. A flag you leave out keeps its value, and each user has
one preferred default per list.

```ts
await savedViews
  .setPreference(viewId, { isDefault: true, pinned: true })
  .orThrow();

const views = await savedViews.list(organizationId, "tasks").orThrow();
// views[0].preference: { isDefault: true, hidden: false, pinned: true }
```

`list` returns the caller's `preference` with every view, hidden ones
included, so a menu can offer to show them again. Only the user reads their
own rows, and deleting a view deletes its preferences. A setup over your own tables can
leave the table out with `tables: { preferences: null }`; `preference` is
then always false.

## Duplicate a view [#duplicate-a-view]

`duplicate(id, { name, scope, teamId })` copies the state, kind and list of
a view the caller sees. The copy is a personal view unless you pass `scope`,
and it goes through the same checks as `upsert`, so a `team` or `tenant` copy
needs `views.share`. A member can copy a shared view to change it on their
own.

```ts
const copy = await savedViews
  .duplicate(viewId, { name: "My open bugs" })
  .orThrow();
```

| Hint                              | When                                                                                                         |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `SAVED_VIEW_INVALID`              | A name, list, kind or scope is out of range, `state` is not an object, or a preference has no signed-in user |
| `SAVED_VIEW_FORBIDDEN`            | The caller can't read, share or change the view                                                              |
| `SAVED_VIEW_NOT_FOUND`            | The view doesn't exist or isn't visible                                                                      |
| `SAVED_VIEW_RANK_INVALID`         | `reorder` was given a key that is not a rank key                                                             |
| `SAVED_VIEW_COLLECTION_NOT_FOUND` | `state.collectionId` names no list in the organization that is shared or the caller's own                    |

## Views of a list [#views-of-a-list]

`state.collectionId` keeps a view to the records of a static list from the
[collections](/docs/blocks/collections) block. The view stores the list's id;
pass `collections.where(state.collectionId)` as the list query's `where` when
you run it. With the collections module installed, `upsert` checks that the
list exists in the organization and is shared or the caller's own.

## Sync with the URL [#sync-with-the-url]

`useSavedViews` is a registry item: `npx shadcn add
@better-supabase/use-saved-views` writes `hooks/use-saved-views.ts` into the
app. The hook reads and writes the list's nuqs state. It loads the views,
opens the default view when the URL holds no query, reports which view the
URL matches, and saves the URL's query as a view.

The columns, grouping, density and layout live outside the URL. Pass
`getState` to return what the table shows now and `onApplyState` to show a
view's state: `saveCurrent` then stores the query and that state together,
`apply` puts the query in the URL and passes everything else to
`onApplyState`, and `active` and `isDirty` compare both. Without `getState`
only the query counts.

```tsx title="app/tasks/views-menu.tsx"
"use client";

import { useState } from "react";
import { useQueryStates, createParser } from "nuqs";
import { useSavedViews } from "@/hooks/use-saved-views";
import type {
  SavedViewColumns,
  SavedViewDensity,
} from "better-supabase/blocks/saved-views";

import { tasksList } from "@/lib/lists";
import {
  listViews,
  saveView,
  deleteView,
  duplicateView,
  setViewPreference,
} from "./actions";

export function ViewsMenu({ organizationId }: { organizationId: string }) {
  const [urlState, setUrlState] = useQueryStates(tasksList.nuqs(createParser));
  const [columns, setColumns] = useState<SavedViewColumns>({});
  const [density, setDensity] = useState<SavedViewDensity>("default");
  const views = useSavedViews({
    load: () => listViews(organizationId, "tasks"),
    save: ({ name, state, id }) =>
      saveView({ organizationId, list: "tasks", name, state, id }),
    remove: deleteView,
    duplicate: ({ id, name }) => duplicateView(id, { name }),
    setPreference: setViewPreference,
    urlState,
    setUrlState,
    getState: () => ({ columns, density }),
    onApplyState: (state) => {
      setColumns(state.columns ?? {});
      setDensity(state.density ?? "default");
    },
  });
  // views.views, views.active, views.isDirty, views.apply(view),
  // views.saveCurrent(name), views.duplicate(id, name),
  // views.setPreference(id, { isDefault: true })
}
```

`apply` calls `onApplyState` with every key the view stores except `query`,
so reset each part the view leaves out. The `upsert` callback receives the
query alone as `query` and the whole state as `state`.

`queryFromUrlState` and `urlStateFromQuery` convert between nuqs values and a
stored query outside React, and `sameQuery` compares two queries while
ignoring the page and empty facets. `displayStateOf` drops `query` from a
state, and `sameDisplayState` compares the rest.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createSavedViewsController` with its options; the controller needs no
provider. `state` is a shallow ref of the snapshot and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController } from "better-supabase/vue";
import { createSavedViewsController } from "better-supabase/blocks/saved-views";

const { state } = useBlockController(
  createSavedViewsController,
  {
    load: () => loadViews(),
    urlState: () => readUrlState(),
    setUrlState: (next) => writeUrlState(next),
  },
  false,
);
</script>

<template>
  <ul>
    <li v-for="view in state.views ?? []" :key="view.id">{{ view.name }}</li>
  </ul>
</template>
```

`loadViews`, `readUrlState` and `writeUrlState` stand for your server call
and your router's query state. The controller's `apply`, `saveCurrent` and
`remove` are on the actions.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createSavedViewsController` with its options; the controller needs no
provider. It returns a readable store of the snapshot, read with `$`,
with the actions on `.actions`. The work starts on mount, and the controller
is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { useBlockController } from "better-supabase/svelte";
  import { createSavedViewsController } from "better-supabase/blocks/saved-views";

  const views = useBlockController(createSavedViewsController, {
    load: () => loadViews(),
    urlState: () => readUrlState(),
    setUrlState: (next) => writeUrlState(next),
  }, false);
</script>

<ul>
  {#each $views.views ?? [] as view (view.id)}
    <li>{view.name}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createSavedViewsController` with its options; the controller needs no
provider. `state()` reads the snapshot through `from`, and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController } from "better-supabase/solid";
import { createSavedViewsController } from "better-supabase/blocks/saved-views";

export function Component() {
  const { state } = useBlockController(
    createSavedViewsController,
    {
      load: () => loadViews(),
      urlState: () => readUrlState(),
      setUrlState: (next) => writeUrlState(next),
    },
    false,
  );
  return (
    <ul>
      <For each={state().views ?? []}>{(view) => <li>{view.name}</li>}</For>
    </ul>
  );
}
```

## Your own fields [#your-own-fields]

`options.extraColumns` adds columns to `saved_views`. `upsert` writes them from `fields`,
and `upsert`, `duplicate` and `list` return them in `fields`. Pass a Standard Schema as `fields`
to type them: writes are validated before they reach the database (a
`validation` error), and reads are parsed. `hooks` refuses, rewrites or
observes any method; see [extending blocks](/docs/extending/blocks).

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { "saved-views": { options: { extraColumns: { color: "text" } } } },
});
```

```ts
const client = createSavedViews({
  transport,
  fields: v.object({ color: v.optional(v.string()) }),
});
await client.upsert({
  organizationId,
  list: "tasks",
  name: "Mine",
  state,
  fields: { color: "teal" },
});
```

## Events [#events]

`upsert` and `delete` record `saved_view.saved` and `saved_view.deleted` in the
outbox when it is installed, and `upsert` calls the `before_view_save` and
`after_view_save` hooks. `duplicate` saves through the same function, so a
copy records `saved_view.saved` and runs both hooks. Preferences record no
event.

## With ReUI [#with-reui]

- Filters edits the filter tree a view stores, and Data Grid's column
  state maps onto `columns` (visibility, order, sizes and pinning).
- A Select or Tabs list in the toolbar switches views with `apply`, with a
  dot when `isDirty` is true.
- `kind` picks the layout: Data Grid, Kanban, Event Calendar or Gantt.

The free `c-data-grid-*` examples show column state, and the Pro data grid
and filters blocks show a toolbar with saved views. Pro blocks are licensed
per user, so copy them from [reui.io](https://reui.io).

## Without the client [#without-the-client]

`db.savedViews` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="lib/views.ts"
import { createSavedViews } from "better-supabase/blocks/saved-views";

import { tasksList } from "./lists";

export const savedViews = createSavedViews({
  transport,
  lists: { tasks: tasksList },
});

await savedViews
  .upsert({
    organizationId,
    list: "tasks",
    name: "Open bugs",
    kind: "board",
    scope: "tenant",
    state: {
      query: { facets: { status: ["open"] }, sort: "newest" },
      grouping: ["status"],
      columns: { visible: ["title", "assignee"], pinned: { left: ["title"] } },
      density: "compact",
    },
  })
  .orThrow();

const views = await savedViews.list(organizationId, "tasks").orThrow();
```

# Search

> A search index kept up by triggers from config.records, search(q) as the caller, recents and favorites, and useCommandSearch for a Cmd+K dialog.

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

`search` keeps one `search_documents` row per searchable subject. Triggers
generated from each `search` entry in `config.records` write the row when the
source row changes, and `search(q)` reads the index as the caller: full-text
matches, prefixes while the user types and trigram matches for typos, filtered
to what the caller can read. With `vector-search` and embeddings, the results
fuse full-text and nearest-neighbour ranks.

```bash
better-supabase add search
```

```ts
import { createSearch, searchSink } from "better-supabase/blocks/search";
import { useCommandSearch } from "@/hooks/use-search";
```

The module needs `tenant`, `permissions` and `subjects`. It reads recents and
favorites from the per-user state of [Share links](/docs/blocks/share-links)
when that module is installed, and keeps embeddings when
[vector search](/docs/blocks/vector-search) is installed and `dimensions` is
set.

## Declare what to index [#declare-what-to-index]

Give each subject a `search` entry. `title` defaults to the subject's
`title` column, and `body` lists the columns joined into the searchable body.
`url` and `deletedAt` come from the same registry entry.

```ts title="better-supabase.config.ts"
subjects: {
  note: {
    table: "public.notes",
    title: "title",
    url: "/notes/{id}",
    deletedAt: "deleted_at",
    permissions: { read: "notes.read" },
    search: { body: ["body", "summary"] },
  },
  customer: { table: "public.customers", title: "name", search: {} },
},
blocks: {
  search: { options: { config: "english" } },
},
```

| Option       | Default  | What it sets                                                                   |
| ------------ | -------- | ------------------------------------------------------------------------------ |
| `config`     | `simple` | The text search configuration for the `tsvector` and the query                 |
| `dimensions` | none     | The embedding size; with `vector-search` the index keeps an `embedding` column |

Each searched subject gets an `after insert or delete or update` trigger on
the columns the entry reads, so editing a column the index ignores writes
nothing. Subjects with `tenant: false` are skipped, because every document
belongs to a tenant. Installing the module indexes the rows that already
exist, and `search.reindex()` does it again after a config change.

## Search [#search]

`search` runs as the caller. The table's policy keeps the caller's tenants
(`search.read`), and `can_subject` drops each hit the caller can't `read`, so
a document is visible only where its source row is.

Server code calls the `search` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSearch } = defineDataHooks(
  "search",
  (db: AppDb) => db.search,
);
```

```ts
const hits = await db.search
  .search("revnue plan", { types: ["note"], limit: 10 })
  .orThrow();
// [{ subjectType: "note", subjectId, title, url, snippet, score }]
```

```tsx title="src/features/search/list.tsx"
"use client";

import { useSearch } from "@/lib/supabase/hooks";

const rows = useSearch({ limit: 50 });
```

The query is lowercased and stripped of accents (`unaccent`), then matched
three ways: `websearch_to_tsquery` (quoted phrases, `or`, `-word`), each word
as a prefix, and trigram similarity on the title for typos. A trashed row
(its `deletedAt` column set) has no document, and a restored row gets it
back.

| Hint                     | When                                          |
| ------------------------ | --------------------------------------------- |
| `SEARCH_QUERY_TOO_LONG`  | The query is longer than 256 characters       |
| `SEARCH_SIGNED_OUT`      | `touch` or `star` without a signed-in user    |
| `SEARCH_SUBJECT_UNKNOWN` | `refresh` for a type without a `search` entry |

## Recents and favorites [#recents-and-favorites]

`touch` records that the caller opened a subject and `star` stars it, both in
the share-links `subject_user_state` table. `recents()` and `favorites()`
list them newest first, with the title and URL from the index, and only for
subjects the caller can still read.

```ts
await search.touch("note", noteId);
await search.star("note", noteId);
const recent = await search.recents({ limit: 5 }).orThrow();
```

## Trash [#trash]

Trashed rows drop out in one of two ways. A subject with a `deletedAt`
column needs nothing more: the trigger removes the document when the column
is set. A trash table that moves rows elsewhere emits events instead, and
`searchSink` turns them into index calls. Build `search` with a service
transport for it.

```ts
outbox.relay("search", searchSink(search));
```

| Event              | Action    |
| ------------------ | --------- |
| `subject.trashed`  | `remove`  |
| `subject.purged`   | `remove`  |
| `subject.restored` | `refresh` |

Pass `on` to map other event types. A subject type without a `search` entry
is skipped.

## Embeddings [#embeddings]

With `dimensions` set and `vector-search` installed, the index keeps an
embedding per document and clears it when the text changes. A partial index
on `updated_at` covers the documents without an embedding, so finding the
pending ones stays fast on a large index. A job embeds what is pending, through any function that returns one vector per text, such as the
AI SDK's `embedMany`:

```ts
await search.embedPending(async (texts) => {
  const { embeddings } = await embedMany({ model, values: [...texts] });
  return embeddings;
});

const hits = await search
  .search(q, { embedding: await embedQuery(q) })
  .orThrow();
```

`search` then fuses the full-text and nearest-neighbour ranks by reciprocal
rank. `setEmbedding` skips a vector whose text changed since it was read.

## Cmd+K [#cmdk]

`useCommandSearch` is a registry item: `npx shadcn add
@better-supabase/use-search` writes `hooks/use-search.ts` into the app. The
hook holds the state of a command dialog: Cmd+K and Ctrl+K
toggle it, the query is debounced, stale responses are dropped, and an empty
query shows favorites and recents. `select` records the item as opened and
closes the dialog.

The shortcut goes through the client's keyboard port: `createClient` from
`better-supabase/client` listens for `keydown` on the window, and React
Native has no keyboard port, so the hook runs there without one. Pass
`shortcut: false` to turn it off. The hook wraps
`createCommandSearchController` from `better-supabase/blocks/search`
(see [block controllers](/docs/frontend/block-controllers)).

```tsx
const search = useCommandSearch({ organizationId });
```

| Field                    | What it holds                                              |
| ------------------------ | ---------------------------------------------------------- |
| `open`, `setOpen`        | The dialog state                                           |
| `query`, `setQuery`      | The input                                                  |
| `groups`                 | Favorites and recents, or the hits grouped by subject type |
| `loading`, `error`       | A query is running, and the last error                     |
| `select(item)`, `star()` | Records an open and closes; stars or unstars an item       |

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs
`createCommandSearchController` with its options; the controller needs no
provider. `state` is a shallow ref of the snapshot and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import {
  createCommandSearchController,
  createSearch,
  rpcTransport,
} from "better-supabase/blocks/search";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(
  createCommandSearchController,
  {
    client: createSearch({ transport: rpcTransport(supabase) }),
    organizationId,
  },
  false,
);
</script>

<template>
  <ul>
    <li v-for="hit in state.results ?? []" :key="hit.subjectId">
      {{ hit.title }}
    </li>
  </ul>
</template>
```

Pass `keyboard: webKeyboard()` from `better-supabase/client` to toggle the
dialog on Cmd+K.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createCommandSearchController` with its options; the controller needs no
provider. It returns a readable store of the snapshot, read with `$`,
with the actions on `.actions`. The work starts on mount, and the controller
is disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import {
    createCommandSearchController,
    createSearch,
    rpcTransport,
  } from "better-supabase/blocks/search";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const search = useBlockController(createCommandSearchController, {
    client: createSearch({ transport: rpcTransport(supabase) }),
    organizationId,
  }, false);
</script>

<ul>
  {#each $search.results ?? [] as hit (hit.subjectId)}
    <li>{hit.title}</li>
  {/each}
</ul>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createCommandSearchController` with its options; the controller needs no
provider. `state()` reads the snapshot through `from`, and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the owner.

```tsx title="Component.tsx"
import { For } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import {
  createCommandSearchController,
  createSearch,
  rpcTransport,
} from "better-supabase/blocks/search";

export function Component(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(
    createCommandSearchController,
    {
      client: createSearch({ transport: rpcTransport(supabase) }),
      organizationId: props.organizationId,
    },
    false,
  );
  return (
    <ul>
      <For each={state().results ?? []}>{(hit) => <li>{hit.title}</li>}</For>
    </ul>
  );
}
```

## With ReUI [#with-reui]

The hook has no markup, so it drives any command component. With the ReUI
(shadcn) `Command` dialog:

```tsx title="components/command-search.tsx"
"use client";

import { useRouter } from "next/navigation";
import { useCommandSearch } from "@/hooks/use-search";
import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";

const LABELS: Record<string, string> = {
  favorites: "Favorites",
  recents: "Recent",
  note: "Notes",
};

export function CommandSearch({ organizationId }: { organizationId: string }) {
  const router = useRouter();
  const search = useCommandSearch({ organizationId });
  return (
    <CommandDialog open={search.open} onOpenChange={search.setOpen}>
      <CommandInput
        value={search.query}
        onValueChange={search.setQuery}
        placeholder="Search"
      />
      <CommandList>
        <CommandEmpty>
          {search.loading ? "Searching" : "No results"}
        </CommandEmpty>
        {search.groups.map((group) => (
          <CommandGroup
            key={group.key}
            heading={LABELS[group.key] ?? group.key}
          >
            {group.items.map((item) => (
              <CommandItem
                key={`${item.subjectType}:${item.subjectId}`}
                value={`${item.subjectType}:${item.subjectId}`}
                onSelect={() => {
                  void search.select(item);
                  if (item.url) router.push(item.url);
                }}
              >
                {item.title ?? item.subjectId}
              </CommandItem>
            ))}
          </CommandGroup>
        ))}
      </CommandList>
    </CommandDialog>
  );
}
```

`cmdk` filters items itself; set `shouldFilter={false}` on the `Command`
when the dialog lets you pass it, since the server already ranked them.

## Hooks [#hooks]

`before_search_document_index` and `after_search_document_index` run around
each document write with the subject type and id. `createSearch` takes
`hooks` around its methods, as every block does.

## Example [#example]

The Next.js example declares `customer` in `config.records` (`deletedAt:
"archived_at"`, so archived customers leave the index) and installs `search`
with `api: "api"`. The header has a Search button that also opens on Cmd+K:
`useCommandSearch({ schema: "api" })` drives a dialog that lists favorites and
recents for an empty query and customer matches as the user types. See
`apps/examples/nextjs/src/features/search`.

## Without the client [#without-the-client]

`db.search` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const search = createSearch({ transport, service });

const hits = await search
  .search("revnue plan", { types: ["note"], limit: 10 })
  .orThrow();
// [{ subjectType: "note", subjectId, title, url, snippet, score }]
```

# Service levels

> First response, next response and resolution clocks on any record, from a policy per record type and priority, in business hours, paused while the record waits, with warnings and breaches a job records.

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

`sla` puts service-level clocks on any record in [`config.records`](/docs/blocks/sql#subject-registry): inbox conversations, tickets, incidents, [work items](/docs/blocks/tasks). A policy per record type and priority says how many minutes a first response, a next response and the resolution may take, and whether only business hours count. A record's clocks start when it is created, pause while it waits on the customer, close when it is answered or resolved, and a job records the ones that warn or breach.

Reading policies and clocks needs `sla.read`, and changing policies needs `sla.manage`. Admins get both, and members get `sla.read`.

```bash
better-supabase add sla
```

The module needs `tenant`, `permissions` and `subjects`.

```ts title="better-supabase.config.ts"
blocks: {
  sla: {
    on: {
      ticket: { status: "status", priority: "priority", pause: ["waiting"], resolve: ["solved", "closed"] },
    },
    options: {
      businessHours: { timeZone: "Europe/Amsterdam", start: "09:00", end: "17:00", days: [1, 2, 3, 4, 5] },
    },
  },
},
```

## Policies [#policies]

Server code calls the `sla` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useSla } = defineDataHooks(
  "sla",
  (db: AppDb) => db.sla,
);
```

```ts
import { createSla } from "better-supabase/blocks/sla";

await db.sla
  .savePolicy({
    subjectType: "ticket",
    name: "Normal",
    firstResponseMinutes: 240,
    nextResponseMinutes: 120,
    resolutionMinutes: 2880,
    businessHours: true,
  })
  .orThrow();
await db.sla
  .savePolicy({
    subjectType: "ticket",
    name: "Urgent",
    priority: "urgent",
    firstResponseMinutes: 30,
    resolutionMinutes: 240,
    warnAtPercent: 50,
  })
  .orThrow();
```

```tsx title="src/features/sla/list.tsx"
"use client";

import { useSla } from "@/lib/supabase/hooks";

const rows = useSla({ limit: 50 });
```

`savePolicy` acts in the block's `tenant`, an organization id or a function that returns the current one (`createBlocks` and `createBlockClient` pass the request's tenant); a call without one fails with `invalid_request` and the hint `TENANT_REQUIRED`. A record uses the policy for its type and priority, or the policy without a priority. One active policy covers each type and priority (`SLA_POLICY_TAKEN`). `warnAtPercent` (default 80) is when a clock warns. With `businessHours`, only the hours of `options.businessHours` count, in its time zone (UTC, 09:00 to 17:00, Monday to Friday by default); `dueAt(start, minutes, businessHours)` shows the result.

## Clocks [#clocks]

Bind a record type in `on` and the module drives its clocks from the record's own columns:

| Change                               | Clocks                                                         |
| ------------------------------------ | -------------------------------------------------------------- |
| a record is created                  | first response and resolution start                            |
| its priority changes                 | the open clocks take the new policy's minutes from their start |
| its status enters a `pause` status   | every open clock pauses                                        |
| its status leaves a `pause` status   | they resume, and the due times move by the pause               |
| its status enters a `resolve` status | every open clock is met                                        |
| its status leaves a `resolve` status | a new resolution clock starts                                  |

A reply is an event only the app knows about: call `record(subject, "responded")` when an agent answers. It meets the first response, and with a next-response time and the record still open, it starts a next-response clock. Without a binding, call `start(subject, priority)` and `record(subject, event)` with `resolved`, `reopened`, `paused` and `resumed` yourself. The subject is a `RecordRef`, such as `{ type: "ticket", id: ticketId }`; these calls take no tenant, since it comes from the record. Each clock carries its record as `subject`.

`clocks(subject)` returns a record's clocks with their state (`running`, `paused`, `met`, `breached`) and the seconds left, for a ticket's timer.

## Warnings and breaches [#warnings-and-breaches]

Run `check()` every minute from a [job](/docs/blocks/queue) with the service role. It records `sla.warned` once a running clock passes its warning share and `sla.breached` once it passes its due time, and, with the [notifications](/docs/blocks/notifications) module installed, notifies the record's watchers at high priority. Escalate from those events with a [workflow](/docs/blocks/workflows), such as reassigning a breached ticket. A clock met after its due time records `sla.completed` with `late: true`.

## Events [#events]

| Event                                         | Data                                                                               |
| --------------------------------------------- | ---------------------------------------------------------------------------------- |
| `sla.warned`, `sla.breached`, `sla.completed` | `organizationId`, `clockId`, `subjectType`, `subjectId`, `metric`, `dueAt`, `late` |

## Wire it to ReUI [#wire-it-to-reui]

The ReUI Support Escalations board shows each ticket's SLA timer and turns it to "Paused" while it waits on the customer: read `clocks(ticket)` and show the open clock's `secondsLeft`, or "Paused" when its state is `paused`.

## Errors [#errors]

| Hint                   | Cause                                                     |
| ---------------------- | --------------------------------------------------------- |
| `SLA_FORBIDDEN`        | the caller lacks `sla.manage`, or can't read the record   |
| `SLA_POLICY_TAKEN`     | another active policy covers the type and priority        |
| `SLA_POLICY_INVALID`   | a policy needs a name, a record type and positive minutes |
| `SLA_POLICY_NOT_FOUND` | no such policy                                            |
| `SLA_SUBJECT_INVALID`  | the record isn't in `config.records`                      |
| `SLA_EVENT_INVALID`    | not one of the events                                     |
| `SLA_HOURS_INVALID`    | the business hours never open                             |

## Your own columns [#your-own-columns]

Add columns to the policy table with `blocks.sla.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`savePolicy` validates and writes them, and `savePolicy` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createSla } from "better-supabase/blocks/sla";
import { z } from "zod";

const client = createSla({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.sla` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createSla } from "better-supabase/blocks/sla";

const sla = createSla({ transport, tenant: organizationId });
await sla
  .savePolicy({
    subjectType: "ticket",
    name: "Normal",
    firstResponseMinutes: 240,
    nextResponseMinutes: 120,
    resolutionMinutes: 2880,
    businessHours: true,
  })
  .orThrow();
await sla
  .savePolicy({
    subjectType: "ticket",
    name: "Urgent",
    priority: "urgent",
    firstResponseMinutes: 30,
    resolutionMinutes: 240,
    warnAtPercent: 50,
  })
  .orThrow();
```

# State machine

> Allowed status transitions as the caller, a history row, status events, and an optional lifecycle with pause, flags and a pinned version.

Source: https://bettersupabase.com/docs/blocks/state-machine

`state-machine` rejects a status change the machine does not allow (`STATE_TRANSITION_INVALID`), writes a history row and records `status.changed`. Audit, webhooks and notifications pick that event up when those modules are installed. A machine can also keep a stages timeline and a lifecycle row that pauses the record, holds flags and pins the machine version it started on.

```bash
better-supabase add state-machine
```

Server code calls the `state-machine` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useStateMachine } = defineDataHooks(
  "stateMachine",
  (db: AppDb) => db.stateMachine,
);
```

```ts
const rows = await db.stateMachine.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/state-machine/list.tsx"
"use client";

import { useStateMachine } from "@/lib/supabase/hooks";

const rows = useStateMachine({ limit: 50 });
```

`options.machines` names each subject table, its status column, the initial status and the allowed edges. A trigger on that table runs the check, writes the history row and records `status.changed`, so a direct update or a board move into another status emits the event too, and an update that keeps the status emits nothing. The machine name is the `subjectType` of the history row and the events, so name a machine after its subject (`deal`, `task`) when watchers or comments use the same subject type.

A machine without a `table` takes its table, id type and tenant column from [`config.records`](/docs/blocks/sql#subject-registry) under the same name, so a subject declared once needs only its status column and edges here. `idType` (without it, ids compare as text) makes the lookups compare the id in its own type, which keeps the primary key index in use.

```ts title="better-supabase.config.ts"
subjects: {
  quote: { table: "public.quotes", idType: "uuid", tenant: "organization_id" },
},
blocks: {
  "state-machine": {
    options: {
      machines: {
        quote: {
          column: "status",
          initial: "draft",
          transitions: { draft: ["sent", "void"], sent: ["accepted", "void"] },
          order: ["draft", "sent", "accepted"],
        },
      },
    },
  },
},
```

## Who may transition [#who-may-transition]

`transition(subject_type, id, to, reason)` runs as the caller. It updates the status column with the caller's privileges, so the subject table's row level security decides whether they may change that record: a member of another tenant, or a member whose policies allow a read but not an update, gets `STATE_SUBJECT_NOT_FOUND` (Postgres `P0002`, a `not_found` error in TypeScript). The trigger then checks the edge, and the history, stages and lifecycle writes happen inside it. The service role passes every policy, as it does for a direct update.

The block's readers run as the caller too, and return `STATE_SUBJECT_NOT_FOUND` for a record the caller can't see:

| Method                   | Returns                                                                                                            |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `state(subject)`         | The status, its `step` and `progress` through `order`, and with a lifecycle its `version`, `flags` and pause       |
| `history(subject, page)` | Status changes, newest first, 50 per page and at most 500; pass the last `createdAt` as `cursor` for the next page |
| `stages(subject)`        | The stages timeline in display order, empty for a machine without `stages: true`                                   |

Every method takes the record as a `RecordRef`, `{ type, id }`, whose type is the machine name; `createStateMachine<"quote">` narrows it. `state` returns the record as `subject` next to its `organizationId`.

`progress` is 0 at the first status of `order` and 1 at the last; a status outside `order` (`void` above) has no `step` or `progress`. The `status_history`, `status_stages` and `subject_lifecycles` tables have select policies that show a row when the caller can see its record, so a query on them returns the same rows as the readers.

History goes to `status_history`, or to a table named on the machine (`history.table` and its columns; `history()` also reads its `id` and `createdAt` columns). Set `stages: true` on a machine to also write `status_stages`: a label snapshot, an optional outcome, a display order and `cleared_at` on the previous open stage.

## Lifecycle: pause, flags and a pinned version [#lifecycle-pause-flags-and-a-pinned-version]

Set `lifecycle: true` on a machine to keep one `subject_lifecycles` row per record. The trigger creates it on insert with the machine's `version` (default 1), so a record keeps the version it started on after you change the machine and raise the version. `flags` lists the flags a record may carry.

```ts title="better-supabase.config.ts"
quote: {
  column: "status",
  initial: "draft",
  transitions: { draft: ["sent"], sent: ["accepted"] },
  lifecycle: true,
  version: 2,
  flags: ["urgent", "needs-legal"],
},
```

```ts
const quote = { type: "quote", id: quoteId } as const;

await machine
  .pause(quote, {
    until: Temporal.Now.instant().add({ hours: 48 }),
    reason: "waiting for the client",
  })
  .orThrow();
await machine.setFlag(quote, "urgent", true).orThrow();
await machine.resume(quote).orThrow();
```

While a pause holds, the trigger refuses every transition with `STATE_PAUSED`, including a direct update of the status column. A pause without `until` holds until `resume()`; a pause with one ends on its own, and the next transition clears it and records `status.resumed`. `pause()` refuses an `until` in the past (`STATE_PAUSE_INVALID`), and `setFlag()` refuses a flag the machine doesn't list (`STATE_FLAG_UNKNOWN`). Each returns `false` when nothing changed: `resume()` on a record that wasn't paused, or `setFlag()` with the value the flag already has.

`pause`, `resume` and `setFlag` run as the caller and are authorized like `transition`: the caller must be able to update the record. They write by updating the status column to its current value, so a `before update` trigger of your own on that column runs for them too. A machine without `lifecycle: true` refuses them with `STATE_LIFECYCLE_DISABLED`; `version` and `flags` need `lifecycle: true`.

## Events [#events]

| Type             | When                                            | Payload beyond `organizationId`, `subjectType`, `subjectId` |
| ---------------- | ----------------------------------------------- | ----------------------------------------------------------- |
| `status.changed` | The status moved along an allowed edge          | `from`, `to`, `reason`                                      |
| `status.paused`  | `pause()` started or replaced a pause           | `until`, `reason`                                           |
| `status.resumed` | `resume()`, or a transition after a pause ended | none                                                        |
| `status.flagged` | `setFlag()` changed a flag                      | `flag`, `enabled`                                           |

The events need the [outbox](/docs/blocks/outbox) module; the subject of each is `statuses/<machine>/<id>`.

## A lifecycle driven by a workflow [#a-lifecycle-driven-by-a-workflow]

A workflow can own the part of a lifecycle that runs on a timer or waits on someone, while the machine keeps the rules. Start a [workflow-editor](/docs/blocks/workflow-editor) definition from an `event` trigger with `config.type` set to `status.changed`, and have your outbox consumer hand it only the events of the subject type the workflow is for:

```ts title="lib/outbox-consumer.ts"
const isQuote = (payload: unknown) =>
  typeof payload === "object" &&
  payload !== null &&
  "subjectType" in payload &&
  payload.subjectType === "quote";

for (const event of batch) {
  if (event.type === "status.changed" && isQuote(event.payload)) {
    await serviceBuilder.triggers.onEvent(event).orThrow();
  }
}
```

The workflow's steps then call `machine.pause()` while it waits (a client reply, a signature), `machine.setFlag()` when a step escalates, and `machine.transition()` when it is done. The trigger still checks each edge, so a workflow can't move a record along an edge the machine doesn't allow, and a member who moves the record by hand while it is paused gets `STATE_PAUSED`.

## Transitions that need approval [#transitions-that-need-approval]

`requiresApproval` lists edges, in the shape of `transitions`, that only an approved request of the [approvals](/docs/blocks/approvals) module may make. Any other caller gets `STATE_APPROVAL_REQUIRED`; the service role can still make them. Pair it with an approvals policy whose `run` is `{ transition: "<status>" }` on a subject type named after the machine.

```ts title="better-supabase.config.ts"
machines: {
  invoice: {
    table: "public.invoices",
    column: "status",
    initial: "draft",
    transitions: { draft: ["sent", "void"] },
    requiresApproval: { draft: ["sent"] },
  },
},
```

## Over your own tables [#over-your-own-tables]

Point `history.table` at `public.quote_status_history` or `public.invoice_status_history`. Portal-only columns such as `actor_customer_id` stay on the table and are not required by the module. `history()` reads the table's `id` and `created_at` columns; name others with `history.id` and `history.createdAt`.

A machine with `lifecycle: true` needs a lifecycles table with the columns of `subject_lifecycles` and a unique key on `(subject_type, subject_id)`. Over a table of your own the block adds the columns it lacks and its own read policy, named `bsb_<table>_select` so it never replaces one of yours; [doctor](/docs/cli/doctor#bs347) reports it when your policies for `select` are narrower.

The subject's status column and the history table's `from_status` and `to_status` can be a Postgres enum or a domain over text. The block casts the status it writes to the column's type, so a value the type rejects fails the transition as a direct write would.

## Hooks [#hooks]

`createStateMachine` takes `hooks` around its methods, such as a rule before
`transition` that returns a `DbError` to refuse the call; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.stateMachine` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createStateMachine } from "better-supabase/blocks/state-machine";

const machine = createStateMachine<"quote">({ transport });
await machine
  .transition({ type: "quote", id: quoteId }, "sent", "ready for the client")
  .orThrow();
```

# Tags

> Tag definitions per kind, applied to subjects through one table or a link table per subject.

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

`tags` keeps definitions (`kind`, `name`, `slug`, `color`) and applies them to records. Applying or removing a tag needs `tags.apply` (or `tags.manage`) in the tenant, the subject's own read permission, and a tag kind the subject allows.

With the [drive](/docs/blocks/drive#labels-and-approvals) installed, `drive_node` subjects follow the node's grants: reading a node's tags needs read access, and tagging or untagging needs write access, which is how the drive's labels work.

| Permission    | Checked by                                                     | Default roles        |
| ------------- | -------------------------------------------------------------- | -------------------- |
| `tags.read`   | `list`                                                         | owner, admin, member |
| `tags.apply`  | `attach`, `detach`                                             | owner, admin, member |
| `tags.manage` | `create`, `update`, `reorder`, `delete`, and every check above | owner, admin         |

```bash
better-supabase add tags
```

```ts
import { createTags } from "better-supabase/blocks/tags";
```

In managed mode every application lives in `taggings` (`subject_type`, `subject_id`). Over your own tables, each subject can name its own link table ([below](#over-your-own-tables)).

Every call acts in the block's `tenant`, an organization id or a function that returns the current one; `createBlocks` and `createBlockClient` pass the request's tenant, and a call without one fails with `invalid_request` and the hint `TENANT_REQUIRED`. A record is a `RecordRef`, `{ type, id }`.

Server code calls the `tags` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTags } = defineDataHooks(
  "tags",
  (db: AppDb) => db.tags,
);
```

```ts
const customer = { type: "customer", id: customerId };

const vip = await db.tags
  .create({ kind: "customer", name: "VIP", color: "amber" })
  .orThrow();
await db.tags.attach(customer, vip.id).orThrow();
const onCustomer = await db.tags.list({ on: customer }).orThrow();
```

```tsx title="src/features/tags/list.tsx"
"use client";

import { useTags } from "@/lib/supabase/hooks";

const rows = useTags({ limit: 50 });
```

`create({ kind, name, color?, description?, fields? })` creates a tag of a kind, and `update(id, { name?, fields? })` renames it or sets its extra columns. `attach(record, tagId)` and `detach(record, tagId)` put a tag on a record and take it off. `list({ kind? })` returns the tenant's tags of a kind (every kind without one) in rank order, for a caller with `tags.read` or `tags.manage`. `list({ on: record })` returns the tags on one record, for a caller who can also read the record. They call `list_tags` and `subject_tags`.

## In the browser [#in-the-browser]

`useTagInput` is a registry item: `npx shadcn add @better-supabase/use-tags` writes `hooks/use-tags.ts` into the app. The hook loads a record's tags and every tag of a kind for suggestions through the provider's client. `add(tagId)` and `remove(tagId)` show the change at once and take it back when the module refuses it. `create(name)` adds the tag of that kind with the same name (ignoring case), or creates it first, which needs `tags.manage`. The same hook runs in React DOM and React Native.

```tsx title="components/tag-input.tsx"
import { useTagInput } from "@/hooks/use-tags";

const { tags, available, add, remove, create } = useTagInput({
  subject: { type: "customer", id: customer.id },
  kind: "customer",
});
```

The hook calls the schema and function names in the `blocks` config passed to the browser client (`createClient(betterSupabase, { blocks: blockConfig })`), so it takes no `schema`, and acts in the client's tenant: the client's `organization` option, then the session's organization claim. It waits while there is no tenant or `subject` is `null`.

`createTagInputController` from `better-supabase/blocks/tags` is the same logic without React. Vue, Svelte and Solid run it with `useBlockController` from `better-supabase/vue`, `/svelte` and `/solid` (see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)), passing the controller's options: a client built with its `organization`, and `subject`. The [tag input recipe](/docs/registry/ui-recipes/tag-input) shows it in the Next.js and Expo examples.

```vue title="CustomerTags.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import { rpcTransport } from "better-supabase/blocks";
import {
  createTagInputController,
  createTags,
} from "better-supabase/blocks/tags";

const props = defineProps<{ organizationId: string; customerId: string }>();
const supabase = useSupabase();
const { state, actions } = useBlockController(createTagInputController, {
  client: createTags({
    transport: rpcTransport(supabase),
    schema: "api",
    tenant: props.organizationId,
  }),
  subject: { type: "customer", id: props.customerId },
  kind: "customer",
});
</script>

<template>
  <button
    v-for="tag in state.tags"
    :key="tag.id"
    @click="actions.remove(tag.id)"
  >
    {{ tag.name }}
  </button>
</template>
```

```svelte title="CustomerTags.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import { rpcTransport } from "better-supabase/blocks";
  import { createTagInputController, createTags } from "better-supabase/blocks/tags";

  let { organizationId, customerId }: { organizationId: string; customerId: string } = $props();
  const { supabase } = getBetterSupabase();
  const tags = useBlockController(createTagInputController, {
    client: createTags({
      transport: rpcTransport(supabase),
      schema: "api",
      tenant: organizationId,
    }),
    subject: { type: "customer", id: customerId },
    kind: "customer",
  });
</script>

<p>{$tags.tags?.map((tag) => tag.name).join(", ")}</p>
```

```tsx title="CustomerTags.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import { rpcTransport } from "better-supabase/blocks";
import {
  createTagInputController,
  createTags,
} from "better-supabase/blocks/tags";

export function CustomerTags(props: {
  organizationId: string;
  customerId: string;
}) {
  const supabase = useSupabase();
  const { state } = useBlockController(createTagInputController, {
    client: createTags({
      transport: rpcTransport(supabase),
      schema: "api",
      tenant: props.organizationId,
    }),
    subject: { type: "customer", id: props.customerId },
    kind: "customer",
  });
  return (
    <p>
      {state()
        .tags?.map((tag) => tag.name)
        .join(", ")}
    </p>
  );
}
```

Tags run in every [scope](/docs/blocks/sql#scope-and-naming). In `user` scope each user has their own tags, and in `none` scope every signed-in user shares one set.

## Over your own tables [#over-your-own-tables]

```ts title="better-supabase.config.ts"
tags: {
  tables: { tags: "public.organization_tags" },
  on: {
    task: {
      table: "public.tasks",
      kinds: ["task"],
      links: { table: "public.task_tags", subject: "task_id", tag: "tag_id" },
    },
  },
},
```

`links` columns are `subject`, `tag` and optional `memberships` and `createdAt`.

Your `kind` and `subjectType` columns may be enums or domains, such as `kind public.organization_tag_kind`: the module casts the text it writes and compares to the column's type, so an unknown kind fails with the enum's `invalid input value` error.

## Order [#order]

Tags of a kind are ordered by `rank`, a fractional key (see [Rank](/docs/blocks/rank)). `create` appends a tag after the last one of its kind, and `reorder(id, { previous, next })` moves it between the tags with those keys. Version 2 of the module replaced the integer `position` column with `rank`: the upgrade backfills keys in the old order and drops `position`. On a table of your own `rank` is optional: map it to `null` when you don't want the column, and the module otherwise adds it.

## Your own columns [#your-own-columns]

Add columns to the tag table with `blocks.tags.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`create` and `update` (the last argument) validates and writes them, and `create` return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createTags } from "better-supabase/blocks/tags";
import { z } from "zod";

const client = createTags({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.tags` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
const tags = createTags({ transport, tenant: organizationId });
const customer = { type: "customer", id: customerId };

const vip = await tags
  .create({ kind: "customer", name: "VIP", color: "amber" })
  .orThrow();
await tags.attach(customer, vip.id).orThrow();
const onCustomer = await tags.list({ on: customer }).orThrow();
```

# Work items

> Issues and tasks on pipelines, with identifiers, parents without cycles, several assignees, priority 0 to 4, estimates, start, end and due dates, locking, projects, iterations, batched edits and a progress tree.

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

`tasks` is the task and issue record of Linear, Jira, GitHub issues and monday items: a title, a body, a parent, assignees, a priority, an estimate, start, end and due dates, and a stage on a [pipeline](/docs/blocks/pipelines). Items can belong to a team, a project and an iteration (a cycle or a sprint). Their identifier is `TASK-12`, or the format of a `work_item` entity in [numbering](/docs/blocks/numbering).

Reading needs `work_items.read`, creating, editing and assigning needs `work_items.write`, and projects, iterations and deleting someone else's item need `work_items.manage`. Admins get every permission, and members get `work_items.read` and `work_items.write`.

```bash
better-supabase add tasks
```

The module needs `tenant`, `access`, `subjects` and `pipelines`. Installing it binds the `work_item` type in the pipelines module with one pipeline per team (`scope: "team_id"`) and the categories of Linear's workflow states:

| Category    | Outcome | Default stages         |
| ----------- | ------- | ---------------------- |
| `triage`    | open    | Triage                 |
| `backlog`   | open    | Backlog                |
| `unstarted` | open    | Todo                   |
| `started`   | open    | In progress, In review |
| `completed` | success | Done                   |
| `canceled`  | failure | Canceled               |

A stage's category sets the item's `state`, so a list or a policy reads `state` and never a stage name. Set `blocks.pipelines.on.work_item` to rename the stages or start with your own, such as CentraKit's Todo, In progress, On hold and Done; your keys are merged over the defaults.

Installing the block registers work items as the `work_item` subject, ranked by stage. Set the page a search result links to on the entity:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: { tasks: { names: { work_item: { url: "/tasks/{id}" } } } },
});
```

## Create, edit and assign [#create-edit-and-assign]

Server code calls the `tasks` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useTasks } = defineDataHooks(
  "tasks",
  (db: AppDb) => db.tasks,
);
```

```ts
import { createTasks } from "better-supabase/blocks/tasks";

const epic = await db.tasks
  .create(organizationId, { title: "Checkout v2", teamId, priority: 2 })
  .orThrow();
const task = await db.tasks
  .create(
    organizationId,
    { title: "Payment API", parentId: epic.id, teamId, dueAt },
    [userId],
  )
  .orThrow();

const { accepted, rejected } = await db.tasks
  .update([
    { workItemId: task.id, field: "stageId", value: inProgressStageId },
    { workItemId: task.id, field: "endAt", value: friday },
  ])
  .orThrow();
```

```tsx title="src/features/tasks/list.tsx"
"use client";

import { useTasks } from "@/lib/supabase/hooks";

const rows = useTasks({ limit: 50 });
```

`update(changes)` applies grid edits and Gantt drags one by one and returns the refused ones with their hint. The table refuses a parent that would put an item under itself (`WORK_ITEM_PARENT_CYCLE`) and date changes on a locked item (`WORK_ITEM_LOCKED`). Moving to a completed or canceled stage stamps `closedAt` and a close reason (`completed`, or `not_planned` for canceled; pass `closeReason: "duplicate"` with the move to mark a duplicate) and records `work_item.closed`. Moving back to an open stage clears both and records `work_item.reopened`.

`assign(id, userIds)` replaces the assignees. Each one watches the item, and the people newly assigned get a `work_item.assigned` notification. `options.maxAssignees` caps the list (`WORK_ITEM_ASSIGNEES_LIMIT`), and `1` gives the single-assignee shape of Linear and Jira.

## Lists, trees, projects and iterations [#lists-trees-projects-and-iterations]

`list(organizationId, { teamId, projectId, iterationId, parentId, assigneeId, states, open, search })` returns active items, newest first, 50 per page; pass the last item's id as `after` for the next page. `tree(rootId)` returns an item and everything under it with its depth; an item without its own `progress` rolls up the share of its open and completed children that are completed, for a Gantt group.

Dependencies between items are [relations](/docs/blocks/relations): `link(design, build, { kind: "blocks" })` is a finish-to-start dependency, and `edges("blocks")` reads them for the chart.

`saveProject(organizationId, { key, name, status, startOn, targetOn })` keeps a project (`PAY`) with the share of its items that are completed. `saveIteration(organizationId, { name, teamId, startsOn, endsOn })` keeps a cycle or a sprint. Turn either table off with `tables: { projects: null }` or `tables: { iterations: null }`.

## Checklists, files, repeat rules and watchers [#checklists-files-repeat-rules-and-watchers]

```ts
const draft = await items
  .saveChecklistItem(task.id, { title: "Draft the copy" })
  .orThrow();
await items.saveChecklistItem(task.id, { done: true }, draft.id).orThrow();
const steps = await items.checklist(task.id).orThrow();
```

`saveChecklistItem(workItemId, input, checklistItemId?)` adds a step at the end, or edits the one you name: its `title`, `done` (which stamps `doneAt` and `doneBy`) or `rank`. `removeChecklistItem(id)` deletes one, and `checklist(workItemId)` lists them in rank order. Every item a read returns carries `checklist: { total, done }`, and `get(id)` reads one item. A checklist change records `work_item.updated` with `fields: ["checklist"]`.

On the work item's own page, the block's record binding (`workItemChecklistBinding`) loads the steps for the item as the subject (`{ type: "work_item", id }`, or the renamed type) and reads again on `work_item.updated`. `workItemChecklistOptimistic` is its `useOptimistic` reducer: `{ type: "save", item }` adds or replaces a step in rank order and `{ type: "remove", id }` drops one.

With the [attachments](/docs/blocks/attachments) block installed, `create` takes `attachmentIds` and `update` takes an `attachments` change with a list of ids. Upload the files first without a subject, then link them: the block links only files the caller uploaded that have no subject yet (`WORK_ITEM_ATTACHMENTS_INVALID`). A bucket whose path template uses `{subject_type}` or `{subject_id}` can't move a file after upload, so link through `attachments.upload` with the item as the subject instead (`WORK_ITEM_ATTACHMENTS_PATH`).

With the [reminders](/docs/blocks/reminders) block installed, `setRecurrence(id, { rrule, tzid })` gives an item a repeat rule, such as `FREQ=MONTHLY;BYMONTHDAY=1`, and `setRecurrence(id, null)` removes it. Items carry the rule as `recurrence`. A repeating item makes its next copy when it is completed, like Asana and Todoist: relay the block's events to `workItemRecurrenceSink`, built on a service transport.

```ts
import {
  createTasks,
  workItemRecurrenceSink,
} from "better-supabase/blocks/tasks";

const serviceItems = createTasks({ transport: serviceTransport });

outbox.relay("tasks", workItemRecurrenceSink(serviceItems));
```

The sink calls `repeat(id, dueAt)` with the rule's first occurrence after the completed item's due date. `repeat` copies the title, body, parent, team, project, priority, estimate, assignees, extra columns and an unchecked checklist, shifts the start and end dates by the same amount, and moves the rule to the copy, so a `COUNT` keeps counting. The copy's `recursFromId` names the item it repeats, and a second `repeat` of the same item returns the same copy, so a redelivered event changes nothing. After `entities: { work_item: "task" }`, pass `{ type: "task.closed" }`.

With the [notifications](/docs/blocks/notifications) block installed, `watch(id, "all")` follows an item, `watch(id, "ignore")` mutes it, `watch(id, null)` goes back to the default, and `watchers(id)` lists who follows it. Assignees already watch their items.

## Extra columns [#extra-columns]

`options.extraColumns` adds columns to `work_items`, such as CentraKit's `customer_id`. Every item a read returns carries them in `fields`.

## Events [#events]

| Event                                                                                                                           | Data                                                                                         |
| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `work_item.created`, `work_item.updated`, `work_item.assigned`, `work_item.reopened`, `work_item.archived`, `work_item.deleted` | `organizationId`, `workItemId`, `subjectType`, `subjectId`, `title`, `fields`, `assigneeIds` |
| `work_item.closed`                                                                                                              | the same, with `state` and `closeReason`                                                     |

## Hooks [#hooks]

The module calls `before_work_item_create(tenant, attrs jsonb)`, `after_work_item_create(work_item uuid)`, `before_work_item_update(work_item uuid, field text, value jsonb)` and `after_work_item_update(work_item uuid, field text)`. A `before_` hook raises to refuse; in `update`, that refuses only its change.

## Wire it to ReUI [#wire-it-to-reui]

The ReUI Kanban blocks commit a drag through `onValueCommit`: call `rank.move("work_item", id, { previous, next, group: stageId })`. The Gantt blocks commit a move or resize through `onEventUpdate`: send `startAt` and `endAt` through `update`, and return `false` when the change comes back in `rejected` (a locked bar). Read the Gantt rows from `tree`, and its arrows from the `blocks` relations.

## Errors [#errors]

| Hint                                           | Cause                                               |
| ---------------------------------------------- | --------------------------------------------------- |
| `WORK_ITEM_FORBIDDEN`                          | the caller lacks the permission                     |
| `WORK_ITEM_TITLE_REQUIRED`                     | the title is empty                                  |
| `WORK_ITEM_ATTRS_INVALID`                      | a field has an invalid value                        |
| `WORK_ITEM_NOT_FOUND`                          | no item the caller can edit                         |
| `WORK_ITEM_FIELD_UNKNOWN`                      | `update` names a field it doesn't write             |
| `WORK_ITEM_PARENT_CYCLE`                       | the parent would put the item under itself          |
| `WORK_ITEM_PARENT_INVALID`                     | the parent is in another organization               |
| `WORK_ITEM_LOCKED`                             | a locked item's dates don't change                  |
| `WORK_ITEM_ASSIGNEES_LIMIT`                    | more assignees than `maxAssignees`                  |
| `WORK_ITEM_PROJECT_*`, `WORK_ITEM_ITERATION_*` | a project or iteration is missing, taken or invalid |
| `WORK_ITEM_CHECKLIST_ITEM_INVALID`             | a checklist step has no title or an invalid rank    |
| `WORK_ITEM_CHECKLIST_ITEM_NOT_FOUND`           | no such step on this item                           |
| `WORK_ITEM_ATTACHMENTS_INVALID`                | a file isn't the caller's or already has a subject  |
| `WORK_ITEM_ATTACHMENTS_UNAVAILABLE`            | the attachments block isn't installed               |
| `WORK_ITEM_ATTACHMENTS_PATH`                   | the bucket's paths name the subject                 |
| `WORK_ITEM_RECURRENCE_INVALID`                 | the rule isn't an RRULE or the time zone is unknown |

## Your own columns [#your-own-columns]

Add columns to the work item table with `blocks.tasks.options.extraColumns`
(column name to SQL type), then pass a Standard Schema for them as `fields`.
`create`, and `update` with the column name as `field` validates and writes them, and every method that returns an item return them parsed under
`fields`. The block also takes `hooks` around its methods; see
[extending blocks](/docs/extending/blocks).

```ts
import { createTasks } from "better-supabase/blocks/tasks";
import { z } from "zod";

const client = createTasks({
  transport,
  fields: z.object({ priority: z.enum(["low", "high"]) }),
});
```

## Without the client [#without-the-client]

`db.tasks` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts
import { createTasks } from "better-supabase/blocks/tasks";

const items = createTasks({ transport });
const epic = await items
  .create(organizationId, { title: "Checkout v2", teamId, priority: 2 })
  .orThrow();
const task = await items
  .create(
    organizationId,
    { title: "Payment API", parentId: epic.id, teamId, dueAt },
    [userId],
  )
  .orThrow();

const { accepted, rejected } = await items
  .update([
    { workItemId: task.id, field: "stageId", value: inProgressStageId },
    { workItemId: task.id, field: "endAt", value: friday },
  ])
  .orThrow();
```

# Undo

> Apply the undo tokens that block methods return, on the server with createUndo and in the browser with a useUndoStack for a toast's Undo button.

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

Block methods whose change has an inverse return an `undo` token next to
their result: `relations.link`, `relations.unlink` and `audit.revert`. A token
is plain JSON (`{ v: 1, calls: [{ fn, args }] }`), so a server action can
return it to the browser and take it back when the user clicks Undo. Applying
it runs the inverse calls as the caller, so the same policies and permission
checks apply as for any other write.

```ts
import { createUndo, sqlTransport } from "better-supabase/blocks/undo";
import { useUndoStack } from "@/hooks/use-undo";
```

The undo block adds no SQL module; it calls the functions of the blocks that
returned the token.

## Apply a token [#apply-a-token]

`createUndo` checks that a value is a token whose calls are all inverses the
blocks return (`link`, `unlink`, `revert_audit_entries`), then runs them in
order and stops at the first error. Check the token on the server, since it
comes back from the browser.

Server code calls the `undo` instance on `db`, in the caller's organization. With `gen.hooks` set, `better-supabase gen` writes the hooks:

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useUndo } = defineDataHooks(
  "undo",
  (db: AppDb) => db.undo,
);
```

```ts
const rows = await db.undo.list({ limit: 50 }).orThrow();
```

```tsx title="src/features/undo/list.tsx"
"use client";

import { useUndo } from "@/lib/supabase/hooks";

const rows = useUndo({ limit: 50 });
```

`apply` resolves to the number of calls it ran. A value that is not an
accepted token is a `validation` error. An app that returns its own inverses
names their functions in `allow`, and builds tokens with `undoToken`.

```ts
const undo = createUndo({
  transport: sqlTransport(ctx.postgres),
  allow: ["restore_task"],
});
```

Each call runs as its own statement. A token from `relations.link` that
replaced a parent holds two calls (unlink the new parent, link the old one),
and when the second fails the first stays applied.

An undo can fail when the rows changed after the change it reverses: an audit
revert refuses with `ROW_CHANGED_SINCE`, and a link that someone else removed
is already gone.

## Keep a stack in the browser [#keep-a-stack-in-the-browser]

`useUndoStack` is a registry item: `npx shadcn add @better-supabase/use-undo`
writes `hooks/use-undo.ts` into the app. The hook keeps the newest tokens (20
by default) for a toast's Undo button or a Cmd+Z handler. `push` ignores a `null` token, so you can push
every result without checking which methods have an inverse.

```tsx title="app/tasks/task-list.tsx"
"use client";

import { useUndoStack } from "@/hooks/use-undo";

const stack = useUndoStack({ apply: undoAction });

async function moveUnder(epicId: string) {
  const moved = await moveTask(taskId, epicId);
  stack.push(moved.undo, "Moved the task");
}

// <Button disabled={!stack.canUndo} onClick={() => stack.undo()}>Undo</Button>
```

`undo` applies the newest entry and removes it. When the apply throws, the
entry is removed too, since its rows changed, and `error` holds what it
threw. Bulk actions return no token, because a row's inverse depends on the
action; revert their [audit entries](/docs/blocks/audit#reverting-changes)
instead.

## Vue [#vue]

`useBlockController` from `better-supabase/vue` runs `createUndoController`
with its options; the controller needs no provider. `state` is a shallow ref of the snapshot and `actions` are the controller's actions.
The work starts on mount, and the controller is disposed with the component.

```vue title="Component.vue"
<script setup lang="ts">
import { useBlockController } from "better-supabase/vue";
import { createUndoController } from "better-supabase/blocks/undo";

const { state, actions } = useBlockController(createUndoController, {
  apply: undoAction,
});
</script>

<template>
  <p>{{ state.entries.length }}</p>
  <button @click="actions.undo()">Undo</button>
</template>
```

`undoAction` stands for your server call that applies a token.

## Svelte [#svelte]

`useBlockController` from `better-supabase/svelte` runs
`createUndoController` with its options; the controller needs no provider.
It returns a readable store of the snapshot, read with `$`, with the
actions on `.actions`. The work starts on mount, and the controller is
disposed with the component.

```svelte title="Component.svelte"
<script lang="ts">
  import { useBlockController } from "better-supabase/svelte";
  import { createUndoController } from "better-supabase/blocks/undo";

  const stack = useBlockController(createUndoController, {
    apply: undoAction,
  });
</script>

<p>{$stack.entries.length}</p>
<button onclick={() => stack.actions.undo()}>Undo</button>
```

## Solid [#solid]

`useBlockController` from `better-supabase/solid` runs
`createUndoController` with its options; the controller needs no provider.
`state()` reads the snapshot through `from`, and `actions` are the
controller's actions. The work starts on mount, and the controller is
disposed with the owner.

```tsx title="Component.tsx"
import { useBlockController } from "better-supabase/solid";
import { createUndoController } from "better-supabase/blocks/undo";

export function Component() {
  const { state, actions } = useBlockController(createUndoController, {
    apply: undoAction,
  });
  return (
    <>
      <p>{state().entries.length}</p>
      <button onClick={() => actions.undo()}>Undo</button>
    </>
  );
}
```

## With ReUI [#with-reui]

- Toast (Sonner): show the entry's `label` with an Undo action that calls
  `stack.undo()`.
- Command menu: an "Undo" item, disabled while `canUndo` is false.

The free `c-sonner-*` examples show a toast with an action; the Pro
application blocks show list toolbars with undo. Pro blocks are licensed per
user, so copy them from [reui.io](https://reui.io).

## Hooks [#hooks]

`createUndo` takes `hooks` around its methods, such as a rule before
`apply` that returns a `DbError` to refuse the call; see
[extending blocks](/docs/extending/blocks).

## Without the client [#without-the-client]

`db.undo` is this client. Code that does not go through `bsb.db()` builds it with a transport and an organization id:

```ts title="app/tasks/actions.ts"
"use server";

import { createUndo, sqlTransport } from "better-supabase/blocks/undo";

export async function undoAction(token: unknown) {
  const ctx = await server.context();
  const undo = createUndo({ transport: sqlTransport(ctx.postgres) });
  return (await undo.apply(token)).orThrow();
}
```

# Overview

> Blocks for SaaS apps. Each one installs SQL into your schema and, when it has a TypeScript side, a client on db.

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. `better-supabase add` installs its SQL into `supabase/schemas`
([what that SQL contains](/docs/blocks/sql)). An app calls an instance on
`db`, and `gen.hooks` writes the list and record hooks. Blocks with a
TypeScript side also export a client from `better-supabase/blocks/<name>`
for code that does not use `bsb.db()`.

```bash
pnpm better-supabase add organizations invitations
```

```ts
import { createOrganizations } from "better-supabase/blocks/organizations";
```

Every block also exports its manifest, such as `organizationsBlock`: its SQL
modules, options, events and the ports it provides and consumes. To build
several blocks with one set of options and wire them on their ports, pass
the manifests to [`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 blocks to keep in sync in `blocks` in `better-supabase.config.ts`,
keyed by instance, with that instance's settings as the value. Core blocks,
such as `memberships`, `permissions` and `updated-at`, go there too. 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.

## By category [#by-category]

- [Organizations and identity](/docs/blocks/catalog-organizations)
- [Database](/docs/blocks/catalog-database)
- [Collaboration](/docs/blocks/catalog-collaboration)
- [Work](/docs/blocks/catalog-work)
- [CRM](/docs/blocks/catalog-crm)
- [Commerce](/docs/blocks/catalog-commerce)
- [Content](/docs/blocks/catalog-content)
- [AI](/docs/blocks/catalog-ai)
- [Automation](/docs/blocks/catalog-automation)
- [Developer](/docs/blocks/catalog-developer)
- [Growth](/docs/blocks/catalog-growth)
- [People and scheduling](/docs/blocks/catalog-people)

## Instances [#instances]

Each key in `blocks` is an instance. The key names the block by default; any
other key installs a second instance of the block in its `block` setting, with
its own tables and the same options as the first:

```ts
blocks: {
  pipelines: {},
  "hiring-pipelines": { block: "pipelines", on: ["candidates"] },
  tasks: { uses: { pipelines: "pipelines" } },
}
```

`on` lists the record types an attachable block attaches to. `uses` picks the
instance of a dependency a block works with; it is required when that
dependency has more than one instance, and its keys are the block's
dependencies. Core blocks, such as `memberships`, `permissions`, `outbox` and
`audit`, install once under their own name. The config check lists every
unknown block, repeated core block, unknown `uses` key and missing `uses`
together.

A second instance renders its own SQL files, named after the instance
(`900_better_supabase_031_hiring_pipelines.sql`). Its entity renames in
`entities` name its tables and functions; any table, function, trigger or
index name those renames leave unchanged gets the instance as a prefix, so
`taggings` in a `labels` instance of `tags` becomes `labels_taggings`. A
second instance has to rename its record entities, since two `pipelines`
tables can't share a schema. The instance writes its own row in
`better_supabase.blocks`, whose `block` column names the block it installs,
and `doctor` and `upgrade` follow each instance's version on its own.

On the TypeScript side, `blocks.generated.ts` has an entry for each instance
and a renamed client for a second one (`ticketsBlock` for `tickets: { block:
"tasks", entities: { work_item: "ticket" } }`), which calls the instance's
functions. Pass it under the instance key:

```ts title="lib/blocks.ts"
import { blockConfig, tasksBlock, ticketsBlock } from "./blocks.generated";

export const blockSet = defineBlocks(blockConfig, {
  tasks: tasksBlock,
  tickets: ticketsBlock,
});
```

When two blocks in a set provide one port, such as two `notifications`
instances providing `notifier`, a consumer takes the one its `uses` names.
`createBlocks({ ports: { notifier: "inbox" } })` picks one for every consumer
whose `uses` names none.

### Contributions [#contributions]

Five generic blocks take work from the blocks around them: `approvals`
(subjects to approve), `bulk` (named actions), `pipelines` (record
types with stages), `share-links` (subjects to share) and `subjects` (summary
counts and my-work rows). The block that has the records declares what it
gives in its definition's `contributes`, so the generic block never names its
consumers. `leave`, for example, contributes the `leave_request` subject and
its approve function to `approvals`.

The default instance of a block contributes to the default instance of the
generic block. A second instance contributes only when its `uses` names the
generic instance, so a `labels` instance of `tags` adds no second `tags.set`
bulk action unless you point it at one:

```ts
blocks: {
  "bulk": {},
  rowActions: { block: "bulk" },
  tags: {},
  labels: {
    block: "tags",
    entities: { tag: "label" },
    uses: { "bulk": "rowActions" },
  },
}
```

Two blocks that give one generic instance the same subject or action name
fail at render with the name and the fix.

### Record types [#record-types]

An attachable block, such as `tags`, `attachments`, `versions`, `trash` or
`sla`, works on the record types in `config.records`. Without `on` it
attaches to every registered type. `on` narrows it: a list names the types,
and an object keyed by type adds the block's settings for each one on top of
the registry entry:

```ts
blocks: {
  tags: { on: ["deal", "contact"] },
  attachments: { on: { contract: { bucket: "contracts" } } },
  "field-history": { on: { deal: { fields: ["amount", "stage_id"] } } },
}
```

`field-history` tracks only the types and columns its `on` names. The
block's SQL checks each `subject_type` against the types `on` resolves to,
and doctor (BS346) reports a type in `on` that `config.records` doesn't
register. `on` replaces `options.subjects` from 0.14; `better-supabase
upgrade` moves it.

## 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 (`blocks.<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/repository/permissions)             | `better-supabase/blocks/permissions`                                             | `access`, `tenant`                     |
| [Subjects](/docs/repository/record-types)                   | `better-supabase/blocks/subjects`                                                | `subjects`                             |
| [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/queue)   | `better-supabase/blocks/queue`                                                   | `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`                                               | `workflows`                            |
| [Workflow SDK](/docs/blocks/workflow-sdk)                   | `better-supabase/workflow-sdk`, `better-supabase/workflow-sdk/world`             | `workflow-sdk`                         |
| [Workflow builder](/docs/blocks/workflow-editor)            | `better-supabase/blocks/workflow-editor`, `better-supabase/workflow-sdk/builder` | `workflow-editor`                      |
| [Notifications](/docs/blocks/notifications)                 | `better-supabase/blocks/notifications`                                           | `notifications`                        |
| [Inbox](/docs/blocks/inbox)                                 | `better-supabase/blocks/inbox`, `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](/docs/blocks/comments)                           | `better-supabase/blocks/comments`                                                | `comments`                             |
| [Activity](/docs/blocks/timeline)                           | `better-supabase/blocks/timeline`                                                | `activity`                             |
| [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`                                              | `onboarding`                           |
| [Waitlist and invite codes](/docs/blocks/waitlist)          | `better-supabase/blocks/waitlist`                                                | `waitlist`                             |
| [Announcements](/docs/blocks/announcements)                 | `better-supabase/blocks/announcements`                                           | `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`                                                 | `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`                               |
| [Messaging](/docs/blocks/messaging)                         | `better-supabase/blocks/messaging`                                               | `messaging`                            |
| [Channels](/docs/blocks/channels)                           | `better-supabase/blocks/channels`                                                | `channels`                             |
| [Agents](/docs/blocks/agents)                               | `better-supabase/blocks/agents`                                                  | `agents`                               |
| [Connectors](/docs/blocks/connectors)                       | `better-supabase/blocks/connectors`                                              | `connectors`                           |
| [AI tasks](/docs/blocks/ai-schedules)                       | `better-supabase/blocks/ai-schedules`                                            | `ai-schedules`                         |
| [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-keys)                        | `better-supabase/blocks/ai-keys`                                                 | `ai-keys`                              |
| [Number sequences](/docs/blocks/numbering)                  | `better-supabase/blocks/numbering`                                               | `numbering`                            |
| [Teams](/docs/blocks/teams)                                 | `better-supabase/blocks/teams`                                                   | `teams`                                |
| [Tags](/docs/blocks/tags)                                   | `better-supabase/blocks/tags`                                                    | `tags`                                 |
| [State machine](/docs/blocks/state-machine)                 | `better-supabase/blocks/state-machine`                                           | `state-machine`                        |
| [Custom fields](/docs/blocks/custom-fields)                 | `better-supabase/blocks/custom-fields`                                           | `custom-fields`                        |
| [Share links](/docs/blocks/share-links)                     | `better-supabase/blocks/share-links`                                             | `share-links`                          |
| [Reminders](/docs/blocks/reminders)                         | `better-supabase/blocks/reminders`                                               | `reminders`                            |
| [Imports](/docs/blocks/imports)                             | `better-supabase/blocks/imports`                                                 | `imports`                              |
| [Rank](/docs/blocks/rank)                                   | `better-supabase/blocks/rank`                                                    | `rank`                                 |
| [Saved views](/docs/blocks/saved-views)                     | `better-supabase/blocks/saved-views`                                             | `saved-views`                          |
| [Relations](/docs/blocks/relations)                         | `better-supabase/blocks/relations`                                               | `relations`                            |
| [Bulk actions](/docs/blocks/bulk)                           | `better-supabase/blocks/bulk`                                                    | `bulk`                                 |
| [Undo](/docs/blocks/undo)                                   | `better-supabase/blocks/undo`                                                    |                                        |
| [Search](/docs/blocks/search)                               | `better-supabase/blocks/search`                                                  | `search`                               |
| [Metrics](/docs/blocks/metrics)                             | `better-supabase/blocks/metrics`                                                 | `metrics`                              |
| [Calendar](/docs/blocks/calendar)                           | `better-supabase/blocks/calendar`                                                | `calendar`                             |
| [Bookings](/docs/blocks/bookings)                           | `better-supabase/blocks/bookings`                                                | `bookings`                             |
| [Versions](/docs/blocks/versions)                           | `better-supabase/blocks/versions`                                                | `versions`                             |
| [Trash](/docs/blocks/trash)                                 | `better-supabase/blocks/trash`                                                   | `trash`                                |
| [Reactions](/docs/blocks/reactions)                         | `better-supabase/blocks/reactions`                                               | `reactions`                            |
| [Approvals](/docs/blocks/approvals)                         | `better-supabase/blocks/approvals`                                               | `approvals`                            |
| [Access requests](/docs/blocks/access-requests)             | `better-supabase/blocks/access-requests`                                         | `access-requests`                      |
| [Documents](/docs/blocks/documents)                         | `better-supabase/blocks/documents`, `better-supabase/yjs`                        | `documents`                            |
| [Catalog](/docs/blocks/catalog)                             | `better-supabase/blocks/catalog`                                                 | `catalog`                              |
| [Ledger](/docs/blocks/ledger)                               | `better-supabase/blocks/ledger`                                                  | `ledger`                               |
| [Assets and locations](/docs/blocks/assets)                 | `better-supabase/blocks/assets`                                                  | `assets`                               |
| [Guests](/docs/blocks/guests)                               | `better-supabase/blocks/guests`                                                  | `guests`                               |
| [Passes](/docs/blocks/passes)                               | `better-supabase/blocks/passes`                                                  | `passes`                               |
| [Billable time](/docs/blocks/billable-time)                 | `better-supabase/blocks/billable-time`                                           | `billable-time`                        |
| [Leases](/docs/blocks/leases)                               | `better-supabase/blocks/leases`                                                  | `leases`                               |
| [Expenses](/docs/blocks/expenses)                           | `better-supabase/blocks/expenses`                                                | `expenses`                             |
| [Donations](/docs/blocks/donations)                         | `better-supabase/blocks/donations`                                               | `donations`                            |
| [Dispatch](/docs/blocks/dispatch)                           | `better-supabase/blocks/dispatch`                                                | `dispatch`                             |
| [Shipments](/docs/blocks/shipments)                         | `better-supabase/blocks/shipments`                                               | `shipments`                            |
| [Contracts](/docs/blocks/contracts)                         | `better-supabase/blocks/contracts`                                               | `contracts`                            |
| [Controls](/docs/blocks/compliance)                         | `better-supabase/blocks/compliance`                                              | `controls`                             |
| [Incidents](/docs/blocks/incidents)                         | `better-supabase/blocks/incidents`                                               | `incidents`                            |
| [Inventory](/docs/blocks/inventory)                         | `better-supabase/blocks/inventory`                                               | `inventory`                            |
| [Drive](/docs/blocks/drive)                                 | `better-supabase/blocks/drive`                                                   | `drive`, `drive-files`                 |
| [Orders](/docs/blocks/orders)                               | `better-supabase/blocks/orders`                                                  | `orders`                               |
| [Promotions](/docs/blocks/promotions)                       | `better-supabase/blocks/promotions`                                              | `promotions`                           |
| [Forms](/docs/blocks/forms)                                 | `better-supabase/blocks/forms`                                                   | `forms`                                |
| [Signatures](/docs/blocks/signatures)                       | `better-supabase/blocks/signatures`                                              | `signatures`                           |
| [Time tracking](/docs/blocks/time-tracking)                 | `better-supabase/blocks/time-tracking`                                           | `time-tracking`                        |
| [Templates](/docs/blocks/templates)                         | `better-supabase/blocks/templates`                                               | `templates`                            |
| [Pipelines](/docs/blocks/pipelines)                         | `better-supabase/blocks/pipelines`                                               | `pipelines`                            |
| [Parties](/docs/blocks/contacts)                            | `better-supabase/blocks/contacts`                                                | `parties`                              |
| [Engagements](/docs/blocks/activities)                      | `better-supabase/blocks/activities`                                              | `engagements`                          |
| [Mail sync](/docs/blocks/mail-sync)                         | `better-supabase/blocks/mail-sync`                                               | `mail-sync`                            |
| [Segments](/docs/blocks/segments)                           | `better-supabase/blocks/segments`                                                | `segments`                             |
| [Assignment](/docs/blocks/assignment)                       | `better-supabase/blocks/assignment`                                              | `assignment`                           |
| [Enrichment](/docs/blocks/enrichment)                       | `better-supabase/blocks/enrichment`                                              | `enrichment`                           |
| [Sequences](/docs/blocks/sequences)                         | `better-supabase/blocks/sequences`                                               | `sequences`                            |
| [Lead scoring](/docs/blocks/lead-scoring)                   | `better-supabase/blocks/lead-scoring`                                            | `lead-scoring`                         |
| [Posts](/docs/blocks/posts)                                 | `better-supabase/blocks/posts`                                                   | `posts`                                |
| [Content](/docs/blocks/content)                             | `better-supabase/blocks/content`                                                 | `content`                              |
| [Forecasts](/docs/blocks/forecasts)                         | `better-supabase/blocks/forecasts`                                               | `forecasts`                            |
| [Field history](/docs/blocks/field-history)                 | `better-supabase/blocks/field-history`                                           | `field-history`                        |
| [Deals](/docs/blocks/deals)                                 | `better-supabase/blocks/deals`                                                   | `deals`                                |
| [Work items](/docs/blocks/tasks)                            | `better-supabase/blocks/tasks`                                                   | `tasks`                                |
| [Quotes](/docs/blocks/quotes)                               | `better-supabase/blocks/quotes`                                                  | `quotes`                               |
| [Invoices](/docs/blocks/invoices)                           | `better-supabase/blocks/invoices`                                                | `invoices`                             |
| [Service levels](/docs/blocks/sla)                          | `better-supabase/blocks/sla`                                                     | `sla`                                  |
| [Targets](/docs/blocks/goals)                               | `better-supabase/blocks/goals`                                                   | `targets`                              |
| [Hiring](/docs/blocks/hiring)                               | `better-supabase/blocks/hiring`                                                  | `hiring`                               |
| [Employees](/docs/blocks/employees)                         | `better-supabase/blocks/employees`                                               | `employees`                            |
| [Collections](/docs/blocks/collections)                     | `better-supabase/blocks/collections`                                             | `collections`                          |
| [Identities](/docs/blocks/identities)                       | `better-supabase/blocks/identities`                                              | `identities`                           |
| [Service principals](/docs/blocks/service-accounts)         | `better-supabase/blocks/service-accounts`                                        | `service-accounts`                     |
| [Signing keys](/docs/blocks/signing-keys)                   | `better-supabase/blocks/signing-keys`                                            | `signing-keys`                         |
| [Shared signals](/docs/blocks/security-events)              | `better-supabase/blocks/security-events`                                         | `security-events`                      |
| [OAuth server](/docs/blocks/oauth-server)                   | `better-supabase/blocks/oauth-server`                                            | `oauth-server`                         |
| [Custom domains](/docs/blocks/domains)                      | `better-supabase/blocks/domains`                                                 | `domains`                              |
| [Reviews](/docs/blocks/reviews)                             | `better-supabase/blocks/reviews`                                                 | `reviews`                              |
| [Moderation](/docs/blocks/moderation)                       | `better-supabase/blocks/moderation`                                              | `moderation`                           |
| [Enrollments](/docs/blocks/enrollments)                     | `better-supabase/blocks/enrollments`                                             | `enrollments`                          |
| [Courses](/docs/blocks/courses)                             | `better-supabase/blocks/courses`                                                 | `courses`                              |
| [Leave](/docs/blocks/leave)                                 | `better-supabase/blocks/leave`                                                   | `leave`                                |
| [Rules](/docs/blocks/automations)                           | `better-supabase/blocks/automations`                                             | `rules`                                |
| [Scores](/docs/blocks/scores)                               | `better-supabase/blocks/scores`                                                  | `scores`                               |
| [Vercel Marketplace](/docs/blocks/vercel-marketplace)       | `better-supabase/blocks/vercel-marketplace`                                      | `vercel-marketplace`                   |
| [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-keys` |
| [Chat SDK](/docs/chat-sdk)                | `better-supabase/chat-sdk`                                                            | `chat-sdk-state`, `inbox`                                                                   |
| [Workflow SDK](/docs/blocks/workflow-sdk) | `better-supabase/workflow-sdk`, `better-supabase/workflow-sdk/world`                  | `workflow-sdk`, `workflows`                                                                 |
| [eve](/docs/eve)                          | `better-supabase/eve`                                                                 | `workflow-sdk`, `ai-chat`, `memory`, `knowledge`, `credentials`, `inbox`                    |

## How modules work together [#how-modules-work-together]

`better-supabase 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 blocks list --installed` prints both lists for each module.

| Module               | Requires                                                                                     | Works with                                                                                                |
| -------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `updated-at`         | none                                                                                         | none                                                                                                      |
| `created-by`         | none                                                                                         | none                                                                                                      |
| `audit`              | none                                                                                         | `permissions`, `organizations`, `data-lifecycle`                                                          |
| `memberships`        | `updated-at`                                                                                 | `permissions`, `invitations`, `organizations`                                                             |
| `invitations`        | `memberships`, `permissions`, `updated-at`                                                   | `organizations`, `profiles`                                                                               |
| `reserved-slugs`     | none                                                                                         | none                                                                                                      |
| `queue`              | none                                                                                         | `data-lifecycle`                                                                                          |
| `idempotency`        | none                                                                                         | none                                                                                                      |
| `webhook-inbox`      | none                                                                                         | `data-lifecycle`                                                                                          |
| `realtime-tables`    | none                                                                                         | none                                                                                                      |
| `jsonb-schemas`      | none                                                                                         | none                                                                                                      |
| `pgtap`              | none                                                                                         | none                                                                                                      |
| `grants`             | none                                                                                         | none                                                                                                      |
| `read-sets`          | none                                                                                         | none                                                                                                      |
| `mfa`                | none                                                                                         | none                                                                                                      |
| `entitlements`       | `memberships`                                                                                | `billing`, `organizations`                                                                                |
| `rate-limit`         | none                                                                                         | none                                                                                                      |
| `vector-search`      | none                                                                                         | none                                                                                                      |
| `permissions`        | `memberships`                                                                                | `invitations`, `organizations`                                                                            |
| `impersonation`      | `permissions`, `audit`                                                                       | `invitations`, `approvals`                                                                                |
| `organizations`      | `memberships`, `permissions`, `updated-at`                                                   | `audit`, `data-lifecycle`, `entitlements`, `invitations`, `reserved-slugs`                                |
| `profiles`           | none                                                                                         | `memberships`                                                                                             |
| `outbox`             | none                                                                                         | `data-lifecycle`                                                                                          |
| `notifications`      | `updated-at`                                                                                 | `permissions`, `profiles`, `memberships`, `data-lifecycle`                                                |
| `webhooks-out`       | `updated-at`                                                                                 | `permissions`, `service-accounts`, `data-lifecycle`                                                       |
| `sessions`           | none                                                                                         | none                                                                                                      |
| `webhooks-in`        | `permissions`, `updated-at`, `webhook-inbox`                                                 | none                                                                                                      |
| `api-keys`           | `memberships`, `permissions`                                                                 | none                                                                                                      |
| `settings`           | `memberships`, `permissions`                                                                 | `jsonb-schemas`                                                                                           |
| `usage`              | `memberships`, `permissions`                                                                 | `entitlements`, `data-lifecycle`                                                                          |
| `billing`            | `memberships`, `permissions`                                                                 | `organizations`                                                                                           |
| `flags`              | `memberships`                                                                                | `permissions`, `entitlements`                                                                             |
| `comments`           | `memberships`, `permissions`, `subjects`, `messaging`                                        | `notifications`, `tasks`, `reactions`, `attachments`                                                      |
| `attachments`        | `memberships`, `permissions`                                                                 | `profiles`, `subjects`                                                                                    |
| `data-lifecycle`     | `memberships`, `permissions`                                                                 | every installed module                                                                                    |
| `sso`                | `memberships`, `permissions`                                                                 | `organizations`, `data-lifecycle`                                                                         |
| `onboarding`         | `memberships`, `permissions`, `enrollments`                                                  | `outbox`                                                                                                  |
| `waitlist`           | `memberships`, `permissions`                                                                 | `invitations`, `organizations`                                                                            |
| `announcements`      | `memberships`, `permissions`                                                                 | `entitlements`                                                                                            |
| `streams`            | none                                                                                         | none                                                                                                      |
| `credentials`        | none                                                                                         | none                                                                                                      |
| `workflows`          | `memberships`, `permissions`                                                                 | `data-lifecycle`                                                                                          |
| `workflow-sdk`       | `workflows`, `queue`, `permissions`                                                          | none                                                                                                      |
| `workflow-editor`    | `workflows`, `memberships`, `permissions`                                                    | `service-accounts`, `subjects`, `teams`                                                                   |
| `chat-sdk-state`     | none                                                                                         | none                                                                                                      |
| `inbox`              | `memberships`, `permissions`, `updated-at`, `teams`, `messaging`, `attachments`              | `queue`, `notifications`, `reactions`, `data-lifecycle`, `automations`, `tasks`                           |
| `ai-chat`            | `memberships`, `permissions`, `streams`                                                      | `entitlements`, `ai-keys`, `approvals`, `data-lifecycle`, `usage`, `share-links`                          |
| `ai-files`           | `memberships`, `permissions`                                                                 | `ai-chat`                                                                                                 |
| `knowledge`          | `memberships`, `permissions`, `vector-search`                                                | `ai-chat`, `ai-files`, `queue`                                                                            |
| `memory`             | `memberships`, `permissions`, `vector-search`                                                | `ai-chat`                                                                                                 |
| `agents`             | `memberships`, `permissions`                                                                 | none                                                                                                      |
| `connectors`         | `memberships`, `permissions`                                                                 | none                                                                                                      |
| `ai-schedules`       | `memberships`, `permissions`                                                                 | `agents`, `ai-chat`, `queue`                                                                              |
| `push`               | none                                                                                         | none                                                                                                      |
| `ai-cache`           | none                                                                                         | none                                                                                                      |
| `ai-keys`            | `memberships`, `permissions`                                                                 | `ai-chat`                                                                                                 |
| `numbering`          | `memberships`, `permissions`                                                                 | `outbox`, `audit`                                                                                         |
| `teams`              | `memberships`, `permissions`                                                                 | `inbox`, `outbox`, `audit`                                                                                |
| `tags`               | `memberships`, `permissions`                                                                 | `outbox`, `drive`, `bulk`                                                                                 |
| `state-machine`      | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `approvals`, `bulk`                                                                    |
| `custom-fields`      | `memberships`, `permissions`                                                                 | `outbox`, `field-encryption`, `audit`                                                                     |
| `share-links`        | `memberships`, `permissions`                                                                 | `outbox`, `versions`                                                                                      |
| `reminders`          | `memberships`, `permissions`, `queue`                                                        | `notifications`, `outbox`, `subjects`                                                                     |
| `imports`            | `memberships`, `permissions`, `queue`                                                        | `attachments`, `outbox`                                                                                   |
| `subjects`           | `permissions`                                                                                | none                                                                                                      |
| `rank`               | `permissions`, `subjects`                                                                    | `queue`                                                                                                   |
| `saved-views`        | `memberships`, `permissions`                                                                 | `outbox`, `teams`, `collections`                                                                          |
| `relations`          | `memberships`, `permissions`                                                                 | `outbox`, `bulk`                                                                                          |
| `bulk`               | `memberships`, `permissions`                                                                 | `queue`, `outbox`                                                                                         |
| `search`             | `memberships`, `permissions`, `subjects`                                                     | `share-links`, `vector-search`                                                                            |
| `metrics`            | `memberships`, `permissions`                                                                 | `usage`, `queue`                                                                                          |
| `calendar`           | `memberships`, `permissions`                                                                 | `share-links`, `reminders`, `outbox`, `leave`                                                             |
| `bookings`           | `memberships`, `permissions`                                                                 | `queue`, `share-links`, `rate-limit`, `calendar`, `reminders`, `outbox`                                   |
| `versions`           | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `share-links`                                                                          |
| `trash`              | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `data-lifecycle`, `bulk`                                                               |
| `reactions`          | `memberships`, `permissions`                                                                 | `outbox`, `messaging`                                                                                     |
| `approvals`          | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `notifications`, `queue`, `drive`, `teams`, `state-machine`, `subjects`                |
| `access-requests`    | `memberships`, `permissions`, `approvals`                                                    | `outbox`, `audit`                                                                                         |
| `documents`          | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `queue`, `versions`                                                                    |
| `catalog`            | `memberships`, `permissions`                                                                 | `outbox`, `state-machine`, `inventory`, `attachments`                                                     |
| `inventory`          | `memberships`, `permissions`, `catalog`, `ledger`                                            | `outbox`, `assets`                                                                                        |
| `drive`              | `memberships`, `permissions`, `drive-files`                                                  | `outbox`, `audit`, `attachments`, `versions`, `trash`, `teams`, `share-links`                             |
| `drive-files`        | `attachments`                                                                                | `drive`, `outbox`, `audit`, `versions`, `trash`, `teams`, `share-links`, `tags`, `approvals`              |
| `orders`             | `memberships`, `permissions`, `catalog`                                                      | `outbox`, `inventory`, `promotions`, `numbering`, `state-machine`, `invoices`                             |
| `promotions`         | `memberships`, `permissions`, `catalog`, `orders`                                            | `outbox`                                                                                                  |
| `forms`              | `memberships`, `permissions`, `jsonb-schemas`                                                | `custom-fields`, `share-links`, `rate-limit`, `outbox`, `audit`                                           |
| `signatures`         | `memberships`, `permissions`, `versions`                                                     | `approvals`, `attachments`, `outbox`, `audit`                                                             |
| `time-tracking`      | `memberships`, `permissions`, `approvals`                                                    | `outbox`, `audit`                                                                                         |
| `identities`         | none                                                                                         | `inbox`                                                                                                   |
| `service-accounts`   | `memberships`, `permissions`                                                                 | `audit`                                                                                                   |
| `templates`          | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `workflow-editor`, `jsonb-schemas`                                                     |
| `pipelines`          | `memberships`, `permissions`, `subjects`                                                     | `outbox`, `audit`, `state-machine`, `notifications`                                                       |
| `contacts`           | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `notifications`, `inbox`                                                               |
| `activities`         | `memberships`, `permissions`, `subjects`                                                     | `outbox`, `notifications`, `ai-files`, `queue`, `bookings`, `contacts`                                    |
| `deals`              | `memberships`, `permissions`, `subjects`, `pipelines`                                        | `outbox`, `notifications`, `numbering`, `activities`                                                      |
| `tasks`              | `memberships`, `permissions`, `subjects`, `pipelines`                                        | `outbox`, `notifications`, `numbering`, `attachments`, `reminders`                                        |
| `quotes`             | `memberships`, `permissions`, `subjects`                                                     | `outbox`, `notifications`, `numbering`, `deals`, `relations`, `catalog`, `automations`, `orders`          |
| `invoices`           | `memberships`, `permissions`, `subjects`, `ledger`                                           | `outbox`, `audit`, `notifications`, `numbering`, `quotes`, `relations`                                    |
| `sla`                | `memberships`, `permissions`, `subjects`                                                     | `outbox`, `notifications`, `tasks`                                                                        |
| `goals`              | `memberships`, `permissions`                                                                 | `deals`, `tasks`, `invoices`                                                                              |
| `hiring`             | `memberships`, `permissions`, `subjects`, `pipelines`                                        | `outbox`, `automations`, `employees`                                                                      |
| `employees`          | `memberships`, `permissions`                                                                 | `outbox`, `relations`, `hiring`, `time-tracking`                                                          |
| `collections`        | `memberships`, `permissions`, `subjects`                                                     | `outbox`, `teams`, `bulk`                                                                                 |
| `signing-keys`       | none                                                                                         | `outbox`, `audit`                                                                                         |
| `security-events`    | none                                                                                         | `webhooks-out`, `outbox`, `audit`                                                                         |
| `oauth-server`       | none                                                                                         | `outbox`, `audit`                                                                                         |
| `domains`            | `memberships`, `permissions`                                                                 | `reserved-slugs`                                                                                          |
| `moderation`         | `memberships`, `permissions`                                                                 | `messaging`, `outbox`, `audit`                                                                            |
| `reviews`            | `memberships`, `permissions`, `moderation`                                                   | `catalog`, `orders`, `subjects`, `notifications`, `outbox`, `audit`                                       |
| `leave`              | `memberships`, `permissions`, `approvals`, `ledger`                                          | `outbox`, `audit`, `employees`                                                                            |
| `automations`        | `memberships`, `permissions`                                                                 | none                                                                                                      |
| `vercel-marketplace` | none                                                                                         | `outbox`, `audit`                                                                                         |
| `ledger`             | `memberships`, `permissions`                                                                 | `outbox`, `audit`                                                                                         |
| `assets`             | `memberships`, `permissions`                                                                 | `bookings`, `tasks`, `outbox`, `audit`                                                                    |
| `scores`             | `memberships`, `permissions`, `automations`                                                  | `reactions`, `outbox`, `audit`                                                                            |
| `enrollments`        | `memberships`, `permissions`                                                                 | `outbox`, `audit`                                                                                         |
| `guests`             | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `invoices`, `bookings`, `contacts`                                                     |
| `passes`             | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `bookings`, `signing-keys`, `ledger`                                                   |
| `billable-time`      | `memberships`, `permissions`, `subjects`, `time-tracking`, `invoices`                        | `outbox`, `audit`                                                                                         |
| `leases`             | `memberships`, `permissions`, `subjects`, `assets`, `ledger`                                 | `outbox`, `audit`                                                                                         |
| `expenses`           | `memberships`, `permissions`, `approvals`, `ledger`                                          | `outbox`, `audit`, `attachments`                                                                          |
| `donations`          | `memberships`, `permissions`, `subjects`, `ledger`, `numbering`                              | `outbox`, `audit`                                                                                         |
| `dispatch`           | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `assets`, `bookings`, `catalog`, `inventory`                                           |
| `shipments`          | `memberships`, `permissions`                                                                 | `outbox`, `audit`, `assets`                                                                               |
| `contracts`          | `memberships`, `permissions`, `subjects`, `versions`, `signatures`, `reminders`              | `outbox`, `audit`                                                                                         |
| `compliance`         | `memberships`, `permissions`, `versions`                                                     | `attachments`, `notifications`, `outbox`, `audit`                                                         |
| `incidents`          | `memberships`, `permissions`                                                                 | `notifications`, `outbox`, `audit`                                                                        |
| `courses`            | `memberships`, `permissions`, `enrollments`                                                  | `forms`, `outbox`, `audit`                                                                                |
| `messaging`          | `memberships`, `permissions`, `subjects`                                                     | `notifications`, `queue`, `profiles`, `attachments`, `reactions`, `data-lifecycle`, `inbox`, `moderation` |
| `channels`           | `memberships`, `permissions`, `subjects`, `messaging`                                        | `outbox`, `audit`, `data-lifecycle`                                                                       |
| `timeline`           | `memberships`, `permissions`                                                                 | `notifications`, `subjects`, `audit`, `workflows`                                                         |
| `mail-sync`          | `memberships`, `permissions`, `subjects`                                                     | `outbox`, `audit`, `contacts`, `activities`, `data-lifecycle`                                             |
| `segments`           | `memberships`, `permissions`, `subjects`, `automations`                                      | `outbox`, `audit`, `data-lifecycle`                                                                       |
| `assignment`         | `memberships`, `permissions`, `automations`                                                  | `outbox`, `data-lifecycle`                                                                                |
| `enrichment`         | `memberships`, `permissions`, `contacts`                                                     | `outbox`, `data-lifecycle`                                                                                |
| `forecasts`          | `memberships`, `permissions`, `deals`                                                        | `outbox`, `data-lifecycle`                                                                                |
| `field-history`      | `memberships`, `permissions`, `subjects`                                                     | `data-lifecycle`                                                                                          |
| `sequences`          | `memberships`, `permissions`, `enrollments`                                                  | `mail-sync`, `contacts`, `tasks`, `outbox`, `audit`                                                       |
| `lead-scoring`       | `memberships`, `permissions`, `contacts`, `scores`                                           | `enrichment`, `segments`, `mail-sync`                                                                     |
| `posts`              | `memberships`, `permissions`, `subjects`, `messaging`, `comments`, `reactions`, `moderation` | `outbox`, `audit`                                                                                         |
| `field-encryption`   | none                                                                                         | none                                                                                                      |
| `content`            | `memberships`, `permissions`, `subjects`                                                     | `approvals`, `versions`, `attachments`, `outbox`, `audit`                                                 |
| `rls-guard`          | none                                                                                         | none                                                                                                      |

The other SQL modules (`updated-at`, `actor`, `rate-limit`,
`impersonation` and the rest) are listed on the
[SQL modules](/docs/blocks/sql#modules) page. The
[roadmap](/docs/roadmap) lists what comes next.

# Insurance and claims

> The claims preset installs claimants, a claim state machine, approvals, evidence attachments, SLA clocks and comments.

Source: https://bettersupabase.com/docs/presets/claims

The `claims` preset is for a claims system in the style of Guidewire. A
claimant reports a claim, an adjuster investigates it with the evidence
attached, a payout above a limit needs approval, and SLA clocks keep each
step on time.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "claims" });
```

`npx better-supabase init --preset claims` starts a new project from it, and
`npx better-supabase add --preset claims` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                       | Role in the app                             |
| ------------------------------------------- | ------------------------------------------- |
| [parties](/docs/blocks/contacts)            | Policyholders, claimants and third parties  |
| [state-machine](/docs/blocks/state-machine) | The claim's status, from reported to closed |
| [approvals](/docs/blocks/approvals)         | Payout approval above a limit               |
| [attachments](/docs/blocks/attachments)     | Photos, reports and invoices as evidence    |
| [sla](/docs/blocks/sla)                     | First contact and resolution clocks         |
| [comments](/docs/blocks/comments)           | Adjusters' notes on the claim               |

## How the blocks connect [#how-the-blocks-connect]

A claim is your own table, registered as a subject, with a state machine on
its status column. Mark the edge to `approved` with `requiresApproval`, and
give the approvals policy for claims `run: { transition: "approved" }`: the
final approval of the payout then runs the transition, and nobody can move a
claim to `approved` without it ([approvals](/docs/blocks/approvals#run-the-approved-action)).
SLA clocks start when the claim is reported and pause while it waits on the
claimant.

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "claims",
  records: {
    claim: {
      table: "public.claims",
      idType: "uuid",
      tenant: "organization_id",
    },
  },
  blocks: {
    "state-machine": {
      options: {
        machines: {
          claim: {
            column: "status",
            initial: "reported",
            transitions: {
              reported: ["investigating", "rejected"],
              investigating: ["approved", "rejected"],
              approved: ["paid"],
              paid: ["closed"],
              rejected: ["closed"],
            },
            requiresApproval: { investigating: ["approved"] },
          },
        },
      },
    },
  },
});
```

## With ReUI [#with-reui]

- Data Grid for the claim queue, with Filters on status, adjuster and SLA.
- Stepper for the claim's status on its page.
- Timeline for the claim's history, evidence and notes.

# Clinic and therapy

> The clinic preset installs appointments, intake forms, note templates, consent signatures, field encryption for sensitive values, a client portal and reminders.

Source: https://bettersupabase.com/docs/presets/clinic

The `clinic` preset is for an app in the style of Jane or SimplePractice. A
client books an appointment, fills in the intake form and signs the consent,
the practitioner writes session notes from a template, and the client reads
their own records in a portal. Sensitive values are encrypted at rest with a
key per organization, and reads of them can go to the audit log.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "clinic" });
```

`npx better-supabase init --preset clinic` starts a new project from it, and
`npx better-supabase add --preset clinic` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                             | Role in the app                                                 |
| ------------------------------------------------- | --------------------------------------------------------------- |
| [calendar](/docs/blocks/calendar)                 | Practitioners' calendars and availability                       |
| [bookings](/docs/blocks/bookings)                 | Appointments on practitioners and rooms, with no double booking |
| [forms](/docs/blocks/forms)                       | Intake and assessment forms                                     |
| [templates](/docs/blocks/templates)               | Session note templates each clinic forks                        |
| signatures                                        | Consent records, signed on a frozen version                     |
| [custom-fields](/docs/blocks/custom-fields)       | Client fields, with sensitive ones encrypted                    |
| [field-encryption](/docs/blocks/field-encryption) | Data keys in Vault that encrypt columns and custom fields       |
| [guests](/docs/blocks/guests)                     | The client portal                                               |
| [reminders](/docs/blocks/reminders)               | Appointment reminders                                           |

The `audit` block from `saas` records who changed what.

## Encryption and read audits [#encryption-and-read-audits]

The preset installs `field-encryption`; what to encrypt and which reads to
log stay in your config, because they name your own tables and fields. Mark
a sensitive app table column `encrypted: true` and set `auditReads` on the
table, and turn on `auditReads` for custom fields:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "clinic",
  blocks: { "custom-fields": { options: { auditReads: true } } },
});
```

A custom field definition is encrypted with `encryptDefinition(id)`, and its
values are read back through `readValues`. Each read with `auditReads` writes
one `read` entry to the audit log per tenant and call, with the ids read
([field encryption](/docs/blocks/field-encryption),
[encrypted columns](/docs/repository/tables#encrypted-columns)). Encrypted
values have no searchable equality, so keep a separate non-sensitive column
for lookups.

## How the blocks connect [#how-the-blocks-connect]

An appointment is a booking on a practitioner's resource. The intake form
and the consent are linked to the client's record, and the consent
signature keeps the exact version the client signed. A client is a guest,
never a member: grant them their bookings and they read those in the portal.
The [guests](/docs/blocks/guests#grant-records) page lists which blocks
honor a grant; show the rest of the client's record from server code after
checking their grants with `guests.mine()`.

## With ReUI [#with-reui]

- Event Calendar for the practitioners' day and week views.
- Stepper for intake: form, consent, booking.
- Data Grid for the client list, with Filters on practitioner and status.

# CMS

> The cms preset installs pages and posts with locales and scheduled publishing, versions, publish approvals, public media, tags, comments and search.

Source: https://bettersupabase.com/docs/presets/cms

The `cms` preset is for a headless CMS in the style of Contentful or
Sanity. Editors write pages and posts per locale, an editor with
`content.publish` approves what goes live, and a marketing site reads the
published entries and their images with the publishable key.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "cms" });
```

`npx better-supabase init --preset cms` starts a new project from it, and
`npx better-supabase add --preset cms` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                              |
| --------------------------------------- | ------------------------------------------------------------ |
| [content](/docs/blocks/content)         | Pages and posts with slugs, locales and scheduled publishing |
| [versions](/docs/blocks/versions)       | Saved versions of an entry to compare and restore            |
| [approvals](/docs/blocks/approvals)     | The review an entry waits on before it goes live             |
| [attachments](/docs/blocks/attachments) | Cover images and media, served by public URL                 |
| [tags](/docs/blocks/tags)               | Topics and sections                                          |
| [comments](/docs/blocks/comments)       | Editorial notes on a draft                                   |
| [search](/docs/blocks/search)           | Search across entries for the editors                        |

## How the blocks connect [#how-the-blocks-connect]

The preset sets two block options for you. `blocks.content.options.requireApproval`
is `true`, so a member's `publish` or `schedule` opens an approvals request
and the entry goes live once it is approved
([with approval](/docs/blocks/content#with-approval)). The attachments
bucket is public (`blocks.attachments.options.publicBuckets` with
`requireScan: false`), so an entry's cover and the images in its body load
from `attachments.publicUrl()` without a signed URL. Every file in that
bucket is readable by anyone who has its path. If your app also keeps
private files, set `publicBuckets: []` and serve media from a bucket of
its own.

Published entries reach the site through `content.published` and
`content.listPublished`, which the `anon` role may call and which return only
public fields ([public reads](/docs/blocks/content#public-reads)). A
scheduled entry goes live on the content block's `publish` timer, which the
preset's queue runs (`blockSchedules` and `blockTickHandler`, in the
[block timers](/docs/blocks/queue#block-timers) section).

To publish without review, turn the approval off:

```ts title="better-supabase.config.ts"
export default defineConfig({
  preset: "cms",
  blocks: { content: { options: { requireApproval: false } } },
});
```

## With ReUI [#with-reui]

- Data Grid for the entry list, with Filters on type, locale and status.
- A Badge for each entry's status (draft, scheduled, published).
- A Dialog to compare two versions before a restore.

# Commerce

> A store with a catalog imported from CSV, stock, carts on anonymous sign-ins, promotion codes, checkout, invoices and shipping.

Source: https://bettersupabase.com/docs/presets/commerce

A visitor fills a cart before signing in, applies a code and checks out
once they sign in. The catalog prices the cart, promotions take their
discount, inventory reserves the stock, and the order gets a gapless order
number and, once paid, an invoice number. The people watching the order
hear when it is paid and when it ships. Revenue, average order value and
units sold chart from paid orders, and a large refund or a large discount
waits for an admin to approve it. Staff search the products from the admin
and keep saved views of the order grid.

## Tables [#tables]

The catalog, stock, carts, orders and promotions are module tables, so the
app adds none of its own. Orders are subjects of type `order`: their events
carry `subjectType: "order"` and the order id, so `watcherHandler` and
`notifications.subscribe` work on them like on any app row. Registering
`order` and `promotion` in `subjects` let approvals file requests on orders
and promotions, and the `search` entry on `product` indexes the catalog.

Three pieces of app SQL read the module tables, so they go in a schema file
that runs after the modules. Order lines carry no time, so units sold reads
them through a view that joins each line to its order's `paid_at`. The
`after_refund_create` hook runs in the transaction that records a refund
and refuses one above 50.00 unless the order's latest `refund` request was
approved. `before_promotion_activate` runs whenever a promotion ends up
active and refuses a percentage above 30 unless the promotion's latest
`activate` request was approved:

```sql title="supabase/schemas/commerce.sql"
create schema store;
grant usage on schema store to authenticated, service_role;
create view store.units_sold with (security_invoker = true) as
  select l.tenant_id, o.paid_at, l.quantity
  from better_supabase.order_lines l
  join better_supabase.orders o on o.id = l.order_id;
grant select on store.units_sold to authenticated, service_role;

create function public.after_refund_create(refund uuid)
returns void
language plpgsql
set search_path = ''
as $$
declare
  v_refund record;
begin
  select r.order_id, r.amount into v_refund
  from better_supabase.order_refunds r where r.id = refund;
  if v_refund.amount > 5000
    and not better_supabase.is_approved('order', v_refund.order_id::text, 'refund') then
    raise exception 'a refund above 50.00 needs an approved refund request'
      using errcode = '42501', hint = 'REFUND_APPROVAL_REQUIRED';
  end if;
end;
$$;
revoke execute on function public.after_refund_create(uuid) from public, anon, authenticated;

create function public.before_promotion_activate(promotion uuid)
returns void
language plpgsql
set search_path = ''
as $$
begin
  if exists (
    select 1 from better_supabase.promotions p
    where p.id = promotion and p.kind = 'percentage' and p.percent > 30
  ) and not better_supabase.is_approved('promotion', promotion::text, 'activate') then
    raise exception 'a discount above 30 percent needs an approved activate request'
      using errcode = '42501', hint = 'PROMOTION_APPROVAL_REQUIRED';
  end if;
end;
$$;
revoke execute on function public.before_promotion_activate(uuid) from public, anon, authenticated;
```

## Config [#config]

```ts title="src/metrics.ts"
import { defineMetric } from "better-supabase/blocks/metrics";
import { orderMetrics } from "better-supabase/blocks/orders";

export const metrics = [
  ...orderMetrics({ currency: "EUR" }),
  defineMetric({
    key: "units_sold",
    label: "Units sold",
    table: "store.units_sold",
    tenant: "tenant_id",
    measure: { sum: "quantity" },
    timeColumn: "paid_at",
  }),
] as const;
```

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

import { metrics } from "./src/metrics";

export default defineConfig({
  preset: "commerce",
  records: {
    order: {
      table: "better_supabase.orders",
      idType: "uuid",
      tenant: "tenant_id",
      title: "number",
    },
    promotion: {
      table: "better_supabase.promotions",
      idType: "uuid",
      tenant: "tenant_id",
      title: "name",
    },
    product: {
      table: "better_supabase.products",
      idType: "uuid",
      tenant: "tenant_id",
      title: "title",
      url: "/admin/products/{id}",
      search: { body: ["description"] },
    },
  },
  blocks: {
    imports: {},
    numbering: {
      options: {
        entities: {
          order: { period: "none", format: "SO-{n:4}" },
          invoice: { period: "year", format: "INV-{yyyy}-{n:5}" },
        },
      },
    },
    search: {},
    "saved-views": {},
    metrics: { options: { metrics } },
    approvals: {
      options: {
        policies: {
          order: { refund: { steps: [{ approvers: { role: "admin" } }] } },
          promotion: {
            activate: { steps: [{ approvers: { role: "admin" } }] },
          },
        },
      },
    },
  },
});
```

An unpaid order has no `paid_at`, so it falls outside every bucket. The
`commerce` preset brings the SaaS blocks plus `catalog`, `inventory`,
`orders` and `promotions`, so orders reserve stock. The `order` and
`invoice` entities give the order and invoice numbers.

## The blocks [#the-blocks]

`orders` takes the payment and tax providers. The service transport merges
carts and records payment events; everything else runs as the caller, so
the policies apply:

```ts title="lib/store.ts"
import { fixedRates, stripePayments } from "better-supabase/commerce";
import { createOrders } from "better-supabase/blocks/orders";
import { createPromotions } from "better-supabase/blocks/promotions";

export const orders = createOrders({
  transport,
  service,
  payments: stripePayments({
    stripe: { secretKey: env.STRIPE_SECRET_KEY },
    webhookSecret: env.STRIPE_WEBHOOK_SECRET,
  }),
  tax: fixedRates({ rates: { NL: { standard: "0.21" } } }),
});
export const promotions = createPromotions({ transport });
```

## Importing the catalog [#importing-the-catalog]

An import reads the product CSV and writes each batch through the catalog
block, so a product gets the same checks as one made in the admin. The row
key is the SKU, so a rerun skips what already imported:

```ts title="lib/imports/products.ts"
import { defineImport } from "better-supabase/blocks/imports";
import { money } from "better-supabase/commerce";

export const products = defineImport({
  kind: "products",
  columns: productRow,
  key: (row) => row.sku,
  async insert(batch, { organizationId }) {
    for (const row of batch) {
      const product = await catalog
        .createProduct(organizationId, { title: row.title })
        .orThrow();
      await catalog.setProductStatus(product.id, "active").orThrow();
      const variant = await catalog
        .createVariant(product.id, { sku: row.sku })
        .orThrow();
      await catalog
        .setPrice(variant.id, { amount: money(BigInt(row.price), "EUR") })
        .orThrow();
    }
  },
});
```

## From cart to shipment [#from-cart-to-shipment]

### Shop anonymously [#shop-anonymously]

The storefront signs the visitor in with `supabase.auth.signInAnonymously()`,
then keeps their cart:

```ts
const cart = await orders
  .createCart(organizationId, { currency: "EUR" })
  .orThrow();
await orders.addToCart(cart.id, variantId, 2).orThrow();
await promotions.applyCode(cart.id, "WELCOME10").orThrow();
```

### Merge on sign-in [#merge-on-sign-in]

When the visitor signs in to an account, the auth callback route moves the
anonymous user's cart to the account. A cart the account already had in the
same currency takes the lines, adding up quantities, and the promotion
codes:

```ts title="app/auth/callback/route.ts"
await orders.mergeCarts(anonymousUserId, user.id).orThrow();
```

### Check out [#check-out]

`priceCart` shows the discount and any problem that blocks checkout, such
as a code that no longer applies. `checkout` places the order, reserves the
stock, redeems the promotions and creates the payment with an idempotency
key, so a retry returns the same order and payment. Once the payment
succeeds, the reservations turn into `sale` movements and the order gets
its invoice number:

```ts
const { order, payment } = await orders.checkout(cart.id).orThrow();
```

### Ship [#ship]

Staff create and ship a fulfillment:

```ts
const fulfillment = await orders.createFulfillment(order.id).orThrow();
await orders.shipFulfillment(fulfillment.id, { carrier: "PostNL" }).orThrow();
```

### Refund [#refund]

A refund up to 50.00 goes through at once. A larger one fails with
`REFUND_APPROVAL_REQUIRED` until an admin approves a `refund` request on the
order:

```ts
const refund = await orders.refund(order.id);
if (!refund.ok && refund.error.hint === "REFUND_APPROVAL_REQUIRED") {
  await approvals
    .request({ type: "order", id: order.id }, { action: "refund", reason })
    .orThrow();
}
```

Once the request is approved, the same `orders.refund(order.id)` call
succeeds.

### Approve a large discount [#approve-a-large-discount]

A promotion above 30 percent can't go live on its own. Save it inactive,
file an `activate` request, and turn it on once an admin approves it:

```ts
const draft = await promotions
  .savePromotion(organizationId, {
    name: "Clearance",
    discount: { kind: "percentage", percent: "50" },
    active: false,
  })
  .orThrow();
await approvals
  .request({ type: "promotion", id: draft.id }, { action: "activate", reason })
  .orThrow();
// After approvals.approve(requestId):
await promotions.setPromotionActive(draft.id, true).orThrow();
```

Saving it active, or activating it before the approval, fails with
`PROMOTION_APPROVAL_REQUIRED`. The hook also runs on every save that leaves a
promotion live, so an edit can't take an unapproved promotion above 30
percent.

## Metrics [#metrics]

Relay `order.placed` and `order.paid` to the metrics sink so the charts
include an order as soon as it is placed and paid, and run `metrics.job` on a schedule for late changes.
The metrics block's `sink` option builds the sink:

```ts title="app/api/outbox/route.ts"
const app = createBlocks(
  { transport: service },
  {
    metrics: metricsBlock
      .with({
        sink: {
          on: {
            ...ORDER_METRIC_EVENTS,
            "order.paid": [...ORDER_METRIC_EVENTS["order.paid"], "units_sold"],
          },
        },
      })
      .using((options) => createMetrics({ ...options, metrics })),
  },
);

export const GET = outbox.relayRoute({
  secret: process.env.CRON_SECRET,
  consumers: { ...app.sinks },
});
```

Stat cards read `kpi("revenue", range, { organizationId })` and charts read
`series`, as the caller.

## Admin search [#admin-search]

[Search](/docs/blocks/search) indexes each product's title and description
as it is saved. `search(q)` runs as the caller and only returns rows of the
caller's organizations, so it belongs in the admin: a shopper who isn't a
member gets no hits. The storefront lists products through the catalog.

```ts
const search = createSearch({ transport, service });

const hits = await search
  .search("linnen", { types: ["product"], limit: 10 })
  .orThrow();
// [{ subjectType: "product", subjectId, title: "Linen tee", url: "/admin/products/..." }]
```

The query matches words in the description and typos in the title, and
`useCommandSearch` puts it behind Cmd+K.

## Saved views of the order grid [#saved-views-of-the-order-grid]

Add `better_supabase` to `schemas` in the config so `gen` types the module
tables, then define the order grid as a [list query](/docs/platform/list)
and pass it to [saved views](/docs/blocks/saved-views), which checks each
view against it before saving:

```ts title="lib/orders-list.ts"
import { createSavedViews } from "better-supabase/blocks/saved-views";
import { defineListQuery } from "better-supabase/list";

export const orderList = defineListQuery(
  betterSupabase,
  "betterSupabaseOrders",
  {
    search: ["number", "email"],
    facets: { status: "status" },
    sorts: {
      newest: [{ placedAt: "desc" }, { id: "asc" }],
      oldest: [{ placedAt: "asc" }, { id: "asc" }],
    },
    defaultSort: "newest",
  },
);

export const savedViews = createSavedViews({
  transport,
  lists: { orders: orderList },
});

await savedViews
  .upsert({
    organizationId,
    list: "orders",
    name: "To ship",
    scope: "tenant",
    state: { query: { facets: { status: ["paid"] }, sort: "oldest" } },
  })
  .orThrow();
```

A view with a sort or facet the list doesn't define returns a `validation`
error and isn't saved.

## Event flow [#event-flow]

| What happens         | Call                            | Event                 | Watchers get    |
| -------------------- | ------------------------------- | --------------------- | --------------- |
| The order is paid    | `orders.checkout(cartId)`       | `order.paid`          | `order.paid`    |
| A code is redeemed   | `orders.checkout(cartId)`       | `promotion.redeemed`  |                 |
| The order ships      | `orders.shipFulfillment(id)`    | `fulfillment.shipped` | `order.shipped` |
| The catalog imported | `runImport(products, csv, ...)` | `import.completed`    |                 |
| A refund is approved | `approvals.approve(requestId)`  | `approval.approved`   |                 |

```ts title="app/api/cron/watchers/route.ts"
const watchers = watcherHandler(notifications, {
  types: { "order.paid": "order.paid", "fulfillment.shipped": "order.shipped" },
});
```

`promotion.redeemed` has no subject, so it goes to a consumer of its own,
such as a marketing sink, instead of to watchers.

## With ReUI [#with-reui]

- Product grid and product detail for the storefront, from the catalog's
  products, variants and prices.
- Shopping cart and coupon for the cart, from `priceCart` and
  `promotions.applyCode`.
- `checkout-2` for checkout, with the payment's `clientSecret`.
- Receipt for the order page, with the order and invoice numbers.
- The inventory blocks for stock levels, from `inventory`.
- Stat cards and charts for revenue, average order value and units sold,
  from `metrics.kpi` and `metrics.series`.
- A command menu for product search, from `useCommandSearch`, and a data
  grid with a views menu for orders, from `orderList` and `useSavedViews`.

Pro ecommerce blocks are licensed per user; copy them from
[reui.io](https://reui.io).

# Community

> The community preset installs forum boards of voted posts with replies, chat channels, reactions, moderation with reports and bans, post scores, tags and search.

Source: https://bettersupabase.com/docs/presets/community

The `community` preset is for an app in the style of Discourse or Circle.
Members ask questions and start discussions on forum boards, vote on the
posts they find useful, reply in threads and talk in channels. Moderators
work through reported content and ban members who break the rules.

## Config [#config]

Hold new posts and edits for review with the posts block's `moderation`
option; posts show at once by default, so the preset leaves it to your
config:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "community",
  blocks: { posts: { options: { moderation: "pre" } } },
});
```

`npx better-supabase init --preset community` starts a new project from it,
and `npx better-supabase add --preset community` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                 | Role in the app                                         |
| ------------------------------------- | ------------------------------------------------------- |
| [posts](/docs/blocks/posts)           | Forum boards of posts, pinned and locked by moderators  |
| [messaging](/docs/blocks/messaging)   | The core under replies, channels and direct messages    |
| [channels](/docs/blocks/channels)     | Public and private chat channels                        |
| [comments](/docs/blocks/comments)     | Threaded replies on a post                              |
| [reactions](/docs/blocks/reactions)   | Up and down votes on posts, emoji on messages           |
| [moderation](/docs/blocks/moderation) | Reports, the review queue, hiding and bans              |
| [scores](/docs/blocks/scores)         | A score per post from its votes, with bands such as hot |
| [tags](/docs/blocks/tags)             | Topics on posts                                         |
| [search](/docs/blocks/search)         | Search over posts and channels for a Cmd+K dialog       |

## How the blocks connect [#how-the-blocks-connect]

A forum is a posts board of kind `forum`. `posts.vote(postId, value)` stores
the vote as a reaction, and `list({ sort: "top" })` orders a board by it.
Replies are comments on the subject `post`, so they are messages on the
messaging core, with mentions and reactions of their own
([posts](/docs/blocks/posts)).

Every post, reply and channel message is moderated through the same queue. A
report hides the content once it reaches the moderation block's
`autoHideThreshold`, a moderator's decision hides, removes or restores it,
and a banned member can't post, reply or report until the ban ends
([moderation](/docs/blocks/moderation)).

A scoring model over the `post` subject type can weigh a post's vote score
(a `votes` metric), and its bands name the hot posts that `scores.top` lists
for a front page. Compute it from an outbox sink on the post events
([scores](/docs/blocks/scores)).

## With ReUI [#with-reui]

- The [post board](/docs/registry/ui-recipes/post-board) recipe for a board
  sorted by votes, with an upvote toggle and a New post dialog.
- Data Grid for the moderation queue, with Filters on status and reason.
- A channel sidebar with unread counts as a Badge.

# Compliance

> The compliance preset installs frameworks and controls with recurring evidence tasks, evidence files, versioned policies that members acknowledge and a posture per framework.

Source: https://bettersupabase.com/docs/presets/compliance

The `compliance` preset is for an app in the style of Vanta or Drata. A
company maps its controls to a framework, collects evidence for each control
on a schedule, publishes policies that every employee acknowledges, and
shows an auditor the evidence trail.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "compliance" });
```

`npx better-supabase init --preset compliance` starts a new project from it,
and `npx better-supabase add --preset compliance` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                             |
| --------------------------------------- | ----------------------------------------------------------- |
| [controls](/docs/blocks/compliance)     | Frameworks, controls, evidence tasks and versioned policies |
| [attachments](/docs/blocks/attachments) | Evidence files                                              |

The controls block requires [versions](/docs/blocks/versions), which the
preset installs with it, and the `audit` block from `saas` keeps who changed
a control or its evidence.

## How the blocks connect [#how-the-blocks-connect]

A control belongs to a framework such as SOC 2 or ISO 27001. `addEvidence`
attaches a file or a note to it, valid until a date, and `saveEvidenceTask`
plans evidence that has to be collected again every `intervalDays`, with an
assignee. A control's status follows from its evidence and tasks: it fails
when a task is overdue or all its evidence has lapsed, and `posture` rolls
the statuses up per framework. `publishPolicy` freezes a policy as a version,
and each member acknowledges that version with `acknowledgePolicy`
([controls](/docs/blocks/compliance)).

## With ReUI [#with-reui]

- Data Grid for controls, with their status as a Badge and Filters on framework.
- Progress for each framework's readiness.
- Timeline for a control's evidence.

# Construction

> The construction preset installs punch lists and RFIs as work items, sites in assets, daily logs as forms, drawings with revisions, comments and submittal approvals.

Source: https://bettersupabase.com/docs/presets/construction

The `construction` preset is for an app in the style of Procore. A project
team keeps the drawings and their revisions, discusses them, raises punch
list items and requests for information (RFIs), files a daily log from site,
and routes submittals for approval.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "construction" });
```

`npx better-supabase init --preset construction` starts a new project from
it, and `npx better-supabase add --preset construction` adds it to one you
have ([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                            |
| --------------------------------------- | ---------------------------------------------------------- |
| [tasks](/docs/blocks/tasks)             | Punch list items and RFIs, with a due date and an assignee |
| [assets](/docs/blocks/assets)           | Sites, buildings, floors and rooms                         |
| [forms](/docs/blocks/forms)             | Daily logs and inspection checklists                       |
| [attachments](/docs/blocks/attachments) | Drawings, photos and submittal files                       |
| [versions](/docs/blocks/versions)       | Drawing revisions                                          |
| [comments](/docs/blocks/comments)       | Discussion on a drawing, an RFI or a punch list item       |
| [approvals](/docs/blocks/approvals)     | Submittal review by the architect and engineer             |

## How the blocks connect [#how-the-blocks-connect]

A drawing is an attachment on a site or a room in `assets`, and each
revision is a frozen version, so every earlier revision stays readable. An RFI is a work item with its own
pipeline, and a submittal is an approval request on the submitted files,
decided by the architect and the engineer in turn. A daily log is a form
submission linked to the site.

## With ReUI [#with-reui]

- Data Grid for punch list items and RFIs, with Filters on location and status.
- A drawing viewer that places comment pins on their anchors.
- Stepper for the daily log form.

# Contracts

> The contracts preset installs a contract lifecycle with renewals and obligations, counterparties as parties, signatures on a frozen version and renewal reminders.

Source: https://bettersupabase.com/docs/presets/contracts

The `contracts` preset is for an app in the style of Ironclad. Legal drafts a
contract with a counterparty, reviews it, sends it for signature, and the app
then tracks the obligations it creates, reminds the owner before the notice
deadline and renews or expires it.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "contracts" });
```

`npx better-supabase init --preset contracts` starts a new project from it,
and `npx better-supabase add --preset contracts` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                               | Role in the app                                        |
| ----------------------------------- | ------------------------------------------------------ |
| [contracts](/docs/blocks/contracts) | Contracts, their lifecycle, renewals and obligations   |
| [parties](/docs/blocks/contacts)    | Counterparties                                         |
| signatures                          | Signature requests on a frozen version of the contract |
| [versions](/docs/blocks/versions)   | The version each signature request signs               |
| [reminders](/docs/blocks/reminders) | The owner's reminder before the notice deadline        |

## How the blocks connect [#how-the-blocks-connect]

A contract moves from `draft` to `in_review`. `sendForSignature` freezes it
as a version and opens a signature request on that version; when every
signer signs, the contract becomes `active`. While it is active with an end
date, its owner has a reminder before the notice deadline, and `runRenewals`
run daily renews it when `autoRenew` is on or expires it otherwise. The
counterparty is any subject, such as a party, and `forCounterparty` lists a
party's contracts ([contracts](/docs/blocks/contracts)).

## With ReUI [#with-reui]

- Data Grid for contracts, with Filters on counterparty, status and end date.
- Timeline for a contract's review, signatures and renewals.
- Data Grid for open obligations, with Filters on owner and due date.

# The Create core

> How better-supabase/create holds the design model, previews and Supabase deploy client that /create and other apps share.

Source: https://bettersupabase.com/docs/presets/create

`better-supabase/create` is the headless code behind
[Create](https://app.bettersupabase.com/create). It runs on every runtime,
including the browser, and has no dependency beyond the Supabase packages.
Designs stay in the URL or in your own storage, as ADR 0040 requires.

```ts
import { previewFiles, diffDesigns } from "better-supabase/create";
import { defaultPreset } from "better-supabase/preset";

const base = { preset: defaultPreset() };
const files = previewFiles(base);
console.log(files.map((file) => file.path));
```

`diffDesigns(base, next)` classifies each change as safe or breaking, so an
app can block a breaking change before it deploys.

## Check a design [#check-a-design]

`checkDesign(input, preset)` runs every rule the canvas and `upgrade` apply
to a design, so a server route, an MCP tool and the canvas give the same
answer. `input` is a design object, a design code or a `/create` URL with
`?design=`; `preset` is a preset name or object.

```ts
import { checkDesign } from "better-supabase/create";

const check = await checkDesign(body.design, body.preset);

if (check.ok) {
  if (check.fitsUrl) {
    save(check.design, check.code);
  } else {
    offerDesignFile(check.design);
  }
} else {
  console.log(check.issues);
  // [{ code: "table_name_taken", path: "/appTables/profiles",
  //    table: "profiles", message: "A block already creates a profiles table. Rename this one." }]
}
```

It never throws for a bad design or preset. Each problem is an issue with a
stable `code`, a `path`, the `table` it is on (or `design` or `preset` for one
outside a table) and a `message`: a design that doesn't parse, a preset that
can't install, extra columns on a block the preset doesn't install, a table
name a block already uses, or a reference to a block table the preset doesn't
install. `path` is a [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901)
into the design, such as `/appTables/tasks/columns/title`, so a form can mark
the field; it is `""` for an issue with the preset. A design that doesn't
parse carries a [`DesignErrorCode`](/docs/presets/designs#designs); the
rules that need the preset add these codes:

| Code                 | What it means                                                       |
| -------------------- | ------------------------------------------------------------------- |
| `table_name_taken`   | a block already creates a table with this name                      |
| `name_too_long`      | a table name too long for the names Postgres derives from it        |
| `subject_type_taken` | another app table already has this subject type                     |
| `invalid_default`    | a column default its type doesn't accept                            |
| `default_required`   | a required column signed-in users can't write, with no default      |
| `column_clash`       | a column that clashes with one its record features add              |
| `unknown_column`     | a unique key or index naming a column the table doesn't have        |
| `missing_reference`  | a reference to a table the preset doesn't install                   |
| `set_null_required`  | a set-null delete on a required column                              |
| `preset_conflict`    | a conflict in the preset itself, such as a block that needs tenants |
| `invalid_preset`     | a preset that isn't a preset name or object                         |

On success, `design` has its empty parts dropped and `fitsUrl` says whether
its code fits in a `/create` URL. `code` is its `encodeDesign` code, and
`null` only when `fitsUrl` is false; offer the
[design file](/docs/presets/designs#design-files) then.

To check only the shape of an untrusted payload, without a preset, use
`parseDesign` from `better-supabase/preset`.

## Supabase deploy [#supabase-deploy]

`better-supabase/create/supabase` signs a user in with Supabase OAuth and
PKCE, seals the session in a cookie value, and wraps the Management API.

```ts
import { managementClient } from "better-supabase/create/supabase";

const client = managementClient(accessToken, fetch);
const projects = await client.projects();
```

Pass your own `fetch` to trace or stub the calls in tests.

### Deploy records [#deploy-records]

Give every deploy an id so a project can be matched to the preset it came
from. `newDeployId()` returns a random UUID, and `deployQuery(files, { id })`
writes it as `-- better-supabase deploy <id>` on the first line of the
migration. `DeployResponse` carries the `id`, the preset (a name or an object, as the
config's `preset` key takes it) and the design code (or `null`), and the host
app stores them as a `DeployRecord`:

```ts
import {
  deployQuery,
  MIGRATION_NAME,
  newDeployId,
  type DeployRecord,
} from "better-supabase/create";

const id = newDeployId();
const migration = deployQuery(files, { id });

// After the migration applies:
const record: DeployRecord = {
  id,
  ref: project.ref,
  preset: presetSpec(resolved.preset),
  design: designCode,
  migration: MIGRATION_NAME,
  deployedAt: Temporal.Now.instant().toString(),
};
```

Keep the record in your own table, keyed by the user who deployed. A visitor
who deploys before signing in to your app can keep the records in a sealed
cookie, and you move them to the table once they sign in. To find the record
for a project later, search the `statements` of the `better_supabase_blocks`
row in `supabase_migrations.schema_migrations` for the comment and look up its
id.

## OAuth for other providers [#oauth-for-other-providers]

`better-supabase/create/oauth` holds the parts of the Supabase sign-in that
don't depend on Supabase, so a second provider (a deploy target or a Git host)
can reuse them. It runs on WebCrypto only, on every runtime.

| Export                                | What it does                                                         |
| ------------------------------------- | -------------------------------------------------------------------- |
| `OAuthSession`, `isOAuthSession`      | An access token, an optional refresh token and `expiresAt` in ms     |
| `OAuthFlow`, `isFlow`                 | The state, PKCE verifier and return path kept between redirects      |
| `pkcePair()`, `randomToken()`         | A PKCE verifier with its S256 challenge, and random base64url tokens |
| `seal(value, secret, info?)`          | Encrypts a value with AES-GCM under a key derived through HKDF       |
| `unseal(sealed, secret, guard, info)` | Opens a sealed value, or returns `undefined` when it doesn't verify  |
| `needsRefresh(session, now)`          | Whether the access token expires within a minute                     |
| `OAuthError`, `DEFAULT_SEAL_INFO`     | The error the flows throw, and the HKDF info used when none is given |

Give each cookie its own `info`, so a cookie sealed for one provider can't be
replayed as another:

```ts
import { isOAuthSession, seal, unseal } from "better-supabase/create/oauth";

const cookie = await seal(session, secret, "my-app vercel session");
const back = await unseal(
  cookie,
  secret,
  isOAuthSession,
  "my-app vercel session",
);
```

`better-supabase/create/supabase` re-exports these helpers. `SupabaseSession`
and `isSession` stay as aliases of `OAuthSession` and `isOAuthSession`, and
cookies sealed before this subpath existed open with the default `info`.

## Migrate between versions [#migrate-between-versions]

`migrationBetween(preset, base, next)` writes the migration that takes a project
deployed from one design to the next. It covers added tables and added
columns, which the app-table SQL applies idempotently.

```ts
import { migrationBetween } from "better-supabase/create";

const plan = migrationBetween("saas", deployed, edited);

if (plan.ok) {
  await client.applyMigration(ref, "better_supabase_design", plan.sql);
} else {
  console.log(
    plan.reason,
    plan.changes.map((change) => change.steps),
  );
}
```

A `breaking` refusal lists the expand and contract steps for each change. An
`unsupported` refusal covers any other change, such as a new default or a
change outside `tables`, so a deploy never guesses.

## Validate in Postgres [#validate-in-postgres]

`better-supabase/create/lite` applies the generated files to an in-memory
PGlite database through the optional `@supabase/lite` peer, and reports what
Postgres rejects before anything reaches a project.

```ts
import { previewFiles } from "better-supabase/create";
import { validateDesign } from "better-supabase/create/lite";
import { defaultPreset } from "better-supabase/preset";

const files = previewFiles(defaultPreset());
const { ok, issues, skipped } = await validateDesign(files);
```

PGlite starts without a Supabase project's roles and `auth` schema, so the
function creates a minimal copy first. A module that needs an extension PGlite
doesn't ship (`pgmq` for jobs, `btree_gist` for time tracking) lands in
`skipped` instead of failing. pgTAP is not available through
`@supabase/lite`, so database tests still run with `supabase test db`.

On [/create](https://app.bettersupabase.com/create), the Migrations tab of Get code
has a Validate in Postgres button that runs the same check on the server for
the current selection and design.

# CRM

> Deals in pipeline stages, quotes made from deals, sales targets, comments, tags, custom fields and follow-up reminders.

Source: https://bettersupabase.com/docs/presets/crm

The `crm` preset holds your deals for you: the [deals](/docs/blocks/deals)
block stores each deal with its amount, company, owner and close date, and
the [pipelines](/docs/blocks/pipelines) block moves it through stages the
organization edits. [Quotes](/docs/blocks/quotes) start from a deal, and
[targets](/docs/blocks/goals) measure won deals against each rep's quota.
The pipeline's transitions refuse a jump it does not allow, and the people
watching a deal hear about comments, tags and stage moves.

## Tables [#tables]

The app adds no table. Deals live in `better_supabase.deals`, their stages
in `better_supabase.pipeline_stages`, and every stage change in
`better_supabase.pipeline_stage_history`. The deals table takes no writes
from the Data API: create and edit deals through the block's functions
(`create_deal`, `update_deals`, `close_deal`), which check `deals.write` and
the deal team. Add your own deal columns with `blocks.deals.options.extraColumns`
([your own fields](/docs/blocks/deals#your-own-fields)).

## Config [#config]

The `crm` preset brings the SaaS blocks (organizations, notifications, the
outbox, ...) plus parties, pipelines, deals, quotes, targets, engagements,
tags, comments, an activity feed, custom fields, saved views and search, and
the sales engagement blocks below. The
deals block registers `deal` as a subject, so the config names no
`subjects.deal`; set the deal page on the block's entity and add `reminders`
for follow-ups:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "crm",
  blocks: { deals: { names: { deal: { url: "/deals/{id}" } } }, reminders: {} },
});
```

A deal created without a pipeline lands in the organization's default deal
pipeline, which the first call creates from the deals block's stages
(Qualified to Closed won and Closed lost). An admin can also set up the
organization's own pipeline:

```ts
const sales = await pipelines
  .upsert(organizationId, { subjectType: "deal", key: "sales", name: "Sales" })
  .orThrow();
const lead = await pipelines
  .saveStage(sales.id, { key: "lead", name: "Lead", category: "open" })
  .orThrow();
// ... qualified (open), won (won), lost (lost)

const deal = await deals
  .create(organizationId, {
    name: "Acme renewal",
    closeOn: Temporal.PlainDate.from("2026-12-31"),
    pipelineId: sales.id,
    stageId: lead.id,
  })
  .orThrow();
```

The first pipeline of a type becomes its default. The deal's owner (the
caller, unless you pass `ownerId`) watches it.

## Leads and outreach [#leads-and-outreach]

A lead is a person in [contacts](/docs/blocks/contacts) with the `lead`
lifecycle, not a table of its own, so a lead that buys stays the same row.
The preset adds the blocks that find, rank and work those leads:

| Block                                     | Role in the app                                                   |
| ----------------------------------------- | ----------------------------------------------------------------- |
| [mail-sync](/docs/blocks/mail-sync)       | Each rep's mailbox, synced into engagements on the contact        |
| [sequences](/docs/blocks/sequences)       | Email steps a lead is enrolled in, sent through the mailbox       |
| [segments](/docs/blocks/segments)         | Saved rules such as "leads in the Netherlands with no owner"      |
| [assignment](/docs/blocks/assignment)     | Queues that hand new leads to reps by turn, weight or territory   |
| [enrichment](/docs/blocks/enrichment)     | Company and person details from a provider, filling empty fields  |
| [lead-scoring](/docs/blocks/lead-scoring) | A score per lead from its fields and activity, rescored on change |
| [forecasts](/docs/blocks/forecasts)       | Forecast snapshots of the open deals per period and rep           |

Mail sync and enrichment call a provider you pass at runtime (a
`MailProvider` and an enrichment provider over `fetch`); the database side
installs without one.

## Event flow [#event-flow]

Each call takes the record as a subject, `deal` being `{ type: "deal", id: dealId }`, and acts in the tenant of the block client (see [one block per request](/docs/blocks/create-blocks#one-block-per-request)).

| The user                  | Call                                                   | Event                                    | Watchers get                                           |
| ------------------------- | ------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------ |
| Creates a deal            | `deals.create(organizationId, { name, closeOn, ... })` | `deal.created`                           | nothing; the owner starts watching                     |
| Comments on a deal        | `comments.create(deal, { body })`                      | `message.sent`                           | `comment.posted`, from comments                        |
| Tags it                   | `tags.attach(deal, tagId)`                             | `subject.tagged`                         | `subject.tagged`                                       |
| Moves it to another stage | `deals.update([{ dealId, field: "stageId", value }])`  | `deal.updated`, `pipeline.stage_changed` | `pipeline.stage_changed`, from pipelines               |
| Marks it won or lost      | `deals.win(dealId)` or `deals.lose(dealId, reason)`    | `pipeline.stage_changed`, `deal.closed`  | `pipeline.stage_changed` and `deal.closed`, from deals |
| Sends a quote             | `quotes.fromDeal(dealId)`                              | `quote.created`                          | map it if you want                                     |
| Sets a custom field       | `customFields.setValue(deal, fieldId, value)`          | `field_value.set`                        | `subject.field_changed`                                |
| Gets a follow-up reminder | `reminders.deliver(payload)`                           | `reminder.sent`                          | `reminder.due`                                         |

Comments and pipelines notify the deal's watchers themselves; the outbox
events in the other rows reach them through a
[watcher handler](/docs/blocks/notifications), which leaves out the user who
acted. A sales target with the metric `deals.won_amount` counts a deal once
it is won.

## Transitions [#transitions]

Without transitions, a deal moves from any stage to any other. To refuse a
jump, list the moves the pipeline allows:

```ts
await pipelines
  .setTransitions(sales.id, [
    { from: lead.id, to: qualified.id },
    { from: lead.id, to: lost.id },
    { from: qualified.id, to: won.id },
    { from: qualified.id, to: lost.id },
    { from: lost.id, to: lead.id },
  ])
  .orThrow();
```

A move from Lead onto Won is then rejected: `deals.update` returns it in
`rejected` with the hint `PIPELINE_TRANSITION_INVALID` and keeps the other
changes of the batch, and `deals.win` fails with the same hint until the deal
is in Qualified.

## With ReUI [#with-reui]

- Kanban for the pipeline board. Read the columns, with their counts and
  totals per currency, from `pipelines.board("deal", pipelineId)`. On drop,
  call `deals.update` with the column's stage id as `stageId`, and move the
  card back when the change comes back in `rejected`.
- Data Grid for the deal table: send a batched edit as one `deals.update`
  call and show each `rejected` reason in a toast. Filters edit the
  [filter tree](/docs/platform/list#filter-trees) of a `defineListQuery` on
  `deals`.
- Timeline for the deal's history: `pipeline_stage_history` rows and
  comments, newest first, or the [activity feed](/docs/blocks/timeline).

The ReUI Pro CRM blocks fit the same data (see
[wire it to ReUI](/docs/blocks/deals#wire-it-to-reui) on the deals page);
their source is licensed per user, so copy them into your app from
[reui.io](https://reui.io).

# Customer success

> The customer-success preset installs accounts with health scores, metrics, rules, NPS surveys, playbooks as enrollments and engagements.

Source: https://bettersupabase.com/docs/presets/customer-success

The `customer-success` preset is for an app in the style of Gainsight or
Vitally. Each account has a health score from usage and survey answers, a
rule starts a playbook when the score drops, and the customer success
manager logs calls and follow-ups on the account.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "customer-success" });
```

`npx better-supabase init --preset customer-success` starts a new project
from it, and `npx better-supabase add --preset customer-success` adds it
to one you have ([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                      |
| --------------------------------------- | ---------------------------------------------------- |
| [parties](/docs/blocks/contacts)        | Customer accounts and their contacts                 |
| [engagements](/docs/blocks/activities)  | Calls, meetings, notes and follow-up tasks           |
| [scores](/docs/blocks/scores)           | Account health as a scoring model with bands         |
| [metrics](/docs/blocks/metrics)         | Usage and adoption charts per account                |
| [rules](/docs/blocks/automations)       | Triggers such as "health dropped to red"             |
| [forms](/docs/blocks/forms)             | NPS and CSAT surveys                                 |
| [enrollments](/docs/blocks/enrollments) | Playbooks: an account enrolled in a program of steps |
| [reminders](/docs/blocks/reminders)     | Renewal dates and check-ins                          |

## How the blocks connect [#how-the-blocks-connect]

Account health is a `health` model in scores over the account's subject
type. It adds rule points, each a filter tree that matches the account, and
metrics, each a number field of the account times a weight, and its bands
name ranges of the total such as `red` and `green`. An NPS survey is a form
with an `nps` field, and `forms.nps(formId)` returns the score, so your app
can store an account's latest answer in a number field the model weighs
([scores](/docs/blocks/scores), [forms](/docs/blocks/forms)).

When a score moves into a band, a rule names the action your app runs, such
as enrolling the account in a recovery playbook. A playbook is an enrollment
program, and each step can be an engagement task for the account's owner.

## With ReUI [#with-reui]

- Data Grid for accounts, with the health band as a Badge and Filters on it.
- Statistic cards for the portfolio's health and renewals.
- Timeline for an account's engagements and score changes.

# Designs and the resolver

> How better-supabase/preset reads a preset name or a design, resolves its blocks and writes the config and wiring Create shows.

Source: https://bettersupabase.com/docs/presets/designs

`better-supabase/preset` is the model behind `preset` in the config and
behind [Create](https://app.bettersupabase.com/create). It runs on every
runtime, including the browser: the resolver reads a generated catalog of
the blocks and never imports the SQL renderers.

```ts
import {
  presetConfigSource,
  presetFromSpec,
  resolvePreset,
} from "better-supabase/preset";

const preset = presetFromSpec({ api: "api", blocks: ["comments"] });
const resolved = resolvePreset(preset);
// resolved.blocks: comments, plus tenant, access and updated-at, each with a reason
console.log(await presetConfigSource(resolved));
```

## The preset key [#the-preset-key]

The config's `preset` key takes a preset name, such as `"saas"`, or an object
with the conventions that differ from the defaults and the blocks to install:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: { casing: "camel", access: "provider", blocks: ["profiles", "tags"] },
});
```

`presetSpec(preset)` turns a `Preset` into that value: the name when the preset
equals a named one, otherwise the object with only the fields that differ.
`presetFromSpec` and `presetOfSpec` read it back; `presetOfSpec` takes an
unknown value and throws for an unknown field or value. `CONVENTION_FIELDS`
lists the fields in a fixed order.

## Conventions [#conventions]

Every field has a default, so a preset sets only what differs.

| Field          | Values                                                                                         | Default        | What it sets                                                                                                                          |
| -------------- | ---------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `framework`    | `next`, `vite`, `tanstack-start`, `expo`, `sveltekit`, `nuxt`, `hono`, `astro`, `react-router` | `next`         | the glue Create shows                                                                                                                 |
| `platforms`    | `web`, `native`, `both`                                                                        | `web`          | the glue Create shows; `native` needs `expo`                                                                                          |
| `casing`       | `snake`, `camel`                                                                               | `snake`        | `casing`                                                                                                                              |
| `idType`       | `uuid`, `text`, `bigint`, `integer`                                                            | `uuid`         | `blocks.permissions.idType`                                                                                                           |
| `scope`        | `tenant`, `user`, `none`                                                                       | `tenant`       | `database.sql.owner`; under `user` or `none`, blocks that need tenants can't install, and the rest need neither `tenant` nor `access` |
| `tenantName`   | `organization`, `workspace`, `team`                                                            | `organization` | `blocks.memberships.names.organization`; a block whose tables or columns the name would take over can't install                       |
| `access`       | `roles`, `catalog`, `provider`, `custom`                                                       | `roles`        | `blocks.permissions.model`                                                                                                            |
| `api`          | `none`, `api`                                                                                  | `none`         | `database.sql.api`                                                                                                                    |
| `integrations` | `next`, `hono`, `orpc`, `edge`, `expo`, `mcp`, `client`, `react` (any number)                  | none           | `integrations`                                                                                                                        |

## Designs [#designs]

A design is plain JSON: the preset, the [app tables](/docs/repository/tables),
extra columns on block tables and where each table sits on the Create canvas.
It rides on a Create URL as `?design=`, compressed:

```ts
import {
  createUrl,
  encodeDesign,
  namedPreset,
  presetAndDesignOf,
  presetInstallCommand,
  resolvePreset,
} from "better-supabase/preset";

const resolved = resolvePreset(namedPreset("crm"));

const design = {
  appTables: { projects: { columns: { name: { type: "text" } } } },
  extraColumns: { organizations: { tier: "text" } },
  positions: { "public.projects": [120, 40] },
};

await createUrl(resolved, design);
// https://app.bettersupabase.com/create?design=0...

await presetInstallCommand(resolved, design);
// npx better-supabase init --preset "https://app.bettersupabase.com/create?design=0..."

const { preset, design: read } = await presetAndDesignOf(
  await createUrl(resolved, design),
);
```

`createUrl` writes `?preset=<name>` for a named preset with nothing else, and
`?design=<code>` with the preset inside the design otherwise.
`presetAndDesignOf` reads a preset name, a Create URL or a bare design code: the
design's own `preset` comes first, then a `preset=` parameter, then the default
preset. The `preset` key takes a name or an object, as in the config.

A design code starts with `0`, which no preset name starts with, followed by
base64url of the design's JSON compressed with `deflate-raw`. It uses
`CompressionStream`, so encoding and decoding are async and run on every
runtime.

The JSON's own format goes in a `version` key. `designFormat(design)` returns
the lowest format that reads the design: `1` when a table or column uses a key
added after the first release (`format`, `options`, `min`, `max`, `comment`,
`identity`, or a table's `id`, `unique`, `indexes` or `comment`), `2` for a
table `subject`, `3` for `rollups` or `lookups`, `4` for `events` or subject
`versions`, `5` for column `access`, `6` for column `encrypted` or table
`auditReads`, `7` for `preset`, `8` for a preset object with `instances`,
and `0` otherwise. `DESIGN_FORMAT` is the
newest format this release reads; a code with a higher `version` fails with a
message to update better-supabase instead of losing keys.

### Codes from 0.14 [#codes-from-014]

Releases up to 0.14 wrote a separate preset code (`?preset=1...`). Reading one
now throws an error that names `better-supabase codemod 0.15`, which turns a
code in the config's `preset` into the name or object it holds, and a 0.14
design file into a design with its preset inside.

`decodeDesign` and `designOf` reject what isn't a design: an unknown key, a
table that `tables` wouldn't accept, a column type outside a plain SQL
type, a block with no table for extra columns, or a code that inflates past
256 KB. `encodeDesign` throws a `DesignParseError` for an invalid design, and a
`DesignTooLongError` (a `DesignParseError` with the code's `length`) when a valid
design would pass `MAX_DESIGN_CODE_LENGTH` (6000 characters); write a
[design file](#design-files) then. Check for `DesignTooLongError` to tell the
two apart.

`parseDesign(value)` checks the shape of a design object, such as a JSON body
or an AI tool's input, with the same rules, and returns it with its empty
parts dropped. It throws a `DesignParseError` naming the first problem. To
check a design against a preset as well, use
[`checkDesign`](/docs/presets/create#check-a-design).

`safeParseDesign(value)` runs the same checks without throwing, so a route
handler can answer a bad body with a 400 and the issue:

```ts
import { safeParseDesign } from "better-supabase/preset";

const result = safeParseDesign(await request.json());

if (!result.ok) {
  return Response.json({ issues: result.issues }, { status: 400 });
}

// result.design is the checked design
```

`designSchema` is the same check as a [Standard Schema](https://standardschema.dev)
(v1), for a library that takes one, such as a form library, a router or an AI
SDK tool. Its output is the design with its empty parts dropped, and each
issue's `path` lists the keys of the JSON Pointer below:

```ts
import { designSchema } from "better-supabase/preset";

const result = await designSchema["~standard"].validate(body);
// { value: { appTables: { ... } } } or { issues: [{ message, path: ["appTables", "tasks"] }] }
```

[`schemas/design-v1.json`](https://unpkg.com/better-supabase/schemas/design-v1.json)
is the JSON Schema of a design's shape, for an editor or a tool outside
TypeScript. It accepts what `designSchema` accepts for the shape; the names,
defaults and limits are checked only by `designSchema` and `parseDesign`.

A `DesignParseError` is a `TypeError` with a `code` and a `path`, and each
issue in `issues` is the same three keys as plain JSON. `path` is a
[JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901) to the key at fault,
such as `/appTables/tasks/columns/title/type`, or its nearest parent when
the key is missing. The codes are stable; the messages may change.

| Code                  | What it means                                                      |
| --------------------- | ------------------------------------------------------------------ |
| `invalid_design`      | a value has the wrong type, such as a design that isn't an object  |
| `unknown_key`         | a key a design or design file doesn't have                         |
| `unsupported_format`  | `version` is newer than this release reads                         |
| `too_large`           | more tables, extra columns or positions than a design may hold     |
| `unknown_block`       | `extraColumns` names a block that doesn't exist                    |
| `no_extra_columns`    | `extraColumns` names a block that takes no extra columns           |
| `block_not_installed` | `extraColumns` names a block the preset doesn't install            |
| `invalid_name`        | a column or table name that isn't a lower snake identifier         |
| `invalid_type`        | an extra column whose type isn't a SQL type                        |
| `invalid_position`    | a canvas position that isn't two finite numbers                    |
| `invalid_app_table`   | an app table that `tables` rejects                                 |
| `invalid_code`        | a design code that is malformed, damaged or too long to read       |
| `too_long`            | a valid design whose code is too long for a URL                    |
| `invalid_file`        | a design file that isn't JSON or lacks its keys                    |
| `invalid_preset`      | a preset that isn't a preset name or object, or a file without one |

### Design files [#design-files]

A design too long for a URL travels as a file instead. `designFileSource`
writes it, and `init --preset` and `add --preset` read it from a path ending
in `.json`. `DESIGN_FILE` is the default name, `better-supabase.design.json`.

```ts
import {
  defaultPreset,
  designFileSource,
  readDesignFile,
  resolvePreset,
} from "better-supabase/preset";

const source = designFileSource(resolvePreset(defaultPreset()), design);
// {
//   "version": 7,
//   "preset": "saas",
//   "appTables": { ... },
//   "extraColumns": { ... }
// }

const { preset, design: read } = readDesignFile(source);
```

The file is the design JSON with its `version` and `preset`, with no length
limit. `readDesignFile` checks it with the same rules as `parseDesign`, needs a
`preset`, and throws a `DesignParseError` for anything else.
`safeDesignFileSource(resolved, design)` returns `{ ok: true, source }` or the
issues instead of throwing.

Two helpers check a design before it is encoded. `appReservedColumns(tenant)`
lists the columns every app table can get from its scope (`id`,
`created_at`, `updated_at`, `user_id` and `<tenant>_id`), which a design's
columns may not name. `isAppDefault(type, value)` says whether a column of
`type` accepts `value` as its default.

`diffDesigns(before, after)` lists what changes between two designs, and
`diffAppTables(before, after, { keepMissing })` does the same for two
`appTables` objects; with `keepMissing: true`, a table missing from `after`
is kept instead of dropped, as `add --preset` merges a design. Each `DesignChange`
has a `kind`, the `table` and `column`, a one-sentence `summary`, and
`breaking`. A dropped or renamed table or column, a type change and a column
made required without a default are breaking, and their `steps` give the
expand-and-contract migration that makes them safe. A change to a table's
rollups or lookups is the safe `table.computed`, because their view keeps
no rows. A change to its `events` is the safe `table.events` and a change to
its subject's `versions` is the safe `table.versions`, because each only
replaces a trigger:

```ts
import { diffAppTables } from "better-supabase/preset";

const changes = diffAppTables(
  { notes: { columns: { body: { type: "jsonb" } } } },
  { notes: { columns: { body: { type: "text" } } } },
);
// [{ kind: "column.type", table: "notes", column: "body", breaking: true,
//    summary: "Changes notes.body from jsonb to text.", steps: [...] }]
```

The AI instructions for a design are at `/create/prompt.md?design=<code>`,
or `/create/prompt.md?preset=<name>` for a named preset.

| Function               | What it does                                                                                          |
| ---------------------- | ----------------------------------------------------------------------------------------------------- |
| `designConfig`         | the preset's config with the design merged in; throws for extra columns on a block it doesn't install |
| `createUrl`            | the `/create` URL for a preset and design; throws `DesignTooLongError` past the URL limit             |
| `presetAndDesignOf`    | the preset and design a name, a `/create` URL or a design code holds                                  |
| `parseDesign`          | checks the shape of a design object and drops its empty parts; throws a `DesignParseError`            |
| `designSchema`         | `parseDesign` as a Standard Schema (v1)                                                               |
| `safeParseDesign`      | `parseDesign` without the throw: `{ ok: true, design }` or `{ ok: false, issues }`                    |
| `designFileSource`     | the design file: the design JSON with its preset                                                      |
| `safeDesignFileSource` | `designFileSource` without the throw: `{ ok: true, source }` or `{ ok: false, issues }`               |
| `readDesignFile`       | the preset and design from a design file; throws a `DesignParseError` for anything else               |

### The default preset [#the-default-preset]

`DEFAULT_PRESET_NAME` is `"saas"`, the preset `/create` and `init` start
from. `defaultPreset()` returns it as a `Preset`, so an app that opens a new
design doesn't hard-code it.

## Resolving [#resolving]

`resolvePreset(preset)` returns:

| Key            | What it holds                                                                                                         |
| -------------- | --------------------------------------------------------------------------------------------------------------------- |
| `blocks`       | every block to install, in install order, with `selected`, `requiredBy` and a `reason` such as "required by comments" |
| `integrations` | the soft integrations active between blocks in the set (`comments` works with `notifications`)                        |
| `conflicts`    | what can't install and why: a block that needs tenants under scope `user`, or Expo on `web` only                      |
| `manifests`    | the block manifests that cover the set, for `lib/blocks.ts`                                                           |

What a block requires can depend on the access model: under `provider` and
`custom`, `access` no longer requires `tenant`. `presetBlockConflict` says
why one block can't install before it is selected, which is how Create
disables a block.

`defineConfig` throws when a preset has conflicts, listing each one.

## Generated files [#generated-files]

`presetConfigSource`, `presetInstallCommand` and `presetApplyCommand` are
async, because the URL in their output is.

| Function               | Writes                                                                                        |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `presetConfigSource`   | `better-supabase.config.ts` with every block, each added block marked with its reason         |
| `presetBlocksSource`   | `lib/blocks.ts`: the blocks with a TypeScript client, wired with `defineBlocks`               |
| `presetInstallCommand` | `npx better-supabase init --preset <name or URL>`, which starts a project from the preset     |
| `presetApplyCommand`   | `npx better-supabase add --preset <name or URL>`, which applies it to an existing project     |
| `presetGlue`           | the registry glue items for the preset's framework and platforms; `glueCommand` installs them |

`scripts/build-catalog.ts` writes the catalog (`src/preset/catalog.generated.ts`)
from the SQL module registry and the block manifests, and
`pnpm catalog:check` fails when it is stale.

# Drive

> Documents in folders with attachments, share links, labels and comments.

Source: https://bettersupabase.com/docs/presets/drive

Documents live in folders, ordered by rank keys within each folder. Files go
to Storage through the attachments block, share links open a document to
people outside the organization, and watchers hear when a document is shared
or labelled.

## Table [#table]

```sql title="supabase/schemas/documents.sql"
create table public.documents (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null,
  name text not null,
  folder text not null default 'root',
  rank text collate "C" not null default 'V'
);
alter table public.documents enable row level security;
```

`folder` is a plain text group here. A folder tree with parents comes with
the relations block.

## Config [#config]

Drive has no preset of its own: it starts from `saas` and adds the blocks a
file list needs. Blocks you name next to a preset join it:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "saas",
  records: {
    document: {
      table: "public.documents",
      idType: "uuid",
      title: "name",
      url: "/files/{id}",
      rank: { column: "rank", group: "folder" },
    },
  },
  blocks: {
    comments: {},
    tags: {},
    attachments: {},
    "share-links": {},
    rank: {},
  },
});
```

## Event flow [#event-flow]

Each call takes the record as a subject, `document` being `{ type: "document", id }`, and acts in the tenant of the block client (see [one block per request](/docs/blocks/create-blocks#one-block-per-request)).

| The user             | Call                                              | Event                | Watchers get                    |
| -------------------- | ------------------------------------------------- | -------------------- | ------------------------------- |
| Shares a document    | `shareLinks.create(document, "viewer")`           | `share_link.created` | `subject.shared`                |
| Labels it            | `tags.attach(document, tagId)`                    | `subject.tagged`     | `subject.tagged`                |
| Comments             | `comments.create(document, { body })`             | `message.sent`       | `comment.posted`, from comments |
| Moves it to a folder | `rank.move("document", id, { group: "archive" })` | none                 |                                 |
| Opens a share link   | the share-links view route                        | `subject.viewed`     | map it if you want              |

There is no state machine on `folder`, so a move changes the folder and the
rank without an event. Add one when a folder change should notify.

## With ReUI [#with-reui]

- Tree for the folder sidebar.
- Data Grid for the file list, with Filters on a `defineListQuery` and File
  Type for each attachment's icon.
- Kanban when folders are columns, calling `rank.move` on drop.

Pro file-manager blocks are licensed per user; copy them from
[reui.io](https://reui.io).

# ERP

> The erp preset installs parties, a catalog, stock by location, quotes priced from the catalog that become orders when accepted, orders that invoice when paid, employees with time and leave, and expenses.

Source: https://bettersupabase.com/docs/presets/erp

The `erp` preset is for a small ERP in the style of Odoo or NetSuite: sell
from a catalog, keep stock per location, quote and invoice customers, and
track the people who do the work.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "erp" });
```

`npx better-supabase init --preset erp` starts a new project from it, and
`npx better-supabase add --preset erp` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                       | Role in the app                                      |
| ------------------------------------------- | ---------------------------------------------------- |
| [contacts](/docs/blocks/contacts)           | Customers and suppliers                              |
| [catalog](/docs/blocks/catalog)             | Products, variants and price lists                   |
| [inventory](/docs/blocks/inventory)         | Stock per variant and location, and purchase orders  |
| [assets](/docs/blocks/assets)               | The warehouses and sites stock is kept at            |
| [quotes](/docs/blocks/quotes)               | Quotes for a customer, priced from the catalog       |
| [orders](/docs/blocks/orders)               | Orders, their payment and fulfilment                 |
| [invoices](/docs/blocks/invoices)           | Invoices and their payments, on the ledger           |
| [numbering](/docs/blocks/numbering)         | Gapless sequences for your own document numbers      |
| [employees](/docs/blocks/employees)         | The org chart, with each member's employee record    |
| [time-tracking](/docs/blocks/time-tracking) | Timesheets with weekly approval                      |
| [leave](/docs/blocks/leave)                 | Leave requests and balances                          |
| [expenses](/docs/blocks/expenses)           | Expense reports with receipts, policies and approval |
| [saved-views](/docs/blocks/saved-views)     | Saved filters for each list                          |
| [automations](/docs/blocks/automations)     | The default automations, which members can edit      |

## How the blocks connect [#how-the-blocks-connect]

The blocks pick each other up when they are installed together, with no
config:

- A quote line that names a catalog variant takes its price and title when
  it leaves them out ([quotes](/docs/blocks/quotes)). A line with no price
  for the quote's currency fails with `LINES_INVALID`.
- An accepted quote becomes an order: the preset turns on the
  `quotes.quote_to_order` default automation, which ships turned off
  ([default automations](/docs/blocks/automations#default-automations)).
  Register `orders.from_quote` in the rules consumer's action registry, or
  turn it off with `disable: ["quotes.quote_to_order"]` in the automations
  options.
- A paid order creates, issues and pays an invoice and returns its id
  ([orders](/docs/blocks/orders)). Orders paid before invoices was installed
  have no invoice.
- Inventory keeps its locations in the assets registry, so a warehouse is
  an asset ([inventory](/docs/blocks/inventory)).
- Time entries and leave requests carry the `employee_id` of their member,
  which employees keeps current when a member's employee record changes
  ([employees](/docs/blocks/employees#time-and-leave)).

## With ReUI [#with-reui]

- Data Grid for orders and invoices, with Filters on status and customer.
- A Badge for each invoice's status (draft, issued, paid, void).
- Kanban for open quotes by status.

# Events and ticketing

> The events preset installs tickets in a catalog, orders, capacity through bookings, QR passes signed with signing keys and checked in, a waitlist and reminders.

Source: https://bettersupabase.com/docs/presets/events

The `events` preset is for an app in the style of Eventbrite or Luma. An
organizer publishes an event with ticket types, attendees buy tickets, each
ticket is a signed QR pass, and the door checks passes in against the
event's capacity. A sold-out event fills from its waitlist.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "events" });
```

`npx better-supabase init --preset events` starts a new project from it, and
`npx better-supabase add --preset events` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                     | Role in the app                                    |
| ----------------------------------------- | -------------------------------------------------- |
| [catalog](/docs/blocks/catalog)           | Ticket types as products, with prices per currency |
| [orders](/docs/blocks/orders)             | Checkout for tickets, refunds and invoices         |
| [bookings](/docs/blocks/bookings)         | The event's capacity as a bookable resource        |
| [passes](/docs/blocks/passes)             | QR and NFC tickets, checked in at the door         |
| [signing-keys](/docs/blocks/signing-keys) | The organization's key that signs each pass        |
| [waitlist](/docs/blocks/waitlist)         | A waitlist for sold-out events                     |
| [reminders](/docs/blocks/reminders)       | Reminders before the event starts                  |

## How the blocks connect [#how-the-blocks-connect]

The event is a bookable resource with a capacity. When an order is paid,
your payment handler books a seat on that resource and issues a pass for the
booking, so the bookings block refuses a sale past capacity. The pass token
is signed on the server with the organization's key and goes in a QR code;
at the door a scanner verifies it and the database checks the pass in once.
A drop-in pass on the resource is counted against the same capacity
([passes](/docs/blocks/passes)).

## With ReUI [#with-reui]

- Card grid for the event list and ticket types.
- Data Grid for attendees, with Filters on ticket type and check-in.
- A scanner page that checks a pass in and shows the result as a Badge.

# Product feedback

> The feedback preset installs feedback boards of voted ideas with roadmap statuses, replies, moderation, links from ideas to the work items that ship them, tags and search.

Source: https://bettersupabase.com/docs/presets/feedback

The `feedback` preset is for an app in the style of Canny or Productboard.
Customers post ideas and vote on them, the product team merges duplicates,
moves the ideas it takes on through a public roadmap and links each one to
the work items that build it.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "feedback" });
```

`npx better-supabase init --preset feedback` starts a new project from it,
and `npx better-supabase add --preset feedback` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                 | Role in the app                                            |
| ------------------------------------- | ---------------------------------------------------------- |
| [posts](/docs/blocks/posts)           | Feedback boards of ideas, with roadmap statuses and merges |
| [comments](/docs/blocks/comments)     | Replies on an idea, from customers and the team            |
| [reactions](/docs/blocks/reactions)   | The votes on each idea                                     |
| [moderation](/docs/blocks/moderation) | Reports on spam or abuse, hiding and bans                  |
| [tasks](/docs/blocks/tasks)           | The issues the team builds on its own board                |
| [relations](/docs/blocks/relations)   | Links from an idea to the work items that ship it          |
| [tags](/docs/blocks/tags)             | Product areas on ideas                                     |
| [search](/docs/blocks/search)         | Finding an existing idea before posting a duplicate        |

## How the blocks connect [#how-the-blocks-connect]

A feedback board is a posts board of kind `feedback`, and `list({ sort:
"top" })` orders its ideas by votes. The roadmap is the post status: the
team moves an idea from `open` through `under_review`, `planned` and
`in_progress` to `done`, so a roadmap page lists a board by status. Merging a
duplicate with `posts.merge` closes it and moves its votes to the idea it
keeps ([posts](/docs/blocks/posts)).

When the team takes an idea on, it creates a work item and links the two with
the relations block's `created_from` kind, so the item names the idea it came
from and the idea lists the items it was `converted_to`
([relations](/docs/blocks/relations)). When the work ships, an outbox sink on
`work_item.updated` can set the idea to `done`, and one on
`post.status_changed` can notify its author.

## With ReUI [#with-reui]

- The [post board](/docs/registry/ui-recipes/post-board) recipe for ideas
  sorted by votes, with an upvote toggle and a New post dialog.
- Kanban for the roadmap, one column per status.
- A Badge with each idea's status, and the linked work items as chips.

# Field service

> The field-service preset installs a dispatch board of work orders, customer sites in assets, parts from inventory, quotes to invoices, checklists and signatures.

Source: https://bettersupabase.com/docs/presets/field-service

The `field-service` preset is for an app in the style of ServiceTitan or
Jobber. The office quotes a job, a dispatcher puts the work order in a
technician's slot on the board, and the technician works through the day
plan, fills in the checklist on site, records the parts used and has the
customer sign.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "field-service" });
```

`npx better-supabase init --preset field-service` starts a new project from
it, and `npx better-supabase add --preset field-service` adds it to one you
have ([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                               |
| --------------------------------------- | ------------------------------------------------------------- |
| [parties](/docs/blocks/contacts)        | Customers and their contacts                                  |
| [dispatch](/docs/blocks/dispatch)       | Work orders, the schedule board, day plans and parts used     |
| [assets](/docs/blocks/assets)           | Customer sites and the equipment installed there              |
| [inventory](/docs/blocks/inventory)     | Van and warehouse stock the parts come from                   |
| [quotes](/docs/blocks/quotes)           | Quotes the customer accepts through a public link             |
| [invoices](/docs/blocks/invoices)       | Invoices from accepted quotes, with payments                  |
| [forms](/docs/blocks/forms)             | Job checklists and inspection forms                           |
| signatures                              | The customer's signature on a frozen version of the job sheet |
| [attachments](/docs/blocks/attachments) | Photos before and after the work                              |

## How the blocks connect [#how-the-blocks-connect]

A work order sits at an asset location or an address. `board` returns the
scheduled work orders per technician and day with the backlog, and
`technicianDay` returns one technician's jobs in route order. The
technician moves a job to en route, on site and done, and records each part
with the catalog variant and stock location it came from, since `catalog`
and `inventory` are installed ([dispatch](/docs/blocks/dispatch)).

A quote the customer accepts becomes an invoice. The checklist is a form
submission linked to the work order, and the signature is taken on a frozen
version, so what the customer signed never changes.

## With ReUI [#with-reui]

- Event Calendar for the dispatch board, one row per technician.
- Data Grid for the backlog, with Filters on priority and customer.
- Stepper for the on-site flow: checklist, parts, photos, signature.

# Fitness and memberships

> The fitness preset installs class schedules, bookings with recurring series, membership passes with class credits signed by signing keys, check-in and reminders.

Source: https://bettersupabase.com/docs/presets/fitness

The `fitness` preset is for an app in the style of Mindbody or Glofox. A
studio schedules its classes, a member books a class with a credit from
their pack, checks in at the door with a QR pass, and gets a reminder before
class.

## Config [#config]

Recurring series and class credits are block options, off by default, so the
preset leaves them to your config:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "fitness",
  blocks: {
    bookings: { options: { recurring: true } },
    passes: { options: { credits: true, creditRefundBefore: "12 hours" } },
  },
});
```

`npx better-supabase init --preset fitness` starts a new project from it, and
`npx better-supabase add --preset fitness` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                     | Role in the app                                     |
| ----------------------------------------- | --------------------------------------------------- |
| [calendar](/docs/blocks/calendar)         | Instructors' calendars and availability             |
| [bookings](/docs/blocks/bookings)         | Classes on rooms and instructors, and weekly series |
| [passes](/docs/blocks/passes)             | Membership passes, class packs and check-in         |
| [signing-keys](/docs/blocks/signing-keys) | The studio's key that signs each pass               |
| [reminders](/docs/blocks/reminders)       | Reminders before a class                            |

Memberships are billed through `billing` from `saas`.

## How the blocks connect [#how-the-blocks-connect]

With `recurring`, a weekly class or a standing appointment is one series of
bookings. With `credits`, a pass with `usesAllowed` is a class pack:
`bookWithCredit` books the class through the bookings block's slot check and
takes one credit in the same transaction, and a cancellation before
`creditRefundBefore` gives the credit back ([passes](/docs/blocks/passes)).

## With ReUI [#with-reui]

- Event Calendar for the class timetable.
- Card for a member's pass with its QR code and credits left.
- Data Grid for a class's attendees, with check-in as a Badge.

# Presets

> Start an app from a named preset or a design from /create, with every block it needs and the conventions it sets.

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

A preset is a block selection plus the conventions to install it with:
framework, platforms, casing, id type, scope, tenant name, access model, API
schema and integrations. Put a named preset or a code from
[Create](https://app.bettersupabase.com/create) in your config, and
`defineConfig` expands it into `blocks`, with the blocks they require:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "saas",
  database: { sql: { api: "api" } },
  blocks: { messaging: { options: { maxBodyLength: 5000 } }, tags: {} },
});
```

The keys you set win. A block you name joins the preset, and its entry is
merged over the preset's settings for that block. `gen.casing`, `database.sql.api` and
`adapters` from the preset apply only when the config leaves them unset.
`npx better-supabase upgrade` then writes the files of every block the
preset expands to, and `supabase db schema declarative sync` turns them into
a migration ([SQL modules](/docs/blocks/sql)).

## Instances in a preset [#instances-in-a-preset]

A preset can also write `blocks` entries, under `instances`: the nouns of a
block it selects, and blocks it installs a second time under another key.
`helpdesk` and `itsm` name the tasks block's work items `ticket` and its
projects `issue`, `cms` and `pim` make the attachments bucket public for
media, and `property` adds a `maintenance` instance of tasks for
maintenance requests:

```ts title="better-supabase.config.ts"
export default defineConfig({
  preset: {
    blocks: ["assets", "leases", "contacts"],
    instances: {
      maintenance: {
        block: "tasks",
        names: { work_item: "maintenance_request" },
        tables: { projects: null, iterations: null },
      },
    },
  },
});
```

An instance takes the keys of a `blocks` entry that describe what it is:
`block`, `names`, `options`, `tables`, `on` and `uses`
([Instances](/docs/blocks#instances)). An instance named after a
block selects that block; one with another `block` installs only what that
block requires next to it. Your own `blocks` entry for the same key is
merged over the preset's.

A new project starts from a preset with
`npx better-supabase init --preset saas`, which writes the config, the module
files and `lib/blocks.ts` in one step. `better-supabase add --preset crm`
applies one to a project you have, and `better-supabase config --preset` shows
how your config differs from its preset ([CLI](/docs/cli/presets)).

## Named presets [#named-presets]

| Preset             | Group      | Blocks                                                                                                                                                                                                         |
| ------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minimal`          | Starters   | profiles                                                                                                                                                                                                       |
| `saas`             | Starters   | organizations, invitations, teams, profiles, billing, entitlements, notifications, audit, api-keys, settings, onboarding, outbox                                                                               |
| `consumer`         | Starters   | profiles, identities, mfa, sessions, notifications, push, with `scope: "user"`                                                                                                                                 |
| `crm`              | Products   | `saas` plus parties, pipelines, deals, quotes, targets, engagements, mail-sync, sequences, segments, assignment, enrichment, forecasts, lead-scoring, jobs, tags, comments, custom-fields, saved-views, search |
| `projects`         | Products   | `saas` plus tags, comments, attachments, state-machine, rank, reminders, time-tracking                                                                                                                         |
| `helpdesk`         | Products   | `saas` plus inbox, tasks as tickets, sla, comments, attachments, tags, saved-views, knowledge, search, automations, jobs                                                                                       |
| `chat`             | Products   | `saas` plus messaging, channels, reactions, attachments                                                                                                                                                        |
| `commerce`         | Products   | `saas` plus catalog, inventory, orders, promotions                                                                                                                                                             |
| `cms`              | Products   | `saas` plus content, versions, approvals, public attachments, tags, comments, search, jobs                                                                                                                     |
| `erp`              | Products   | `saas` plus parties, catalog, inventory, assets, quotes, orders, invoices, numbering, employees, time-tracking, leave, expenses, saved-views, automations with quote to order on                               |
| `pim`              | Products   | `saas` plus catalog, public attachments, custom-fields, imports, bulk, tags, saved-views, search                                                                                                               |
| `marketplace`      | Products   | `saas` plus parties, catalog, orders, invoices, reactions, share-links                                                                                                                                         |
| `scheduling`       | Products   | `saas` plus calendar, bookings, reminders                                                                                                                                                                      |
| `people`           | Products   | `saas` plus employees, hiring, time-tracking, approvals, calendar, documents, automations                                                                                                                      |
| `drive`            | Products   | `saas` plus drive, attachments, share-links, versions, trash, search                                                                                                                                           |
| `docs`             | Products   | `saas` plus documents, versions, comments, reactions, templates, search, share-links                                                                                                                           |
| `invoicing`        | Products   | `saas` plus parties, quotes, invoices, numbering                                                                                                                                                               |
| `forms`            | Products   | `saas` plus forms, templates, imports                                                                                                                                                                          |
| `property`         | Verticals  | `saas` plus assets, leases, ledger, parties, guests, comments, attachments, reminders, a maintenance tasks instance                                                                                            |
| `field-service`    | Verticals  | `saas` plus parties, dispatch, assets, inventory, quotes, invoices, forms, signatures, attachments                                                                                                             |
| `clinic`           | Verticals  | `saas` plus calendar, bookings, forms, templates, signatures, custom-fields, field-encryption, guests, reminders                                                                                               |
| `lms`              | Verticals  | `saas` plus courses, enrollments, forms, comments, attachments                                                                                                                                                 |
| `events`           | Verticals  | `saas` plus catalog, orders, bookings, passes, signing-keys, waitlist, reminders                                                                                                                               |
| `legal`            | Verticals  | `saas` plus parties, time-tracking, billable-time, invoices, ledger, documents, versions, signatures                                                                                                           |
| `spend`            | Verticals  | `saas` plus expenses, approvals, attachments, ai-files                                                                                                                                                         |
| `contracts`        | Verticals  | `saas` plus contracts, parties, signatures, versions, reminders                                                                                                                                                |
| `customer-success` | Verticals  | `saas` plus parties, engagements, scores, metrics, rules, forms, enrollments, reminders                                                                                                                        |
| `logistics`        | Verticals  | `saas` plus parties, orders, shipments, assets                                                                                                                                                                 |
| `pos`              | Verticals  | `saas` plus catalog, orders, inventory, bookings, promotions, ledger                                                                                                                                           |
| `fitness`          | Verticals  | `saas` plus calendar, bookings, passes, signing-keys, reminders                                                                                                                                                |
| `nonprofit`        | Verticals  | `saas` plus parties, donations, ledger, numbering, targets, saved-views                                                                                                                                        |
| `itsm`             | Verticals  | `saas` plus tasks as tickets, sla, approvals, assets, relations, incidents, knowledge, jobs                                                                                                                    |
| `compliance`       | Verticals  | `saas` plus controls, attachments                                                                                                                                                                              |
| `construction`     | Verticals  | `saas` plus tasks, assets, forms, attachments, versions, comments, approvals                                                                                                                                   |
| `claims`           | Verticals  | `saas` plus parties, state-machine, approvals, attachments, sla, comments, jobs                                                                                                                                |
| `community`        | Verticals  | `saas` plus posts, messaging, channels, comments, reactions, moderation, scores, tags, search                                                                                                                  |
| `feedback`         | Verticals  | `saas` plus posts, comments, reactions, moderation, tasks, relations, tags, search                                                                                                                             |
| `ai`               | AI         | `saas` plus ai-chat, ai-files, knowledge, memory, agents, ai-schedules, jobs                                                                                                                                   |
| `ai-chat`          | AI         | `saas` plus ai-chat, ai-files, knowledge, memory, ai-keys, usage, jobs                                                                                                                                         |
| `ai-agents`        | AI         | `saas` plus agents, ai-schedules, approvals, connectors, credentials, workflows, metrics, jobs                                                                                                                 |
| `developer-api`    | Platforms  | `saas` plus webhooks-out, usage, service-accounts, signing-keys                                                                                                                                                |
| `automation`       | Platforms  | `saas` plus workflow-editor, workflows, jobs, webhooks-out, connectors, credentials, automations                                                                                                               |
| `enterprise`       | Platforms  | `saas` plus sso, mfa, sessions, access-requests, impersonation, data-lifecycle, grants                                                                                                                         |
| `admin`            | Platforms  | `saas` plus flags, metrics, approvals, saved-views, bulk                                                                                                                                                       |
| `everything`       | Everything | every block, for demos and tests                                                                                                                                                                               |

Each one also installs what its blocks require (`tenant`, `access`,
`updated-at`, ...). The named presets use the default conventions, except
`consumer`, which scopes rows to the user (`scope: "user"`) and so leaves out
the blocks that need tenants, such as billing and settings. The `/create`
preset picker and `init` list them in these groups.
`tests/validation-presets` renders each one, applies it to the local stack
as one migration, and typechecks the `lib/blocks.ts` it generates.

## Vertical presets [#vertical-presets]

The presets in the Verticals group each start one kind of business app.
Each page lists the blocks the preset adds to `saas` and how they connect.

| Vertical                                           | Preset             | In the style of                     |
| -------------------------------------------------- | ------------------ | ----------------------------------- |
| [Property management](/docs/presets/property)      | `property`         | AppFolio, Buildium                  |
| [Field service](/docs/presets/field-service)       | `field-service`    | ServiceTitan, Jobber                |
| [Clinic and therapy](/docs/presets/clinic)         | `clinic`           | Jane, SimplePractice                |
| [Courses and learning](/docs/presets/lms)          | `lms`              | Teachable, Canvas                   |
| [Events and ticketing](/docs/presets/events)       | `events`           | Eventbrite, Luma                    |
| [Legal practice](/docs/presets/legal)              | `legal`            | Clio                                |
| [Expenses and spend](/docs/presets/spend)          | `spend`            | Ramp, Expensify                     |
| [Contracts](/docs/presets/contracts)               | `contracts`        | Ironclad                            |
| [Customer success](/docs/presets/customer-success) | `customer-success` | Gainsight, Vitally                  |
| [Logistics and fleet](/docs/presets/logistics)     | `logistics`        | Shippo, Samsara                     |
| [Restaurants and POS](/docs/presets/pos)           | `pos`              | Toast, Square                       |
| [Fitness and memberships](/docs/presets/fitness)   | `fitness`          | Mindbody                            |
| [Nonprofit fundraising](/docs/presets/nonprofit)   | `nonprofit`        | Bloomerang                          |
| [IT service management](/docs/presets/itsm)        | `itsm`             | ServiceNow, Jira Service Management |
| [Compliance](/docs/presets/compliance)             | `compliance`       | Vanta, Drata                        |
| [Construction](/docs/presets/construction)         | `construction`     | Procore                             |
| [Insurance and claims](/docs/presets/claims)       | `claims`           | Guidewire                           |
| [Community](/docs/presets/community)               | `community`        | Discourse, Circle                   |
| [Product feedback](/docs/presets/feedback)         | `feedback`         | Canny, Productboard                 |

## Apps built on a preset [#apps-built-on-a-preset]

These pages start from a preset and add the subject (a table of their own or
a block's), the options and the event flow of one product. `tests/validation-recipes` installs each
config on the local stack and drives the flows end to end.

| App                                    | Preset       | Subject    | Adds                                                        |
| -------------------------------------- | ------------ | ---------- | ----------------------------------------------------------- |
| [CRM](/docs/presets/crm)               | `crm`        | `deal`     | reminders                                                   |
| [Projects](/docs/presets/projects)     | `projects`   | `task`     | numbering, the state machine's workflow                     |
| [Drive](/docs/presets/drive)           | `saas`       | `document` | comments, tags, attachments, share-links, rank              |
| [Scheduling](/docs/presets/scheduling) | `scheduling` | `booking`  | custom-fields, share-links, state-machine                   |
| [Commerce](/docs/presets/commerce)     | `commerce`   | `order`    | imports, numbering, search, saved-views, metrics, approvals |

The [CMS](/docs/presets/cms), [ERP](/docs/presets/erp) and
[product information](/docs/presets/pim) pages describe the `cms`, `erp` and
`pim` presets and how their blocks connect. `tests/validation-presets`
installs each one and checks those links on the local stack.

## Designs and Create [#designs-and-create]

[Create](https://app.bettersupabase.com/create) builds a design in the browser
and keeps it in the URL: `/create?preset=<name>` for an unchanged named
preset, `/create?design=<code>` otherwise, with the preset inside the design.
`init --preset` and `add --preset` take the whole URL, and the config's
`preset` key takes the name or the preset object the design holds. See
[Designs and the resolver](/docs/presets/designs) for the format and the
`better-supabase/preset` API.

Create runs in the better-supabase cloud app and needs no account for the
canvas, Get code and Deploy to Supabase. Signing in adds the AI chat, saved
projects with version history, designs too large for a link, deploy
history and the cloud MCP server at `https://app.bettersupabase.com/api/mcp`.
That server edits your saved designs; the docs MCP server at
`https://bettersupabase.com/mcp` answers questions about these docs. Old
`bettersupabase.com/create` links redirect to the app with their query
string, so links you already shared keep opening.

To skip the blank canvas, pick "Start from a template" under Preset. Each
template sets a named preset, adds the blocks its tables use and draws the
app tables with their relations and features. The templates are projects
and tasks, a content calendar, an asset register, event planning, a
feedback board and a course catalog. Starting from one replaces the canvas,
and Undo brings the previous design back.

The Data tab on the table inspector shows eight sample rows for an app
table. The values follow each column's format, options and bounds, a
reference shows a row title from the table it points to, and a nullable
column is empty in some rows. The rows are generated in the browser and
never reach a database.

The Computed section at the end of the Columns tab adds
[rollups and lookups](/docs/repository/tables#rollups-and-lookups). A
rollup counts, sums or takes the min, max or average of the rows that link
to the table, optionally only the rows with one select value. A lookup
shows a column of the row the table links to. You pick each part from the
links the design already has, so there is no SQL to type, and the table's
node lists them under its columns. Deleting or retyping a column a rollup
reads removes the rollup.

The Changes tab in Get code lists what you changed in the app tables since
the page loaded the design in its link. Each change is marked safe or
breaking, and a breaking one (a dropped, renamed or retyped column, or a
column made required without a default) lists the expand-and-contract steps
that [`add --preset`](/docs/cli/presets#breaking-changes) prints before it refuses
to write the design.

## The shared wiring [#the-shared-wiring]

Three pieces connect the blocks in every recipe.

The subject is declared once in `config.records`. Comments, tags, custom
fields, share links and reminders all read it from the
[subject registry](/docs/blocks/sql#subject-registry), and `rank` reads its `rank` entry.

A state machine is named after its subject (`deal`, `task`, `booking`). The
machine name is the `subjectType` of its history rows and of
`status.changed`, so a status change reaches the same watchers as a comment.

A cron route hands the outbox to the watchers. `watcherHandler` reads the
actor from each outbox row, so whoever acts is not notified of their own
change. Comments need no entry: the comments module sends `comment.posted`
to a record's followers itself.

```ts title="app/api/cron/watchers/route.ts"
import { watcherHandler } from "better-supabase/blocks/notifications";

const watchers = watcherHandler(notifications, {
  types: {
    "subject.tagged": "subject.tagged",
    "status.changed": "subject.moved",
    "share_link.created": "subject.shared",
    "field_value.set": "subject.field_changed",
  },
});

export const GET = outbox.relayRoute({
  secret: process.env.CRON_SECRET,
  consumers: { watchers },
});
```

Users follow a row with `notifications.subscribe({ subject, level })`.
Watchers at level `all` get every mapped event. See
[watchers](/docs/blocks/notifications#watchers) for rules with `activity`,
`recipients` and `summary`.

Reminders deliver through the `notifications` block, the `notifier` port,
when the reminders block has a `notification` type. The reminder's owner and the subject's watchers get
one notification per occurrence:

```ts title="lib/blocks.ts"
const { comments, reminders, stateMachine } = createBlocks(
  { transport, service },
  {
    notifications: notificationsBlock.using((options) =>
      createNotifications({ ...options, types }),
    ),
    comments: commentsBlock,
    reminders: remindersBlock.with({ notification: "reminder.due" }),
    stateMachine: stateMachineBlock,
  },
);
```

`reminders.job` runs as the handler of the `reminders.deliver` queue
([delivery](/docs/blocks/reminders#delivery)).

# IT service management

> The itsm preset installs tickets with SLAs, change approvals, an asset registry, relations, incidents with a public status page and a knowledge base.

Source: https://bettersupabase.com/docs/presets/itsm

The `itsm` preset is for an app in the style of ServiceNow or Jira Service
Management. Requests and problems are tickets with SLA clocks, changes go
through approval, every ticket names the configuration item it affects, and
an outage is an incident with a public status page.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "itsm" });
```

`npx better-supabase init --preset itsm` starts a new project from it, and
`npx better-supabase add --preset itsm` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                               | Role in the app                                            |
| ----------------------------------- | ---------------------------------------------------------- |
| [tasks](/docs/blocks/tasks)         | Requests, problems and changes, with the asset they affect |
| [sla](/docs/blocks/sla)             | Response and resolution clocks per priority                |
| [approvals](/docs/blocks/approvals) | The change advisory approval                               |
| [assets](/docs/blocks/assets)       | The configuration items (CMDB) and where they run          |
| [relations](/docs/blocks/relations) | Links between tickets, problems and incidents              |
| [incidents](/docs/blocks/incidents) | Incidents, their updates and the public status page        |
| [knowledge](/docs/blocks/knowledge) | Searchable articles for agents and requesters              |

## How the blocks connect [#how-the-blocks-connect]

The preset names the tasks block's work items `ticket` and its projects
`issue` (`blocks.tasks.names`), so the tables, functions and generated
hooks say tickets. A ticket has an SLA policy by priority, and it names the
asset it affects. A change request waits on its approval before the work
starts. During an outage, an incident names the components it affects and
links the tickets it causes through relations. Its public updates and each
component's daily uptime appear on the organization's status page, which
`publicStatusPage(slug, days)` returns to anyone, signed in or not, with only
public fields ([incidents](/docs/blocks/incidents)).

## With ReUI [#with-reui]

- Data Grid for the ticket queue, with Filters on priority, SLA and assignee.
- Kanban for changes by stage.
- A status page with each component's state as a Badge.

# Legal practice

> The legal preset installs clients and matters, time billed from a rate card into invoices, a trust ledger, documents with versions and signatures.

Source: https://bettersupabase.com/docs/presets/legal

The `legal` preset is for an app in the style of Clio. A firm opens a matter
for a client, checks for conflicts against the parties it already knows,
tracks time on the matter, bills it from a rate card, and holds the client's
retainer in a trust account.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "legal" });
```

`npx better-supabase init --preset legal` starts a new project from it, and
`npx better-supabase add --preset legal` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                       | Role in the app                                               |
| ------------------------------------------- | ------------------------------------------------------------- |
| [parties](/docs/blocks/contacts)            | Clients and opposing parties, with dedupe for conflict checks |
| [time-tracking](/docs/blocks/time-tracking) | Time on each matter, with approved timesheets                 |
| [billable-time](/docs/blocks/billable-time) | Rate cards that turn tracked time into draft invoice lines    |
| [invoices](/docs/blocks/invoices)           | Invoices to clients, with payments                            |
| [ledger](/docs/blocks/ledger)               | Trust and retainer accounts per client                        |
| [documents](/docs/blocks/documents)         | Briefs and pleadings, edited together                         |
| [versions](/docs/blocks/versions)           | Numbered drafts of each document                              |
| signatures                                  | Engagement letters signed by the client                       |

## How the blocks connect [#how-the-blocks-connect]

A matter is your own table, registered as a subject, so time entries,
documents and invoices point at it. Billable time prices the matter's time
from the rate card and writes draft invoice lines in minor units. A retainer
is a ledger account kind: money received into trust and money applied to an
invoice are balanced ledger transactions, so the trust balance is always the
ledger's.

## With ReUI [#with-reui]

- Data Grid for matters and time entries, with Filters on client and status.
- A weekly timesheet grid from the time-tracking week shape.
- Timeline for a matter's documents, time and invoices.

# Courses and learning

> The lms preset installs courses with modules, lessons and quizzes graded by forms, enrollments with progress, certificates, comments and attachments.

Source: https://bettersupabase.com/docs/presets/lms

The `lms` preset is for an app in the style of Teachable or Canvas. An
instructor builds a course from modules and lessons, some of them quizzes,
learners enroll and work through it, quizzes are graded in the database, and
a completed course can issue a certificate anyone can verify.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "lms" });
```

`npx better-supabase init --preset lms` starts a new project from it, and
`npx better-supabase add --preset lms` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                        |
| --------------------------------------- | ------------------------------------------------------ |
| [courses](/docs/blocks/courses)         | Courses, modules, lessons, completion and certificates |
| [enrollments](/docs/blocks/enrollments) | Each learner's enrollment, with progress per lesson    |
| [forms](/docs/blocks/forms)             | Quiz lessons, scored against a pass mark               |
| [comments](/docs/blocks/comments)       | Questions and discussion on a lesson                   |
| [attachments](/docs/blocks/attachments) | Lesson files and assignment uploads                    |

The `entitlements` block from `saas` can decide who may enroll in a paid
course.

## How the blocks connect [#how-the-blocks-connect]

A course's required lessons are the steps of its enrollment program, so a
learner's progress is enrollment progress, and finishing the last required
lesson completes the enrollment. A quiz lesson is a form: Postgres scores
the submission inside `submit` against answer keys no client can read, so a
learner can't send their own grade. When the course issues certificates,
completion creates one with a random code, and `verifyCertificate(code)`
checks it for anyone, signed in or not ([courses](/docs/blocks/courses)).

## With ReUI [#with-reui]

- Progress and Stepper for a learner's way through a course.
- Data Grid for the enrollments of a course, with Filters on progress.
- Card grid for the course catalog.

# Logistics and fleet

> The logistics preset installs shipments with carrier rates, labels and tracking, orders to ship, customers as parties, and warehouses and vehicles in assets.

Source: https://bettersupabase.com/docs/presets/logistics

The `logistics` preset is for an app in the style of Shippo or a fleet
tracker. An order is packed into a shipment, the app asks your carriers for
rates and buys a label, and the tracking timeline follows the package to
delivery. Warehouses and vehicles are assets, so a shipment can name the
location it ships from.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "logistics" });
```

`npx better-supabase init --preset logistics` starts a new project from it,
and `npx better-supabase add --preset logistics` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                               | Role in the app                                   |
| ----------------------------------- | ------------------------------------------------- |
| [parties](/docs/blocks/contacts)    | Shippers, consignees and carriers' accounts       |
| [orders](/docs/blocks/orders)       | The orders that ship                              |
| [shipments](/docs/blocks/shipments) | Packages, rates, labels and the tracking timeline |
| [assets](/docs/blocks/assets)       | Warehouses, docks and vehicles                    |

## How the blocks connect [#how-the-blocks-connect]

A shipment is for a record, such as an order, and ships from an asset
location to an address. Carriers plug in through the versioned
`ShippingProvider` interface: you register one per carrier you use, and the
block asks each for rates and buys the label from the one you pick. The
status follows the latest tracking event, whether it came from a carrier
poll, a signed webhook or a person, and a repeated webhook adds no duplicate
([shipments](/docs/blocks/shipments)).

## With ReUI [#with-reui]

- Data Grid for shipments, with Filters on status, carrier and date.
- Timeline for a shipment's tracking events.
- A rate picker as a Radio Group of carrier services and prices.

# Nonprofit fundraising

> The nonprofit preset installs donors, gifts and pledges posted on the ledger per fund, numbered tax receipts, campaign targets and saved views for donor segments.

Source: https://bettersupabase.com/docs/presets/nonprofit

The `nonprofit` preset is for an app in the style of Bloomerang. A
fundraiser keeps donors, records one-off gifts, charges monthly pledges
through a payments provider, follows each fund towards its goal and issues
numbered tax receipts.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "nonprofit" });
```

`npx better-supabase init --preset nonprofit` starts a new project from it,
and `npx better-supabase add --preset nonprofit` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                 |
| --------------------------------------- | ----------------------------------------------- |
| [parties](/docs/blocks/contacts)        | Donors, households and organizations            |
| [donations](/docs/blocks/donations)     | Funds, gifts, pledges and tax receipts          |
| [ledger](/docs/blocks/ledger)           | The income account each gift posts to, per fund |
| [numbering](/docs/blocks/numbering)     | Receipt numbers in your own format              |
| [targets](/docs/blocks/goals)           | Fundraising targets per team or period          |
| [saved-views](/docs/blocks/saved-views) | Donor segments, such as lapsed or major donors  |

## How the blocks connect [#how-the-blocks-connect]

A gift names its donor, any record in `config.records` such as a party, and
posts on the ledger to its fund, so what a fund raised is a ledger balance
and `funds()` returns it next to the goal. A pledge repeats a gift every
week, month, quarter or year: run `chargeDuePledges` from a scheduled job and
it charges each due installment once through the versioned `PaymentProvider`
interface, and the provider's webhook settles the gift. `issueGiftReceipt`
and `issueYearlyReceipt` number receipts with the `donation_receipt` format
from numbering ([donations](/docs/blocks/donations)).

## With ReUI [#with-reui]

- Data Grid for donors and gifts, with Filters for segments.
- Progress for each fund towards its goal.
- Statistic cards for the year's total, donors and average gift.

# Product information

> The pim preset installs a catalog with sales channels, translations and public product media, plus custom fields, imports, bulk edits, tags, saved views and search.

Source: https://bettersupabase.com/docs/presets/pim

The `pim` preset is for product information management in the style of
Akeneo or Salsify. A team keeps one catalog, translates it, decides what
each sales channel lists, and publishes images that any storefront can load.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "pim",
  blocks: { catalog: { options: { locales: ["en", "de", "fr"] } } },
});
```

`blocks.catalog.options.locales` lists the languages the catalog keeps; the
first is the default. `npx better-supabase init --preset pim` starts a new
project from it, and `npx better-supabase add --preset pim` adds it to one
you have ([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                       | Role in the app                                        |
| ------------------------------------------- | ------------------------------------------------------ |
| [catalog](/docs/blocks/catalog)             | Products, variants, channels, translations and media   |
| [attachments](/docs/blocks/attachments)     | The product images, served by public URL               |
| [custom-fields](/docs/blocks/custom-fields) | Product attributes that the team defines               |
| [imports](/docs/blocks/imports)             | Supplier spreadsheets, mapped and checked in a dry run |
| [bulk](/docs/blocks/bulk)                   | One change to many products at once                    |
| [tags](/docs/blocks/tags)                   | Labels such as season or campaign                      |
| [saved-views](/docs/blocks/saved-views)     | Saved filters, such as products missing a translation  |
| [search](/docs/blocks/search)               | Search across the catalog                              |

## How the blocks connect [#how-the-blocks-connect]

A sales channel lists the products it sells and can take its prices from a
price list ([channels](/docs/blocks/catalog#channels)). Each product and
variant keeps a title and description per locale
([locales and translations](/docs/blocks/catalog#locales-and-translations)).
With attachments installed, a product keeps an ordered gallery with alt
text per locale ([product media](/docs/blocks/catalog#product-media)).

The preset makes the attachments bucket public
(`blocks.attachments.options.publicBuckets` with `requireScan: false`), so a
storefront loads each image from `attachments.publicUrl()`. Every file in
that bucket is readable by anyone who has its path. Keep supplier contracts
and other private files out of it, or set `publicBuckets: []` and serve
images from a bucket of their own.

## With ReUI [#with-reui]

- Data Grid for the product list, with Filters on channel, tag and a
  missing translation.
- A sortable gallery for the product media, saved with
  `catalog.reorderProductMedia`.
- A Badge on each product for the channels that list it.

# Restaurants and POS

> The pos preset installs a menu in the catalog, orders with an optional kitchen display, stock in inventory, table reservations, promotions and the ledger.

Source: https://bettersupabase.com/docs/presets/pos

The `pos` preset is for an app in the style of Toast or Square for
restaurants. Staff take an order from the menu, the kitchen prepares its
lines at their stations, stock goes down as items sell, guests reserve
tables, and the day's takings land on the ledger.

## Config [#config]

Turn on the kitchen display with the orders block's `kitchen` option; it is
off by default, so the preset leaves it to your config:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "pos",
  blocks: { orders: { options: { kitchen: true } } },
});
```

`npx better-supabase init --preset pos` starts a new project from it, and
`npx better-supabase add --preset pos` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                 | Role in the app                                          |
| ------------------------------------- | -------------------------------------------------------- |
| [catalog](/docs/blocks/catalog)       | Menu items and their variants with prices                |
| [orders](/docs/blocks/orders)         | Tickets at the till, and kitchen stations with `kitchen` |
| [inventory](/docs/blocks/inventory)   | Stock that goes down as items sell                       |
| [bookings](/docs/blocks/bookings)     | Table reservations                                       |
| [promotions](/docs/blocks/promotions) | Happy hours and discount codes                           |
| [ledger](/docs/blocks/ledger)         | Takings and payouts                                      |

## How the blocks connect [#how-the-blocks-connect]

An order's lines are catalog variants. With `kitchen`, `orders.kitchen.send`
routes each line to its station with a note, and the line moves through its
preparation states on a kitchen display that refreshes on the kitchen event
([orders](/docs/blocks/orders#kitchen)). A table is a bookable resource, so a
reservation can't double book it.

## With ReUI [#with-reui]

- Kanban for the kitchen display, one column per preparation state.
- A card grid of menu items for the till.
- Event Calendar for the table reservations of a service.

# Project management

> Tasks on a status board with a workflow, numbered keys, comments, tags and due-date reminders.

Source: https://bettersupabase.com/docs/presets/projects

Tasks move through `todo`, `doing` and `done` on a board. Teams group the
people, number sequences give each task a readable key, and watchers hear
about status changes, comments and due dates.

## Table [#table]

```sql title="supabase/schemas/tasks.sql"
create table public.tasks (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null,
  title text not null,
  status text not null default 'todo',
  rank text collate "C" not null default 'V'
);
create index on public.tasks (organization_id, status, rank);
alter table public.tasks enable row level security;
```

## Config [#config]

The `projects` preset brings the SaaS blocks (organizations with teams,
notifications, the outbox, ...) plus tags, comments, attachments, the state
machine, `rank`, reminders and time tracking. Task keys add
`numbering`, and the workflow is the state machine's options:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "projects",
  records: {
    task: {
      table: "public.tasks",
      idType: "uuid",
      title: "title",
      url: "/tasks/{id}",
      rank: { column: "rank", group: "status" },
    },
  },
  blocks: {
    numbering: {},
    "state-machine": {
      options: {
        machines: {
          task: {
            table: "public.tasks",
            column: "status",
            initial: "todo",
            tenant: "organization_id",
            transitions: {
              todo: ["doing"],
              doing: ["todo", "done"],
              done: ["doing"],
            },
          },
        },
      },
    },
  },
});
```

## Event flow [#event-flow]

Each call takes the record as a subject, `task` being `{ type: "task", id: taskId }`, and acts in the tenant of the block client (see [one block per request](/docs/blocks/create-blocks#one-block-per-request)).

| The user                    | Call                                                   | Event                                    | Watchers get                    |
| --------------------------- | ------------------------------------------------------ | ---------------------------------------- | ------------------------------- |
| Starts a task               | `stateMachine.transition(task, "doing")`               | `status.changed`                         | `subject.moved`                 |
| Sends it back with a reason | `stateMachine.transition(task, "todo", "blocked")`     | `status.changed`                         | `subject.moved`                 |
| Comments                    | `comments.create(task, { body })`                      | `message.sent`                           | `comment.posted`, from comments |
| Drags it on the board       | `rank.move("task", taskId, { previous, next, group })` | `status.changed` when the column changes | `subject.moved`                 |
| Reaches the due date        | `reminders.deliver(payload)`                           | `reminder.sent`                          | `reminder.due`                  |

`status.changed` carries the previous status in `from` and the reason, so a
notification can say "moved back to todo: blocked". Every change also writes
a `status_history` row. A reminder delivers once per occurrence: a second
`deliver` for the same occurrence returns `false` and notifies no one.

## With ReUI [#with-reui]

- Kanban for the status board, calling `rank.move` on drop.
- Data Grid for the task list, with Filters on a `defineListQuery`.
- Gantt when tasks get dates; rank keys keep the row order.
- Stepper for the workflow on the task page, from the machine's
  `transitions`.

ReUI's Tempo template shows a full task product on these components. The
Pro blocks are licensed per user, so copy them from
[reui.io](https://reui.io).

# Property management

> The property preset installs units in assets, leases with rent and deposits on the ledger, maintenance requests, tenants with a portal and attachments.

Source: https://bettersupabase.com/docs/presets/property

The `property` preset is for an app in the style of AppFolio or Buildium. A
property manager keeps buildings and units, lets units to tenants, posts the
rent every period, files maintenance requests on a unit, and gives each
tenant a portal with the records granted to them.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "property" });
```

`npx better-supabase init --preset property` starts a new project from it,
and `npx better-supabase add --preset property` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                         | Role in the app                                                  |
| --------------------------------------------- | ---------------------------------------------------------------- |
| [assets](/docs/blocks/assets)                 | Sites, buildings and units as a location hierarchy               |
| [leases](/docs/blocks/leases)                 | Leases on units, rent per period and deposits                    |
| [tasks](/docs/blocks/tasks), as `maintenance` | Maintenance requests on a unit                                   |
| [ledger](/docs/blocks/ledger)                 | The receivable account each lease posts its rent and payments to |
| [parties](/docs/blocks/contacts)              | Tenants and owners                                               |
| [guests](/docs/blocks/guests)                 | The tenant portal, limited to the records you grant              |
| [comments](/docs/blocks/comments)             | Questions and updates on a lease or a request                    |
| [attachments](/docs/blocks/attachments)       | Signed leases and inspection photos                              |
| [reminders](/docs/blocks/reminders)           | Inspections and lease ends                                       |

## How the blocks connect [#how-the-blocks-connect]

A lease lets a unit from `assets` to a party. `postDueRent` posts each rent
period on the ledger once, and `recordPayment` settles it, so the balance
due is the lease's ledger balance and never a second copy. The deposit is a
ledger hold while the lease runs; `terminate` captures the deduction and
releases the rest. Run `postDueRent` from a scheduled job or cron route; a
second run posts nothing new.

Maintenance requests are an instance of the tasks block, which the preset
writes as `blocks.maintenance` with the work item named
`maintenance_request`. Add a `unit_id` column with
`blocks.maintenance.options.extraColumns` to tie each request to a unit. Its
client creates a request and moves it through its statuses. A tenant is a guest, never a member:
invite them and grant their lease, and they comment on it and upload files
to it through the comments and attachments blocks. The
[guests](/docs/blocks/guests#grant-records) page lists which blocks honor a
grant; read the rest of the portal (the lease terms, the balance due) in
server code after checking the tenant's grants with `guests.mine()`.

## With ReUI [#with-reui]

- Data Grid for units and leases, with Filters on status and the lease end.
- Kanban for maintenance requests by status.
- Timeline for a lease's rent postings and payments, newest first.

# Scheduling

> Bookings with a request and confirm workflow, custom fields, share links and reminders before the start.

Source: https://bettersupabase.com/docs/presets/scheduling

A booking is requested, then confirmed or cancelled. Custom fields hold what
each organization asks for (a room, a phone number), a share link lets the
guest open the booking, and reminders go out before it starts.

## Table [#table]

```sql title="supabase/schemas/bookings.sql"
create table public.bookings (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null,
  title text not null,
  status text not null default 'requested',
  starts_at timestamptz not null
);
alter table public.bookings enable row level security;
```

## Config [#config]

The `scheduling` preset brings the SaaS blocks plus calendars, bookings and
reminders. This app tracks its own `bookings` table, so it adds custom
fields, share links and a state machine for the booking status:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: "scheduling",
  records: {
    booking: {
      table: "public.bookings",
      idType: "uuid",
      title: "title",
      url: "/bookings/{id}",
    },
  },
  blocks: {
    "custom-fields": {},
    "share-links": {},
    "state-machine": {
      options: {
        machines: {
          booking: {
            table: "public.bookings",
            column: "status",
            initial: "requested",
            tenant: "organization_id",
            transitions: {
              requested: ["confirmed", "cancelled"],
              confirmed: ["cancelled"],
              cancelled: [],
            },
          },
        },
      },
    },
  },
});
```

## Event flow [#event-flow]

Each call takes the record as a subject, `booking` being `{ type: "booking", id }`, and acts in the tenant of the block client (see [one block per request](/docs/blocks/create-blocks#one-block-per-request)).

| The user               | Call                                             | Event                | Watchers get            |
| ---------------------- | ------------------------------------------------ | -------------------- | ----------------------- |
| Confirms a booking     | `stateMachine.transition(booking, "confirmed")`  | `status.changed`     | `subject.moved`         |
| Sets a field           | `customFields.setValue(booking, fieldId, value)` | `field_value.set`    | `subject.field_changed` |
| Shares it with a guest | `shareLinks.create(booking, "viewer")`           | `share_link.created` | `subject.shared`        |
| Is reminded            | `reminders.deliver(payload)`                     | `reminder.sent`      | `reminder.due`          |

A cancelled booking stays cancelled: `cancelled` has no outgoing
transitions, so `transition` returns `STATE_TRANSITION_INVALID`.

This recipe has no overlap check. For resources, weekly hours, open slots
and an exclusion constraint against double bookings, use the
[bookings block](/docs/blocks/bookings).

## With ReUI [#with-reui]

- Event Calendar for the booking calendar, from `starts_at`.
- Data Grid for the booking list, with Filters on a `defineListQuery`.
- Stepper for the request and confirm workflow on the booking page.

Pro scheduling blocks are licensed per user; copy them from
[reui.io](https://reui.io).

# Expenses and spend

> The spend preset installs expense reports with receipts and spend policies, approvals, the approved total and reimbursement on the ledger, and receipt files for AI extraction.

Source: https://bettersupabase.com/docs/presets/spend

The `spend` preset is for an app in the style of Ramp or Expensify. A member
files an expense report with its receipts, the category's spend policy checks
it on submit, an approver decides it, and the reimbursement is paid through
the ledger.

## Config [#config]

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({ preset: "spend" });
```

`npx better-supabase init --preset spend` starts a new project from it, and
`npx better-supabase add --preset spend` adds it to one you have
([CLI](/docs/cli/presets)).

## Blocks [#blocks]

The preset is [`saas`](/docs/presets#named-presets) plus these blocks, and
whatever they require:

| Block                                   | Role in the app                                               |
| --------------------------------------- | ------------------------------------------------------------- |
| [expenses](/docs/blocks/expenses)       | Reports, lines, categories, spend policies and reimbursements |
| [approvals](/docs/blocks/approvals)     | The approval each submitted report waits on                   |
| [attachments](/docs/blocks/attachments) | Receipt images and PDFs on each line                          |
| [ai-files](/docs/blocks/ai-files)       | Receipt files handed to a model to read the amount and date   |

The expenses block posts on the [ledger](/docs/blocks/ledger), which the
preset installs as one of its requirements.

## How the blocks connect [#how-the-blocks-connect]

`submit` checks each line against its category's spend policy, then the
report waits on its approval. When the approval is final, each category's
total posts on its expense account against the member's payable account, and
`reimburse` settles the payable account from expense cash. Each posting has
a key per report, so it can't post twice, and what the organization owes a
member is a ledger balance ([expenses](/docs/blocks/expenses)).

## With ReUI [#with-reui]

- Data Grid for reports, with Filters on status, category and amount.
- A file upload with a preview of the receipt next to the line form.
- Badge variants for the report status (submitted, approved, reimbursed).

# Registry

> Install better-supabase glue (block wiring, server actions, hooks, route handlers and Expo config) with the shadcn CLI from the @better-supabase registry.

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

The `@better-supabase` registry serves glue as [shadcn registry](https://ui.shadcn.com/docs/registry) items: the code that wires blocks into your app and that you are expected to edit. The shadcn CLI copies an item into your project, so it becomes yours.

The registry holds no UI. better-supabase ships no visible components: the interface comes from your own shadcn components and a block's hook, following a [UI recipe](/docs/registry/ui-recipes). It holds no config or SQL either: install a block with `npx better-supabase add <block>`, which writes its migration and config, and each item's docs say which blocks it needs.

## Add the namespace [#add-the-namespace]

Add the registry to `components.json`:

```json title="components.json"
{
  "registries": {
    "@better-supabase": "https://bettersupabase.com/r/{name}.json"
  }
}
```

Then install items by name:

```bash
npx better-supabase add outbox
npx shadcn add @better-supabase/outbox-relay-next
```

Without the namespace, install an item by its URL: `npx shadcn add https://bettersupabase.com/r/outbox-relay-next.json`.

The shadcn MCP server reads the same namespace, so an agent connected to it can search and install the items ([for AI agents](/docs/for-ai-agents#shadcn-registry)).

## Items [#items]

| Item                         | Type            | What it installs                                                                                  | Blocks                                      |
| ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `blocks`                     | `registry:lib`  | `lib/blocks.ts`: typed block clients over the Data API as the caller, and the notification types  | organizations, notifications, search, audit |
| `postgres`                   | `registry:lib`  | `lib/postgres.ts`: one server pool for the outbox, jobs, webhook inbox and billing                | jobs, outbox, billing                       |
| `notifications-actions-next` | `registry:lib`  | server actions that list, count and mark the caller's notifications                               | notifications                               |
| `onboarding-checklist`       | `registry:lib`  | `lib/onboarding.ts`: a getting-started checklist definition                                       | onboarding                                  |
| `outbox`                     | `registry:lib`  | `lib/outbox.ts`: the outbox on the server connection                                              | outbox                                      |
| `use-current-organization`   | `registry:hook` | the active organization and `switchTo`, for React DOM and React Native                            | organizations                               |
| `use-rule-editor`            | `registry:hook` | `useRuleEditor`: one rule's condition tree, validation and save in the active organization        | rules                                       |
| `push-native`                | `registry:hook` | `usePushRegistration`: registers the Expo push token                                              | push                                        |
| `outbox-relay-next`          | `registry:file` | `app/api/outbox/route.ts`: the cron route that relays outbox events                               | outbox                                      |
| `outbox-relay-hono`          | `registry:file` | `src/routes/outbox.ts`: the same relay as a Hono route                                            | outbox                                      |
| `stripe-webhook-next`        | `registry:file` | the Stripe webhook route, a cron route that hands stored events to billing, and `lib/stripe.ts`   | billing, webhook-inbox                      |
| `jobs-drain-next`            | `registry:file` | `lib/jobs.ts` and `app/api/jobs/drain/route.ts`: the queues and their cron route                  | jobs                                        |
| `expo-config-native`         | `registry:file` | `app.config.ts`: the URL scheme for auth deep links, secure storage and push on top of `app.json` | push                                        |
| `saas-next`                  | `registry:item` | the glue of the `saas` preset on Next.js, as registry dependencies                                | the `saas` blocks                           |
| `saas-expo`                  | `registry:item` | the glue of the `saas` preset on Expo                                                             | the `saas` blocks                           |

A framework or platform variant is its own item, named after it (`outbox-relay-next`, `outbox-relay-hono`, `push-native`). The `registry:file` items target your project root (`~/app/api/...`); with a `src/` folder, move the file under `src/`.

## What every item carries [#what-every-item-carries]

| Field                  | Content                                                                                                 |
| ---------------------- | ------------------------------------------------------------------------------------------------------- |
| `dependencies`         | `better-supabase@^<version>` of the release that built the registry, plus the packages the code imports |
| `registryDependencies` | other `@better-supabase/*` items only                                                                   |
| `envVars`              | development values only, such as the local stack's database URL; set real values in your deployment     |
| `docs`                 | the blocks to install and a link to their docs page                                                     |
| `meta`                 | `block` (one block or a list), `platforms` (`web`, `native`) and `frameworks`                           |

`packages/registry` in the repository holds the sources. Every item typechecks against the package, a test validates the built items against the shadcn registry schema of the release pinned in `SPEC_PINS.shadcnRegistry` ([standards](/docs/standards)), and each `meta.block` must name a block in the catalog.

# Access requests inbox

> Requests for access to a resource, with approve and deny buttons for the owner.

Source: https://bettersupabase.com/docs/registry/ui-recipes/access-requests-inbox

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [access-requests block](https://bettersupabase.com/blocks/access-requests) must be installed (`npx better-supabase add access-requests`). Its page on bettersupabase.com is [/recipes/access-requests-inbox](https://bettersupabase.com/recipes/access-requests-inbox).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/access-requests/components/access-requests-inbox.tsx"
/**
 * Requests for a temporary role in the active organization. Members ask for
 * one; members with `members.update_role` approve or deny it, and the role goes
 * back to the previous one when the duration runs out.
 */
export async function AccessRequestsInbox() {
  if (!can(await getSession(), "access_requests.create")) {
    forbidden();
  }

  const [t, format, result] = await Promise.all([
    getExtracted("accessRequests"),
    getFormatter(),
    loadInbox(),
  ]);

  if (result.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="access-requests-inbox">
        <TriangleAlertIcon />
        <AlertTitle>{t("Access requests could not be loaded")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={refreshAccessRequests}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  if (result.data === null) {
    return (
      <p
        className="text-sm text-muted-foreground"
        data-recipe="access-requests-inbox"
      >
        {t("Pick an organization to see its access requests.")}
      </p>
    );
  }

  const { requests, role, userId, canDecide } = result.data;
  const canRequest = role !== undefined && role !== "owner";

  const statusLabel = (status: AccessRequestStatus) => {
    switch (status) {
      case "pending": {
        return t("Pending");
      }

      case "granted": {
        return t("Granted");
      }

      case "rejected": {
        return t("Denied");
      }

      case "cancelled": {
        return t("Withdrawn");
      }

      case "expired": {
        return t("Expired");
      }

      case "ended": {
        return t("Ended");
      }

      default: {
        const unknown: never = status;

        return unknown;
      }
    }
  };

  const roleLabel = (value: string) =>
    value === "admin" ? t("Admin") : value === "member" ? t("Member") : value;

  const durationLabel = (value: string | null) => {
    switch (value) {
      case null: {
        return t("Until revoked");
      }

      case "PT1H": {
        return t("1 hour");
      }

      case "P1D": {
        return t("1 day");
      }

      case "P7D": {
        return t("7 days");
      }

      default: {
        return value;
      }
    }
  };

  const date = (value: string) =>
    format.dateTime(new Date(value), {
      dateStyle: "medium",
      timeStyle: "short",
    });

  return (
    <div className="flex flex-col gap-6" data-recipe="access-requests-inbox">
      {canRequest ? (
        <Card id="request-access">
          <CardHeader>
            <CardTitle>{t("Request access")}</CardTitle>
            <CardDescription>
              {t(
                "Ask for a higher role for a limited time. An admin or owner approves it.",
              )}
            </CardDescription>
          </CardHeader>
          <CardContent>
            <RequestAccessForm currentRole={role} />
          </CardContent>
        </Card>
      ) : null}
      <Card>
        <CardHeader>
          <CardTitle>{t("Requests")}</CardTitle>
          <CardDescription>
            {canDecide
              ? t("Approve or deny what members asked for.")
              : t("The requests you sent and what happened to them.")}
          </CardDescription>
        </CardHeader>
        <CardContent>
          {requests.length === 0 ? (
            <Empty>
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <InboxIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No access requests")}</EmptyTitle>
                <EmptyDescription>
                  {canRequest
                    ? t("Requests you and other members send show up here.")
                    : t(
                        "Requests from members show up here for you to decide.",
                      )}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                {canRequest ? (
                  <a
                    href="#request-access"
                    className={buttonVariants({ variant: "outline" })}
                  >
                    {t("Request a role")}
                  </a>
                ) : (
                  <Link
                    href="/settings/members"
                    className={buttonVariants({ variant: "outline" })}
                  >
                    {t("View members")}
                  </Link>
                )}
              </EmptyContent>
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Member")}</TableHead>
                  <TableHead>{t("Role")}</TableHead>
                  <TableHead className="hidden md:table-cell">
                    {t("Duration")}
                  </TableHead>
                  <TableHead>{t("Status")}</TableHead>
                  <TableHead className="text-right">
                    <span className="sr-only">{t("Actions")}</span>
                  </TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {requests.map((request) => (
                  <TableRow key={request.id}>
                    <TableCell>
                      <div className="flex flex-col gap-1">
                        <span className="font-medium">
                          {request.userId === userId
                            ? t("You")
                            : request.requester}
                        </span>
                        <span className="text-xs text-muted-foreground">
                          {request.reason ?? date(request.createdAt)}
                        </span>
                      </div>
                    </TableCell>
                    <TableCell>{roleLabel(request.role)}</TableCell>
                    <TableCell className="hidden text-muted-foreground md:table-cell">
                      {request.status === "granted" && request.expiresAt
                        ? t("Until {date}", { date: date(request.expiresAt) })
                        : durationLabel(request.duration)}
                    </TableCell>
                    <TableCell>
                      <Badge variant={STATUS_VARIANT[request.status]}>
                        {statusLabel(request.status)}
                      </Badge>
                    </TableCell>
                    <TableCell className="text-right">
                      <AccessRequestActions
                        request={request}
                        mine={request.userId === userId}
                        canDecide={canDecide}
                      />
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
    </div>
  );
}
```

# Activity timeline

> Calls, emails and meetings with a contact, newest first.

Source: https://bettersupabase.com/docs/registry/ui-recipes/activity-timeline

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [activities block](https://bettersupabase.com/blocks/activities) must be installed (`npx better-supabase add activities`). Its page on bettersupabase.com is [/recipes/activity-timeline](https://bettersupabase.com/recipes/activity-timeline).

Web, with shadcn components:

```bash
npx shadcn add badge card empty separator skeleton
```

```tsx title="apps/examples/nextjs/src/features/crm/components/activity-timeline.tsx"
const ICONS = new Map<string, LucideIcon>([
  ["call", PhoneIcon],
  ["email", MailIcon],
  ["meeting", CalendarIcon],
  ["note", StickyNoteIcon],
  ["task", CircleCheckIcon],
]);

/**
 * Render inside `<Suspense>`: the calls, emails, meetings and notes logged on a
 * contact, newest first, with a form to log the next one.
 */
export async function ActivityTimeline({
  subject,
}: {
  subject: { type: ContactKind; id: string };
}) {
  const [session, t, format] = await Promise.all([
    getSession(),
    getExtracted("crm"),
    getFormatter(),
  ]);

  if (!can(session, "engagements.read")) {
    return null;
  }

  const timeline = await settle(() => loadTimeline(subject.type, subject.id));

  if (timeline.kind === "no-organization") {
    return null;
  }

  const canLog = can(session, "engagements.write");

  const types = new Map([
    ["call", t("Call")],
    ["email", t("Email")],
    ["meeting", t("Meeting")],
    ["note", t("Note")],
    ["task", t("Task")],
  ]);

  return (
    <Card data-recipe="activity-timeline">
      <CardHeader>
        <CardTitle>{t("Activity")}</CardTitle>
        <CardDescription>
          {t("Calls, emails and meetings with this contact, newest first.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-6">
        {timeline.kind === "error" ? (
          <CrmLoadError title={t("The activity didn't load")} />
        ) : timeline.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing logged yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Log the first call, email, meeting or note.")}
              </EmptyDescription>
            </EmptyHeader>
            {canLog ? (
              <EmptyContent>
                <LogActivityForm subject={subject} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <>
            {canLog ? (
              <>
                <LogActivityForm subject={subject} />
                <Separator />
              </>
            ) : null}
            <ol className="flex flex-col gap-4">
              {timeline.data.map((entry) => {
                const Icon = ICONS.get(entry.type) ?? StickyNoteIcon;

                return (
                  <li key={entry.id} className="flex gap-3">
                    <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
                      <Icon className="size-4" aria-hidden />
                    </span>
                    <div className="flex min-w-0 flex-1 flex-col gap-1">
                      <div className="flex flex-wrap items-center gap-2">
                        <span className="text-sm font-medium">
                          {entry.title}
                        </span>
                        <Badge variant="secondary">
                          {types.get(entry.type) ?? entry.type}
                        </Badge>
                        {entry.overdue ? (
                          <Badge variant="warning-light">{t("Overdue")}</Badge>
                        ) : null}
                      </div>
                      {entry.body ? (
                        <p className="text-sm whitespace-pre-line text-muted-foreground">
                          {entry.body}
                        </p>
                      ) : null}
                      <time
                        dateTime={entry.createdAt}
                        className="text-xs text-muted-foreground"
                      >
                        {format.dateTime(new Date(entry.createdAt), {
                          dateStyle: "medium",
                          timeStyle: "short",
                        })}
                      </time>
                    </div>
                  </li>
                );
              })}
            </ol>
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

# Agent activity

> The agent runs for the organization with their steps and the approvals that wait for a person.

Source: https://bettersupabase.com/docs/registry/ui-recipes/agent-run-trace

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [agents block](https://bettersupabase.com/blocks/agents) must be installed (`npx better-supabase add agents`). Its page on bettersupabase.com is [/recipes/agent-run-trace](https://bettersupabase.com/recipes/agent-run-trace).

Web, with shadcn components:

```bash
npx shadcn add badge empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/assistant/components/agent-activity.tsx"
import { ActivityIcon, InboxIcon } from "lucide-react";
import { getExtracted, getFormatter } from "next-intl/server";

import { Badge } from "@/components/ui/badge";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "@/i18n/navigation";

import { getActivity } from "../assistant-queries";
import { ApprovalButtons } from "./approval-buttons";

/**
 * Render inside `<Suspense>`: the approval inbox, then the latest runs with
 * their steps.
 */
export async function AgentActivity() {
  const [{ runs, approvals }, t, format] = await Promise.all([
    getActivity(),
    getExtracted("assistant"),
    getFormatter(),
  ]);

  const when = (iso: string) =>
    format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short" });

  return (
    <div className="flex flex-col gap-6">
      <section className="flex flex-col gap-3" aria-labelledby="approvals">
        <h2 id="approvals" className="text-sm font-medium">
          {t("Waiting for approval")}
        </h2>
        {approvals.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <InboxIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing to approve")}</EmptyTitle>
              <EmptyDescription>
                {t("Tool calls that need your approval show up here.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ul className="divide-y rounded-xl border">
            {approvals.map((approval) => (
              <li
                key={approval.approvalId}
                className="flex flex-col gap-2 px-4 py-3 text-sm"
              >
                <p className="flex flex-wrap items-center gap-2">
                  <span className="font-medium">{approval.tool}</span>
                  <Link
                    href={`/assistant/${approval.chatId}`}
                    className="text-muted-foreground underline"
                  >
                    {approval.chatTitle === ""
                      ? t("Untitled chat")
                      : approval.chatTitle}
                  </Link>
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {when(approval.createdAt)}
                  </span>
                </p>
                <pre className="overflow-x-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
                  {approval.input}
                </pre>
                <ApprovalButtons
                  chatId={approval.chatId}
                  approvalId={approval.approvalId}
                />
              </li>
            ))}
          </ul>
        )}
      </section>
      <section className="flex flex-col gap-3" aria-labelledby="runs">
        <h2 id="runs" className="text-sm font-medium">
          {t("Latest runs")}
        </h2>
        {runs.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <ActivityIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No runs yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Every answer the assistant writes is a run.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ul className="divide-y rounded-xl border">
            {runs.map((run) => (
              <li
                key={run.id}
                className="flex flex-col gap-2 px-4 py-3 text-sm"
              >
                <p className="flex flex-wrap items-center gap-2">
                  <Badge variant="outline">{run.status}</Badge>
                  <Badge variant="secondary">
                    {run.engine === "workflow" ? t("Durable") : t("Standard")}
                  </Badge>
                  <Link
                    href={`/assistant/${run.chatId}`}
                    className="flex-1 truncate underline"
                  >
                    {run.model ?? t("Default model")}
                  </Link>
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {when(run.startedAt)}
                  </span>
                </p>
                {run.steps.length > 0 ? (
                  <ol className="flex flex-col gap-1 ps-4 text-xs text-muted-foreground">
                    {run.steps.map((step) => (
                      <li key={step.key} className="flex gap-2">
                        <span className="w-14 shrink-0">{step.status}</span>
                        <span className="truncate">{step.label}</span>
                      </li>
                    ))}
                  </ol>
                ) : null}
              </li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}

export function AgentActivitySkeleton() {
  return <Skeleton className="h-96 w-full rounded-xl" />;
}
```

# AI provider settings

> A settings card that lists the organization's own AI provider keys and models, adds a key to Vault and removes one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/ai-provider-settings

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [ai-keys block](https://bettersupabase.com/blocks/ai-keys) must be installed (`npx better-supabase add ai-keys`). Its page on bettersupabase.com is [/recipes/ai-provider-settings](https://bettersupabase.com/recipes/ai-provider-settings).

Web, with shadcn components:

```bash
npx shadcn add alert alert-dialog badge button card dialog empty field input select skeleton table
```

```tsx title="apps/examples/nextjs/src/features/ai-providers/components/ai-provider-settings.tsx"
"use client";

import type { SubmitEvent } from "react";

import { useAction } from "better-supabase/react";
import { CpuIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useId, useState, useTransition } from "react";
import { toast } from "sonner";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { useRouter } from "@/i18n/navigation";
import { useErrorMessage } from "@/lib/use-error-message";

import type { Provider } from "../ai-provider-options";
import type { ProviderKeyRow, ProviderKeysRead } from "../ai-provider-queries";

import { addProviderKey, removeProviderKey } from "../ai-provider-actions";
import { PROVIDERS } from "../ai-provider-options";

const PROVIDER_LABELS = {
  anthropic: "Anthropic",
  openai: "OpenAI",
  google: "Google",
  mistral: "Mistral",
} satisfies Record<Provider, string>;

const labelOf = (provider: string): string => {
  const known = PROVIDERS.find((slug) => slug === provider);

  return known ? PROVIDER_LABELS[known] : provider;
};

function AddProviderKeyDialog({ variant }: { variant: "default" | "outline" }) {
  const t = useExtracted("aiProviders");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const [provider, setProvider] = useState<Provider>("anthropic");
  const [model, setModel] = useState("");
  const [apiKey, setApiKey] = useState("");

  const add = useAction(addProviderKey, {
    onSuccess: () => {
      setOpen(false);
      toast.success(t("Key saved"));
    },
  });

  const message = add.error === undefined ? null : errorMessage(add.error);

  const items = PROVIDERS.map((value) => ({
    value,
    label: PROVIDER_LABELS[value],
  }));

  const submit = (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    const trimmed = model.trim();

    void add.run(
      trimmed ? { provider, apiKey, model: trimmed } : { provider, apiKey },
    );
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        setModel("");
        setApiKey("");
        add.reset();
      }}
    >
      <DialogTrigger render={<Button size="sm" variant={variant} />}>
        <PlusIcon />
        {t("Add a key")}
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={submit} className="grid gap-6">
          <DialogHeader>
            <DialogTitle>{t("Add a provider key")}</DialogTitle>
            <DialogDescription>
              {t(
                "The key goes to Vault under this organization. Requests then bill the organization's own provider account.",
              )}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-provider`}>
                {t("Provider")}
              </FieldLabel>
              <Select
                value={provider}
                items={items}
                onValueChange={(value) => {
                  const picked = PROVIDERS.find((known) => known === value);

                  if (picked) {
                    setProvider(picked);
                  }
                }}
              >
                <SelectTrigger id={`${fieldId}-provider`} className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {items.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-model`}>{t("Model")}</FieldLabel>
              <Input
                id={`${fieldId}-model`}
                value={model}
                onChange={(event) => {
                  setModel(event.target.value);
                }}
                maxLength={120}
                placeholder="claude-sonnet-4.5"
              />
              <FieldDescription>
                {t("Optional. Leave it empty to use the app's default model.")}
              </FieldDescription>
            </Field>
            <Field data-invalid={message ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-api-key`}>
                {t("API key")}
              </FieldLabel>
              <Input
                id={`${fieldId}-api-key`}
                type="password"
                autoComplete="off"
                value={apiKey}
                onChange={(event) => {
                  setApiKey(event.target.value);
                }}
                required
                minLength={8}
                maxLength={500}
              />
              {message ? <FieldError>{message}</FieldError> : null}
            </Field>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={add.pending}>
              {t("Save key")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

function RemoveKeyButton({ providerKey }: { providerKey: ProviderKeyRow }) {
  const t = useExtracted("aiProviders");
  const errorMessage = useErrorMessage();
  const label = labelOf(providerKey.provider);

  const remove = useAction(removeProviderKey, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            aria-label={t("Remove the {provider} key", { provider: label })}
          />
        }
      >
        <Trash2Icon />
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>
            {t("Remove the {provider} key?", { provider: label })}
          </AlertDialogTitle>
          <AlertDialogDescription>
            {t(
              "Its secret is revoked in Vault, and requests fall back to the app's own key.",
            )}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={remove.pending}>
            {t("Cancel")}
          </AlertDialogCancel>
          <AlertDialogAction
            variant="destructive"
            disabled={remove.pending}
            onClick={() => {
              void remove.run({ id: providerKey.id });
            }}
          >
            {t("Remove")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

function ProviderRows({ keys }: { keys: readonly ProviderKeyRow[] }) {
  const t = useExtracted("aiProviders");
  const format = useFormatter();

  return (
    <Table data-testid="ai-provider-keys">
      <TableHeader>
        <TableRow>
          <TableHead>{t("Provider")}</TableHead>
          <TableHead>{t("Model")}</TableHead>
          <TableHead>{t("Status")}</TableHead>
          <TableHead className="hidden md:table-cell">{t("Updated")}</TableHead>
          <TableHead>
            <span className="sr-only">{t("Actions")}</span>
          </TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {keys.map((key) => (
          <TableRow key={key.id}>
            <TableCell>
              <div className="flex flex-col gap-0.5">
                <span className="font-medium">{labelOf(key.provider)}</span>
                {key.name === "default" ? null : (
                  <span className="text-xs text-muted-foreground">
                    {key.name}
                  </span>
                )}
              </div>
            </TableCell>
            <TableCell>
              {key.model ? (
                <span className="font-mono text-xs">{key.model}</span>
              ) : (
                <span className="text-muted-foreground">
                  {t("App default")}
                </span>
              )}
            </TableCell>
            <TableCell>
              {key.enabled ? (
                <Badge variant="success-light">{t("Enabled")}</Badge>
              ) : (
                <Badge variant="secondary">{t("Disabled")}</Badge>
              )}
            </TableCell>
            <TableCell className="hidden whitespace-nowrap text-muted-foreground tabular-nums md:table-cell">
              {format.dateTime(new Date(key.updatedAt), {
                dateStyle: "medium",
              })}
            </TableCell>
            <TableCell className="text-right">
              <RemoveKeyButton providerKey={key} />
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

/**
 * The organization's own model provider keys (bring your own key), read on the
 * server through `aiProviders.keys.list`, with the model each one calls. Saving
 * a key stores it in Vault first; the row only names it.
 */
export function AiProviderSettings({ read }: { read: ProviderKeysRead }) {
  const t = useExtracted("aiProviders");
  const errorMessage = useErrorMessage();
  const router = useRouter();
  const [retrying, startRetry] = useTransition();
  const hasKeys = read.ok && read.keys.length > 0;

  return (
    <Card data-recipe="ai-provider-settings">
      <CardHeader>
        <CardTitle>{t("AI providers")}</CardTitle>
        <CardDescription>
          {t(
            "The organization's own provider keys, used instead of the app's key for the assistant.",
          )}
        </CardDescription>
        {hasKeys ? (
          <CardAction>
            <AddProviderKeyDialog variant="outline" />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {read.ok ? (
          read.keys.length === 0 ? (
            <Empty className="border" data-testid="ai-providers-empty">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <CpuIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No provider keys yet")}</EmptyTitle>
                <EmptyDescription>
                  {t(
                    "The assistant uses the app's key until you add one for a provider.",
                  )}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                <AddProviderKeyDialog variant="default" />
              </EmptyContent>
            </Empty>
          ) : (
            <ProviderRows keys={read.keys} />
          )
        ) : (
          <Alert variant="destructive">
            <AlertTitle>{t("The providers didn't load")}</AlertTitle>
            <AlertDescription>{errorMessage(read.error)}</AlertDescription>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                disabled={retrying}
                onClick={() => {
                  startRetry(() => {
                    router.refresh();
                  });
                }}
              >
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        )}
      </CardContent>
    </Card>
  );
}

const SKELETON_ROWS = ["first", "second"];

/** Keeps the card's title in the static shell, so the tab commits it at once. */
export function AiProviderSettingsSkeleton() {
  const t = useExtracted("aiProviders");

  return (
    <Card aria-busy="true">
      <CardHeader>
        <CardTitle>{t("AI providers")}</CardTitle>
        <CardDescription>
          {t(
            "The organization's own provider keys, used instead of the app's key for the assistant.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-2">
        {SKELETON_ROWS.map((row) => (
          <Skeleton key={row} className="h-11 w-full" />
        ))}
      </CardContent>
    </Card>
  );
}
```

# AI task queue

> A table of scheduled AI prompts with each task's next run and latest run status, plus pause and resume.

Source: https://bettersupabase.com/docs/registry/ui-recipes/ai-task-queue

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [ai-schedules block](https://bettersupabase.com/blocks/ai-schedules) must be installed (`npx better-supabase add ai-schedules`). Its page on bettersupabase.com is [/recipes/ai-task-queue](https://bettersupabase.com/recipes/ai-task-queue).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/ai-tasks/components/ai-task-queue.tsx"
"use client";

import type { AiTaskRunStatus } from "better-supabase/blocks/ai-schedules";

import { useAction } from "better-supabase/react";
import { CalendarClockIcon, PauseIcon, PlayIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useTransition } from "react";
import { toast } from "sonner";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { useRouter } from "@/i18n/navigation";
import { useErrorMessage } from "@/lib/use-error-message";

import type { AiTaskRow, AiTasksRead } from "../ai-task-queries";

import { scheduleSampleTask, setAiTaskEnabled } from "../ai-task-actions";

function RunStatusBadge({ status }: { status: AiTaskRunStatus | undefined }) {
  const t = useExtracted("aiTasks");

  switch (status) {
    case undefined: {
      return <Badge variant="outline">{t("Not run yet")}</Badge>;
    }

    case "queued": {
      return <Badge variant="info-light">{t("Queued")}</Badge>;
    }

    case "running": {
      return <Badge variant="primary-light">{t("Running")}</Badge>;
    }

    case "completed": {
      return <Badge variant="success-light">{t("Completed")}</Badge>;
    }

    case "failed": {
      return <Badge variant="destructive-light">{t("Failed")}</Badge>;
    }

    default: {
      const unknown: never = status;

      return unknown;
    }
  }
}

function ScheduleSampleButton({ variant }: { variant: "default" | "outline" }) {
  const t = useExtracted("aiTasks");
  const errorMessage = useErrorMessage();

  const schedule = useAction(scheduleSampleTask, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <Button
      type="button"
      size="sm"
      variant={variant}
      disabled={schedule.pending}
      onClick={() => {
        void schedule.run({});
      }}
    >
      <CalendarClockIcon />
      {t("Schedule a sample task")}
    </Button>
  );
}

function TaskRows({ tasks }: { tasks: readonly AiTaskRow[] }) {
  const t = useExtracted("aiTasks");
  const format = useFormatter();
  const errorMessage = useErrorMessage();

  const toggle = useAction(setAiTaskEnabled, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const when = (iso: string) =>
    format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short" });

  return (
    <Table data-testid="ai-schedules">
      <TableHeader>
        <TableRow>
          <TableHead>{t("Task")}</TableHead>
          <TableHead className="hidden md:table-cell">
            {t("Schedule")}
          </TableHead>
          <TableHead>{t("Next run")}</TableHead>
          <TableHead>{t("Last run")}</TableHead>
          <TableHead>
            <span className="sr-only">{t("Actions")}</span>
          </TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {tasks.map((task) => (
          <TableRow key={task.id}>
            <TableCell className="max-w-72">
              <div className="flex flex-col gap-0.5">
                <span className="truncate font-medium">{task.title}</span>
                <span className="truncate text-xs text-muted-foreground">
                  {task.prompt}
                </span>
              </div>
            </TableCell>
            <TableCell className="hidden md:table-cell">
              <div className="flex flex-col gap-0.5">
                <span className="font-mono text-xs">{task.cron}</span>
                <span className="text-xs text-muted-foreground">
                  {task.timezone}
                </span>
              </div>
            </TableCell>
            <TableCell className="whitespace-nowrap tabular-nums">
              {task.enabled ? (
                task.nextRunAt ? (
                  when(task.nextRunAt)
                ) : (
                  <span className="text-muted-foreground">
                    {t("On the next tick")}
                  </span>
                )
              ) : (
                <Badge variant="secondary">{t("Paused")}</Badge>
              )}
            </TableCell>
            <TableCell>
              <div className="flex flex-col items-start gap-0.5">
                <RunStatusBadge status={task.lastRun?.status} />
                {task.lastRun?.error ? (
                  <span className="max-w-48 truncate text-xs text-muted-foreground">
                    {task.lastRun.error}
                  </span>
                ) : null}
              </div>
            </TableCell>
            <TableCell className="text-right">
              <Button
                type="button"
                variant="ghost"
                size="icon-sm"
                disabled={toggle.pendingInputs.some(
                  (input) => input.id === task.id,
                )}
                aria-label={
                  task.enabled
                    ? t("Pause {title}", { title: task.title })
                    : t("Resume {title}", { title: task.title })
                }
                onClick={() => {
                  void toggle.run({ id: task.id, enabled: !task.enabled });
                }}
              >
                {task.enabled ? <PauseIcon /> : <PlayIcon />}
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

/**
 * The caller's scheduled AI tasks, read on the server through `aiTasks.list`,
 * with each task's next occurrence and its latest run. An admin with `ai.admin`
 * sees every task in the organization.
 */
export function AiTaskQueue({ read }: { read: AiTasksRead }) {
  const t = useExtracted("aiTasks");
  const errorMessage = useErrorMessage();
  const router = useRouter();
  const [retrying, startRetry] = useTransition();
  const hasTasks = read.ok && read.tasks.length > 0;

  return (
    <Card data-recipe="ai-task-queue">
      <CardHeader>
        <CardTitle>{t("Scheduled tasks")}</CardTitle>
        <CardDescription>
          {t(
            "Prompts the assistant runs on a cron, with the status of each task's latest run.",
          )}
        </CardDescription>
        {hasTasks ? (
          <CardAction>
            <ScheduleSampleButton variant="outline" />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {read.ok ? (
          read.tasks.length === 0 ? (
            <Empty className="border" data-testid="ai-tasks-empty">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <CalendarClockIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No tasks scheduled")}</EmptyTitle>
                <EmptyDescription>
                  {t(
                    "Schedule a prompt to run every weekday at 9:00, then watch its runs here.",
                  )}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                <ScheduleSampleButton variant="default" />
              </EmptyContent>
            </Empty>
          ) : (
            <TaskRows tasks={read.tasks} />
          )
        ) : (
          <Alert variant="destructive">
            <AlertTitle>{t("The tasks didn't load")}</AlertTitle>
            <AlertDescription>{errorMessage(read.error)}</AlertDescription>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                disabled={retrying}
                onClick={() => {
                  startRetry(() => {
                    router.refresh();
                  });
                }}
              >
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        )}
      </CardContent>
    </Card>
  );
}

const SKELETON_ROWS = ["first", "second", "third"];

export function AiTaskQueueSkeleton() {
  return (
    <Card aria-busy="true">
      <CardHeader>
        <Skeleton className="h-5 w-40" />
        <Skeleton className="h-4 w-72" />
      </CardHeader>
      <CardContent className="flex flex-col gap-2">
        {SKELETON_ROWS.map((row) => (
          <Skeleton key={row} className="h-11 w-full" />
        ))}
      </CardContent>
    </Card>
  );
}
```

# Announcement banner

> The newest live announcement for the user and organization, with a dismiss button.

Source: https://bettersupabase.com/docs/registry/ui-recipes/announcement-banner

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useAnnouncements` from `@/hooks/use-announcements`; the [announcements block](https://bettersupabase.com/blocks/announcements) must be installed (`npx better-supabase add announcements`). Its page on bettersupabase.com is [/recipes/announcement-banner](https://bettersupabase.com/recipes/announcement-banner).

Web, with shadcn components:

```bash
npx shadcn add alert button
```

```bash
npx shadcn add @better-supabase/use-announcements
```

```tsx title="apps/examples/nextjs/src/features/announcements/components/announcement-banner.tsx"
"use client";

import { MegaphoneIcon, XIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useAnnouncements } from "@/hooks/use-announcements";

/**
 * The newest live announcement for this user and organization. Loads in the
 * browser and reloads on the module's broadcast, so it adds nothing to the
 * server render.
 */
export function AnnouncementBanner({
  organizationId,
}: {
  organizationId: string | null;
}) {
  const t = useExtracted("announcements");

  const { items, dismiss } = useAnnouncements({
    organizationId,
    schema: "api",
  });

  const announcement = items?.[0];

  if (!announcement) {
    return null;
  }

  return (
    <Alert
      variant={announcement.severity === "critical" ? "destructive" : "default"}
      data-testid="announcement"
    >
      <MegaphoneIcon />
      <AlertTitle>{announcement.title}</AlertTitle>
      {announcement.body ? (
        <AlertDescription>{announcement.body}</AlertDescription>
      ) : null}
      {announcement.dismissible ? (
        <AlertAction>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={t("Dismiss")}
            onClick={() => void dismiss(announcement.id)}
          >
            <XIcon />
          </Button>
        </AlertAction>
      ) : null}
    </Alert>
  );
}
```

# API keys table

> The organization's API keys, with a form that creates one and shows its token once, and a revoke button per key.

Source: https://bettersupabase.com/docs/registry/ui-recipes/api-keys-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useApiKeys` from `@/hooks/use-api-keys`; the [api-keys block](https://bettersupabase.com/blocks/api-keys) must be installed (`npx better-supabase add api-keys`). Its page on bettersupabase.com is [/recipes/api-keys-table](https://bettersupabase.com/recipes/api-keys-table).

Web, with shadcn components:

```bash
npx shadcn add alert badge button dialog empty field input skeleton table
```

```bash
npx shadcn add @better-supabase/use-api-keys
```

```tsx title="apps/examples/nextjs/src/features/api-keys/components/api-key-table.tsx"
"use client";

import { KeyRoundIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { toast } from "sonner";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { useApiKeys } from "@/hooks/use-api-keys";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import { apiKeyCreated } from "../api-key-actions";
import { CreateApiKeyDialog } from "./create-api-key-dialog";

/**
 * The organization's API keys through the browser client, with a button that
 * creates one and shows its token once. Members without `api_keys.manage`
 * create `personal` keys, which act as them.
 */
export function ApiKeyTable({
  organizationId,
  personal,
}: {
  organizationId: string;
  personal: boolean;
}) {
  const t = useExtracted("apiKeys");
  const errorMessage = useErrorMessage();
  const format = useFormatter();

  const { keys, created, creating, revoking, error, create, revoke, dismiss } =
    useApiKeys({
      organizationId,
      schema: blockConfig["api-keys"].api,
      onChanged: async ({ kind }) => {
        if (kind === "created") {
          await apiKeyCreated({});
        }
      },
    });

  const revokeKey = async (id: string) => {
    const result = await revoke(id);

    if (result.ok) {
      toast.success(t("Key revoked"));
    } else {
      toast.error(errorMessage(result.error));
    }
  };

  const date = (value: Temporal.Instant | undefined) =>
    value
      ? format.dateTime(new Date(value.epochMilliseconds), {
          dateStyle: "medium",
        })
      : "–";

  const dialog = (
    <CreateApiKeyDialog
      token={created?.token}
      creating={creating}
      error={error}
      onCreate={async (name) => {
        await create({ name, personal });
      }}
      onClose={dismiss}
    />
  );

  if (keys === undefined) {
    return (
      <div className="grid gap-4">
        <div className="flex justify-end">{dialog}</div>
        <Skeleton className="h-32" />
      </div>
    );
  }

  return (
    <div className="grid gap-4">
      <div className="flex justify-end">{dialog}</div>
      {keys.length === 0 ? (
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <KeyRoundIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No API keys yet")}</EmptyTitle>
            <EmptyDescription>
              {t("Create one to call the API from a script.")}
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      ) : (
        <Table data-testid="api-keys">
          <TableHeader>
            <TableRow>
              <TableHead>{t("Name")}</TableHead>
              <TableHead>{t("Key")}</TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Created")}
              </TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Last used")}
              </TableHead>
              <TableHead className="w-24" />
            </TableRow>
          </TableHeader>
          <TableBody>
            {keys.map((key) => (
              <TableRow
                key={key.id}
                aria-busy={revoking.includes(key.id) ? true : undefined}
                className={revoking.includes(key.id) ? "opacity-50" : undefined}
              >
                <TableCell className="font-medium">
                  {key.name}{" "}
                  {key.userId ? (
                    <Badge variant="outline">{t("Personal")}</Badge>
                  ) : null}
                </TableCell>
                <TableCell className="font-mono text-xs">
                  {key.prefix}_{key.publicId}…
                </TableCell>
                <TableCell className="hidden text-muted-foreground md:table-cell">
                  {date(key.createdAt)}
                </TableCell>
                <TableCell className="hidden text-muted-foreground md:table-cell">
                  {date(key.lastUsedAt)}
                </TableCell>
                <TableCell className="text-right">
                  {key.state === "revoked" || key.state === "expired" ? (
                    <Badge variant="secondary">{t("Revoked")}</Badge>
                  ) : (
                    <Button
                      variant="ghost"
                      size="sm"
                      disabled={revoking.includes(key.id)}
                      onClick={() => {
                        void revokeKey(key.id);
                      }}
                    >
                      {t("Revoke")}
                    </Button>
                  )}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      )}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Button`, `Pressable`, `Text`, `TextInput`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-api-keys
```

```tsx title="apps/examples/expo-powersync/src/components/api-keys-table.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  Pressable,
  Text,
  TextInput,
  View,
} from "react-native";

import { useApiKeys } from "../hooks/use-api-keys";
import { API_SCHEMA } from "../lib/blocks";

/**
 * The organization's API keys with a field that creates one, whose token shows
 * only until it is dismissed, and a revoke button per key. Members who can't
 * manage the organization's keys create personal ones.
 */
export function ApiKeysTable({ personal = false }: { personal?: boolean }) {
  const [name, setName] = useState("");

  const { keys, created, creating, revoking, error, create, revoke, dismiss } =
    useApiKeys({ organizationId: tenantOf(useAuth()), schema: API_SCHEMA });

  const submit = async () => {
    if ((await create({ name: name.trim(), personal })).ok) {
      setName("");
    }
  };

  if (keys === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 8 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>API keys</Text>
      {keys.map((key) => {
        const revoked = key.state === "revoked" || key.state === "expired";

        return (
          <View
            key={key.id}
            style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
          >
            <View style={{ flex: 1 }}>
              <Text>{key.name}</Text>
              <Text style={{ color: "#737373", fontFamily: "monospace" }}>
                {key.prefix}_{key.publicId}…
              </Text>
            </View>
            {revoked ? (
              <Text style={{ color: "#737373" }}>Revoked</Text>
            ) : (
              <Pressable
                accessibilityRole="button"
                disabled={revoking.includes(key.id)}
                onPress={() => void revoke(key.id)}
              >
                <Text style={{ color: "#b91c1c" }}>Revoke</Text>
              </Pressable>
            )}
          </View>
        );
      })}
      {created ? (
        <View style={{ gap: 4, padding: 8, backgroundColor: "#f5f5f5" }}>
          <Text>Copy the key now. It is not shown again.</Text>
          <Text selectable style={{ fontFamily: "monospace" }}>
            {created.token}
          </Text>
          <Button title="Done" onPress={dismiss} />
        </View>
      ) : null}
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Key name, such as CI"
        maxLength={80}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
      />
      <Button
        title="Create key"
        disabled={creating || !name.trim()}
        onPress={() => void submit()}
      />
    </View>
  );
}
```

# Approvals queue

> Requests waiting for the caller's decision, with approve and reject buttons.

Source: https://bettersupabase.com/docs/registry/ui-recipes/approvals-queue

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [approvals block](https://bettersupabase.com/blocks/approvals) must be installed (`npx better-supabase add approvals`). Its page on bettersupabase.com is [/recipes/approvals-queue](https://bettersupabase.com/recipes/approvals-queue).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/approvals/components/approvals-queue.tsx"
/**
 * Render inside `<Suspense>`: the organization's pending approval requests,
 * newest first, with Approve and Reject on each. The block checks that the
 * caller is an approver of the current step.
 */
export async function ApprovalsQueue() {
  const [session, t, format] = await Promise.all([
    getSession(),
    getExtracted("approvals"),
    getFormatter(),
  ]);

  if (!can(session, "approvals.read")) {
    forbidden();
  }

  const queue = await getApprovalsQueue();

  if (queue.kind === "no-organization") {
    return null;
  }

  const canRequest = can(session, "approvals.request");
  const canDecide = can(session, "approvals.manage");
  const customers = queue.kind === "ok" ? queue.data.customers : [];

  const actionLabel = (action: string) =>
    action === "discount" ? t("Discount") : action;

  return (
    <Card data-recipe="approvals-queue">
      <CardHeader>
        <CardTitle>{t("Pending requests")}</CardTitle>
        <CardDescription>
          {t("Each request waits for its approvers, one step at a time.")}
        </CardDescription>
        {canRequest && queue.kind === "ok" && queue.data.requests.length > 0 ? (
          <CardAction>
            <RequestApprovalDialog customers={customers} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {queue.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The requests didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retryApprovals}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : queue.data.requests.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <ShieldCheckIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing waiting")}</EmptyTitle>
              <EmptyDescription>
                {canRequest
                  ? t(
                      "Ask for an approval before giving a customer a discount.",
                    )
                  : t("New requests show up here.")}
              </EmptyDescription>
            </EmptyHeader>
            {canRequest ? (
              <EmptyContent>
                <RequestApprovalDialog customers={customers} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Request")}</TableHead>
                <TableHead>{t("Requested by")}</TableHead>
                <TableHead>{t("Step")}</TableHead>
                <TableHead>{t("Requested")}</TableHead>
                {canDecide ? (
                  <TableHead className="text-right">{t("Decision")}</TableHead>
                ) : null}
              </TableRow>
            </TableHeader>
            <TableBody>
              {queue.data.requests.map((request) => {
                const label = `${actionLabel(request.action)}: ${
                  request.subjectName ?? request.subjectId
                }`;

                return (
                  <TableRow key={request.id}>
                    <TableCell>
                      <div className="flex flex-col items-start gap-1">
                        <span className="font-medium">{label}</span>
                        <SubjectChip
                          subject={{
                            type: request.subjectType,
                            id: request.subjectId,
                          }}
                        />
                        {request.reason ? (
                          <span className="text-xs text-muted-foreground">
                            {request.reason}
                          </span>
                        ) : null}
                      </div>
                    </TableCell>
                    <TableCell className="text-muted-foreground">
                      {request.requestedBy ?? t("Unknown")}
                    </TableCell>
                    <TableCell>
                      <Badge variant="warning-light">
                        {t("{step, number} of {steps, number}", {
                          step: request.step + 1,
                          steps: request.steps,
                        })}
                      </Badge>
                    </TableCell>
                    <TableCell className="text-muted-foreground">
                      {format.dateTime(new Date(request.createdAt), {
                        dateStyle: "medium",
                        timeStyle: "short",
                      })}
                    </TableCell>
                    {canDecide ? (
                      <TableCell>
                        <ApprovalDecision id={request.id} label={label} />
                      </TableCell>
                    ) : null}
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}
```

# Attachments uploader

> A drop zone that uploads files to a record and lists them with a download link.

Source: https://bettersupabase.com/docs/registry/ui-recipes/attachments-uploader

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [attachments block](https://bettersupabase.com/blocks/attachments) must be installed (`npx better-supabase add attachments`). Its page on bettersupabase.com is [/recipes/attachments-uploader](https://bettersupabase.com/recipes/attachments-uploader).

Web, with shadcn components:

```bash
npx shadcn add alert-dialog badge button card empty
```

```tsx title="apps/examples/nextjs/src/features/customers/components/attachments-uploader.tsx"
type FileState = "unfinished" | "scanning" | "ready" | "blocked" | "failed";

function stateOf(file: AttachmentRow): FileState {
  switch (file.status) {
    case "pending": {
      return file.uploaded ? "scanning" : "unfinished";
    }

    case "clean": {
      return "ready";
    }

    case "infected": {
      return "blocked";
    }

    case "failed": {
      return "failed";
    }

    default: {
      const unhandled: never = file.status;

      return unhandled;
    }
  }
}

const VARIANT = {
  unfinished: "warning-light",
  scanning: "info-light",
  ready: "success-light",
  blocked: "destructive-light",
  failed: "destructive-light",
} as const satisfies Record<FileState, string>;

/**
 * Files on the customer, in the private `attachments` bucket. Pick, drop or
 * paste files: the server records each one and signs its upload,
 * `useAttachmentUpload` sends the bytes with progress, and the server confirms
 * it. A file downloads once the scanner marked it clean.
 */
export function AttachmentsUploader({
  customerId,
  files,
  canUpload,
  canRemove,
}: {
  customerId: string;
  files: Loaded<readonly AttachmentRow[]>;
  canUpload: boolean;
  canRemove: boolean;
}) {
  const t = useExtracted("customers");
  const upload = useCustomerUpload(customerId);

  return (
    <Card size="sm" data-recipe="attachments-uploader">
      <CardHeader>
        <CardTitle>{t("Files")}</CardTitle>
        <CardDescription>
          {canUpload
            ? t(
                "Drop or paste files to upload. They download after the virus scan.",
              )
            : t("Files download after the virus scan marks them clean.")}
        </CardDescription>
        {canUpload && files.kind === "ok" && files.data.length > 0 ? (
          <CardAction>
            <UploadButton busy={upload.busy} onPick={upload.add} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        <DropZone enabled={canUpload} onFiles={upload.add}>
          {upload.uploads.length > 0 ? <UploadList upload={upload} /> : null}
          {files.kind === "error" ? (
            <RecordLoadError title={t("The files didn't load")} />
          ) : files.data.length === 0 ? (
            upload.uploads.length > 0 ? null : (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <PaperclipIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No files yet")}</EmptyTitle>
                  <EmptyDescription>
                    {t(
                      "Attach contracts, quotes or anything else about this customer.",
                    )}
                  </EmptyDescription>
                </EmptyHeader>
                {canUpload ? (
                  <EmptyContent>
                    <UploadButton busy={upload.busy} onPick={upload.add} />
                  </EmptyContent>
                ) : null}
              </Empty>
            )
          ) : (
            <FileList
              customerId={customerId}
              files={files.data}
              canRemove={canRemove}
            />
          )}
        </DropZone>
      </CardContent>
    </Card>
  );
}

/**
 * Uploads to the customer through the server actions, which check
 * `attachments.upload` and read the page again once a file is confirmed.
 */
function useCustomerUpload(customerId: string): AttachmentUploadState {
  const t = useExtracted("customers");
  const start = useAction(startAttachmentUpload);
  const confirm = useAction(confirmAttachment);

  return useAttachmentUpload({
    subject: { type: "customer", id: customerId },
    start: (file) =>
      start.run({
        customerId,
        name: file.name,
        mimeType: file.mimeType,
        size: file.size,
      }),
    confirm: (id) => confirm.run({ id }),
    refresh: false,
    onUploaded: () => {
      toast.success(t("File uploaded"));
    },
  });
}

const asInputs = (files: Iterable<File>): AttachmentFileInput[] =>
  [...files].map((file) => ({ file, name: file.name }));

const EDITABLE = "input, textarea, select, [contenteditable]";

/**
 * Takes files dropped on it and files pasted anywhere on the page outside a
 * text field; a plain wrapper when uploads are off. The Upload button is the
 * keyboard path, so the zone itself takes no focus.
 */
function DropZone({
  enabled,
  onFiles,
  children,
}: {
  enabled: boolean;
  onFiles: (files: readonly AttachmentFileInput[]) => void;
  children: ReactNode;
}) {
  const zone = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState(false);

  const take = useEffectEvent((files: FileList | null | undefined) => {
    const inputs = asInputs(files ?? []);

    if (inputs.length > 0) {
      onFiles(inputs);
    }
  });

  useEffect(() => {
    const element = zone.current;

    if (!enabled || !element) {
      return;
    }

    const over = (event: DragEvent) => {
      event.preventDefault();
      setDragging(true);
    };

    const leave = () => {
      setDragging(false);
    };

    const drop = (event: DragEvent) => {
      event.preventDefault();
      setDragging(false);
      take(event.dataTransfer?.files);
    };

    const paste = (event: ClipboardEvent) => {
      const files = event.clipboardData?.files;

      if (
        !files ||
        files.length === 0 ||
        (event.target instanceof Element && event.target.closest(EDITABLE))
      ) {
        return;
      }

      event.preventDefault();
      take(files);
    };

    element.addEventListener("dragover", over);
    element.addEventListener("dragleave", leave);
    element.addEventListener("drop", drop);
    document.addEventListener("paste", paste);

    return () => {
      element.removeEventListener("dragover", over);
      element.removeEventListener("dragleave", leave);
      element.removeEventListener("drop", drop);
      document.removeEventListener("paste", paste);
    };
  }, [enabled]);

  return (
    <div
      ref={zone}
      data-dragging={(enabled && dragging) || undefined}
      className="-m-2 flex flex-col gap-3 p-2 transition-colors data-dragging:bg-muted"
    >
      {children}
    </div>
  );
}

function UploadList({ upload }: { upload: AttachmentUploadState }) {
  return (
    <ul className="flex flex-col divide-y" data-testid="uploads">
      {upload.uploads.map((item) => (
        <UploadRow
          key={item.key}
          item={item}
          onRetry={upload.retry}
          onDismiss={upload.dismiss}
        />
      ))}
    </ul>
  );
}

const UPLOAD_VARIANT = {
  uploading: "info-light",
  confirming: "info-light",
  scanning: "info-light",
  done: "success-light",
  blocked: "destructive-light",
  error: "destructive-light",
} as const satisfies Record<AttachmentUploadItem["status"], string>;

function UploadRow({
  item,
  onRetry,
  onDismiss,
}: {
  item: AttachmentUploadItem;
  onRetry: (key: string) => void;
  onDismiss: (key: string) => void;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const label: Record<AttachmentUploadItem["status"], string> = {
    uploading: t("Uploading"),
    confirming: t("Saving"),
    scanning: t("Waiting for scan"),
    done: t("Uploaded"),
    blocked: t("Blocked"),
    error: t("Failed"),
  };

  return (
    <li className="flex items-center justify-between gap-2 py-2">
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <span className="truncate text-sm font-medium">{item.name}</span>
        <div className="flex items-center gap-2">
          <Badge variant={UPLOAD_VARIANT[item.status]}>
            {label[item.status]}
          </Badge>
          {item.status === "uploading" ? (
            <Progress
              className="flex-1"
              value={Math.round(item.progress * 100)}
              aria-label={t("Upload progress for {name}", { name: item.name })}
            />
          ) : item.error === undefined ? null : (
            <span className="truncate text-xs text-muted-foreground">
              {errorMessage(item.error)}
            </span>
          )}
        </div>
      </div>
      <div className="flex items-center gap-1">
        {item.status === "error" ? (
          <Button
            size="icon-sm"
            variant="ghost"
            aria-label={t("Retry {name}", { name: item.name })}
            onClick={() => {
              onRetry(item.key);
            }}
          >
            <RotateCwIcon />
          </Button>
        ) : null}
        <Button
          size="icon-sm"
          variant="ghost"
          aria-label={t("Dismiss {name}", { name: item.name })}
          onClick={() => {
            onDismiss(item.key);
          }}
        >
          <XIcon />
        </Button>
      </div>
    </li>
  );
}

function FileList({
  customerId,
  files,
  canRemove,
}: {
  customerId: string;
  files: readonly AttachmentRow[];
  canRemove: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();
  const errorMessage = useErrorMessage();

  const label: Record<FileState, string> = {
    unfinished: t("Upload unfinished"),
    scanning: t("Waiting for scan"),
    ready: t("Ready"),
    blocked: t("Blocked"),
    failed: t("Scan failed"),
  };

  const download = useAction(downloadAttachment, {
    onSuccess: ({ signedUrl }) => {
      window.location.assign(signedUrl);
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <ul className="flex flex-col divide-y" data-testid="attachments">
      {files.map((file) => {
        const state = stateOf(file);

        return (
          <li
            key={file.id}
            className="flex items-center justify-between gap-2 py-2"
          >
            <div className="flex min-w-0 flex-col gap-1">
              <span className="truncate text-sm font-medium">{file.name}</span>
              <div className="flex items-center gap-2">
                <Badge variant={VARIANT[state]}>{label[state]}</Badge>
                <span className="text-xs text-muted-foreground">
                  {format.number(file.size / 1024, {
                    style: "unit",
                    unit: "kilobyte",
                    maximumFractionDigits: 1,
                  })}
                </span>
              </div>
            </div>
            <div className="flex items-center gap-1">
              <Button
                size="icon-sm"
                variant="ghost"
                aria-label={t("Download {name}", { name: file.name })}
                disabled={state !== "ready" || download.pending}
                onClick={() => {
                  void download.run({ id: file.id });
                }}
              >
                <DownloadIcon />
              </Button>
              {canRemove ? (
                <RemoveFile
                  id={file.id}
                  customerId={customerId}
                  name={file.name}
                />
              ) : null}
            </div>
          </li>
        );
      })}
    </ul>
  );
}

function UploadButton({
  busy,
  onPick,
}: {
  busy: boolean;
  onPick: (files: readonly AttachmentFileInput[]) => void;
}) {
  const t = useExtracted("customers");
  const input = useRef<HTMLInputElement>(null);

  const pick = (event: ChangeEvent<HTMLInputElement>) => {
    const inputs = asInputs(event.target.files ?? []);
    event.target.value = "";

    if (inputs.length > 0) {
      onPick(inputs);
    }
  };

  return (
    <>
      <input
        ref={input}
        type="file"
        multiple
        className="sr-only"
        tabIndex={-1}
        aria-hidden
        onChange={pick}
      />
      <Button size="sm" onClick={() => input.current?.click()}>
        <UploadIcon />
        {busy ? t("Upload more") : t("Upload files")}
      </Button>
    </>
  );
}

function RemoveFile({
  id,
  customerId,
  name,
}: {
  id: string;
  customerId: string;
  name: string;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const remove = useAction(removeCustomerAttachments, {
    onSuccess: () => {
      toast.success(t("File removed"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={<Button size="sm" variant="ghost" disabled={remove.pending} />}
      >
        {t("Remove")}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{t("Remove {name}?", { name })}</AlertDialogTitle>
          <AlertDialogDescription>
            {t("The file is deleted from storage. This can't be undone.")}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
          <AlertDialogAction
            variant="destructive"
            onClick={() => {
              void remove.run({ id: customerId, input: { id } });
            }}
          >
            {t("Remove")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

/** The files card while its rows load. */
export function AttachmentsUploaderSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

# Audit log table

> The audit trail, newest first, with filters by actor and action and older pages on demand.

Source: https://bettersupabase.com/docs/registry/ui-recipes/audit-log-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useAuditLog` from `@/hooks/use-audit`; the [audit block](https://bettersupabase.com/blocks/audit) must be installed (`npx better-supabase add audit`). Its page on bettersupabase.com is [/recipes/audit-log-table](https://bettersupabase.com/recipes/audit-log-table).

Web, with shadcn components:

```bash
npx shadcn add button select skeleton table
```

```bash
npx shadcn add @better-supabase/use-audit
```

```tsx title="apps/examples/nextjs/src/features/audit/components/audit-log-table.tsx"
"use client";

import { useExtracted, useFormatter } from "next-intl";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { useAuditLog } from "@/hooks/use-audit";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

const ALL = "all";

/** The values a filter offers: those on the loaded page, and the one it has. */
function choices(
  values: readonly (string | undefined)[],
  current: string | undefined,
): readonly string[] {
  return [
    ...new Set([...values, current].filter((value) => value !== undefined)),
  ].toSorted();
}

/**
 * The organization's audit trail through the browser client, newest first,
 * filtered by actor and action. The audit module's read policy decides which
 * entries the caller sees.
 */
export function AuditLogTable({ organizationId }: { organizationId: string }) {
  const t = useExtracted("audit");
  const format = useFormatter();
  const errorMessage = useErrorMessage();

  const { entries, filters, hasMore, loading, error, setFilters, loadMore } =
    useAuditLog({ organizationId, schema: blockConfig.audit.api });

  const actors = new Map(
    (entries ?? []).flatMap((entry) =>
      entry.actorId ? [[entry.actorId, entry.actorLabel ?? entry.actorId]] : [],
    ),
  );

  const actions = choices(
    (entries ?? []).map((entry) => entry.eventType),
    filters.eventType,
  );

  const actorItems = [
    { value: ALL, label: t("Every actor") },
    ...choices([...actors.keys()], filters.actorId).map((id) => ({
      value: id,
      label: actors.get(id) ?? id,
    })),
  ];

  const actionItems = [
    { value: ALL, label: t("Every action") },
    ...actions.map((action) => ({ value: action, label: action })),
  ];

  const pick = (value: string | null): string | undefined =>
    value === null || value === ALL ? undefined : value;

  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap gap-2">
        <Select
          value={filters.actorId ?? ALL}
          items={actorItems}
          onValueChange={(value) => {
            void setFilters({ ...filters, actorId: pick(value) });
          }}
        >
          <SelectTrigger size="sm" className="w-48" aria-label={t("Actor")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {actorItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Select
          value={filters.eventType ?? ALL}
          items={actionItems}
          onValueChange={(value) => {
            void setFilters({ ...filters, eventType: pick(value) });
          }}
        >
          <SelectTrigger size="sm" className="w-56" aria-label={t("Action")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {actionItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      {error ? (
        <p className="text-sm text-destructive">{errorMessage(error)}</p>
      ) : null}
      {entries === undefined ? (
        <Skeleton className="h-64" />
      ) : entries.length === 0 ? (
        <p className="text-sm text-muted-foreground">
          {t("Nothing recorded yet.")}
        </p>
      ) : (
        <Table data-testid="audit-events">
          <TableHeader>
            <TableRow>
              <TableHead>{t("When")}</TableHead>
              <TableHead>{t("Event")}</TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Actor")}
              </TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Target")}
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {entries.map((entry) => (
              <TableRow key={entry.id}>
                <TableCell className="whitespace-nowrap text-muted-foreground tabular-nums">
                  {format.dateTime(
                    new Date(entry.occurredAt.epochMilliseconds),
                    { dateStyle: "short", timeStyle: "short" },
                  )}
                </TableCell>
                <TableCell className="font-mono text-xs">
                  {entry.eventType ??
                    `${entry.op ?? "change"} ${entry.table ?? ""}`.trim()}
                </TableCell>
                <TableCell className="hidden max-w-48 truncate md:table-cell">
                  {entry.actorLabel ?? entry.actorId ?? "–"}
                </TableCell>
                <TableCell className="hidden max-w-48 truncate md:table-cell">
                  {entry.targetLabel ?? entry.record ?? "–"}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      )}
      {hasMore ? (
        <Button
          variant="outline"
          size="sm"
          className="justify-self-center"
          disabled={loading}
          onClick={() => {
            void loadMore();
          }}
        >
          {t("Load more")}
        </Button>
      ) : null}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Button`, `Pressable`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-audit
```

```tsx title="apps/examples/expo-powersync/src/components/audit-log-table.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Button, Pressable, Text, View } from "react-native";

import { useAuditLog } from "../hooks/use-audit";
import { API_SCHEMA } from "../lib/blocks";

/** A row of chips that filters by one value, or by none. */
function Chips({
  values,
  selected,
  onSelect,
}: {
  readonly values: readonly string[];
  readonly selected: string | undefined;
  readonly onSelect: (value: string | undefined) => void;
}) {
  return (
    <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
      {[undefined, ...values].map((value) => (
        <Pressable
          key={value ?? "all"}
          accessibilityRole="radio"
          accessibilityState={{ checked: value === selected }}
          onPress={() => {
            onSelect(value);
          }}
          style={{
            borderWidth: 1,
            borderRadius: 16,
            paddingHorizontal: 10,
            paddingVertical: 2,
            backgroundColor: value === selected ? "#e5e5e5" : undefined,
          }}
        >
          <Text>{value ?? "All"}</Text>
        </Pressable>
      ))}
    </View>
  );
}

/**
 * The organization's audit trail, newest first, filtered by actor and by action
 * with the values on the loaded page. Members without `audit.read` get the
 * module's error.
 */
export function AuditLogTable() {
  const { entries, filters, hasMore, loading, error, setFilters, loadMore } =
    useAuditLog({ organizationId: tenantOf(useAuth()), schema: API_SCHEMA });

  const distinct = (values: readonly (string | undefined)[]) =>
    [...new Set(values.filter((value) => value !== undefined))].toSorted();

  const actors = distinct([
    ...(entries ?? []).map((entry) => entry.actorId),
    filters.actorId,
  ]);

  const actions = distinct([
    ...(entries ?? []).map((entry) => entry.eventType),
    filters.eventType,
  ]);

  return (
    <View style={{ gap: 8 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Audit log</Text>
      <Chips
        values={actors}
        selected={filters.actorId}
        onSelect={(actorId) => void setFilters({ ...filters, actorId })}
      />
      <Chips
        values={actions}
        selected={filters.eventType}
        onSelect={(eventType) => void setFilters({ ...filters, eventType })}
      />
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      {entries === undefined ? (
        <ActivityIndicator />
      ) : (
        entries.map((entry) => (
          <View key={entry.id} style={{ paddingVertical: 6 }}>
            <Text style={{ fontFamily: "monospace" }}>
              {entry.eventType ?? entry.op}
            </Text>
            <Text style={{ color: "#737373", fontSize: 12 }}>
              {entry.occurredAt.toLocaleString()} by{" "}
              {entry.actorLabel ?? entry.actorId ?? "the system"}
            </Text>
          </View>
        ))
      )}
      {hasMore ? (
        <Button
          title="Load more"
          disabled={loading}
          onPress={() => void loadMore()}
        />
      ) : null}
    </View>
  );
}
```

# Availability editor

> Weekly working hours and date overrides that set which slots can be booked.

Source: https://bettersupabase.com/docs/registry/ui-recipes/availability-editor

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [bookings block](https://bettersupabase.com/blocks/bookings) must be installed (`npx better-supabase add bookings`). Its page on bettersupabase.com is [/recipes/availability-editor](https://bettersupabase.com/recipes/availability-editor).

Web, with shadcn components:

```bash
npx shadcn add badge card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/bookings/components/availability-editor.tsx"
export async function AvailabilityEditor() {
  const session = await getSession();

  if (!can(session, "bookings.manage")) {
    forbidden();
  }

  const [t, setup] = await Promise.all([
    getExtracted("bookings"),
    settle(loadBookingSetup),
  ]);

  const page = setup.kind === "ok" ? setup.data.page : null;

  return (
    <Card data-recipe="availability-editor">
      <CardHeader>
        <CardTitle>{t("Weekly hours")}</CardTitle>
        <CardDescription>
          {page
            ? t(
                "Visitors can book 30 minute slots inside these hours, in {tz}.",
                { tz: page.tz },
              )
            : t("The hours visitors can book you in.")}
        </CardDescription>
        {page ? (
          <CardAction className="flex items-center gap-2">
            <Badge variant={page.liveLinks > 0 ? "success-light" : "secondary"}>
              {t(
                "{count, plural, =0 {No live links} one {# live link} other {# live links}}",
                { count: page.liveLinks },
              )}
            </Badge>
            {can(session, "share_links.create") ? <BookingLinkButton /> : null}
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {setup.kind === "error" ? (
          <BookingsLoadError title={t("Your booking page didn't load.")} />
        ) : page && setup.kind === "ok" ? (
          <WeeklyHoursForm hours={setup.data.hours} />
        ) : (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <CalendarClockIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No booking page yet")}</EmptyTitle>
              <EmptyDescription>
                {setup.kind === "missing"
                  ? t("Pick an organization to set up a booking page.")
                  : t(
                      "Set it up with weekday hours from 09:00 to 17:00, then change them here.",
                    )}
              </EmptyDescription>
            </EmptyHeader>
            {setup.kind === "ok" ? (
              <EmptyContent>
                <SetUpBookingButton />
              </EmptyContent>
            ) : null}
          </Empty>
        )}
      </CardContent>
    </Card>
  );
}
```

# Booking page

> A public page that shows open slots and books one without overlaps.

Source: https://bettersupabase.com/docs/registry/ui-recipes/booking-page

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [bookings block](https://bettersupabase.com/blocks/bookings) must be installed (`npx better-supabase add bookings`). Its page on bettersupabase.com is [/recipes/booking-page](https://bettersupabase.com/recipes/booking-page).

Web, with shadcn components:

```bash
npx shadcn add button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/bookings/components/booking-page.tsx"
/** The public page of a booking link: open slots for a week, then a form. */
export async function BookingPage({
  params,
  searchParams,
}: {
  params: Promise<{ token: string }>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [{ token }, query, t, format] = await Promise.all([
    params,
    searchParams,
    getExtracted("bookings"),
    getFormatter(),
  ]);

  const parsed = v.safeParse(v.pipe(v.string(), v.isoDate()), query["from"]);
  const from = parsed.success ? parsed.output : undefined;
  const page = await settle(() => loadPublicBooking(token, from));

  if (page.kind === "missing") {
    notFound();
  }

  if (page.kind === "error") {
    return (
      <Card data-recipe="booking-page" className="w-full max-w-xl">
        <CardHeader>
          <CardTitle>{t("Book a time")}</CardTitle>
        </CardHeader>
        <CardContent>
          <BookingsLoadError title={t("The open times didn't load.")} />
        </CardContent>
      </Card>
    );
  }

  const { data } = page;

  const next = Temporal.PlainDate.from(data.from).add({ days: 7 }).toString();

  const days = data.days.map((day) => ({
    ...day,
    label: format.dateTime(new Date(`${day.date}T12:00:00Z`), {
      weekday: "long",
      day: "numeric",
      month: "long",
      timeZone: "UTC",
    }),
  }));

  return (
    <Card data-recipe="booking-page" className="w-full max-w-xl">
      <CardHeader>
        <CardTitle>{data.serviceName}</CardTitle>
        <CardDescription>
          {t("{minutes} minutes with {host}. Times are in {tz}.", {
            minutes: String(data.minutes),
            host: data.hostName,
            tz: data.tz,
          })}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-6">
        {days.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <CalendarXIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No open times this week")}</EmptyTitle>
              <EmptyDescription>
                {t("Look at the week after for an open time.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Link
                href={`/book/${token}?from=${next}`}
                className={buttonVariants({ variant: "outline" })}
              >
                {t("Next week")}
                <ChevronRightIcon />
              </Link>
            </EmptyContent>
          </Empty>
        ) : (
          <>
            <SlotPicker token={token} days={days} />
            <div className="flex justify-end">
              <Link
                href={`/book/${token}?from=${next}`}
                className={buttonVariants({ variant: "ghost", size: "sm" })}
              >
                {t("Next week")}
                <ChevronRightIcon />
              </Link>
            </div>
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

# Bulk action bar

> A bar over a selection of rows that runs an action on all of them and reports failures.

Source: https://bettersupabase.com/docs/registry/ui-recipes/bulk-action-bar

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [bulk block](https://bettersupabase.com/blocks/bulk) must be installed (`npx better-supabase add bulk`). Its page on bettersupabase.com is [/recipes/bulk-action-bar](https://bettersupabase.com/recipes/bulk-action-bar).

Web, with shadcn components:

```bash
npx shadcn add alert alert-dialog button select
```

```tsx title="apps/examples/nextjs/src/features/work/components/bulk-action-bar.tsx"
/**
 * The bar over the grid's selected rows. It changes their state or moves them
 * to the trash through the bulk block, which runs each row on its own; the rows
 * that failed stay selected and are listed with their reason.
 */
export function BulkActionBar({
  selected,
  onSettled,
}: {
  selected: readonly Pick<WorkItemRow, "id" | "identifier">[];
  onSettled: (failedIds: readonly string[]) => void;
}) {
  const t = useExtracted("work");
  const errorMessage = useErrorMessage();
  const stateLabel = useStateLabel();
  const [to, setTo] = useState<WorkItemState | null>(null);
  const [confirming, setConfirming] = useState(false);
  const [failures, setFailures] = useState<readonly BulkFailure[]>([]);
  const bulk = useAction(runWorkBulkAction);

  const identifiers = new Map(selected.map((row) => [row.id, row.identifier]));

  const run = async (
    input: { action: "state"; to: WorkItemState } | { action: "delete" },
  ): Promise<void> => {
    const ids = selected.map((row) => row.id);
    const result = await bulk.run({ ...input, ids });

    if (!result.ok) {
      toast.error(errorMessage(result.error));

      return;
    }

    const { succeeded, failed } = result.data;

    setFailures(
      failed.map((row) => ({
        id: row.id,
        identifier: identifiers.get(row.id) ?? row.id,
        message: row.message === "" ? t("No reason given.") : row.message,
      })),
    );

    if (failed.length === 0) {
      toast.success(
        t("{count, plural, one {# item updated} other {# items updated}}", {
          count: succeeded,
        }),
      );
    } else {
      toast.warning(
        t("{failed, number} of {total, number} items failed", {
          failed: failed.length,
          total: result.data.total,
        }),
      );
    }

    onSettled(failed.map((row) => row.id));
  };

  const stateItems = WORK_ITEM_STATES.map((state) => ({
    value: state,
    label: stateLabel(state),
  }));

  if (selected.length === 0 && failures.length === 0) {
    return null;
  }

  return (
    <div className="flex flex-col gap-3" data-recipe="bulk-action-bar">
      {selected.length === 0 ? null : (
        <div
          role="toolbar"
          aria-label={t("Selected items")}
          className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/50 p-2"
        >
          <span className="px-2 text-sm font-medium tabular-nums">
            {t("{count, plural, one {# selected} other {# selected}}", {
              count: selected.length,
            })}
          </span>
          <Select
            value={to}
            items={stateItems}
            disabled={bulk.pending}
            onValueChange={(value) => {
              const state = WORK_ITEM_STATES.find((each) => each === value);

              setTo(state ?? null);
            }}
          >
            <SelectTrigger
              size="sm"
              className="w-40"
              aria-label={t("New state")}
            >
              <SelectValue placeholder={t("Set state")} />
            </SelectTrigger>
            <SelectContent>
              {stateItems.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <Button
            size="sm"
            disabled={to === null || bulk.pending}
            onClick={() => {
              if (to !== null) {
                void run({ action: "state", to });
              }
            }}
          >
            {t("Apply")}
          </Button>
          <AlertDialog
            open={confirming}
            onOpenChange={(next) => {
              if (!bulk.pending) {
                setConfirming(next);
              }
            }}
          >
            <AlertDialogTrigger
              render={
                <Button
                  type="button"
                  size="sm"
                  variant="destructive"
                  disabled={bulk.pending}
                />
              }
            >
              <Trash2Icon />
              {t("Delete")}
            </AlertDialogTrigger>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>
                  {t(
                    "{count, plural, one {Delete # item?} other {Delete # items?}}",
                    { count: selected.length },
                  )}
                </AlertDialogTitle>
                <AlertDialogDescription>
                  {t("They move to the trash, where they can be restored.")}
                </AlertDialogDescription>
              </AlertDialogHeader>
              <AlertDialogFooter>
                <AlertDialogCancel disabled={bulk.pending}>
                  {t("Cancel")}
                </AlertDialogCancel>
                <AlertDialogAction
                  type="button"
                  variant="destructive"
                  disabled={bulk.pending}
                  onClick={() => {
                    void run({ action: "delete" }).then(() => {
                      setConfirming(false);
                    });
                  }}
                >
                  {t("Delete")}
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </AlertDialog>
          <Button
            size="sm"
            variant="ghost"
            disabled={bulk.pending}
            onClick={() => {
              onSettled([]);
            }}
          >
            <XIcon />
            {t("Clear")}
          </Button>
        </div>
      )}
      {failures.length === 0 ? null : (
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>
            {t(
              "{count, plural, one {# item wasn't changed} other {# items weren't changed}}",
              { count: failures.length },
            )}
          </AlertTitle>
          <AlertDescription>
            <ul className="flex flex-col gap-1">
              {failures.map((failure) => (
                <li key={failure.id}>
                  <span className="font-medium tabular-nums">
                    {failure.identifier}
                  </span>
                  {": "}
                  {failure.message}
                </li>
              ))}
            </ul>
          </AlertDescription>
          <AlertAction>
            <Button
              size="sm"
              variant="outline"
              onClick={() => {
                setFailures([]);
              }}
            >
              {t("Dismiss")}
            </Button>
          </AlertAction>
        </Alert>
      )}
    </div>
  );
}
```

# Calendar view

> A month and week calendar of events with a dialog to add one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/calendar-view

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [calendar block](https://bettersupabase.com/blocks/calendar) must be installed (`npx better-supabase add calendar`). Its page on bettersupabase.com is [/recipes/calendar-view](https://bettersupabase.com/recipes/calendar-view).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/calendar/components/calendar-view.tsx"
export async function CalendarView({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const session = await getSession();

  if (!can(session, "calendar.read")) {
    forbidden();
  }

  const [t, format, query] = await Promise.all([
    getExtracted("calendar"),
    getFormatter(),
    searchParams,
    connection(),
  ]);

  const canWrite = can(session, "calendar.write");
  const canManage = can(session, "calendar.manage");
  const first = firstOfMonth(query["month"], "UTC");
  const weeks = weeksOf(first);
  const from = weeks[0]?.[0] ?? first;
  const until = (weeks.at(-1)?.[6] ?? first).add({ days: 1 });

  const month = await settle(() =>
    loadCalendarMonth(from.toString(), until.toString()),
  );

  const monthKey = (date: Temporal.PlainDate) =>
    date.toPlainYearMonth().toString();

  const label = (date: Temporal.PlainDate, options: DateTimeFormatOptions) =>
    format.dateTime(new Date(`${date.toString()}T12:00:00Z`), {
      ...options,
      timeZone: "UTC",
    });

  const byDay = new Map<string, MonthEvent[]>();

  if (month.kind === "ok") {
    for (const event of month.data.events) {
      byDay.set(event.day, [...(byDay.get(event.day) ?? []), event]);
    }
  }

  const today =
    month.kind === "ok"
      ? Temporal.Now.plainDateISO(month.data.tz).toString()
      : null;

  const calendars = month.kind === "ok" ? month.data.calendars : [];

  return (
    <Card data-recipe="calendar-view">
      <CardHeader>
        <CardTitle>
          {label(first, { month: "long", year: "numeric" })}
        </CardTitle>
        <CardDescription>
          {month.kind === "ok" && calendars.length > 0
            ? t("{count, plural, one {# event} other {# events}} in view", {
                count: month.data.events.length,
              })
            : t("Events of your organization by day.")}
        </CardDescription>
        <CardAction className="flex items-center gap-2">
          <Link
            href={`/calendar?month=${monthKey(first.subtract({ months: 1 }))}`}
            aria-label={t("Previous month")}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
          >
            <ChevronLeftIcon />
          </Link>
          <Link
            href="/calendar"
            className={buttonVariants({ variant: "outline", size: "sm" })}
          >
            {t("Today")}
          </Link>
          <Link
            href={`/calendar?month=${monthKey(first.add({ months: 1 }))}`}
            aria-label={t("Next month")}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
          >
            <ChevronRightIcon />
          </Link>
          {calendars.length > 0 ? <WorkingHoursDialog /> : null}
          {canWrite && calendars.length > 0 ? (
            <CreateEventDialog
              calendars={calendars}
              defaultDate={today ?? first.toString()}
            />
          ) : null}
        </CardAction>
      </CardHeader>
      <CardContent>
        {month.kind === "error" ? (
          <CalendarLoadError title={t("The calendar didn't load.")} />
        ) : month.kind === "no-organization" || calendars.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <CalendarIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No calendar yet")}</EmptyTitle>
              <EmptyDescription>
                {month.kind === "no-organization"
                  ? t("Pick an organization to see its calendar.")
                  : t("Create the team calendar, then add events to it.")}
              </EmptyDescription>
            </EmptyHeader>
            {month.kind === "ok" && canManage ? (
              <EmptyContent>
                <CreateCalendarButton />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <div className="overflow-x-auto">
            <div className="min-w-[42rem] overflow-hidden rounded-lg border">
              <div
                aria-hidden="true"
                className="grid grid-cols-7 border-b bg-muted/50"
              >
                {weeks[0]?.map((day) => (
                  <span
                    key={day.toString()}
                    className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
                  >
                    {label(day, { weekday: "short" })}
                  </span>
                ))}
              </div>
              <ol
                aria-label={label(first, { month: "long", year: "numeric" })}
                className="grid grid-cols-7"
              >
                {weeks.flat().map((day) => {
                  const key = day.toString();
                  const events = byDay.get(key) ?? [];
                  const outside = day.month !== first.month;

                  return (
                    <li
                      key={key}
                      className={cn(
                        "flex min-h-24 flex-col gap-1 border-e border-b p-1.5 [&:nth-child(7n)]:border-e-0",
                        outside && "bg-muted/30",
                      )}
                    >
                      <span className="sr-only">
                        {label(day, { dateStyle: "full" })}
                      </span>
                      <span
                        aria-hidden="true"
                        className={cn(
                          "text-xs",
                          outside ? "text-muted-foreground" : "font-medium",
                        )}
                      >
                        {key === today ? (
                          <Badge variant="default">{day.day}</Badge>
                        ) : (
                          day.day
                        )}
                      </span>
                      {events.slice(0, SHOWN_PER_DAY).map((event) => (
                        <span
                          key={event.key}
                          title={event.title}
                          className="truncate rounded-sm bg-primary/10 px-1.5 py-0.5 text-xs text-foreground"
                        >
                          {event.time ? (
                            <span className="text-muted-foreground tabular-nums">
                              {event.time}{" "}
                            </span>
                          ) : null}
                          {event.title}
                        </span>
                      ))}
                      {events.length > SHOWN_PER_DAY ? (
                        <span className="text-xs text-muted-foreground">
                          {t("{count} more", {
                            count: String(events.length - SHOWN_PER_DAY),
                          })}
                        </span>
                      ) : null}
                    </li>
                  );
                })}
              </ol>
            </div>
          </div>
        )}
      </CardContent>
    </Card>
  );
}
```

# Can

> Shows an action only to members who hold a permission key; the checks of one render share one round trip.

Source: https://bettersupabase.com/docs/registry/ui-recipes/can

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [permissions block](https://bettersupabase.com/blocks/permissions) must be installed (`npx better-supabase add permissions`). Its page on bettersupabase.com is [/recipes/can](https://bettersupabase.com/recipes/can).

Web, with no shadcn components.

```tsx title="apps/examples/nextjs/src/features/projects/components/task-import-action.tsx"
"use client";

import { Can } from "@/hooks/use-permissions";
import { permissions } from "@/lib/supabase/blocks.generated";

import { TaskImportDialog } from "./task-import-dialog";

/**
 * The import button, for members with `imports.manage` in the active
 * organization. The `<Can>` checks of one render share one `my_permissions`
 * call; the import action checks the key again on the server.
 */
export function TaskImportAction({ projectId }: { projectId: string }) {
  return (
    <Can permission={permissions.imports.manage}>
      <TaskImportDialog projectId={projectId} />
    </Can>
  );
}
```

# Channel sidebar

> The caller's channels with unread and mention counts, kept live, with dialogs to browse, join and create channels.

Source: https://bettersupabase.com/docs/registry/ui-recipes/channel-sidebar

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useConversations` from `@/hooks/use-messaging`; the [channels](https://bettersupabase.com/blocks/channels), [messaging](https://bettersupabase.com/blocks/messaging) blocks must be installed (`npx better-supabase add channels messaging`). Its page on bettersupabase.com is [/recipes/channel-sidebar](https://bettersupabase.com/recipes/channel-sidebar).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input label scroll-area skeleton switch
```

```bash
npx shadcn add @better-supabase/use-messaging
```

```tsx title="apps/examples/nextjs/src/features/chat/components/channel-sidebar.tsx"
"use client";

import type { SubmitEvent } from "react";

import { useAction } from "better-supabase/react";
import {
  HashIcon,
  LockIcon,
  PlusIcon,
  RotateCwIcon,
  SearchIcon,
} from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useState } from "react";
import { toast } from "sonner";

import { Alert, AlertAction, AlertTitle } from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useConversations } from "@/hooks/use-messaging";
import { useErrorMessage } from "@/lib/use-error-message";
import { cn } from "@/lib/utils";

import type { ChannelRow } from "../chat-actions";

import { browseChannels, createChannel, joinChannel } from "../chat-actions";

export interface OpenChannel {
  readonly id: string;
  readonly name: string;
}

const KINDS = ["channel"] as const;

/**
 * The caller's channels with unread and mention counts, kept live over
 * Realtime, plus dialogs to browse the organization's channels and create one.
 */
export function ChannelSidebar({
  selected,
  onSelect,
}: {
  selected: string | null;
  onSelect: (channel: OpenChannel) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const { conversations, error, refresh } = useConversations({ kinds: KINDS });

  const opened = (channel: OpenChannel) => {
    void refresh();
    onSelect(channel);
  };

  return (
    <Card size="sm" className="min-h-0">
      <CardHeader>
        <CardTitle>{t("Channels")}</CardTitle>
        <CardAction className="flex gap-1">
          <BrowseChannelsDialog onOpened={opened} />
          <CreateChannelDialog onCreated={opened} />
        </CardAction>
      </CardHeader>
      <CardContent className="flex min-h-0 flex-1 flex-col gap-3">
        {error ? (
          <Alert variant="destructive" aria-live="polite">
            <AlertTitle>{errorMessage(error)}</AlertTitle>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void refresh()}
              >
                <RotateCwIcon aria-hidden />
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : null}
        {conversations === undefined ? (
          <div
            className="flex flex-col gap-2"
            aria-busy="true"
            aria-live="polite"
          >
            <span className="sr-only">{t("Loading channels")}</span>
            <Skeleton className="h-8" />
            <Skeleton className="h-8" />
            <Skeleton className="h-8" />
          </div>
        ) : conversations.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HashIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No channels yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Create one, or browse the channels others made.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ScrollArea className="min-h-0 flex-1">
            <ul className="flex flex-col gap-0.5" data-testid="channels">
              {conversations.map((conversation) => {
                const name = conversation.title ?? conversation.id;
                const current = selected === conversation.id;

                return (
                  <li key={conversation.id}>
                    <Button
                      type="button"
                      variant={current ? "secondary" : "ghost"}
                      size="sm"
                      className="w-full justify-start"
                      aria-current={current ? "true" : undefined}
                      onClick={() => {
                        onSelect({ id: conversation.id, name });
                      }}
                    >
                      {conversation.access === "members" ? (
                        <LockIcon aria-hidden />
                      ) : (
                        <HashIcon aria-hidden />
                      )}
                      <span
                        className={cn(
                          "truncate",
                          conversation.unread > 0 && "font-semibold",
                        )}
                      >
                        {name}
                      </span>
                      {conversation.mentions > 0 ? (
                        <Badge variant="destructive" className="ml-auto">
                          @{conversation.mentions}
                        </Badge>
                      ) : conversation.unread > 0 ? (
                        <Badge variant="secondary" className="ml-auto">
                          {conversation.unread}
                        </Badge>
                      ) : null}
                    </Button>
                  </li>
                );
              })}
            </ul>
          </ScrollArea>
        )}
      </CardContent>
    </Card>
  );
}

function BrowseChannelsDialog({
  onOpened,
}: {
  onOpened: (channel: OpenChannel) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const browse = useAction(browseChannels);
  const join = useAction(joinChannel);

  const pick = async (channel: ChannelRow) => {
    if (!channel.joined) {
      const joined = await join.run({ channelId: channel.id });

      if (!joined.ok) {
        toast.error(errorMessage(joined.error));

        return;
      }
    }

    setOpen(false);
    onOpened({ id: channel.id, name: channel.name });
  };

  const rows = browse.data;

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (next) {
          void browse.run({});
        }
      }}
    >
      <DialogTrigger
        render={
          <Button
            size="icon-sm"
            variant="outline"
            aria-label={t("Browse channels")}
          />
        }
      >
        <SearchIcon aria-hidden />
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{t("Browse channels")}</DialogTitle>
          <DialogDescription>
            {t("Every public channel, and the private ones you are in.")}
          </DialogDescription>
        </DialogHeader>
        {browse.error ? (
          <Alert variant="destructive" aria-live="polite">
            <AlertTitle>{errorMessage(browse.error)}</AlertTitle>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void browse.run({})}
              >
                <RotateCwIcon aria-hidden />
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : rows === undefined ? (
          <div
            className="flex flex-col gap-2"
            aria-busy="true"
            aria-live="polite"
          >
            <span className="sr-only">{t("Loading channels")}</span>
            <Skeleton className="h-10" />
            <Skeleton className="h-10" />
          </div>
        ) : rows.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyTitle>{t("No channels to join")}</EmptyTitle>
              <EmptyDescription>
                {t("Close this and create the first one.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ul className="flex flex-col gap-2">
            {rows.map((channel) => (
              <li key={channel.id} className="flex items-center gap-3">
                <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                  <span className="flex items-center gap-1 text-sm font-medium">
                    {channel.visibility === "private" ? (
                      <LockIcon aria-hidden className="size-3.5" />
                    ) : (
                      <HashIcon aria-hidden className="size-3.5" />
                    )}
                    <span className="truncate">{channel.name}</span>
                  </span>
                  <span className="truncate text-xs text-muted-foreground">
                    {channel.topic ??
                      t("{count, plural, one {# member} other {# members}}", {
                        count: channel.memberCount,
                      })}
                  </span>
                </div>
                <Button
                  type="button"
                  size="sm"
                  variant={channel.joined ? "outline" : "default"}
                  disabled={join.pending}
                  onClick={() => void pick(channel)}
                >
                  {channel.joined ? t("Open") : t("Join")}
                </Button>
              </li>
            ))}
          </ul>
        )}
      </DialogContent>
    </Dialog>
  );
}

function CreateChannelDialog({
  onCreated,
}: {
  onCreated: (channel: OpenChannel) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const fieldId = useId();
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const [topic, setTopic] = useState("");
  const [isPrivate, setPrivate] = useState(false);

  const create = useAction(createChannel, {
    onSuccess: (id) => {
      setOpen(false);
      onCreated({ id, name: name.trim().toLowerCase() });
    },
  });

  const message =
    create.error === undefined ? null : errorMessage(create.error);

  const submit = (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    void create.run({
      name,
      topic,
      visibility: isPrivate ? "private" : "public",
    });
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        setName("");
        setTopic("");
        setPrivate(false);
        create.reset();
      }}
    >
      <DialogTrigger
        render={<Button size="icon-sm" aria-label={t("Create a channel")} />}
      >
        <PlusIcon aria-hidden />
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={submit} className="grid gap-6">
          <DialogHeader>
            <DialogTitle>{t("Create a channel")}</DialogTitle>
            <DialogDescription>
              {t("A place for one team, project or topic.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field data-invalid={message ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-name`}>{t("Name")}</FieldLabel>
              <Input
                id={`${fieldId}-name`}
                value={name}
                onChange={(event) => {
                  setName(event.target.value);
                }}
                required
                maxLength={80}
                pattern="[A-Za-z0-9][A-Za-z0-9_\-]*"
                placeholder="launch"
              />
              <FieldDescription>
                {t("Lowercase letters, digits, - and _.")}
              </FieldDescription>
              {message ? <FieldError>{message}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-topic`}>{t("Topic")}</FieldLabel>
              <Input
                id={`${fieldId}-topic`}
                value={topic}
                onChange={(event) => {
                  setTopic(event.target.value);
                }}
                maxLength={250}
              />
            </Field>
            <div className="flex items-center gap-2">
              <Switch
                id={`${fieldId}-private`}
                checked={isPrivate}
                onCheckedChange={setPrivate}
              />
              <Label
                htmlFor={`${fieldId}-private`}
                className="text-sm font-normal"
              >
                {t("Private: only the people you add see it")}
              </Label>
            </div>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={create.pending}>
              {t("Create")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

# Chat with history

> An AI chat that stores every message, resumes a stored conversation and streams the reply.

Source: https://bettersupabase.com/docs/registry/ui-recipes/chat-with-history

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useAssistant` from `better-supabase/ai-sdk/react`; the [ai-chat block](https://bettersupabase.com/blocks/ai-chat) must be installed (`npx better-supabase add ai-chat`). Its page on bettersupabase.com is [/recipes/chat-with-history](https://bettersupabase.com/recipes/chat-with-history).

Web, with shadcn components:

```bash
npx shadcn add button textarea
```

```tsx title="apps/examples/nextjs/src/features/assistant/components/assistant-chat.tsx"
"use client";

import type { UIMessage } from "ai";

import { useAssistant } from "better-supabase/ai-sdk/react";
import { useDurableAssistant } from "better-supabase/ai-sdk/workflow/react";
import { useExtracted } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";

import { Button } from "@/components/ui/button";

import { ChatView } from "./chat-view";

export type ChatMode = "standard" | "durable" | "research";

interface PanelProps {
  readonly id: string;
  readonly messages: UIMessage[];
  readonly streaming: boolean;
}

/**
 * The answer streams from the request; a reload picks it up from the stream
 * store.
 */
function StandardPanel({ id, messages }: PanelProps) {
  const router = useRouter();

  const chat = useAssistant({
    id,
    messages,
    onFinish: () => {
      router.refresh();
    },
  });

  return <ChatView id={id} chat={chat} />;
}

/**
 * The answer runs as a workflow: it survives a closed tab and waits for
 * approvals.
 */
function DurablePanel({
  id,
  messages,
  streaming,
  agent,
}: PanelProps & { readonly agent: "assistant" | "research" }) {
  const router = useRouter();

  const chat = useDurableAssistant({
    id,
    api: "/api/chat/durable",
    body: { agent },
    messages,
    resume: streaming,
    onFinish: () => {
      router.refresh();
    },
  });

  return <ChatView id={id} chat={chat} />;
}

/**
 * One chat. Standard answers stream from the request; Durable and Research
 * answers run as workflows, which need the AI Gateway key.
 */
export function AssistantChat({
  id,
  messages,
  streaming = false,
  initialMode = "standard",
  durableAvailable,
}: {
  id: string;
  messages: UIMessage[];
  streaming?: boolean;
  initialMode?: ChatMode;
  durableAvailable: boolean;
}) {
  const t = useExtracted("assistant");

  const [mode, setMode] = useState<ChatMode>(
    durableAvailable ? initialMode : "standard",
  );

  const modes: readonly { mode: ChatMode; label: string }[] = [
    { mode: "standard", label: t("Standard") },
    { mode: "durable", label: t("Durable") },
    { mode: "research", label: t("Research") },
  ];

  const panel = { id, messages, streaming };

  return (
    <div className="flex flex-col gap-3">
      <fieldset className="flex flex-wrap items-center gap-2">
        <legend className="sr-only">{t("Answer mode")}</legend>
        {modes.map((entry) => (
          <Button
            key={entry.mode}
            size="sm"
            variant={mode === entry.mode ? "default" : "outline"}
            aria-pressed={mode === entry.mode}
            disabled={entry.mode !== "standard" && !durableAvailable}
            onClick={() => {
              setMode(entry.mode);
            }}
          >
            {entry.label}
          </Button>
        ))}
        {durableAvailable ? null : (
          <p className="text-xs text-muted-foreground">
            {t("Durable answers need AI_GATEWAY_API_KEY.")}
          </p>
        )}
      </fieldset>
      {mode === "standard" ? (
        <StandardPanel key="standard" {...panel} />
      ) : (
        <DurablePanel
          key={mode}
          {...panel}
          agent={mode === "research" ? "research" : "assistant"}
        />
      )}
    </div>
  );
}
```

# Command search

> A search dialog over the search index, with favorites and recents before the user types.

Source: https://bettersupabase.com/docs/registry/ui-recipes/command-search

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useCommandSearch` from `@/hooks/use-search`; the [search block](https://bettersupabase.com/blocks/search) must be installed (`npx better-supabase add search`). Its page on bettersupabase.com is [/recipes/command-search](https://bettersupabase.com/recipes/command-search).

Web, with shadcn components:

```bash
npx shadcn add button dialog input
```

```bash
npx shadcn add @better-supabase/use-search
```

```tsx title="apps/examples/nextjs/src/features/search/components/command-search.tsx"
"use client";

import { SearchIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useCommandSearch } from "@/hooks/use-search";
import { Link } from "@/i18n/navigation";
import { blockConfig } from "@/lib/supabase/blocks.generated";

/** The API schema `gen` wrote for the search block. */
const API_SCHEMA = blockConfig.search.api;

/** Cmd+K over the search index: customers by name, plus recents and favorites. */
export function CommandSearch() {
  const t = useExtracted("search");
  const search = useCommandSearch({ schema: API_SCHEMA });

  const heading = (key: string) => {
    if (key === "favorites") {
      return t("Favorites");
    }

    if (key === "recents") {
      return t("Recent");
    }

    if (key === "customer") {
      return t("Customers");
    }

    return key;
  };

  return (
    <>
      <Button
        variant="ghost"
        size="sm"
        aria-label={t("Search")}
        aria-keyshortcuts="Meta+K Control+K"
        onClick={() => {
          search.setOpen(true);
        }}
      >
        <SearchIcon />
        <span className="hidden sm:inline">{t("Search")}</span>
        <kbd className="hidden text-xs text-muted-foreground sm:inline">⌘K</kbd>
      </Button>
      <Dialog open={search.open} onOpenChange={search.setOpen}>
        <DialogContent className="gap-4 sm:max-w-lg">
          <DialogHeader>
            <DialogTitle>{t("Search")}</DialogTitle>
            <DialogDescription>
              {t("Find a customer by name, even with a typo.")}
            </DialogDescription>
          </DialogHeader>
          <Input
            type="search"
            value={search.query}
            onChange={(event) => {
              search.setQuery(event.target.value);
            }}
            placeholder={t("Search customers")}
            aria-label={t("Search customers")}
          />
          <div className="max-h-80 overflow-y-auto" aria-live="polite">
            {search.error === undefined ? null : (
              <p className="text-sm text-destructive">
                {t("Search failed. Try again.")}
              </p>
            )}
            {search.groups.length === 0 && !search.loading ? (
              <p className="text-sm text-muted-foreground">
                {search.query.trim() === ""
                  ? t("Type to search.")
                  : t("No matches.")}
              </p>
            ) : null}
            {search.groups.map((group) => (
              <section key={group.key} className="mb-3">
                <h3 className="mb-1 text-xs font-medium text-muted-foreground">
                  {heading(group.key)}
                </h3>
                <ul>
                  {group.items.map((item) => (
                    <li key={`${item.subjectType}:${item.subjectId}`}>
                      <Link
                        href={item.url ?? "/"}
                        onClick={() => void search.select(item)}
                        className="block rounded-md px-2 py-1.5 outline-none hover:bg-muted focus-visible:bg-muted"
                      >
                        {item.title ?? item.subjectId}
                      </Link>
                    </li>
                  ))}
                </ul>
              </section>
            ))}
          </div>
        </DialogContent>
      </Dialog>
    </>
  );
}
```

React Native, with `Modal`, `Pressable`, `SectionList`, `Text`, `TextInput`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-search
```

```tsx title="apps/examples/expo-powersync/src/components/command-search.tsx"
import {
  Modal,
  Pressable,
  SectionList,
  Text,
  TextInput,
  View,
} from "react-native";

import { useCommandSearch } from "../hooks/use-search";
import { API_SCHEMA } from "../lib/blocks";

function heading(key: string): string {
  if (key === "favorites") {
    return "Favorites";
  }

  if (key === "recents") {
    return "Recent";
  }

  if (key === "customer") {
    return "Customers";
  }

  return key;
}

/** Search over the search index: customers by name, plus recents and favorites. */
export function CommandSearch() {
  // Native has no Cmd+K, so the button opens it.
  const search = useCommandSearch({ schema: API_SCHEMA, shortcut: false });

  return (
    <>
      <Pressable
        accessibilityRole="button"
        onPress={() => {
          search.setOpen(true);
        }}
        style={{ padding: 8 }}
      >
        <Text>Search</Text>
      </Pressable>
      <Modal
        visible={search.open}
        animationType="slide"
        onRequestClose={() => {
          search.setOpen(false);
        }}
      >
        <View style={{ flex: 1, gap: 12, padding: 16 }}>
          <TextInput
            value={search.query}
            onChangeText={search.setQuery}
            placeholder="Search customers"
            accessibilityLabel="Search customers"
            style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
          />
          {search.error === undefined ? null : (
            <Text>Search failed. Try again.</Text>
          )}
          <SectionList
            sections={search.groups.map((group) => ({
              key: group.key,
              title: heading(group.key),
              data: group.items,
            }))}
            keyExtractor={(item) => `${item.subjectType}:${item.subjectId}`}
            renderSectionHeader={({ section }) => (
              <Text style={{ color: "#737373", marginTop: 12 }}>
                {section.title}
              </Text>
            )}
            renderItem={({ item }) => (
              <Pressable
                accessibilityRole="link"
                onPress={() => void search.select(item)}
                style={{ paddingVertical: 8 }}
              >
                <Text>{item.title ?? item.subjectId}</Text>
              </Pressable>
            )}
            ListEmptyComponent={
              search.loading ? null : (
                <Text>
                  {search.query.trim() === ""
                    ? "Type to search."
                    : "No matches."}
                </Text>
              )
            }
          />
          <Pressable
            accessibilityRole="button"
            onPress={() => {
              search.setOpen(false);
            }}
          >
            <Text>Close</Text>
          </Pressable>
        </View>
      </Modal>
    </>
  );
}
```

# Comment thread

> A record's comments with their authors and a form to add one, kept current after each write.

Source: https://bettersupabase.com/docs/registry/ui-recipes/comment-thread

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useComments` from `@/hooks/use-comments`; the [comments block](https://bettersupabase.com/blocks/comments) must be installed (`npx better-supabase add comments`). Its page on bettersupabase.com is [/recipes/comment-thread](https://bettersupabase.com/recipes/comment-thread).

Web, with shadcn components:

```bash
npx shadcn add button field skeleton textarea
```

```bash
npx shadcn add @better-supabase/use-comments
```

```tsx title="apps/examples/nextjs/src/features/customers/components/comment-thread.tsx"
"use client";

import type { SubmitEvent } from "react";

import { useExtracted, useFormatter } from "next-intl";
import { useId, useState } from "react";

import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useComments } from "@/hooks/use-comments";
import { useErrorMessage } from "@/lib/use-error-message";

/**
 * A record's comments, oldest first, through the browser client. `subject` is
 * any type in `config.records`, such as a customer or a project.
 */
export function CommentThread({
  subject,
  authors,
  canComment,
}: {
  subject: { readonly type: string; readonly id: string };
  /** Display names by user id. */
  authors: Readonly<Record<string, string>>;
  canComment: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();
  const errorMessage = useErrorMessage();
  const fieldId = useId();
  const [body, setBody] = useState("");
  const [pending, setPending] = useState(false);

  const { comments, error, add } = useComments({ subject });

  const visible = comments?.filter((comment) => !comment.deletedAt);

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    const text = body.trim();

    if (!text) {
      return;
    }

    setPending(true);

    if ((await add(text)).ok) {
      setBody("");
    }

    setPending(false);
  };

  return (
    <div className="space-y-6">
      {visible === undefined ? (
        <Skeleton className="h-16" />
      ) : visible.length === 0 ? (
        <p className="text-sm text-muted-foreground">{t("No comments yet.")}</p>
      ) : (
        <ol className="space-y-4" data-testid="comments">
          {visible.map((comment) => (
            <li key={comment.id} className="space-y-1">
              <p className="text-sm">
                <span className="font-medium">
                  {(comment.authorId && authors[comment.authorId]) ??
                    t("Someone")}
                </span>{" "}
                <span className="text-xs text-muted-foreground">
                  {format.dateTime(
                    (comment.sentAt ?? comment.createdAt).epochMilliseconds,
                    { dateStyle: "medium", timeStyle: "short" },
                  )}
                </span>
              </p>
              <p className="text-sm whitespace-pre-wrap">{comment.body}</p>
            </li>
          ))}
        </ol>
      )}
      {canComment ? (
        <form onSubmit={(event) => void submit(event)} className="space-y-3">
          <Field data-invalid={error ? true : undefined}>
            <FieldLabel htmlFor={`${fieldId}-comment-body`} className="sr-only">
              {t("Comment")}
            </FieldLabel>
            <Textarea
              id={`${fieldId}-comment-body`}
              value={body}
              onChange={(event) => {
                setBody(event.target.value);
              }}
              placeholder={t("Write a comment")}
              required
              maxLength={4000}
              rows={3}
            />
            {error ? <FieldError>{errorMessage(error)}</FieldError> : null}
          </Field>
          <Button type="submit" size="sm" disabled={pending}>
            {t("Comment")}
          </Button>
        </form>
      ) : null}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Button`, `FlatList`, `Text`, `TextInput`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-comments
```

```tsx title="apps/examples/expo-powersync/src/components/comment-thread.tsx"
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  FlatList,
  Text,
  TextInput,
  View,
} from "react-native";

import { useComments } from "../hooks/use-comments";

/** A customer's comments, oldest first, with a box to add one. */
export function CommentThread({ customerId }: { readonly customerId: string }) {
  const [body, setBody] = useState("");
  const [pending, setPending] = useState(false);

  const { comments, error, add } = useComments({
    subject: { type: "customer", id: customerId },
  });

  const submit = async () => {
    const text = body.trim();

    if (!text) {
      return;
    }

    setPending(true);

    if ((await add(text)).ok) {
      setBody("");
    }

    setPending(false);
  };

  if (comments === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ flex: 1, gap: 12 }}>
      <FlatList
        data={comments.filter((comment) => !comment.deletedAt)}
        keyExtractor={(comment) => comment.id}
        ListEmptyComponent={<Text>No comments yet.</Text>}
        renderItem={({ item }) => (
          <View style={{ paddingVertical: 8 }}>
            <Text style={{ color: "#737373", fontSize: 12 }}>
              {(item.sentAt ?? item.createdAt).toLocaleString()}
            </Text>
            <Text>{item.body}</Text>
          </View>
        )}
      />
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={body}
        onChangeText={setBody}
        placeholder="Write a comment"
        multiline
        maxLength={4000}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8, minHeight: 64 }}
      />
      <Button
        title="Comment"
        disabled={pending || !body.trim()}
        onPress={() => void submit()}
      />
    </View>
  );
}
```

# Connector credentials

> A sheet that stores a connector credential encrypted and revokes it, without reading the secret back.

Source: https://bettersupabase.com/docs/registry/ui-recipes/connector-setup

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [connectors](https://bettersupabase.com/blocks/connectors), [credentials](https://bettersupabase.com/blocks/credentials) blocks must be installed (`npx better-supabase add connectors credentials`). Its page on bettersupabase.com is [/recipes/connector-setup](https://bettersupabase.com/recipes/connector-setup).

Web, with shadcn components:

```bash
npx shadcn add button field input sheet
```

```tsx title="apps/examples/nextjs/src/features/workflows/builder/components/credential-sheet.tsx"
"use client";

import { useAction } from "better-supabase/react";
import { KeySquareIcon, Trash2Icon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";
import { useErrorMessage } from "@/lib/use-error-message";

import type { CredentialRow } from "../builder-queries";

import { createCredential, revokeCredential } from "../builder-actions";

/**
 * The organization's Slack credentials: the token goes to Vault, the row keeps
 * a reference.
 */
export function CredentialSheet({
  credentials,
}: {
  credentials: readonly CredentialRow[];
}) {
  const t = useExtracted("workflows");
  const id = useId();
  const errorMessage = useErrorMessage();

  const onError = (error: Parameters<typeof errorMessage>[0]) => {
    toast.error(errorMessage(error));
  };

  const create = useAction(createCredential, {
    onError,
    onSuccess: () => {
      toast.success(t("Credential saved"));
    },
  });

  const revoke = useAction(revokeCredential, { onError });

  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" size="sm" />}>
        <KeySquareIcon />
        {t("Credentials")}
      </SheetTrigger>
      <SheetContent className="gap-6 p-6">
        <SheetHeader className="p-0">
          <SheetTitle>{t("Credentials")}</SheetTitle>
          <SheetDescription>
            {t(
              "Slack bot tokens for the Post to Slack step. The token is stored in Vault and never shown again.",
            )}
          </SheetDescription>
        </SheetHeader>
        <ul className="space-y-2">
          {credentials.map((credential) => (
            <li
              key={credential.id}
              className="flex items-center justify-between gap-2 text-sm"
            >
              <span className="truncate">{credential.name}</span>
              <Button
                variant="ghost"
                size="sm"
                aria-label={t("Revoke")}
                disabled={revoke.pending}
                onClick={() => {
                  void revoke.run({ id: credential.id });
                }}
              >
                <Trash2Icon />
              </Button>
            </li>
          ))}
        </ul>
        <form
          className="space-y-3"
          action={(form) => {
            void create.run({
              name: String(form.get("name") ?? ""),
              secret: String(form.get("secret") ?? ""),
            });
          }}
        >
          <Field>
            <FieldLabel htmlFor={`${id}-name`}>{t("Name")}</FieldLabel>
            <Input id={`${id}-name`} name="name" required maxLength={100} />
          </Field>
          <Field>
            <FieldLabel htmlFor={`${id}-secret`}>{t("Bot token")}</FieldLabel>
            <Input
              id={`${id}-secret`}
              name="secret"
              type="password"
              required
              autoComplete="off"
              placeholder="xoxb-…"
            />
          </Field>
          <Button type="submit" disabled={create.pending}>
            {t("Add credential")}
          </Button>
        </form>
      </SheetContent>
    </Sheet>
  );
}
```

# Contact detail

> A person or company with its details, related contacts and the latest activity.

Source: https://bettersupabase.com/docs/registry/ui-recipes/contact-detail

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [contacts block](https://bettersupabase.com/blocks/contacts) must be installed (`npx better-supabase add contacts`). Its page on bettersupabase.com is [/recipes/contact-detail](https://bettersupabase.com/recipes/contact-detail).

Web, with shadcn components:

```bash
npx shadcn add badge card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/crm/components/contact-detail.tsx"
/**
 * Render inside `<Suspense>`: a company or a person with its details, its
 * people (for a company) and the activity timeline.
 */
export async function ContactDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const [{ id }, session, t] = await Promise.all([
    params,
    getSession(),
    getExtracted("crm"),
  ]);

  if (!can(session, "parties.read")) {
    forbidden();
  }

  const loaded = await settle(() => loadContact(id));

  if (loaded.kind === "no-organization") {
    return null;
  }

  if (loaded.kind === "error") {
    return <CrmLoadError title={t("The contact didn't load")} />;
  }

  const contact = loaded.data;

  if (!contact) {
    notFound();
  }

  const lifecycles = new Map([
    ["lead", t("Lead")],
    ["prospect", t("Prospect")],
    ["customer", t("Customer")],
    ["churned", t("Churned")],
    ["mql", t("Marketing qualified")],
    ["sql", t("Sales qualified")],
    ["opportunity", t("Opportunity")],
  ]);

  const details = [
    { label: t("Email"), value: contact.email },
    { label: t("Phone"), value: contact.phone },
    { label: t("Title"), value: contact.title },
    { label: t("Domain"), value: contact.domain },
    { label: t("Website"), value: contact.website },
  ].filter((detail) => detail.value !== null);

  return (
    <div data-recipe="contact-detail" className="grid gap-6 lg:grid-cols-3">
      <div className="flex flex-col gap-6 lg:col-span-2">
        <Card>
          <CardHeader>
            <CardTitle>{contact.name}</CardTitle>
            <CardDescription className="flex items-center gap-2">
              <Badge variant="outline">
                {contact.kind === "company" ? t("Company") : t("Person")}
              </Badge>
              <Badge variant="primary-light">
                {lifecycles.get(contact.lifecycle) ?? contact.lifecycle}
              </Badge>
            </CardDescription>
          </CardHeader>
          <CardContent>
            {details.length === 0 ? (
              <p className="text-sm text-muted-foreground">
                {t("No details yet.")}
              </p>
            ) : (
              <dl className="grid gap-3 sm:grid-cols-2">
                {details.map((detail) => (
                  <div key={detail.label} className="flex flex-col gap-0.5">
                    <dt className="text-xs text-muted-foreground">
                      {detail.label}
                    </dt>
                    <dd className="text-sm">{detail.value}</dd>
                  </div>
                ))}
              </dl>
            )}
          </CardContent>
        </Card>
        <Suspense fallback={<ActivityTimelineSkeleton />}>
          <ActivityTimeline subject={{ type: contact.kind, id: contact.id }} />
        </Suspense>
      </div>
      {contact.kind === "company" ? (
        <Card>
          <CardHeader>
            <CardTitle>{t("People")}</CardTitle>
            <CardDescription>
              {t("The people you talk to at {name}.", { name: contact.name })}
            </CardDescription>
          </CardHeader>
          <CardContent className="flex flex-col gap-4">
            {contact.people.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <UserPlusIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No people yet")}</EmptyTitle>
                  <EmptyDescription>
                    {contact.candidates.length === 0
                      ? t("Add a person first, then link them here.")
                      : t("Link a person below.")}
                  </EmptyDescription>
                </EmptyHeader>
                {contact.candidates.length === 0 ? (
                  <EmptyContent>
                    <CreateContactDialog variant="outline" />
                  </EmptyContent>
                ) : null}
              </Empty>
            ) : (
              <ul className="flex flex-col divide-y">
                {contact.people.map((person) => (
                  <li
                    key={person.id}
                    className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
                  >
                    <div className="flex min-w-0 flex-col gap-0.5">
                      <Link
                        href={`/crm/contacts/${person.id}`}
                        className="truncate text-sm font-medium hover:underline"
                      >
                        {person.name}
                      </Link>
                      <span className="truncate text-xs text-muted-foreground">
                        {[person.role ?? person.title, person.email]
                          .filter((part) => part !== null)
                          .join(" · ")}
                      </span>
                    </div>
                    {person.primary ? (
                      <Badge variant="success-light">{t("Primary")}</Badge>
                    ) : null}
                  </li>
                ))}
              </ul>
            )}
            {can(session, "parties.write") ? (
              <LinkPersonForm
                companyId={contact.id}
                people={contact.candidates.map((person) => ({
                  value: person.id,
                  label: person.name,
                }))}
              />
            ) : null}
          </CardContent>
        </Card>
      ) : (
        <Card>
          <CardHeader>
            <CardTitle>{t("Companies")}</CardTitle>
            <CardDescription>
              {t("Link this person from a company's page.")}
            </CardDescription>
          </CardHeader>
          <CardContent>
            <Link
              href="/crm/contacts"
              className="flex items-center gap-2 text-sm font-medium hover:underline"
            >
              <BuildingIcon className="size-4" aria-hidden />
              {t("All contacts")}
            </Link>
          </CardContent>
        </Card>
      )}
    </div>
  );
}
```

# Contract renewals

> Active contracts with their value, end date and notice deadline, the nearest deadline first, so a renewal is decided in time.

Source: https://bettersupabase.com/docs/registry/ui-recipes/contract-renewals

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [contracts block](https://bettersupabase.com/blocks/contracts) must be installed (`npx better-supabase add contracts`). Its page on bettersupabase.com is [/recipes/contract-renewals](https://bettersupabase.com/recipes/contract-renewals).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/compliance/components/contract-renewals.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

const SOON_DAYS = 30;

/**
 * Render inside `<Suspense>`: the organization's active contracts, the nearest
 * notice deadline first, so the team decides on a renewal before the
 * counterparty's notice period runs out.
 */
export async function ContractRenewals() {
  const [state, t, format, locale] = await Promise.all([
    getRenewals(),
    getExtracted("compliance"),
    getFormatter(),
    getLocale(),
  ]);

  if (state.kind === "no-organization") {
    return null;
  }

  const day = (date: string) =>
    format.dateTime(new Date(date), { dateStyle: "medium", timeZone: "UTC" });

  const renewal = (row: RenewalRow) => {
    const days = row.noticeInDays;

    if (days === null) {
      return <Badge variant="secondary">{t("No end date")}</Badge>;
    }

    if (days < 0) {
      return (
        <Badge variant="destructive-light">
          {row.autoRenew ? t("Renews, notice passed") : t("Notice passed")}
        </Badge>
      );
    }

    if (days <= SOON_DAYS) {
      return (
        <Badge variant="warning-light">
          {t("Notice in {days, number} days", { days })}
        </Badge>
      );
    }

    return row.autoRenew ? (
      <Badge variant="info-light">{t("Auto-renews")}</Badge>
    ) : (
      <Badge variant="success-light">{t("Active")}</Badge>
    );
  };

  return (
    <Card data-recipe="contract-renewals">
      <CardHeader>
        <CardTitle>{t("Contract renewals")}</CardTitle>
        <CardDescription>
          {t(
            "Active contracts, the nearest notice deadline first. A contract that auto-renews extends itself unless someone gives notice before the deadline.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {state.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The contracts didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : state.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <FileSignatureIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No active contracts")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "A contract becomes active once every signer has signed. Draft one from a customer's record.",
                )}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Link href="/customers" className={buttonVariants()}>
                {t("Open customers")}
              </Link>
            </EmptyContent>
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Contract")}</TableHead>
                <TableHead className="text-right">{t("Value")}</TableHead>
                <TableHead>{t("Ends")}</TableHead>
                <TableHead>{t("Notice by")}</TableHead>
                <TableHead>{t("Renewal")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {state.data.map((row) => (
                <TableRow key={row.id}>
                  <TableCell className="font-medium">{row.title}</TableCell>
                  <TableCell className="text-right tabular-nums">
                    {row.value
                      ? formatMoney(moneyFromJson(row.value), locale)
                      : "–"}
                  </TableCell>
                  <TableCell className="text-xs text-muted-foreground">
                    {row.endsOn ? day(row.endsOn) : "–"}
                  </TableCell>
                  <TableCell className="text-xs text-muted-foreground">
                    {row.noticeDeadline ? day(row.noticeDeadline) : "–"}
                  </TableCell>
                  <TableCell>{renewal(row)}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}

export function ContractRenewalsSkeleton() {
  return <Skeleton className="h-40 rounded-xl" aria-busy="true" />;
}
```

# Control posture

> One card per compliance framework with its passing, failing and not started controls, then the overdue evidence tasks.

Source: https://bettersupabase.com/docs/registry/ui-recipes/control-posture

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [compliance block](https://bettersupabase.com/blocks/compliance) must be installed (`npx better-supabase add compliance`). Its page on bettersupabase.com is [/recipes/control-posture](https://bettersupabase.com/recipes/control-posture).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty progress skeleton table
```

```tsx title="apps/examples/nextjs/src/features/compliance/components/control-posture.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/**
 * Render inside `<Suspense>`: one card per compliance framework with how many
 * of its controls pass, then the evidence tasks that are past their due date.
 */
export async function ControlPosture() {
  const [state, t, format] = await Promise.all([
    getPosture(),
    getExtracted("compliance"),
    getFormatter(),
  ]);

  if (state.kind === "no-organization") {
    return null;
  }

  if (state.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="control-posture">
        <TriangleAlertIcon />
        <AlertTitle>{t("The control posture didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { frameworks, overdue } = state.data;

  if (frameworks.length === 0) {
    return (
      <Empty className="border" data-recipe="control-posture">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <ShieldCheckIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No frameworks yet")}</EmptyTitle>
          <EmptyDescription>
            {t(
              "Add a framework such as SOC 2 or ISO 27001 with the controls block, then link evidence to each control.",
            )}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <a
            className="text-sm underline underline-offset-4"
            href="https://bettersupabase.com/docs/blocks/compliance"
          >
            {t("Read the controls guide")}
          </a>
        </EmptyContent>
      </Empty>
    );
  }

  const day = (date: string) =>
    format.dateTime(new Date(date), { dateStyle: "medium", timeZone: "UTC" });

  return (
    <div className="flex flex-col gap-6" data-recipe="control-posture">
      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
        {frameworks.map((framework) => {
          const applicable = framework.total - framework.notApplicable;

          const percent =
            applicable === 0
              ? 100
              : Math.round((framework.passing / applicable) * 100);

          return (
            <Card key={framework.frameworkId}>
              <CardHeader>
                <CardTitle>{framework.name}</CardTitle>
                <CardDescription>
                  {t(
                    "{passing, number} of {applicable, number} controls passing",
                    { passing: framework.passing, applicable },
                  )}
                </CardDescription>
              </CardHeader>
              <CardContent className="flex flex-col gap-3">
                <Progress value={percent} aria-label={t("Controls passing")} />
                <div className="flex flex-wrap gap-2">
                  <Badge variant="success-light">
                    {t("{count, number} passing", { count: framework.passing })}
                  </Badge>
                  {framework.failing > 0 ? (
                    <Badge variant="destructive-light">
                      {t("{count, number} failing", {
                        count: framework.failing,
                      })}
                    </Badge>
                  ) : null}
                  {framework.notStarted > 0 ? (
                    <Badge variant="secondary">
                      {t("{count, number} not started", {
                        count: framework.notStarted,
                      })}
                    </Badge>
                  ) : null}
                  {framework.overdueTasks > 0 ? (
                    <Badge variant="warning-light">
                      {t("{count, number} overdue tasks", {
                        count: framework.overdueTasks,
                      })}
                    </Badge>
                  ) : null}
                </div>
              </CardContent>
            </Card>
          );
        })}
      </div>
      <Card>
        <CardHeader>
          <CardTitle>{t("Overdue evidence")}</CardTitle>
          <CardDescription>
            {t(
              "Recurring tasks whose evidence is past due. Collecting evidence moves the task to its next due date.",
            )}
          </CardDescription>
        </CardHeader>
        <CardContent>
          {overdue.length === 0 ? (
            <p className="text-sm text-muted-foreground">
              {t("Every evidence task is on schedule.")}
            </p>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Task")}</TableHead>
                  <TableHead>{t("Control")}</TableHead>
                  <TableHead>{t("Due")}</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {overdue.map((task) => (
                  <TableRow key={task.id}>
                    <TableCell className="font-medium">{task.title}</TableCell>
                    <TableCell className="font-mono text-xs">
                      {task.controlCode ?? "–"}
                    </TableCell>
                    <TableCell>
                      <Badge variant="destructive-light">
                        {day(task.dueOn)}
                      </Badge>
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

export function ControlPostureSkeleton() {
  return (
    <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3" aria-busy="true">
      <Skeleton className="h-36 rounded-xl" />
      <Skeleton className="h-36 rounded-xl" />
      <Skeleton className="h-36 rounded-xl" />
    </div>
  );
}
```

# Course outline

> A course's modules and lessons in rank order, with the caller's progress, certificate and an Enroll button.

Source: https://bettersupabase.com/docs/registry/ui-recipes/course-outline

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [courses block](https://bettersupabase.com/blocks/courses) must be installed (`npx better-supabase add courses`). Its page on bettersupabase.com is [/recipes/course-outline](https://bettersupabase.com/recipes/course-outline).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty progress skeleton
```

```tsx title="apps/examples/nextjs/src/features/courses/components/course-outline.tsx"
import type {
  CourseLessonKind,
  CourseStatus,
} from "better-supabase/blocks/courses";
import type { LucideIcon } from "lucide-react";

import {
  AwardIcon,
  CircleCheckIcon,
  CircleIcon,
  FileTextIcon,
  ListChecksIcon,
  PlayCircleIcon,
  TriangleAlertIcon,
} from "lucide-react";
import { getExtracted } from "next-intl/server";
import { refresh } from "next/cache";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "@/i18n/navigation";

import { getOutline } from "../courses-queries";
import { EnrollButton } from "./course-controls";

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

const statusVariants = {
  draft: "secondary",
  published: "success-light",
  archived: "outline",
} as const satisfies Record<CourseStatus, string>;

const kindIcons = {
  content: FileTextIcon,
  video: PlayCircleIcon,
  quiz: ListChecksIcon,
} as const satisfies Record<CourseLessonKind, LucideIcon>;

/**
 * Render inside `<Suspense>`: a course's modules, lessons and the caller's
 * progress.
 */
export async function CourseOutline({
  params,
}: {
  params: Promise<{ courseId: string }>;
}) {
  const { courseId } = await params;

  const [view, t] = await Promise.all([
    getOutline(courseId),
    getExtracted("courses"),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="course-outline">
        <TriangleAlertIcon />
        <AlertTitle>{t("The course didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const course = view.data;
  const learner = course.learner;

  const statusLabels: Record<CourseStatus, string> = {
    draft: t("Draft"),
    published: t("Published"),
    archived: t("Archived"),
  };

  const kindLabels: Record<CourseLessonKind, string> = {
    content: t("Reading"),
    video: t("Video"),
    quiz: t("Quiz"),
  };

  const next = course.modules
    .flatMap((module) => module.lessons)
    .find((lesson) => !lesson.done);

  return (
    <div data-recipe="course-outline" className="flex flex-col gap-6">
      <div className="flex flex-wrap items-start justify-between gap-3">
        <div className="flex flex-col gap-1">
          <div className="flex items-center gap-2">
            <h2 className="text-lg font-semibold">{course.title}</h2>
            <Badge variant={statusVariants[course.status]}>
              {statusLabels[course.status]}
            </Badge>
          </div>
          {course.summary ? (
            <p className="text-sm text-muted-foreground">{course.summary}</p>
          ) : null}
        </div>
        {learner === null ? (
          course.status === "published" ? (
            <EnrollButton courseId={course.id} />
          ) : null
        ) : next === undefined ? null : (
          <Link
            href={`/courses/${course.id}/lessons/${next.id}`}
            className={buttonVariants()}
          >
            <PlayCircleIcon />
            {t("Continue")}
          </Link>
        )}
      </div>

      <div className="grid gap-6 lg:grid-cols-3">
        <div className="flex flex-col gap-4 lg:col-span-2">
          {course.modules.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <ListChecksIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No lessons yet")}</EmptyTitle>
                <EmptyDescription>
                  {t("Lessons show up here once a manager adds them.")}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                <Link
                  href="/courses"
                  className={buttonVariants({ variant: "outline" })}
                >
                  {t("All courses")}
                </Link>
              </EmptyContent>
            </Empty>
          ) : (
            course.modules.map((module) => (
              <Card key={module.id}>
                <CardHeader>
                  <CardTitle>{module.title}</CardTitle>
                  <CardDescription>
                    {t("{count, plural, one {# lesson} other {# lessons}}", {
                      count: module.lessons.length,
                    })}
                  </CardDescription>
                </CardHeader>
                <CardContent>
                  <ul className="divide-y">
                    {module.lessons.map((lesson) => {
                      const KindIcon = kindIcons[lesson.kind];

                      return (
                        <li
                          key={lesson.id}
                          className="flex items-center gap-3 py-2"
                        >
                          {lesson.done ? (
                            <CircleCheckIcon
                              className="size-4 text-success"
                              aria-label={t("Done")}
                            />
                          ) : (
                            <CircleIcon
                              className="size-4 text-muted-foreground"
                              aria-label={t("Not done")}
                            />
                          )}
                          {learner === null ? (
                            <span className="flex-1 text-sm">
                              {lesson.title}
                            </span>
                          ) : (
                            <Link
                              href={`/courses/${course.id}/lessons/${lesson.id}`}
                              className="flex-1 text-sm transition-colors hover:text-primary"
                            >
                              {lesson.title}
                            </Link>
                          )}
                          <span className="flex items-center gap-1 text-xs text-muted-foreground">
                            <KindIcon className="size-3.5" />
                            {kindLabels[lesson.kind]}
                          </span>
                          {lesson.minutes === null ? null : (
                            <span className="text-xs text-muted-foreground">
                              {t("{minutes, number} min", {
                                minutes: lesson.minutes,
                              })}
                            </span>
                          )}
                          {lesson.required ? null : (
                            <Badge variant="outline">{t("Optional")}</Badge>
                          )}
                        </li>
                      );
                    })}
                  </ul>
                </CardContent>
              </Card>
            ))
          )}
        </div>

        <Card>
          <CardHeader>
            <CardTitle>{t("Your progress")}</CardTitle>
            <CardDescription>
              {learner === null
                ? t("Enroll to track your progress.")
                : t("{done, number} of {total, number} lessons done", {
                    done: learner.completed,
                    total: learner.total,
                  })}
            </CardDescription>
          </CardHeader>
          {learner === null ? null : (
            <CardContent className="flex flex-col gap-4">
              <Progress
                value={
                  learner.total === 0
                    ? 0
                    : (learner.completed / learner.total) * 100
                }
                aria-label={t("Your progress")}
              />
              {learner.certificateCode === null ? null : (
                <div className="flex items-center gap-2 text-sm">
                  <AwardIcon className="size-4 text-success" />
                  <span>{t("Certificate")}</span>
                  <code className="text-xs text-muted-foreground">
                    {learner.certificateCode}
                  </code>
                </div>
              )}
            </CardContent>
          )}
        </Card>
      </div>
    </div>
  );
}

export function CourseOutlineSkeleton() {
  return (
    <div className="flex flex-col gap-6" aria-busy="true">
      <Skeleton className="h-8 w-64" />
      <div className="grid gap-6 lg:grid-cols-3">
        <Skeleton className="h-64 rounded-xl lg:col-span-2" />
        <Skeleton className="h-40 rounded-xl" />
      </div>
    </div>
  );
}
```

# CSV import wizard

> A stepper that uploads a CSV, maps its columns, validates the rows and imports them.

Source: https://bettersupabase.com/docs/registry/ui-recipes/csv-import-wizard

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [imports block](https://bettersupabase.com/blocks/imports) must be installed (`npx better-supabase add imports`). Its page on bettersupabase.com is [/recipes/csv-import-wizard](https://bettersupabase.com/recipes/csv-import-wizard).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty field select @reui/stepper table
```

```tsx title="apps/examples/nextjs/src/features/imports/components/csv-import-wizard.tsx"
"use client";

import type { DbError } from "better-supabase";

import { parseCsv, suggestMapping } from "better-supabase/blocks/imports";
import { useAction } from "better-supabase/react";
import {
  ArrowLeftIcon,
  CheckIcon,
  FileSpreadsheetIcon,
  PlayIcon,
  RotateCcwIcon,
  UploadIcon,
} from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useReducer, useRef } from "react";

import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperNav,
  StepperPanel,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/reui/stepper";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldLabel } from "@/components/ui/field";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Link } from "@/i18n/navigation";
import { useErrorMessage } from "@/lib/use-error-message";

import type { ImportOutcome, ImportPreview } from "../imports-actions";
import type { ColumnMapping, CustomerImportField } from "../imports-fields";

import { previewCustomerImport, runCustomerImport } from "../imports-actions";
import { CUSTOMER_IMPORT_FIELDS } from "../imports-fields";

const SKIP = "skip";
const MAX_BYTES = 500_000;

type Choice = CustomerImportField | typeof SKIP;

type Mapping = Readonly<Record<string, Choice>>;

const CHOICES: readonly Choice[] = [...CUSTOMER_IMPORT_FIELDS, SKIP];

interface CsvFile {
  readonly name: string;
  readonly text: string;
  readonly headers: readonly string[];
  readonly rowCount: number;
}

function isField(value: unknown): value is CustomerImportField | typeof SKIP {
  return (
    value === SKIP || CUSTOMER_IMPORT_FIELDS.some((field) => field === value)
  );
}

/** The mapping without skipped columns, as the server actions take it. */
function mappedOnly(mapping: Mapping): ColumnMapping {
  const result: Record<string, CustomerImportField> = {};

  for (const [header, field] of Object.entries(mapping)) {
    if (field !== SKIP) {
      result[header] = field;
    }
  }

  return result;
}

interface WizardState {
  readonly step: number;
  readonly file: CsvFile | null;
  readonly fileError: string | null;
  readonly mapping: Mapping;
  readonly preview: ImportPreview | null;
  readonly outcome: ImportOutcome | null;
  readonly failure: DbError | null;
}

type WizardEvent =
  | { readonly type: "step"; readonly step: number }
  | { readonly type: "reset" }
  | { readonly type: "file-error"; readonly message: string | null }
  | {
      readonly type: "loaded";
      readonly file: CsvFile;
      readonly mapping: Mapping;
    }
  | { readonly type: "map"; readonly header: string; readonly field: Choice }
  | { readonly type: "running" }
  | { readonly type: "previewed"; readonly preview: ImportPreview }
  | { readonly type: "ran"; readonly outcome: ImportOutcome }
  | { readonly type: "failed"; readonly error: DbError };

const INITIAL: WizardState = {
  step: 1,
  file: null,
  fileError: null,
  mapping: {},
  preview: null,
  outcome: null,
  failure: null,
};

function wizard(state: WizardState, event: WizardEvent): WizardState {
  switch (event.type) {
    case "step": {
      return { ...state, step: event.step };
    }

    case "reset": {
      return INITIAL;
    }

    case "file-error": {
      return { ...state, fileError: event.message };
    }

    case "loaded": {
      return { ...INITIAL, step: 2, file: event.file, mapping: event.mapping };
    }

    case "map": {
      return {
        ...state,
        mapping: { ...state.mapping, [event.header]: event.field },
      };
    }

    case "running": {
      return { ...state, failure: null };
    }

    case "previewed": {
      return { ...state, step: 3, preview: event.preview };
    }

    case "ran": {
      return { ...state, step: 4, outcome: event.outcome };
    }

    case "failed": {
      return { ...state, failure: event.error };
    }

    default: {
      const unhandled: never = event;

      return unhandled;
    }
  }
}

/**
 * Imports customers from a CSV file in four steps: upload, map the columns,
 * preview the validated rows, then run the import and show what it created.
 */
export function CsvImportWizard() {
  const t = useExtracted("imports");
  const message = useErrorMessage();
  const inputId = useId();
  const fileInput = useRef<HTMLInputElement>(null);
  const [state, dispatch] = useReducer(wizard, INITIAL);
  const { step, file, fileError, mapping, preview, outcome, failure } = state;
  const previewAction = useAction(previewCustomerImport);
  const runAction = useAction(runCustomerImport);

  const fieldLabels: Record<Choice, string> = {
    name: t("Name"),
    status: t("Status"),
    kvk: t("KvK number"),
    skip: t("Don't import"),
  };

  const fieldItems = CHOICES.map((value) => ({
    value,
    label: fieldLabels[value],
  }));

  const mapped = Object.values(mapping).filter((field) => field !== SKIP);
  const nameMapped = mapped.includes("name");

  const duplicate = mapped.some(
    (field, index) => mapped.indexOf(field) !== index,
  );

  const reached = outcome ? 4 : preview ? 3 : file ? 2 : 1;

  const reset = () => {
    dispatch({ type: "reset" });
  };

  const choose = async (picked: File | undefined) => {
    dispatch({ type: "file-error", message: null });

    if (picked === undefined) {
      return;
    }

    if (picked.size > MAX_BYTES) {
      dispatch({
        type: "file-error",
        message: t("Pick a file smaller than 500 KB."),
      });

      return;
    }

    const text = await picked.text();
    const [headers = [], ...rows] = parseCsv(text);

    if (headers.length === 0 || rows.length === 0) {
      dispatch({
        type: "file-error",
        message: t("The file needs a header row and at least one row."),
      });

      return;
    }

    const suggested = suggestMapping(headers, CUSTOMER_IMPORT_FIELDS);
    const initial: Record<string, Choice> = {};

    for (const header of headers) {
      const field = suggested[header];

      initial[header] = isField(field) ? field : SKIP;
    }

    dispatch({
      type: "loaded",
      file: { name: picked.name, text, headers, rowCount: rows.length },
      mapping: initial,
    });
  };

  const runPreview = async () => {
    if (file === null) {
      return;
    }

    dispatch({ type: "running" });

    const result = await previewAction.run({
      csv: file.text,
      mapping: mappedOnly(mapping),
    });

    if (result.ok) {
      dispatch({ type: "previewed", preview: result.data });
    } else {
      dispatch({ type: "failed", error: result.error });
    }
  };

  const runImport = async () => {
    if (file === null) {
      return;
    }

    dispatch({ type: "running" });

    const result = await runAction.run({
      csv: file.text,
      mapping: mappedOnly(mapping),
    });

    if (result.ok) {
      dispatch({ type: "ran", outcome: result.data });
    } else {
      dispatch({ type: "failed", error: result.error });
    }
  };

  const failureAlert = failure ? (
    <Alert variant="destructive">
      <AlertTitle>{t("That didn't work")}</AlertTitle>
      <AlertDescription className="flex flex-col items-start gap-2">
        {message(failure)}
        <Button
          variant="outline"
          size="sm"
          disabled={previewAction.pending || runAction.pending}
          onClick={() => {
            void (step === 3 ? runImport() : runPreview());
          }}
        >
          <RotateCcwIcon />
          {t("Retry")}
        </Button>
      </AlertDescription>
    </Alert>
  ) : null;

  const steps = [
    { step: 1, title: t("Upload") },
    { step: 2, title: t("Map columns") },
    { step: 3, title: t("Preview") },
    { step: 4, title: t("Results") },
  ];

  return (
    <Card data-recipe="csv-import-wizard">
      <CardHeader>
        <CardTitle>{t("Import customers")}</CardTitle>
        <CardDescription>
          {t(
            "Upload a CSV file with a name column, and optionally status and KvK number.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <Stepper
          value={step}
          onValueChange={(next) => {
            dispatch({ type: "step", step: next });
          }}
          className="flex flex-col gap-6"
        >
          <StepperNav>
            {steps.map((item) => (
              <StepperItem
                key={item.step}
                step={item.step}
                disabled={item.step > reached || outcome !== null}
              >
                <StepperTrigger className="flex items-center gap-2">
                  <StepperIndicator>{item.step}</StepperIndicator>
                  <StepperTitle>{item.title}</StepperTitle>
                </StepperTrigger>
                {item.step < steps.length ? <StepperSeparator /> : null}
              </StepperItem>
            ))}
          </StepperNav>
          <StepperPanel>
            <StepperContent value={1}>
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <FileSpreadsheetIcon />
                  </EmptyMedia>
                  <EmptyTitle>
                    {file ? file.name : t("Choose a CSV file")}
                  </EmptyTitle>
                  <EmptyDescription>
                    {file
                      ? t("{count, plural, =1 {# row} other {# rows}}", {
                          count: file.rowCount,
                        })
                      : t("The first row names the columns.")}
                  </EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <input
                    ref={fileInput}
                    id={inputId}
                    type="file"
                    accept=".csv,text/csv"
                    className="sr-only"
                    onChange={(event) => {
                      void choose(event.target.files?.[0]);
                      event.target.value = "";
                    }}
                  />
                  <Button onClick={() => fileInput.current?.click()}>
                    <UploadIcon />
                    {file ? t("Choose another file") : t("Choose file")}
                  </Button>
                  {fileError ? (
                    <p className="text-sm text-destructive">{fileError}</p>
                  ) : null}
                </EmptyContent>
              </Empty>
            </StepperContent>
            <StepperContent value={2} className="flex flex-col gap-4">
              <div className="grid gap-4 sm:grid-cols-2">
                {file?.headers.map((header) => (
                  <Field key={header}>
                    <FieldLabel htmlFor={`${inputId}-${header}`}>
                      {header}
                    </FieldLabel>
                    <Select
                      value={mapping[header] ?? SKIP}
                      items={fieldItems}
                      onValueChange={(value) => {
                        if (isField(value)) {
                          dispatch({ type: "map", header, field: value });
                        }
                      }}
                    >
                      <SelectTrigger
                        id={`${inputId}-${header}`}
                        className="w-full"
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {fieldItems.map((item) => (
                          <SelectItem key={item.value} value={item.value}>
                            {item.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </Field>
                ))}
              </div>
              {nameMapped ? null : (
                <p className="text-sm text-muted-foreground">
                  {t("Map one column to Name to continue.")}
                </p>
              )}
              {duplicate ? (
                <p className="text-sm text-destructive">
                  {t("Map each customer field to one column only.")}
                </p>
              ) : null}
              {failureAlert}
            </StepperContent>
            <StepperContent value={3} className="flex flex-col gap-4">
              {preview ? (
                <>
                  <div className="flex flex-wrap items-center gap-2">
                    <Badge variant="success-light">
                      {t(
                        "{count, plural, =1 {# valid row} other {# valid rows}}",
                        { count: preview.valid },
                      )}
                    </Badge>
                    {preview.errors.length > 0 ? (
                      <Badge variant="destructive-light">
                        {t(
                          "{count, plural, =1 {# row with errors} other {# rows with errors}}",
                          { count: preview.errors.length },
                        )}
                      </Badge>
                    ) : null}
                  </div>
                  {preview.errors.length > 0 ? (
                    <Alert variant="destructive">
                      <AlertTitle>
                        {t("Fix these rows and upload the file again")}
                      </AlertTitle>
                      <AlertDescription>
                        <ul className="flex flex-col gap-1">
                          {preview.errors.slice(0, 10).map((error) => (
                            <li key={error.row}>
                              {t("Row {row}: {message}", {
                                row: String(error.row),
                                message: error.message,
                              })}
                            </li>
                          ))}
                        </ul>
                      </AlertDescription>
                    </Alert>
                  ) : null}
                  {preview.rows.length > 0 ? (
                    <Table>
                      <TableHeader>
                        <TableRow>
                          <TableHead>{t("Name")}</TableHead>
                          <TableHead>{t("Status")}</TableHead>
                          <TableHead>{t("KvK number")}</TableHead>
                        </TableRow>
                      </TableHeader>
                      <TableBody>
                        {preview.rows.map((row, index) => (
                          // oxlint-disable-next-line react/no-array-index-key, react-doctor/no-array-index-as-key -- preview rows have no unique column and the list never reorders (1 finding)
                          <TableRow key={`${row.name}-${index}`}>
                            <TableCell className="font-medium">
                              {row.name}
                            </TableCell>
                            <TableCell>
                              {row.status === "active"
                                ? t("Active")
                                : t("Lead")}
                            </TableCell>
                            <TableCell className="text-muted-foreground">
                              {row.kvk ?? t("None")}
                            </TableCell>
                          </TableRow>
                        ))}
                      </TableBody>
                    </Table>
                  ) : null}
                  {preview.valid > preview.rows.length ? (
                    <p className="text-xs text-muted-foreground">
                      {t("Showing the first {shown} of {total} rows.", {
                        shown: String(preview.rows.length),
                        total: String(preview.valid),
                      })}
                    </p>
                  ) : null}
                </>
              ) : null}
              {failureAlert}
            </StepperContent>
            <StepperContent value={4}>
              {outcome ? (
                <Alert
                  variant={
                    outcome.status === "completed" ? "success" : "destructive"
                  }
                >
                  <AlertTitle>
                    {outcome.status === "completed"
                      ? t("Import finished")
                      : t("Import failed")}
                  </AlertTitle>
                  <AlertDescription className="flex flex-col items-start gap-2">
                    {outcome.status === "completed"
                      ? t(
                          "Created {imported, plural, =1 {# customer} other {# customers}}. Skipped {skipped, plural, =1 {# row} other {# rows}} whose name already exists.",
                          {
                            imported: outcome.imported,
                            skipped: outcome.skipped,
                          },
                        )
                      : t(
                          "No customers were created. Fix the rows and try again.",
                        )}
                    <Link
                      href="/customers"
                      className={buttonVariants({
                        variant: "outline",
                        size: "sm",
                      })}
                    >
                      {t("View customers")}
                    </Link>
                  </AlertDescription>
                </Alert>
              ) : null}
            </StepperContent>
          </StepperPanel>
        </Stepper>
      </CardContent>
      <CardFooter className="justify-between gap-2">
        {step === 2 || step === 3 ? (
          <Button
            variant="outline"
            onClick={() => {
              dispatch({ type: "step", step: step - 1 });
            }}
          >
            <ArrowLeftIcon />
            {t("Back")}
          </Button>
        ) : (
          <span />
        )}
        {step === 2 ? (
          <Button
            disabled={!nameMapped || duplicate || previewAction.pending}
            onClick={() => {
              void runPreview();
            }}
          >
            <CheckIcon />
            {previewAction.pending ? t("Checking") : t("Check rows")}
          </Button>
        ) : null}
        {step === 3 ? (
          <Button
            disabled={
              preview === null ||
              preview.valid === 0 ||
              preview.errors.length > 0 ||
              runAction.pending
            }
            onClick={() => {
              void runImport();
            }}
          >
            <PlayIcon />
            {runAction.pending ? t("Importing") : t("Import customers")}
          </Button>
        ) : null}
        {step === 4 ? (
          <Button variant="outline" onClick={reset}>
            <RotateCcwIcon />
            {t("Import another file")}
          </Button>
        ) : null}
      </CardFooter>
    </Card>
  );
}
```

# Custom fields editor

> A settings page that adds, orders and removes the custom fields of a record type.

Source: https://bettersupabase.com/docs/registry/ui-recipes/custom-fields-editor

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [custom-fields block](https://bettersupabase.com/blocks/custom-fields) must be installed (`npx better-supabase add custom-fields`). Its page on bettersupabase.com is [/recipes/custom-fields-editor](https://bettersupabase.com/recipes/custom-fields-editor).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/custom-fields/components/custom-fields-editor.tsx"
/**
 * Render inside `<Suspense>`: the custom fields of the record type in
 * `?entity=`, in display order. Managers add, relabel, reorder and delete
 * them.
 */
export async function CustomFieldsEditor({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, params] = await Promise.all([
    getSession(),
    getExtracted("custom-fields"),
    searchParams,
  ]);

  if (!can(session, "custom_fields.read")) {
    forbidden();
  }

  const parsed = v.safeParse(FieldEntitySchema, params["entity"]);
  const entity = parsed.success ? parsed.output : "customer";
  const fields = await getFields(entity);

  if (fields.kind === "no-organization") {
    return null;
  }

  const canManage = can(session, "custom_fields.manage");

  return (
    <Card data-recipe="custom-fields-editor">
      <CardHeader>
        <CardTitle>{t("Custom fields")}</CardTitle>
        <CardDescription>
          {t("Extra fields on your records, shown in this order.")}
        </CardDescription>
        <CardAction>
          <div className="flex items-center gap-2">
            <EntityPicker entity={entity} />
            {canManage && fields.kind === "ok" && fields.data.length > 0 ? (
              <FieldDialog key={entity} entity={entity} />
            ) : null}
          </div>
        </CardAction>
      </CardHeader>
      <CardContent>
        {fields.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The fields didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retryCustomFields}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : fields.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <ListPlusIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No custom fields yet")}</EmptyTitle>
              <EmptyDescription>
                {canManage
                  ? t(
                      "Add a field to record what your team tracks on top of the built-in ones.",
                    )
                  : t("An admin can add fields for this record type.")}
              </EmptyDescription>
            </EmptyHeader>
            {canManage ? (
              <EmptyContent>
                <FieldDialog key={entity} entity={entity} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Label")}</TableHead>
                <TableHead>{t("Key")}</TableHead>
                <TableHead>{t("Type")}</TableHead>
                {canManage ? (
                  <TableHead className="text-right">{t("Actions")}</TableHead>
                ) : null}
              </TableRow>
            </TableHeader>
            <TableBody>
              {fields.data.map((field, index) => (
                <TableRow key={field.id}>
                  <TableCell>
                    <div className="flex items-center gap-2">
                      <span className="font-medium">{field.label}</span>
                      {field.sensitive ? (
                        <Badge variant="warning-light">{t("Sensitive")}</Badge>
                      ) : null}
                    </div>
                  </TableCell>
                  <TableCell className="font-mono text-xs text-muted-foreground">
                    {field.key}
                  </TableCell>
                  <TableCell>
                    <FieldTypeBadge type={field.type} />
                  </TableCell>
                  {canManage ? (
                    <TableCell>
                      <FieldRowControls
                        entity={entity}
                        field={field}
                        first={index === 0}
                        last={index === fields.data.length - 1}
                      />
                    </TableCell>
                  ) : null}
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}
```

# Data export and delete

> Buttons to export the caller's data and to request account deletion, with the request status.

Source: https://bettersupabase.com/docs/registry/ui-recipes/data-export-delete

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [data-lifecycle block](https://bettersupabase.com/blocks/data-lifecycle) must be installed (`npx better-supabase add data-lifecycle`). Its page on bettersupabase.com is [/recipes/data-export-delete](https://bettersupabase.com/recipes/data-export-delete).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/privacy/components/privacy-settings.tsx"
/**
 * Data exports for the caller and, with `organization.export`, for the
 * organization, each with its status and a download. Owners schedule the
 * organization's deletion here; account deletion stays on the security page.
 */
export async function PrivacySettings() {
  const [t, format, result] = await Promise.all([
    getExtracted("privacy"),
    getFormatter(),
    loadSettings(),
  ]);

  if (result.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="data-export-delete">
        <TriangleAlertIcon />
        <AlertTitle>{t("Your data settings could not be loaded")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={refreshPrivacy}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { mine, organization, canDelete, purgeAfter } = result.data;

  const date = (value: string) =>
    format.dateTime(new Date(value), {
      dateStyle: "medium",
      timeStyle: "short",
    });

  const statusLabel = (status: DataExportStatus) => {
    switch (status) {
      case "pending": {
        return t("Queued");
      }

      case "running": {
        return t("Running");
      }

      case "ready": {
        return t("Ready");
      }

      case "failed": {
        return t("Failed");
      }

      default: {
        const unknown: never = status;

        return unknown;
      }
    }
  };

  const exportList = (
    rows: readonly ExportRow[],
    scope: "user" | "organization",
  ) =>
    rows.length === 0 ? (
      <Empty>
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <ArchiveIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No exports yet")}</EmptyTitle>
          <EmptyDescription>
            {scope === "user"
              ? t("Export a copy of everything stored about you.")
              : t("Export a copy of the organization's data.")}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <RequestExportButton scope={scope} />
        </EmptyContent>
      </Empty>
    ) : (
      <ul className="flex flex-col divide-y rounded-lg border">
        {rows.map((row) => (
          <li
            key={row.id}
            className="flex flex-wrap items-center justify-between gap-2 p-3"
          >
            <div className="flex flex-col gap-1">
              <div className="flex items-center gap-2">
                <span className="text-sm font-medium">
                  {date(row.requestedAt)}
                </span>
                <Badge variant={STATUS_VARIANT[row.status]}>
                  {statusLabel(row.status)}
                </Badge>
              </div>
              <span className="text-xs text-muted-foreground">
                {row.status === "failed"
                  ? (row.error ?? t("The export failed. Request a new one."))
                  : row.expiresAt
                    ? t("{count} files, available until {date}", {
                        count: String(row.files),
                        date: date(row.expiresAt),
                      })
                    : t("{count} files", { count: String(row.files) })}
              </span>
            </div>
            {row.status === "ready" ? (
              <DownloadExportButton id={row.id} />
            ) : null}
          </li>
        ))}
      </ul>
    );

  return (
    <div className="flex flex-col gap-6" data-recipe="data-export-delete">
      <Card>
        <CardHeader>
          <CardTitle>{t("Your data")}</CardTitle>
          <CardDescription>
            {t(
              "Download your profile, notifications and everything else stored about you.",
            )}
          </CardDescription>
          {mine.length > 0 ? (
            <CardAction>
              <RequestExportButton scope="user" variant="outline" />
            </CardAction>
          ) : null}
        </CardHeader>
        <CardContent>{exportList(mine, "user")}</CardContent>
      </Card>
      {organization ? (
        <Card>
          <CardHeader>
            <CardTitle>{t("Organization data")}</CardTitle>
            <CardDescription>
              {t("Download every record the organization owns.")}
            </CardDescription>
            {organization.length > 0 ? (
              <CardAction>
                <RequestExportButton scope="organization" variant="outline" />
              </CardAction>
            ) : null}
          </CardHeader>
          <CardContent>{exportList(organization, "organization")}</CardContent>
        </Card>
      ) : null}
      {canDelete || purgeAfter ? (
        <Card className="ring-destructive/30">
          <CardHeader>
            <CardTitle>{t("Delete organization")}</CardTitle>
            <CardDescription>
              {t(
                "Disables the organization now and purges its data after a 30 day grace period.",
              )}
            </CardDescription>
          </CardHeader>
          <CardContent>
            {purgeAfter ? (
              <Alert variant="destructive">
                <TriangleAlertIcon />
                <AlertTitle>{t("Deletion scheduled")}</AlertTitle>
                <AlertDescription>
                  {t("The organization's data is purged on {date}.", {
                    date: date(purgeAfter),
                  })}
                </AlertDescription>
                <AlertAction>
                  <CancelDeletionButton />
                </AlertAction>
              </Alert>
            ) : (
              <DeleteOrganizationButton />
            )}
          </CardContent>
        </Card>
      ) : null}
      <p className="text-sm text-muted-foreground">
        {t("To delete your own account, go to")}{" "}
        <Link
          href="/settings/security"
          className="text-foreground underline underline-offset-4"
        >
          {t("Security")}
        </Link>
      </p>
    </div>
  );
}
```

# Deal board

> Deals as cards in pipeline stage columns, moved by drag and drop.

Source: https://bettersupabase.com/docs/registry/ui-recipes/deal-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [deals](https://bettersupabase.com/blocks/deals), [pipelines](https://bettersupabase.com/blocks/pipelines) blocks must be installed (`npx better-supabase add deals pipelines`). Its page on bettersupabase.com is [/recipes/deal-board](https://bettersupabase.com/recipes/deal-board).

Web, with shadcn components:

```bash
npx shadcn add card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/crm/components/deal-board.tsx"
/**
 * Render inside `<Suspense>`: one column per stage of the default deal
 * pipeline. Dragging a deal saves its new stage through `moveDeal`.
 */
export async function DealBoard() {
  const [session, t] = await Promise.all([getSession(), getExtracted("crm")]);

  if (!can(session, "deals.read")) {
    forbidden();
  }

  const board = await settle(loadDealBoard);

  if (board.kind === "no-organization") {
    return null;
  }

  const pipeline = board.kind === "ok" ? board.data.pipeline : null;

  const openStages =
    board.kind === "ok"
      ? board.data.stages
          .filter((stage) => stage.category === "open")
          .map((stage) => ({ value: stage.id, label: stage.name }))
      : [];

  return (
    <Card data-recipe="deal-board">
      <CardHeader>
        <CardTitle>{pipeline?.name ?? t("Deals")}</CardTitle>
        <CardDescription>
          {t("Drag a deal to another stage to move it.")}
        </CardDescription>
        {pipeline && can(session, "deals.write") ? (
          <CardAction>
            <CreateDealDialog pipelineId={pipeline.id} stages={openStages} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {board.kind === "error" ? (
          <CrmLoadError title={t("The board didn't load")} />
        ) : board.data.pipeline === null ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <KanbanIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No pipeline yet")}</EmptyTitle>
              <EmptyDescription>
                {can(session, "pipelines.manage")
                  ? t(
                      "Start with six stages, from qualified to closed won or lost.",
                    )
                  : t("Ask an admin to create the sales pipeline.")}
              </EmptyDescription>
            </EmptyHeader>
            {can(session, "pipelines.manage") ? (
              <EmptyContent>
                <CreatePipelineButton />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <DealKanban
            key={board.data.deals
              .map((deal) => `${deal.id}:${deal.stageId}`)
              .join(",")}
            stages={board.data.stages}
            deals={board.data.deals}
            canMove={can(session, "deals.write")}
          />
        )}
      </CardContent>
    </Card>
  );
}
```

# Dispatch board

> A week of technicians by day with their scheduled work orders, and the backlog to schedule.

Source: https://bettersupabase.com/docs/registry/ui-recipes/dispatch-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [dispatch block](https://bettersupabase.com/blocks/dispatch) must be installed (`npx better-supabase add dispatch`). Its page on bettersupabase.com is [/recipes/dispatch-board](https://bettersupabase.com/recipes/dispatch-board).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input skeleton table
```

```tsx title="apps/examples/nextjs/src/features/field/components/dispatch-board.tsx"
/**
 * Render inside `<Suspense>`: a week of technicians by day with their scheduled
 * work orders, and the unscheduled backlog below. `?week=YYYY-MM-DD` picks the
 * week.
 */
export async function DispatchBoard({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, format, query] = await Promise.all([
    getSession(),
    getExtracted("field"),
    getFormatter(),
    searchParams,
    connection(),
  ]);

  if (!can(session, "dispatch.read")) {
    forbidden();
  }

  const manage = can(session, "dispatch.manage");
  const monday = mondayOf(query["week"]);
  const board = await settle(() => loadDispatchWeek(monday.toString()));

  if (board.kind === "no-organization") {
    return null;
  }

  const dayLabel = (date: string) =>
    format.dateTime(new Date(`${date}T12:00:00Z`), {
      weekday: "short",
      day: "numeric",
      timeZone: "UTC",
    });

  const range = format.dateTimeRange(
    new Date(`${monday.toString()}T12:00:00Z`),
    new Date(`${monday.add({ days: 6 }).toString()}T12:00:00Z`),
    { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" },
  );

  const newWorkOrder = manage ? <NewWorkOrderDialog /> : null;

  return (
    <div data-recipe="dispatch-board" className="flex flex-col gap-6">
      <Card>
        <CardHeader>
          <CardTitle>{t("Week of {range}", { range })}</CardTitle>
          <CardDescription>
            {board.kind === "ok"
              ? t("Scheduled work per technician, in {zone}.", {
                  zone: board.data.timeZone,
                })
              : t("Scheduled work per technician.")}
          </CardDescription>
          <CardAction className="flex items-center gap-2">
            <Link
              href={`/field/dispatch?week=${monday.subtract({ weeks: 1 }).toString()}`}
              className={buttonVariants({
                variant: "outline",
                size: "icon-sm",
              })}
              aria-label={t("Previous week")}
            >
              <ChevronLeftIcon />
            </Link>
            <Link
              href={`/field/dispatch?week=${monday.add({ weeks: 1 }).toString()}`}
              className={buttonVariants({
                variant: "outline",
                size: "icon-sm",
              })}
              aria-label={t("Next week")}
            >
              <ChevronRightIcon />
            </Link>
          </CardAction>
        </CardHeader>
        <CardContent>
          {board.kind === "error" ? (
            <FieldLoadError title={t("The schedule board didn't load")} />
          ) : board.data.rows.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <CalendarRangeIcon />
                </EmptyMedia>
                <EmptyTitle>{t("Nothing scheduled this week")}</EmptyTitle>
                <EmptyDescription>
                  {t("Work orders show here once they have a technician.")}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                <Link
                  href={`/field/dispatch?week=${monday.add({ weeks: 1 }).toString()}`}
                  className={buttonVariants({ variant: "outline" })}
                >
                  {t("Show next week")}
                </Link>
              </EmptyContent>
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Technician")}</TableHead>
                  {board.data.days.map((day) => (
                    <TableHead key={day}>{dayLabel(day)}</TableHead>
                  ))}
                </TableRow>
              </TableHeader>
              <TableBody>
                {board.data.rows.map((row) => (
                  <TableRow key={row.technicianId}>
                    <TableCell className="align-top text-sm font-medium">
                      {row.name}
                    </TableCell>
                    {row.days.map(({ date, orders }) => (
                      <TableCell key={date} className="align-top">
                        <div className="flex flex-col gap-2">
                          {orders.map((order) => (
                            <div key={order.id} className="flex flex-col gap-1">
                              <span className="text-sm font-medium">
                                {order.title}
                              </span>
                              <span className="text-xs text-muted-foreground tabular-nums">
                                {order.startsAt
                                  ? format.dateTime(new Date(order.startsAt), {
                                      timeStyle: "short",
                                      timeZone: board.data.timeZone,
                                    })
                                  : null}
                              </span>
                              <WorkOrderStatusBadge status={order.status} />
                            </div>
                          ))}
                        </div>
                      </TableCell>
                    ))}
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
      <Card>
        <CardHeader>
          <CardTitle>{t("Backlog")}</CardTitle>
          <CardDescription>
            {t("Work orders without a slot, most urgent first.")}
          </CardDescription>
          {newWorkOrder ? <CardAction>{newWorkOrder}</CardAction> : null}
        </CardHeader>
        <CardContent>
          {board.kind === "error" ? (
            <FieldLoadError title={t("The backlog didn't load")} />
          ) : board.data.backlog.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <InboxIcon />
                </EmptyMedia>
                <EmptyTitle>{t("The backlog is empty")}</EmptyTitle>
                <EmptyDescription>
                  {t("Every open work order has a slot.")}
                </EmptyDescription>
              </EmptyHeader>
              {newWorkOrder ? (
                <EmptyContent>{newWorkOrder}</EmptyContent>
              ) : null}
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Job")}</TableHead>
                  <TableHead>{t("Address")}</TableHead>
                  <TableHead>{t("Priority")}</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {board.data.backlog.map((order) => (
                  <TableRow key={order.id}>
                    <TableCell className="text-sm font-medium">
                      {order.title}
                    </TableCell>
                    <TableCell className="text-sm text-muted-foreground">
                      {order.address ?? ""}
                    </TableCell>
                    <TableCell>
                      <WorkOrderPriorityBadge priority={order.priority} />
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
    </div>
  );
}
```

# Document editor

> A rich text editor for a document with autosave, collaborators and a suggestion mode that editors accept or reject.

Source: https://bettersupabase.com/docs/registry/ui-recipes/document-editor

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [documents block](https://bettersupabase.com/blocks/documents) must be installed (`npx better-supabase add documents`). Its page on bettersupabase.com is [/recipes/document-editor](https://bettersupabase.com/recipes/document-editor).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty field input skeleton tabs textarea
```

```tsx title="apps/examples/nextjs/src/features/documents/components/document-editor.tsx"
"use client";

import type {
  DocumentSuggestion,
  Documents,
} from "better-supabase/blocks/documents";
import type {
  DocumentProviderMode,
  DocumentProviderStatus,
} from "better-supabase/yjs";

import { rpcTransport } from "better-supabase/blocks";
import { createDocuments } from "better-supabase/blocks/documents";
import { createDocumentProvider } from "better-supabase/yjs";
import { MessageSquareDiffIcon, TriangleAlertIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import * as Y from "yjs";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useSupabase } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";

const DOCUMENT_NAME = "notes";
const LOCAL = Symbol("local edit");

/**
 * Replaces the text with `next` by deleting and inserting only the changed
 * middle, so an edit merges with another editor's edit elsewhere in it.
 */
function replaceText(text: Y.Text, next: string) {
  const current = text.toString();

  if (current === next) {
    return;
  }

  let start = 0;

  while (
    start < current.length &&
    start < next.length &&
    current[start] === next[start]
  ) {
    start += 1;
  }

  let end = 0;

  while (
    end < current.length - start &&
    end < next.length - start &&
    current[current.length - 1 - end] === next[next.length - 1 - end]
  ) {
    end += 1;
  }

  text.doc?.transact(() => {
    text.delete(start, current.length - start - end);
    text.insert(start, next.slice(start, next.length - end));
  }, LOCAL);
}

interface Editing {
  readonly title: Y.Text;
  readonly body: Y.Text;
}

interface DocumentTarget {
  readonly organizationId: string;
  readonly customerId: string;
  readonly customerName: string;
  readonly canWrite: boolean;
  readonly canSuggest: boolean;
}

type SuggestionsState =
  | { readonly kind: "loading" }
  | { readonly kind: "error" }
  | { readonly kind: "ok"; readonly items: readonly DocumentSuggestion[] };

function isMode(value: unknown): value is DocumentProviderMode {
  return value === "edit" || value === "suggest";
}

/**
 * The customer's notes as a Yjs document: a title and a body. Edits are stored
 * as updates through the documents block and reach the other editors over the
 * document's realtime topic.
 */
export function DocumentEditor(target: DocumentTarget) {
  const [attempt, setAttempt] = useState(0);

  return (
    <DocumentSession
      key={attempt}
      {...target}
      onRetry={() => {
        setAttempt((current) => current + 1);
      }}
    />
  );
}

/** One connection to the document; Retry remounts it with fresh state. */
function DocumentSession({
  organizationId,
  customerId,
  customerName,
  canWrite,
  canSuggest,
  onRetry,
}: DocumentTarget & { readonly onRetry: () => void }) {
  const t = useExtracted("documents");
  const fieldId = useId();
  const supabase = useSupabase();
  const editing = useRef<Editing | null>(null);

  const session = useRef<{
    readonly documents: Documents;
    readonly provider: ReturnType<typeof createDocumentProvider>;
  } | null>(null);

  const [status, setStatus] = useState<DocumentProviderStatus>("connecting");
  const [failed, setFailed] = useState(false);
  const [writable, setWritable] = useState(false);
  const [suggestable, setSuggestable] = useState(false);
  const [documentId, setDocumentId] = useState<string | null>(null);

  const [mode, setMode] = useState<DocumentProviderMode>(
    canWrite ? "edit" : "suggest",
  );

  const [hasDraft, setHasDraft] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  const [suggestions, setSuggestions] = useState<SuggestionsState>({
    kind: "loading",
  });

  useEffect(() => {
    const doc = new Y.Doc();
    const texts = { title: doc.getText("title"), body: doc.getText("body") };

    const sync = () => {
      setTitle(texts.title.toString());
      setBody(texts.body.toString());
    };

    const documents = createDocuments({
      transport: rpcTransport(supabase),
      schema: blockConfig.documents.api,
    });

    const provider = createDocumentProvider({
      documents,
      client: supabase,
      doc,
      yjs: Y,
      ref: {
        organizationId,
        subjectType: "customer",
        subjectId: customerId,
        name: DOCUMENT_NAME,
      },
      mode: canWrite ? "edit" : "suggest",
      onStatus: setStatus,
    });

    texts.title.observe(sync);
    texts.body.observe(sync);
    editing.current = texts;
    session.current = { documents, provider };

    void provider.connect().then((opened) => {
      if (opened.ok) {
        setWritable(opened.data.canWrite);
        setSuggestable(opened.data.canSuggest);
        setDocumentId(opened.data.id);
        sync();
      } else {
        setFailed(true);
      }
    });

    return () => {
      texts.title.unobserve(sync);
      texts.body.unobserve(sync);
      editing.current = null;
      session.current = null;
      void provider.destroy();
      doc.destroy();
    };
  }, [supabase, organizationId, customerId, canWrite]);

  const loadSuggestions = useCallback(async () => {
    if (documentId === null || session.current === null) {
      return;
    }

    setSuggestions({ kind: "loading" });
    const listed = await session.current.documents.suggestions(documentId);

    setSuggestions(
      listed.ok ? { kind: "ok", items: listed.data } : { kind: "error" },
    );
  }, [documentId]);

  useEffect(() => {
    void loadSuggestions();
  }, [loadSuggestions]);

  const statusLabels: Record<DocumentProviderStatus, string> = {
    connecting: t("Connecting"),
    synced: t("Saved"),
    offline: t("Offline"),
    closed: t("Closed"),
  };

  const editable = canWrite && writable;
  const suggesting = mode === "suggest" && canSuggest && suggestable;
  const readOnly = !(suggesting || (mode === "edit" && editable));

  const changeMode = (next: DocumentProviderMode) => {
    setMode(next);
    session.current?.provider.setMode(next);
  };

  const edit = (text: "title" | "body", value: string) => {
    if (editing.current) {
      replaceText(editing.current[text], value);
    }

    setHasDraft(session.current?.provider.hasDraft ?? false);
  };

  const submit = async () => {
    if (session.current === null) {
      return;
    }

    setSubmitting(true);
    const stored = await session.current.provider.submitSuggestion();
    setSubmitting(false);

    if (stored.ok) {
      setHasDraft(false);
      await loadSuggestions();
    }
  };

  if (failed) {
    return (
      <Alert variant="destructive" data-recipe="document-editor">
        <TriangleAlertIcon />
        <AlertTitle>{t("The document didn't open")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <Button size="sm" variant="outline" onClick={onRetry}>
            {t("Retry")}
          </Button>
        </AlertAction>
      </Alert>
    );
  }

  return (
    <Card data-recipe="document-editor">
      <CardHeader>
        <div className="flex items-center justify-between gap-2">
          <CardTitle>{t("Notes for {name}", { name: customerName })}</CardTitle>
          <Badge variant={statusVariant(status)}>{statusLabels[status]}</Badge>
        </div>
        <CardDescription>
          {readOnly
            ? t("You can read this document. Ask an admin for edit access.")
            : suggesting
              ? t("Your edits become a suggestion that an editor accepts.")
              : t("Changes save as you type and show up for everyone editing.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-6">
        {editable && canSuggest ? (
          <Tabs
            value={mode}
            onValueChange={(value) => {
              if (isMode(value)) {
                changeMode(value);
              }
            }}
          >
            <TabsList>
              <TabsTrigger value="edit">{t("Editing")}</TabsTrigger>
              <TabsTrigger value="suggest">{t("Suggesting")}</TabsTrigger>
            </TabsList>
          </Tabs>
        ) : null}
        {status === "connecting" && title === "" && body === "" ? (
          <div className="flex flex-col gap-4" aria-busy="true">
            <Skeleton className="h-9 w-full" />
            <Skeleton className="h-64 w-full" />
          </div>
        ) : (
          <div className="flex flex-col gap-4">
            <Field>
              <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
              <Input
                id={`${fieldId}-title`}
                value={title}
                readOnly={readOnly}
                maxLength={200}
                placeholder={t("Untitled")}
                onChange={(event) => {
                  setTitle(event.target.value);
                  edit("title", event.target.value);
                }}
              />
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-body`}>{t("Body")}</FieldLabel>
              <Textarea
                id={`${fieldId}-body`}
                value={body}
                readOnly={readOnly}
                rows={16}
                placeholder={t("Write the notes here.")}
                onChange={(event) => {
                  setBody(event.target.value);
                  edit("body", event.target.value);
                }}
              />
              <FieldDescription>
                {t(
                  "Plain text. Everyone who can read the customer can read it.",
                )}
              </FieldDescription>
            </Field>
            {suggesting ? (
              <div className="flex items-center justify-end gap-2">
                <Button
                  size="sm"
                  disabled={!hasDraft || submitting}
                  onClick={() => {
                    void submit();
                  }}
                >
                  {t("Submit suggestion")}
                </Button>
              </div>
            ) : null}
          </div>
        )}
        <SuggestionList
          state={suggestions}
          canDecide={editable}
          canSuggest={canSuggest && suggestable}
          onRetry={() => {
            void loadSuggestions();
          }}
          onSuggest={() => {
            changeMode("suggest");
          }}
          onDecide={async (suggestion, decision) => {
            if (session.current === null) {
              return;
            }

            const decided =
              decision === "accepted"
                ? await session.current.provider.acceptSuggestion(suggestion.id)
                : await session.current.documents.decideSuggestion(
                    suggestion.id,
                    "rejected",
                  );

            if (decided.ok) {
              await loadSuggestions();
            }
          }}
        />
      </CardContent>
    </Card>
  );
}

interface SuggestionListProps {
  readonly state: SuggestionsState;
  readonly canDecide: boolean;
  readonly canSuggest: boolean;
  readonly onRetry: () => void;
  readonly onSuggest: () => void;
  readonly onDecide: (
    suggestion: DocumentSuggestion,
    decision: "accepted" | "rejected",
  ) => Promise<void>;
}

/** The open suggestions; editors accept them into the document or reject them. */
function SuggestionList({
  state,
  canDecide,
  canSuggest,
  onRetry,
  onSuggest,
  onDecide,
}: SuggestionListProps) {
  const t = useExtracted("documents");
  const format = useFormatter();
  const [busy, setBusy] = useState<string | null>(null);

  if (state.kind === "loading") {
    return (
      <div className="flex flex-col gap-2" aria-busy="true">
        <Skeleton className="h-5 w-32" />
        <Skeleton className="h-14 w-full" />
      </div>
    );
  }

  if (state.kind === "error") {
    return (
      <Alert variant="destructive">
        <TriangleAlertIcon />
        <AlertTitle>{t("The suggestions didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <Button size="sm" variant="outline" onClick={onRetry}>
            {t("Retry")}
          </Button>
        </AlertAction>
      </Alert>
    );
  }

  if (state.items.length === 0) {
    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <MessageSquareDiffIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No open suggestions")}</EmptyTitle>
          <EmptyDescription>
            {t("Suggested edits wait here until an editor accepts them.")}
          </EmptyDescription>
        </EmptyHeader>
        {canSuggest ? (
          <EmptyContent>
            <Button size="sm" variant="outline" onClick={onSuggest}>
              {t("Suggest a change")}
            </Button>
          </EmptyContent>
        ) : null}
      </Empty>
    );
  }

  return (
    <section className="flex flex-col gap-2">
      <h3 className="text-sm font-medium tracking-tight">{t("Suggestions")}</h3>
      <ul className="flex flex-col divide-y">
        {state.items.map((suggestion) => (
          <li
            key={suggestion.id}
            className="flex items-center justify-between gap-4 py-3"
          >
            <div className="flex flex-col gap-0.5">
              <span className="text-sm">
                {suggestion.summary ?? t("Suggested edit")}
              </span>
              <span className="text-xs text-muted-foreground">
                {format.relativeTime(
                  new Date(suggestion.createdAt.epochMilliseconds),
                )}
              </span>
            </div>
            {canDecide ? (
              <div className="flex items-center gap-2">
                <Button
                  size="sm"
                  variant="outline"
                  disabled={busy !== null}
                  onClick={() => {
                    setBusy(suggestion.id);
                    void onDecide(suggestion, "rejected").finally(() => {
                      setBusy(null);
                    });
                  }}
                >
                  {t("Reject")}
                </Button>
                <Button
                  size="sm"
                  disabled={busy !== null}
                  onClick={() => {
                    setBusy(suggestion.id);
                    void onDecide(suggestion, "accepted").finally(() => {
                      setBusy(null);
                    });
                  }}
                >
                  {t("Accept")}
                </Button>
              </div>
            ) : null}
          </li>
        ))}
      </ul>
    </section>
  );
}

function statusVariant(
  status: DocumentProviderStatus,
): "success-light" | "warning-light" | "secondary" | "info-light" {
  switch (status) {
    case "synced": {
      return "success-light";
    }

    case "offline": {
      return "warning-light";
    }

    case "connecting": {
      return "info-light";
    }

    case "closed": {
      return "secondary";
    }

    default: {
      const unhandled: never = status;

      return unhandled;
    }
  }
}
```

# Drive explorer

> Folders and files with upload, rename, move and a breadcrumb trail, each file with its labels and the approval of its current version.

Source: https://bettersupabase.com/docs/registry/ui-recipes/drive-explorer

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [drive block](https://bettersupabase.com/blocks/drive) must be installed (`npx better-supabase add drive`). Its page on bettersupabase.com is [/recipes/drive-explorer](https://bettersupabase.com/recipes/drive-explorer).

Web, with shadcn components:

```bash
npx shadcn add alert badge breadcrumb button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/drive/components/drive-explorer.tsx"
import {
  FileIcon,
  FolderIcon,
  FolderOpenIcon,
  TriangleAlertIcon,
} from "lucide-react";
import { getExtracted, getFormatter } from "next-intl/server";
import { refresh } from "next/cache";
import { Fragment } from "react";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Link } from "@/i18n/navigation";

import type { DriveItem } from "../drive-queries";

import { getDriveFolder } from "../drive-queries";
import { DriveItemActions } from "./drive-item-actions";
import { CreateFolderDialog, UploadFileButton } from "./drive-toolbar";

type Approval = NonNullable<DriveItem["approval"]>;

const APPROVAL_BADGE: Record<
  Approval,
  ComponentProps<typeof Badge>["variant"]
> = {
  pending: "warning-light",
  approved: "success-light",
  rejected: "destructive-light",
  cancelled: "secondary",
  expired: "secondary",
};

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: one folder of the drive (the root for `null`). */
export async function DriveExplorer({
  folderId,
}: {
  folderId: Promise<string | null>;
}) {
  const [view, t, format] = await Promise.all([
    folderId.then(getDriveFolder),
    getExtracted("drive"),
    getFormatter(),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="drive-explorer">
        <TriangleAlertIcon />
        <AlertTitle>{t("The drive didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const current = view.crumbs.at(-1);

  const approvalLabels: Record<Approval, string> = {
    pending: t("Awaiting approval"),
    approved: t("Approved"),
    rejected: t("Rejected"),
    cancelled: t("Approval cancelled"),
    expired: t("Approval expired"),
  };

  return (
    <Card data-recipe="drive-explorer">
      <CardHeader className="flex flex-wrap items-center justify-between gap-3">
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem>
              {current === undefined ? (
                <BreadcrumbPage>{t("My drive")}</BreadcrumbPage>
              ) : (
                <BreadcrumbLink render={<Link href="/drive" />}>
                  {t("My drive")}
                </BreadcrumbLink>
              )}
            </BreadcrumbItem>
            {view.crumbs.map((crumb) => (
              <Fragment key={crumb.id}>
                <BreadcrumbSeparator />
                <BreadcrumbItem>
                  {crumb.id === current?.id ? (
                    <BreadcrumbPage>{crumb.name}</BreadcrumbPage>
                  ) : (
                    <BreadcrumbLink
                      render={<Link href={`/drive/${crumb.id}`} />}
                    >
                      {crumb.name}
                    </BreadcrumbLink>
                  )}
                </BreadcrumbItem>
              </Fragment>
            ))}
          </BreadcrumbList>
        </Breadcrumb>
        <div className="flex gap-2">
          <CreateFolderDialog parentId={view.folderId} />
          <UploadFileButton parentId={view.folderId} />
        </div>
      </CardHeader>
      <CardContent>
        {view.items.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <FolderOpenIcon />
              </EmptyMedia>
              <EmptyTitle>{t("This folder is empty")}</EmptyTitle>
              <EmptyDescription>
                {t("Create a folder or upload a file to get started.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <UploadFileButton parentId={view.folderId} />
            </EmptyContent>
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Name")}</TableHead>
                <TableHead>{t("Size")}</TableHead>
                <TableHead>{t("Added")}</TableHead>
                <TableHead className="w-12">
                  <span className="sr-only">{t("Actions")}</span>
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {view.items.map((item) => (
                <TableRow key={item.id}>
                  <TableCell className="font-medium">
                    <div className="flex flex-col gap-1">
                      {item.kind === "folder" ? (
                        <Link
                          href={`/drive/${item.id}`}
                          className="flex items-center gap-2 transition-colors hover:text-primary"
                        >
                          <FolderIcon className="size-4 text-muted-foreground" />
                          {item.name}
                        </Link>
                      ) : (
                        <span className="flex flex-wrap items-center gap-2">
                          <FileIcon className="size-4 text-muted-foreground" />
                          {item.name}
                          {item.approval === null ? null : (
                            <Badge variant={APPROVAL_BADGE[item.approval]}>
                              {approvalLabels[item.approval]}
                            </Badge>
                          )}
                        </span>
                      )}
                      {item.labels.length === 0 ? null : (
                        <span className="flex flex-wrap gap-1">
                          {item.labels.map((label) => (
                            <Badge key={label.id} variant="outline">
                              {label.name}
                            </Badge>
                          ))}
                        </span>
                      )}
                    </div>
                  </TableCell>
                  <TableCell className="text-muted-foreground">
                    {item.kind === "folder"
                      ? t("Folder")
                      : item.size === null
                        ? t("Uploading")
                        : t("{size} KB", {
                            size: format.number(item.size / 1024, {
                              maximumFractionDigits: 1,
                            }),
                          })}
                  </TableCell>
                  <TableCell className="text-muted-foreground">
                    {format.dateTime(new Date(item.createdAt), {
                      dateStyle: "medium",
                    })}
                  </TableCell>
                  <TableCell>
                    <DriveItemActions
                      id={item.id}
                      kind={item.kind}
                      name={item.name}
                    />
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}

export function DriveExplorerSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

# Employee directory

> A searchable list of employees with an org chart of who reports to whom.

Source: https://bettersupabase.com/docs/registry/ui-recipes/employee-directory

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [employees block](https://bettersupabase.com/blocks/employees) must be installed (`npx better-supabase add employees`). Its page on bettersupabase.com is [/recipes/employee-directory](https://bettersupabase.com/recipes/employee-directory).

Web, with shadcn components:

```bash
npx shadcn add card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/people/components/employee-directory.tsx"
/**
 * Render inside `<Suspense>`: the employees as an org chart, each manager above
 * their reports, with a search. Private details open in a sheet for callers
 * with `employees.view_sensitive`.
 */
export async function EmployeeDirectory() {
  const [session, t] = await Promise.all([
    getSession(),
    getExtracted("people"),
  ]);

  if (!can(session, "employees.read")) {
    forbidden();
  }

  const directory = await settle(loadDirectory);

  if (directory.kind === "no-organization") {
    return null;
  }

  const canWrite = can(session, "employees.write");
  const canViewSensitive = can(session, "employees.view_sensitive");

  const managers =
    directory.kind === "ok"
      ? directory.data.map((employee) => ({
          value: employee.id,
          label: employee.name,
        }))
      : [];

  const addEmployee = canWrite ? (
    <AddEmployeeDialog
      managers={managers}
      canViewSensitive={canViewSensitive}
    />
  ) : null;

  return (
    <Card data-recipe="employee-directory">
      <CardHeader>
        <CardTitle>{t("Employees")}</CardTitle>
        <CardDescription>
          {t("Everyone in the organization and who they report to.")}
        </CardDescription>
        {addEmployee && managers.length > 0 ? (
          <CardAction>{addEmployee}</CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {directory.kind === "error" ? (
          <PeopleLoadError title={t("The directory didn't load")} />
        ) : directory.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <UsersIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No employees yet")}</EmptyTitle>
              <EmptyDescription>
                {canWrite
                  ? t("Add the first employee to start the org chart.")
                  : t("Ask an admin to add employees.")}
              </EmptyDescription>
            </EmptyHeader>
            {addEmployee ? <EmptyContent>{addEmployee}</EmptyContent> : null}
          </Empty>
        ) : (
          <EmployeeTable
            employees={directory.data}
            canViewSensitive={canViewSensitive}
          />
        )}
      </CardContent>
    </Card>
  );
}
```

# Entity form

> A create dialog whose inputs come from `recordForm`: the entity's own fields and the organization's custom fields, validated together by `useRecordForm` before the Server Action runs.

Source: https://bettersupabase.com/docs/registry/ui-recipes/entity-form

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useRecordForm` from `better-supabase/react`; the [tasks](https://bettersupabase.com/blocks/tasks), [custom-fields](https://bettersupabase.com/blocks/custom-fields) blocks must be installed (`npx better-supabase add tasks custom-fields`). Its page on bettersupabase.com is [/recipes/entity-form](https://bettersupabase.com/recipes/entity-form).

Web, with shadcn components:

```bash
npx shadcn add button checkbox dialog field input select
```

```tsx title="apps/examples/nextjs/src/features/work/components/create-work-item-dialog.tsx"
import type { DbError } from "better-supabase";
import type { RecordFormInput } from "better-supabase/records";

import { workItemInputSchema } from "better-supabase/blocks/tasks";
import { useRecordForm } from "better-supabase/react";
import { recordForm } from "better-supabase/records";
import { PlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useMemo, useState } from "react";
import { toast } from "sonner";

import type { FieldRow } from "@/features/custom-fields/custom-fields-types";

import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useErrorMessage } from "@/lib/use-error-message";

import type { TeamOption } from "../work-types";

import { usePriorityLabel } from "../use-work-labels";
import { createWorkItem } from "../work-actions";

const NO_TEAM = "";

/** The HTML input type for each form input; the rest are text inputs. */
const INPUT_TYPES: Partial<Record<RecordFormInput, string>> = {
  number: "number",
  integer: "number",
  date: "date",
  datetime: "datetime-local",
  email: "email",
  url: "url",
};

/**
 * A dialog that creates a work item with a title, a team, a priority and the
 * organization's custom fields for work items. `recordForm` turns the entity
 * schema and the field definitions into one list of inputs and validates them
 * together.
 */
export function CreateWorkItemDialog({
  teams,
  defaultTeamId,
  customFields,
}: {
  teams: readonly TeamOption[];
  defaultTeamId: string | null;
  customFields: readonly FieldRow[];
}) {
  const t = useExtracted("work");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const priorityLabel = usePriorityLabel();
  const [open, setOpen] = useState(false);
  const [teamId, setTeamId] = useState(defaultTeamId ?? NO_TEAM);

  const definition = useMemo(
    () =>
      recordForm(workItemInputSchema, {
        fields: ["title", "priority"],
        customFields: customFields.map((field) => ({
          key: field.key,
          label: field.label,
          type: field.type,
          required: false,
          sensitive: field.sensitive,
        })),
      }),
    [customFields],
  );

  const [failure, setFailure] = useState<DbError>();

  const form = useRecordForm(definition, {
    initial: { title: "", priority: "0" },
    onSubmit: async ({ input, custom }) => {
      const result = await createWorkItem({
        title: input.title,
        teamId,
        priority: input.priority ?? 0,
        custom: Object.fromEntries(
          Object.entries(custom).map(([key, value]) => [key, String(value)]),
        ),
      });

      if (!result.ok) {
        setFailure(result.error);

        return;
      }

      toast.success(t("Created {identifier}", { identifier: result.data }));
      setOpen(false);
      form.reset();
    },
  });

  const error = failure === undefined ? null : errorMessage(failure);
  const textOf = (key: string) => String(form.values[key] ?? "");

  const teamItems = [
    { value: NO_TEAM, label: t("No team") },
    ...teams.map((team) => ({ value: team.id, label: team.name })),
  ];

  const priorityItems = ["0", "1", "2", "3", "4"].map((value) => ({
    value,
    label: priorityLabel(Number(value)),
  }));

  const custom = definition.fields.filter((field) => field.source === "custom");

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (!next) {
          form.reset();
          setFailure(undefined);
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <PlusIcon />
        {t("New item")}
      </DialogTrigger>
      <DialogContent>
        <form
          onSubmit={(event) => {
            void form.submit(event);
          }}
          className="grid gap-6"
        >
          <DialogHeader>
            <DialogTitle>{t("New work item")}</DialogTitle>
            <DialogDescription>
              {t("It starts in the first stage of your workflow.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field
              data-invalid={error || form.errors["title"] ? true : undefined}
            >
              <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
              <Input
                id={`${fieldId}-title`}
                name="title"
                required
                maxLength={200}
                autoComplete="off"
                value={textOf("title")}
                onChange={(event) => {
                  form.set("title", event.target.value);
                }}
              />
              {form.errors["title"] ? (
                <FieldError>{t("Enter a title.")}</FieldError>
              ) : null}
              {error ? <FieldError>{error}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-team`}>{t("Team")}</FieldLabel>
              <Select
                value={teamId}
                items={teamItems}
                onValueChange={(value) => {
                  setTeamId(value ?? NO_TEAM);
                }}
              >
                <SelectTrigger id={`${fieldId}-team`}>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {teamItems.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-priority`}>
                {t("Priority")}
              </FieldLabel>
              <Select
                value={textOf("priority")}
                items={priorityItems}
                onValueChange={(value) => {
                  form.set("priority", value ?? "0");
                }}
              >
                <SelectTrigger id={`${fieldId}-priority`}>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {priorityItems.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            {custom.map((field) =>
              field.input === "bool" ? (
                <Field key={field.key} orientation="horizontal">
                  <Checkbox
                    id={`${fieldId}-${field.key}`}
                    checked={form.values[field.key] === true}
                    onCheckedChange={(checked) => {
                      form.set(field.key, checked);
                    }}
                  />
                  <FieldLabel htmlFor={`${fieldId}-${field.key}`}>
                    {field.label}
                  </FieldLabel>
                </Field>
              ) : (
                <Field
                  key={field.key}
                  data-invalid={form.errors[field.key] ? true : undefined}
                >
                  <FieldLabel htmlFor={`${fieldId}-${field.key}`}>
                    {field.label}
                  </FieldLabel>
                  <Input
                    id={`${fieldId}-${field.key}`}
                    type={INPUT_TYPES[field.input] ?? "text"}
                    autoComplete="off"
                    value={textOf(field.key)}
                    onChange={(event) => {
                      form.set(field.key, event.target.value);
                    }}
                  />
                  {form.errors[field.key] ? (
                    <FieldError>{form.errors[field.key]}</FieldError>
                  ) : null}
                </Field>
              ),
            )}
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={form.pending}>
              {t("Create item")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

# Feature flags

> Which feature flags are on for the active organization, read-only.

Source: https://bettersupabase.com/docs/registry/ui-recipes/feature-flag-toggles

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [flags block](https://bettersupabase.com/blocks/flags) must be installed (`npx better-supabase add flags`). Its page on bettersupabase.com is [/recipes/feature-flag-toggles](https://bettersupabase.com/recipes/feature-flag-toggles).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card skeleton
```

```tsx title="apps/examples/nextjs/src/features/flags/components/feature-flag-list.tsx"
import { tenantOf } from "better-supabase/next";
import { TriangleAlertIcon } from "lucide-react";
import { getExtracted } from "next-intl/server";
import { refresh } from "next/cache";
import { unstable_rethrow } from "next/navigation";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { bsb } from "@/lib/supabase/server";

/** The flags `src/flags.ts` defines. */
const FLAG_KEYS = ["beta-page", "pricing-banner"] as const;

type FlagKey = (typeof FLAG_KEYS)[number];

interface FlagValue {
  readonly key: FlagKey;
  readonly on: boolean;
}

type FlagValues =
  | { readonly kind: "ok"; readonly flags: readonly FlagValue[] }
  | { readonly kind: "error" }
  | { readonly kind: "no-organization" };

/**
 * Each flag as `flag_enabled` answers it for the active organization, with its
 * rules and overrides. Changing a flag is for platform staff (`flags.manage`),
 * so the list only reads. A failed read throws, so it is never cached and Retry
 * reads again.
 */
async function loadFlags(): Promise<readonly FlagValue[] | null> {
  "use cache: private";
  const { session, db } = await bsb.cached();
  const tenant = tenantOf(session);

  if (!tenant) {
    return null;
  }

  return Promise.all(
    FLAG_KEYS.map(async (key) => ({
      key,
      on: (await db.$api("flag_enabled", { key, tenant })) === true,
    })),
  );
}

async function loadFlagValues(): Promise<FlagValues> {
  try {
    const flags = await loadFlags();

    return flags ? { kind: "ok", flags } : { kind: "no-organization" };
  } catch (error) {
    unstable_rethrow(error);

    return { kind: "error" };
  }
}

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: the flags that are on for this organization. */
export async function FeatureFlagList() {
  const [values, t] = await Promise.all([
    loadFlagValues(),
    getExtracted("flags"),
  ]);

  if (values.kind === "no-organization") {
    return null;
  }

  const labels: Record<FlagKey, string> = {
    "beta-page": t("The beta page and its menu entry"),
    "pricing-banner": t("The upgrade banner on the pricing page"),
  };

  return (
    <Card data-testid="flags" data-recipe="feature-flag-toggles">
      <CardHeader>
        <CardTitle>{t("Feature flags")}</CardTitle>
        <CardDescription>
          {t(
            "What's turned on for this organization. Support changes these for you.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {values.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The flags didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : (
          <ul className="flex flex-col divide-y">
            {values.flags.map((flag) => (
              <li
                key={flag.key}
                data-testid={`feature-flag-${flag.key}`}
                className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
              >
                <div className="flex flex-col gap-1">
                  <span className="font-mono text-sm">{flag.key}</span>
                  <span className="text-xs text-muted-foreground">
                    {labels[flag.key]}
                  </span>
                </div>
                {flag.on ? (
                  <Badge variant="success-light">{t("On")}</Badge>
                ) : (
                  <Badge variant="secondary">{t("Off")}</Badge>
                )}
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}

export function FeatureFlagListSkeleton() {
  return <Skeleton className="h-40 rounded-xl" aria-busy="true" />;
}
```

# Feature gate

> Content shown only when the active organization's plan includes a feature, read from the session's features claim and updated when the session refreshes.

Source: https://bettersupabase.com/docs/registry/ui-recipes/feature-gate

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useFeatureGate` from `@/hooks/use-entitlements`; the [entitlements block](https://bettersupabase.com/blocks/entitlements) must be installed (`npx better-supabase add entitlements`). Its page on bettersupabase.com is [/recipes/feature-gate](https://bettersupabase.com/recipes/feature-gate).

Web, with shadcn components:

```bash
npx shadcn add button skeleton
```

```bash
npx shadcn add @better-supabase/use-entitlements
```

```tsx title="apps/examples/nextjs/src/features/billing/components/feature-gate.tsx"
"use client";

import type { ReactNode } from "react";

import { LockIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import type { Entitlement } from "@/lib/claims";

import { buttonVariants } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useFeatureGate } from "@/hooks/use-entitlements";
import { Link } from "@/i18n/navigation";

/**
 * Renders `children` only when the active organization's plan includes
 * `feature`, read from the session's `features` claim; otherwise a link to the
 * plans. UX only: RLS and the route behind it check the feature too.
 */
export function FeatureGate({
  feature,
  children,
}: {
  feature: Entitlement;
  children: ReactNode;
}) {
  const t = useExtracted("billing");
  const { allowed } = useFeatureGate(feature);

  if (allowed === undefined) {
    return <Skeleton className="h-8 w-28" aria-busy="true" />;
  }

  if (allowed) {
    return children;
  }

  return (
    <Link
      href="/settings/billing"
      className={buttonVariants({ variant: "outline", size: "sm" })}
      data-testid={`upgrade-${feature}`}
    >
      <LockIcon />
      {t("Upgrade to unlock")}
    </Link>
  );
}
```

React Native, with `ActivityIndicator`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-entitlements
```

```tsx title="apps/examples/expo-powersync/src/components/feature-gate.tsx"
import type { ReactNode } from "react";

import { ActivityIndicator, Text, View } from "react-native";

import { useFeatureGate } from "../hooks/use-entitlements";

/**
 * Renders `children` only when the active organization's plan includes
 * `feature`, read from the session's `features` claim. UX only: RLS checks the
 * feature too.
 */
export function FeatureGate({
  feature,
  children,
}: {
  readonly feature: string;
  readonly children: ReactNode;
}) {
  const { allowed } = useFeatureGate(feature);

  if (allowed === undefined) {
    return <ActivityIndicator />;
  }

  if (allowed) {
    return children;
  }

  return (
    <View style={{ padding: 12, borderWidth: 1, borderRadius: 12 }}>
      <Text style={{ fontWeight: "600" }}>Not in your plan</Text>
      <Text style={{ color: "#737373" }}>
        Upgrade to a plan with {feature} to use this.
      </Text>
    </View>
  );
}
```

# Form builder

> An editor for form fields and a preview, with the responses in a table.

Source: https://bettersupabase.com/docs/registry/ui-recipes/form-builder

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [forms block](https://bettersupabase.com/blocks/forms) must be installed (`npx better-supabase add forms`). Its page on bettersupabase.com is [/recipes/form-builder](https://bettersupabase.com/recipes/form-builder).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/forms/components/form-builder.tsx"
import type { FormSubmissionStatus } from "better-supabase/blocks/forms";

import { InboxIcon, TriangleAlertIcon } from "lucide-react";
import { getExtracted, getFormatter } from "next-intl/server";
import { refresh } from "next/cache";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

import type { AnswerCell } from "../forms-queries";

import { getForm } from "../forms-queries";
import { FormBuilderWorkspace } from "./form-builder-workspace";
import { FormStatusBadge } from "./form-status-badge";

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

const statusVariants = {
  draft: "secondary",
  submitted: "info-light",
  accepted: "success-light",
  rejected: "destructive-light",
} as const satisfies Record<FormSubmissionStatus, string>;

function answerText(
  cell: AnswerCell | undefined,
  yes: string,
  no: string,
): string {
  if (cell === undefined) {
    return "";
  }

  switch (cell.kind) {
    case "text": {
      return cell.text;
    }

    case "bool": {
      return cell.value ? yes : no;
    }

    default: {
      const unhandled: never = cell;

      return unhandled;
    }
  }
}

/** Render inside `<Suspense>`: a form's builder, preview and responses. */
export async function FormBuilder({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  const [view, t, format] = await Promise.all([
    getForm(id),
    getExtracted("forms"),
    getFormatter(),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="form-builder">
        <TriangleAlertIcon />
        <AlertTitle>{t("The form didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { form, version, fields, submissions } = view.data;

  const statusLabels: Record<FormSubmissionStatus, string> = {
    draft: t("Draft"),
    submitted: t("Submitted"),
    accepted: t("Accepted"),
    rejected: t("Rejected"),
  };

  return (
    <div data-recipe="form-builder" className="flex flex-col gap-6">
      <div className="flex flex-wrap items-center gap-2">
        <h2 className="text-lg font-semibold">{form.title}</h2>
        <FormStatusBadge status={form.status} />
        <span className="text-xs text-muted-foreground">
          {version === null
            ? t("Not published yet")
            : t("Version {version, number}", { version })}
        </span>
      </div>
      <FormBuilderWorkspace formId={form.id} published={fields} />
      <Card>
        <CardHeader>
          <CardTitle>{t("Responses")}</CardTitle>
          <CardDescription>
            {t("{count, plural, one {# response} other {# responses}}", {
              count: submissions.length,
            })}
          </CardDescription>
        </CardHeader>
        <CardContent>
          {submissions.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <InboxIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No responses yet")}</EmptyTitle>
                <EmptyDescription>
                  {t(
                    "Publish the form, then fill in the preview to add the first response.",
                  )}
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Submitted")}</TableHead>
                  {fields.map((field) => (
                    <TableHead key={field.key}>
                      {field.label ?? field.key}
                    </TableHead>
                  ))}
                  <TableHead>{t("Status")}</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {submissions.map((submission) => (
                  <TableRow key={submission.id}>
                    <TableCell className="text-muted-foreground">
                      {format.dateTime(new Date(submission.createdAt), {
                        dateStyle: "medium",
                        timeStyle: "short",
                      })}
                    </TableCell>
                    {fields.map((field) => (
                      <TableCell key={field.key}>
                        {answerText(
                          submission.answers[field.key],
                          t("Yes"),
                          t("No"),
                        )}
                      </TableCell>
                    ))}
                    <TableCell>
                      <Badge variant={statusVariants[submission.status]}>
                        {statusLabels[submission.status]}
                      </Badge>
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

export function FormBuilderSkeleton() {
  return (
    <div className="flex flex-col gap-6" aria-busy="true">
      <Skeleton className="h-8 w-64" />
      <div className="grid gap-6 lg:grid-cols-2">
        <Skeleton className="h-80 rounded-xl" />
        <Skeleton className="h-80 rounded-xl" />
      </div>
      <Skeleton className="h-48 rounded-xl" />
    </div>
  );
}
```

# Grants editor

> The grants on a drive item, inherited ones included, with a picker to share it with a member or team at a role.

Source: https://bettersupabase.com/docs/registry/ui-recipes/grants-editor

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [drive block](https://bettersupabase.com/blocks/drive) must be installed (`npx better-supabase add drive`). Its page on bettersupabase.com is [/recipes/grants-editor](https://bettersupabase.com/recipes/grants-editor).

Web, with shadcn components:

```bash
npx shadcn add badge button dialog field select skeleton
```

```tsx title="apps/examples/nextjs/src/features/drive/components/grants-editor.tsx"
/**
 * Loads an item's grants; `open` starts a fresh read, so call it with the
 * dialog.
 */
export function useDriveSharing(id: string) {
  const load = useAction(loadDriveSharing);

  return {
    ...load,
    open: () => {
      load.reset();
      void load.run({ id });
    },
  };
}

/**
 * Who can open a drive item: its grants, inherited ones from the folders above
 * included, and a picker to share it with a member or a team.
 */
export function GrantsEditor({
  id,
  name,
  load,
  open,
  onOpenChange,
}: {
  id: string;
  name: string;
  load: ReturnType<typeof useDriveSharing>;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  const t = useExtracted("drive");
  const errorMessage = useErrorMessage();

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent data-recipe="grants-editor">
        <DialogHeader>
          <DialogTitle>{t("Share {name}", { name })}</DialogTitle>
          <DialogDescription>
            {t("Members and teams you add here can open it.")}
          </DialogDescription>
        </DialogHeader>
        {load.error ? (
          <div
            role="alert"
            className="flex items-center justify-between gap-2 text-sm text-destructive"
          >
            {errorMessage(load.error)}
            <Button
              variant="outline"
              size="sm"
              onClick={() => {
                void load.run({ id });
              }}
            >
              {t("Retry")}
            </Button>
          </div>
        ) : load.data === undefined ? (
          <div className="flex flex-col gap-2" aria-busy="true">
            <Skeleton className="h-8 w-full" />
            <Skeleton className="h-8 w-full" />
          </div>
        ) : (
          <SharingPanel
            id={id}
            grants={load.data.grants}
            targets={load.data.targets}
            onChange={() => {
              void load.run({ id });
            }}
          />
        )}
      </DialogContent>
    </Dialog>
  );
}

function SharingPanel({
  id,
  grants,
  targets,
  onChange,
}: {
  id: string;
  grants: readonly SharingGrant[];
  targets: readonly SharingTarget[];
  onChange: () => void;
}) {
  const t = useExtracted("drive");
  const fieldId = useId();
  const roleLabel = useRoleLabel();
  const errorMessage = useErrorMessage();

  const onError = (error: Parameters<typeof errorMessage>[0]) => {
    toast.error(errorMessage(error));
  };

  const share = useAction(shareDriveItem, { onSuccess: onChange, onError });
  const unshare = useAction(unshareDriveItem, { onSuccess: onChange, onError });

  const granted = new Set(grants.map(keyOf));

  const options = targets
    .filter((target) => !granted.has(keyOf(target)))
    .map((target) => ({
      value: keyOf(target),
      label:
        target.type === "team"
          ? t("{name} (team)", { name: target.name })
          : target.name,
    }));

  const [picked, setPicked] = useState<string | null>(null);
  const [role, setRole] = useState<DriveRole>("viewer");

  const target = targets.find(
    (each) => keyOf(each) === (picked ?? options[0]?.value),
  );

  return (
    <div className="flex flex-col gap-4">
      {grants.length === 0 ? (
        <p className="text-sm text-muted-foreground">
          {t("Not shared yet. Pick a member or team below.")}
        </p>
      ) : (
        <ul className="flex flex-col gap-2">
          {grants.map((grant) => (
            <li
              key={`${keyOf(grant)}:${grant.inherited ? "up" : "here"}`}
              className="flex items-center justify-between gap-2"
            >
              <div className="flex min-w-0 flex-col gap-1">
                <span className="truncate text-sm">{grant.name}</span>
                {grant.inherited ? (
                  <span className="text-xs text-muted-foreground">
                    {t("From a folder above")}
                  </span>
                ) : null}
              </div>
              <div className="flex items-center gap-2">
                <Badge variant="secondary">{roleLabel(grant.role)}</Badge>
                {!grant.inherited && grant.type !== "role" ? (
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    disabled={unshare.pending}
                    aria-label={t("Stop sharing with {name}", {
                      name: grant.name,
                    })}
                    onClick={() => {
                      if (grant.type !== "role") {
                        void unshare.run({
                          id,
                          principal: { type: grant.type, id: grant.id },
                        });
                      }
                    }}
                  >
                    <XIcon />
                  </Button>
                ) : null}
              </div>
            </li>
          ))}
        </ul>
      )}
      {target === undefined ? null : (
        <form
          className="flex items-end gap-2"
          onSubmit={(event) => {
            event.preventDefault();
            void share.run({
              id,
              principal: { type: target.type, id: target.id },
              role,
            });
            setPicked(null);
          }}
        >
          <Field className="min-w-0 flex-1">
            <FieldLabel htmlFor={`${fieldId}-target`}>
              {t("Share with")}
            </FieldLabel>
            <Select
              value={keyOf(target)}
              items={options}
              onValueChange={(value) => {
                const match = options.find((option) => option.value === value);

                if (match) {
                  setPicked(match.value);
                }
              }}
            >
              <SelectTrigger id={`${fieldId}-target`} className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {options.map((option) => (
                  <SelectItem key={option.value} value={option.value}>
                    {option.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
          <Field className="w-36">
            <FieldLabel htmlFor={`${fieldId}-role`}>{t("Access")}</FieldLabel>
            <Select
              value={role}
              items={DRIVE_ROLES.map((each) => ({
                value: each,
                label: roleLabel(each),
              }))}
              onValueChange={(value) => {
                if (isRole(value)) {
                  setRole(value);
                }
              }}
            >
              <SelectTrigger id={`${fieldId}-role`} className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {DRIVE_ROLES.map((each) => (
                  <SelectItem key={each} value={each}>
                    {roleLabel(each)}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
          <Button type="submit" disabled={share.pending}>
            <UserPlusIcon />
            {t("Share")}
          </Button>
        </form>
      )}
    </div>
  );
}
```

# Hiring board

> Candidates for an opening as cards in interview stage columns.

Source: https://bettersupabase.com/docs/registry/ui-recipes/hiring-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [hiring block](https://bettersupabase.com/blocks/hiring) must be installed (`npx better-supabase add hiring`). Its page on bettersupabase.com is [/recipes/hiring-board](https://bettersupabase.com/recipes/hiring-board).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/people/components/hiring-board.tsx"
/**
 * Render inside `<Suspense>`: the candidates of one opening as cards in its
 * stage columns. Dragging a card to Hired or Rejected closes the application;
 * to another open stage, moves it. `?job=` picks the opening.
 */
export async function HiringBoard({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, query] = await Promise.all([
    getSession(),
    getExtracted("people"),
    searchParams,
  ]);

  if (!can(session, "hiring.read")) {
    forbidden();
  }

  const board = await settle(loadHiringBoard);

  if (board.kind === "no-organization") {
    return null;
  }

  const canManage = can(session, "hiring.manage");
  const canWrite = can(session, "hiring.write");
  const jobs = board.kind === "ok" ? board.data.jobs : [];
  const job = jobs.find((item) => item.id === query["job"]) ?? jobs[0];

  const cards =
    board.kind === "ok" && job
      ? board.data.cards.filter((card) => card.jobId === job.id)
      : [];

  const createOpening = canManage ? <CreateOpeningDialog /> : null;

  return (
    <Card data-recipe="hiring-board">
      <CardHeader>
        <CardTitle>{job?.title ?? t("Hiring")}</CardTitle>
        <CardDescription>
          {t("Drag a candidate to the next stage, or to Hired or Rejected.")}
        </CardDescription>
        {jobs.length > 0 || board.kind === "error" ? (
          <CardAction className="flex items-center gap-2">
            {job && canWrite && board.kind === "ok" && cards.length > 0 ? (
              <AddCandidateDialog
                jobId={job.id}
                candidates={board.data.candidates}
              />
            ) : null}
            {createOpening}
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {board.kind === "error" ? (
          <PeopleLoadError title={t("The hiring board didn't load")} />
        ) : job === undefined ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <BriefcaseIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No openings yet")}</EmptyTitle>
              <EmptyDescription>
                {canManage
                  ? t("Create an opening, then add candidates to it.")
                  : t("Ask a hiring manager to create an opening.")}
              </EmptyDescription>
            </EmptyHeader>
            {createOpening ? (
              <EmptyContent>{createOpening}</EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <>
            {jobs.length > 1 ? (
              <nav
                aria-label={t("Openings")}
                className="flex flex-wrap items-center gap-2"
              >
                {jobs.map((item) => (
                  <Link
                    key={item.id}
                    href={`/people/hiring?job=${item.id}`}
                    aria-current={item.id === job.id ? "page" : undefined}
                    className={buttonVariants({
                      variant: item.id === job.id ? "secondary" : "ghost",
                      size: "sm",
                    })}
                  >
                    {item.title}
                    {item.status === "open" ? null : (
                      <Badge variant="outline">
                        {item.status === "draft" ? t("Draft") : t("Closed")}
                      </Badge>
                    )}
                  </Link>
                ))}
              </nav>
            ) : null}
            {cards.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <UserPlusIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No candidates yet")}</EmptyTitle>
                  <EmptyDescription>
                    {t("Candidates start in the first stage of the pipeline.")}
                  </EmptyDescription>
                </EmptyHeader>
                {canWrite ? (
                  <EmptyContent>
                    <AddCandidateDialog
                      jobId={job.id}
                      candidates={board.data.candidates}
                    />
                  </EmptyContent>
                ) : null}
              </Empty>
            ) : (
              <HiringKanban
                key={cards
                  .map((card) => `${card.id}:${card.stageId}`)
                  .join(",")}
                stages={board.data.stages}
                cards={cards}
                canMove={canWrite}
              />
            )}
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

# Inbox with threads

> A shared inbox of conversations with status tabs, an assignee filter and the open thread beside the list.

Source: https://bettersupabase.com/docs/registry/ui-recipes/inbox-threads

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useInbox` from `@/hooks/use-inbox`; the [inbox block](https://bettersupabase.com/blocks/inbox) must be installed (`npx better-supabase add inbox`). Its page on bettersupabase.com is [/recipes/inbox-threads](https://bettersupabase.com/recipes/inbox-threads).

Web, with shadcn components:

```bash
npx shadcn add badge empty select tabs
```

```bash
npx shadcn add @better-supabase/use-inbox
```

```tsx title="apps/examples/nextjs/src/features/inbox/components/inbox-view.tsx"
"use client";

import { InboxIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useState } from "react";
import * as v from "valibot";

import { Badge } from "@/components/ui/badge";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInbox } from "@/hooks/use-inbox";
import { cn } from "@/lib/utils";

import type { ConversationRow } from "../inbox-types";

import { loadConversations } from "../inbox-actions";
import { unwrap } from "../inbox-load";
import { AssigneeFilter, ConversationStatus } from "../inbox-types";
import { ConversationPanel } from "./conversation-panel";

export function InboxView({
  organizationId,
  initial,
}: {
  organizationId: string;
  initial: readonly ConversationRow[];
}) {
  const t = useExtracted("inbox");
  const [status, setStatus] = useState<ConversationStatus>("open");
  const [assignee, setAssignee] = useState<AssigneeFilter>("anyone");
  const [selected, setSelected] = useState<string | null>(null);

  const assigneeItems = [
    { value: "anyone", label: t("Anyone") },
    { value: "me", label: t("Mine") },
    { value: "unassigned", label: t("Unassigned") },
  ] satisfies readonly { value: AssigneeFilter; label: string }[];

  return (
    <div className="grid min-h-128 gap-4 lg:grid-cols-3">
      <section aria-label={t("Conversations")} className="space-y-3">
        <div className="flex flex-wrap items-center gap-2">
          <Tabs
            value={status}
            onValueChange={(value) => {
              if (!v.is(ConversationStatus, value)) {
                return;
              }

              setStatus(value);
            }}
          >
            <TabsList>
              <TabsTrigger value="open">{t("Open")}</TabsTrigger>
              <TabsTrigger value="pending">{t("Pending")}</TabsTrigger>
              <TabsTrigger value="resolved">{t("Resolved")}</TabsTrigger>
              <TabsTrigger value="all">{t("All")}</TabsTrigger>
            </TabsList>
          </Tabs>
          <Select
            value={assignee}
            items={assigneeItems}
            onValueChange={(value) => {
              if (!v.is(AssigneeFilter, value)) {
                return;
              }

              setAssignee(value);
            }}
          >
            <SelectTrigger
              size="sm"
              className="w-32"
              aria-label={t("Assignee")}
            >
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {assigneeItems.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <ConversationList
          key={`${status}:${assignee}`}
          organizationId={organizationId}
          status={status}
          assignee={assignee}
          initial={status === "open" && assignee === "anyone" ? initial : []}
          selected={selected}
          onSelect={setSelected}
        />
      </section>
      {selected === null ? (
        <Empty className="border lg:col-span-2">
          <EmptyHeader>
            <EmptyTitle>{t("Pick a conversation")}</EmptyTitle>
            <EmptyDescription>
              {t("Its messages, notes and actions show here.")}
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      ) : (
        <div className="lg:col-span-2">
          <ConversationPanel key={selected} conversationId={selected} />
        </div>
      )}
    </div>
  );
}

function ConversationList({
  organizationId,
  status,
  assignee,
  initial,
  selected,
  onSelect,
}: {
  organizationId: string;
  status: ConversationStatus;
  assignee: AssigneeFilter;
  initial: readonly ConversationRow[];
  selected: string | null;
  onSelect: (id: string) => void;
}) {
  const t = useExtracted("inbox");
  const format = useFormatter();

  const inbox = useInbox({
    organizationId,
    load: async () => unwrap(await loadConversations({ status, assignee })),
  });

  const conversations = inbox.items ?? initial;

  // Staff reads send no broadcast, so an opened conversation clears its badge here.
  const [openedAt, setOpenedAt] = useState<ReadonlyMap<string, number>>(
    () => new Map(),
  );

  const unread = (conversation: ConversationRow): boolean => {
    const opened = openedAt.get(conversation.id);

    return (
      conversation.unread &&
      (opened === undefined ||
        (conversation.lastMessageAt !== null &&
          Date.parse(conversation.lastMessageAt) > opened))
    );
  };

  return conversations.length === 0 ? (
    <Empty className="border">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <InboxIcon />
        </EmptyMedia>
        <EmptyTitle>{t("No conversations")}</EmptyTitle>
        <EmptyDescription>
          {t("Open Help in the header to start one as a customer.")}
        </EmptyDescription>
      </EmptyHeader>
    </Empty>
  ) : (
    <ul className="divide-y rounded-xl border">
      {conversations.map((conversation) => (
        <li key={conversation.id}>
          <button
            type="button"
            aria-current={selected === conversation.id ? "true" : undefined}
            className={cn(
              "flex w-full flex-col gap-1 px-4 py-3 text-left text-sm hover:bg-muted/50",
              selected === conversation.id && "bg-muted",
            )}
            onClick={() => {
              setOpenedAt((current) =>
                new Map(current).set(conversation.id, Date.now()),
              );
              onSelect(conversation.id);
            }}
          >
            <span className="flex items-center gap-2">
              <span className="flex-1 truncate font-medium">
                {conversation.contactName || t("Visitor")}
              </span>
              {unread(conversation) ? <Badge>{t("New")}</Badge> : null}
            </span>
            <span className="truncate">
              {conversation.subject ?? t("No subject")}
            </span>
            <span className="flex gap-2 text-xs text-muted-foreground">
              <span className="flex-1 truncate">{conversation.preview}</span>
              {conversation.lastMessageAt ? (
                <span className="tabular-nums">
                  {format.dateTime(new Date(conversation.lastMessageAt), {
                    dateStyle: "short",
                    timeStyle: "short",
                  })}
                </span>
              ) : null}
            </span>
          </button>
        </li>
      ))}
    </ul>
  );
}
```

# UI recipes

> Build components and screens for the blocks, such as notifications, search, onboarding, comments, members, API keys, the audit log, billing and settings, from shadcn or React Native components and the blocks' hooks.

Source: https://bettersupabase.com/docs/registry/ui-recipes

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

better-supabase ships no visible UI components. A recipe builds one component or one screen from your own shadcn components (web) or React Native primitives (native) and one or more blocks. Every recipe has web code; the ones that fit a phone also have native code, which calls the same hook. Both come from the example apps, `apps/examples/nextjs` and `apps/examples/expo-powersync`, so they typecheck and run.

Each recipe below has a docs page with its install commands and code, and a page at [bettersupabase.com/recipes](https://bettersupabase.com/recipes) with a screenshot. The native snippets use plain React Native primitives, so they run without a styling library; [React Native Reusables](https://reactnativereusables.com) serves shadcn-style native components from a working registry if you want them.

## Identity & access [#identity--access]

### Two-factor setup [#two-factor-setup]

A card that enrolls an authenticator app with a QR code, verifies the first code and removes the factor. [Web code](/docs/registry/ui-recipes/mfa-setup).

### Can [#can]

Shows an action only to members who hold a permission key; the checks of one render share one round trip. [Web code](/docs/registry/ui-recipes/can).

### Subject link [#subject-link]

A link to any subject type, titled and routed by subject_titles, with plain text for a record the caller can't read. [Web code](/docs/registry/ui-recipes/subject-link).

### Subject chip [#subject-chip]

A compact badge with a subject's type and title that links to its page. [Web code](/docs/registry/ui-recipes/subject-chip).

### Sessions list [#sessions-list]

The caller's signed-in devices with the last activity and a button to end each session. [Web code](/docs/registry/ui-recipes/sessions-list).

### SSO connection settings [#sso-connection-settings]

The organization's SAML or OIDC connection with its domains and the role new members get. [Web code](/docs/registry/ui-recipes/sso-settings).

### Access requests inbox [#access-requests-inbox]

Requests for access to a resource, with approve and deny buttons for the owner. [Web code](/docs/registry/ui-recipes/access-requests-inbox).

### My work [#my-work]

What waits on the caller across the installed blocks, from `loadMyWork`: assigned work, approvals, due reminders, mentions and SLA warnings, each linked to its record. [Web code](/docs/registry/ui-recipes/my-work).

## Tenancy & accounts [#tenancy--accounts]

### Organization switcher [#organization-switcher]

A menu of the user's organizations that switches the active one and refreshes the session for its tenant claim. [Web and React Native code](/docs/registry/ui-recipes/org-switcher).

### Settings layout [#settings-layout]

Settings pages generated from the settings definition: a section per scope and a control per key, picked by the kind its schema declares. [Web and React Native code](/docs/registry/ui-recipes/settings-layout).

### Members table [#members-table]

An organization's members with their roles, where a role change shows at once and a member can be removed. [Web and React Native code](/docs/registry/ui-recipes/members-table).

### Invite dialog [#invite-dialog]

A form that invites a teammate by email with a role, then shows the token for the accept link. [Web and React Native code](/docs/registry/ui-recipes/invite-dialog).

### Profile form [#profile-form]

A form that updates the caller's display name through a server action and shows field errors. [Web code](/docs/registry/ui-recipes/profile-form).

### Workspace URL field [#workspace-url-field]

An organization form with a name and a URL slug, where reserved and taken slugs come back as field errors. [Web code](/docs/registry/ui-recipes/workspace-url-field).

### Team picker [#team-picker]

A select of the caller's teams in the active organization, to scope a page or assign work. [Web code](/docs/registry/ui-recipes/team-picker).

## Collaboration [#collaboration]

### Comment thread [#comment-thread]

A record's comments with their authors and a form to add one, kept current after each write. [Web and React Native code](/docs/registry/ui-recipes/comment-thread).

### Channel sidebar [#channel-sidebar]

The caller's channels with unread and mention counts, kept live, with dialogs to browse, join and create channels. [Web code](/docs/registry/ui-recipes/channel-sidebar).

### Message list [#message-list]

A conversation's messages over Realtime, with older pages on demand, who is typing, reply counts and the composer. [Web code](/docs/registry/ui-recipes/message-list).

### Message composer [#message-composer]

A message box that sends on Enter, signals typing, and uploads dropped, pasted or picked files onto the message. [Web code](/docs/registry/ui-recipes/message-composer).

### Thread panel [#thread-panel]

A thread beside the channel: the root message, its replies live over Realtime, and a composer that replies into it. [Web code](/docs/registry/ui-recipes/thread-panel).

### Presence avatars [#presence-avatars]

Who else has a record open (a channel, a comment thread's record or a document), on the record's presence topic. [Web code](/docs/registry/ui-recipes/presence-avatars).

### Reactions bar [#reactions-bar]

Emoji reactions under a record with counts, which toggle the caller's own reaction. [Web code](/docs/registry/ui-recipes/reactions-bar).

### Share link dialog [#share-link-dialog]

A dialog that creates an expiring link to a record and revokes it. [Web code](/docs/registry/ui-recipes/share-link-dialog).

### Attachments uploader [#attachments-uploader]

A drop zone that uploads files to a record and lists them with a download link. [Web code](/docs/registry/ui-recipes/attachments-uploader).

### Subject timeline [#subject-timeline]

One record's comments, files and versions merged newest first, from `bsb.record(...).timeline`, skipping the bindings the caller can't read. [Web code](/docs/registry/ui-recipes/subject-timeline).

### Post board [#post-board]

A feedback board's posts ranked by votes, with an upvote toggle, the roadmap status, moderation state and a New post dialog. [Web code](/docs/registry/ui-recipes/post-board).

## Notifications & messaging [#notifications--messaging]

### Notification bell [#notification-bell]

A header button with the unread count, which updates when a notification arrives or is read. [Web and React Native code](/docs/registry/ui-recipes/notification-bell).

### Notification list [#notification-list]

The caller's latest notifications with an unread marker, loaded again on every change. [Web and React Native code](/docs/registry/ui-recipes/notification-list).

### Inbox with threads [#inbox-with-threads]

A shared inbox of conversations with status tabs, an assignee filter and the open thread beside the list. [Web code](/docs/registry/ui-recipes/inbox-threads).

### Push opt-in [#push-opt-in]

A card that registers this browser for web push and removes it again. [Web code](/docs/registry/ui-recipes/push-opt-in).

## Growth & engagement [#growth--engagement]

### Onboarding checklist [#onboarding-checklist]

The organization's getting-started steps with progress, hidden once every step is done. [Web and React Native code](/docs/registry/ui-recipes/onboarding-checklist).

### Announcement banner [#announcement-banner]

The newest live announcement for the user and organization, with a dismiss button. [Web code](/docs/registry/ui-recipes/announcement-banner).

### Feature flags [#feature-flags]

Which feature flags are on for the active organization, read-only. [Web code](/docs/registry/ui-recipes/feature-flag-toggles).

### Waitlist signup [#waitlist-signup]

An email form that joins the waitlist and shows the position in line. [Web code](/docs/registry/ui-recipes/waitlist-signup).

## CRM & sales [#crm--sales]

### Contact detail [#contact-detail]

A person or company with its details, related contacts and the latest activity. [Web code](/docs/registry/ui-recipes/contact-detail).

### Deal board [#deal-board]

Deals as cards in pipeline stage columns, moved by drag and drop. [Web code](/docs/registry/ui-recipes/deal-board).

### Sequence builder [#sequence-builder]

Each sequence's email, wait and task steps in order, with its enrollment counts, an Add step dialog and an Activate or Pause button. [Web code](/docs/registry/ui-recipes/sequence-builder).

### Activity timeline [#activity-timeline]

Calls, emails and meetings with a contact, newest first. [Web code](/docs/registry/ui-recipes/activity-timeline).

### Quote builder [#quote-builder]

A quote with line items, discounts and totals in minor units, sent for acceptance. [Web code](/docs/registry/ui-recipes/quote-builder).

### Targets progress [#targets-progress]

Sales targets for the period with the progress toward each one. [Web code](/docs/registry/ui-recipes/targets-progress).

### Contract renewals [#contract-renewals]

Active contracts with their value, end date and notice deadline, the nearest deadline first, so a renewal is decided in time. [Web code](/docs/registry/ui-recipes/contract-renewals).

## Work management [#work-management]

### Command search [#command-search]

A search dialog over the search index, with favorites and recents before the user types. [Web and React Native code](/docs/registry/ui-recipes/command-search).

### Tag input [#tag-input]

A record's tags with suggestions, where typing a name adds a tag or creates it. [Web and React Native code](/docs/registry/ui-recipes/tag-input).

### Work item board [#work-item-board]

Work items in state columns, ordered by rank and moved through allowed transitions. [Web code](/docs/registry/ui-recipes/work-item-board).

### Entity form [#entity-form]

A create dialog whose inputs come from `recordForm`: the entity's own fields and the organization's custom fields, validated together by `useRecordForm` before the Server Action runs. [Web code](/docs/registry/ui-recipes/entity-form).

### Work item grid [#work-item-grid]

A data grid of work items with filters, sorting and saved views. [Web code](/docs/registry/ui-recipes/work-item-grid).

### Approvals queue [#approvals-queue]

Requests waiting for the caller's decision, with approve and reject buttons. [Web code](/docs/registry/ui-recipes/approvals-queue).

### SLA badges [#sla-badges]

A badge on a record with the time left before its SLA is breached. [Web code](/docs/registry/ui-recipes/sla-badges).

### Custom fields editor [#custom-fields-editor]

A settings page that adds, orders and removes the custom fields of a record type. [Web code](/docs/registry/ui-recipes/custom-fields-editor).

### Bulk action bar [#bulk-action-bar]

A bar over a selection of rows that runs an action on all of them and reports failures. [Web code](/docs/registry/ui-recipes/bulk-action-bar).

### Related items panel [#related-items-panel]

The records linked to a record, with a picker to add or remove a link. [Web code](/docs/registry/ui-recipes/related-items-panel).

### Dispatch board [#dispatch-board]

A week of technicians by day with their scheduled work orders, and the backlog to schedule. [Web code](/docs/registry/ui-recipes/dispatch-board).

### Technician day [#technician-day]

A technician's stops for the day in route order, each with the button that moves it on. [Web code](/docs/registry/ui-recipes/technician-day).

## People [#people]

### Employee directory [#employee-directory]

A searchable list of employees with an org chart of who reports to whom. [Web code](/docs/registry/ui-recipes/employee-directory).

### Hiring board [#hiring-board]

Candidates for an opening as cards in interview stage columns. [Web code](/docs/registry/ui-recipes/hiring-board).

### Timesheet [#timesheet]

A week of time entries per project with a running timer and totals. [Web code](/docs/registry/ui-recipes/timesheet).

## Billing & commerce [#billing--commerce]

### Plan picker [#plan-picker]

The plans with their prices and a checkout button each. Checkout starts in a server action or API route that holds the Stripe key, and the client opens the URL it returns. [Web and React Native code](/docs/registry/ui-recipes/plan-picker).

### Usage meter [#usage-meter]

This period's usage per meter against the plan's quota, with the reset date. [Web and React Native code](/docs/registry/ui-recipes/usage-meter).

### Feature gate [#feature-gate]

Content shown only when the active organization's plan includes a feature, read from the session's features claim and updated when the session refreshes. [Web and React Native code](/docs/registry/ui-recipes/feature-gate).

### Shipment tracking [#shipment-tracking]

Shipments with their status and the tracking timeline of the selected one, newest event first. [Web code](/docs/registry/ui-recipes/shipment-tracking).

### Invoices table [#invoices-table]

The organization's invoices with their status, amount and a link to the PDF. [Web code](/docs/registry/ui-recipes/invoices-table).

### Product catalog grid [#product-catalog-grid]

Products in a grid with prices, variants and a filter by collection. [Web code](/docs/registry/ui-recipes/product-catalog-grid).

### Inventory table [#inventory-table]

Stock levels per product and location, with a form to adjust a count. [Web code](/docs/registry/ui-recipes/inventory-table).

### Order detail [#order-detail]

An order with its lines, totals, payment state and fulfillment steps. [Web code](/docs/registry/ui-recipes/order-detail).

### Kitchen display [#kitchen-display]

Open tickets per kitchen station, each line moved from queued to served, refreshed over Realtime. [Web code](/docs/registry/ui-recipes/kitchen-display).

### Order state board [#order-state-board]

Today's kitchen orders in preparation-state columns; dragging an order forward moves all of its lines. [Web code](/docs/registry/ui-recipes/order-state-board).

### Promotion code field [#promotion-code-field]

A field that checks a promotion code and shows the discount it gives. [Web code](/docs/registry/ui-recipes/promotion-code-field).

## Scheduling [#scheduling]

### Calendar view [#calendar-view]

A month and week calendar of events with a dialog to add one. [Web code](/docs/registry/ui-recipes/calendar-view).

### Booking page [#booking-page]

A public page that shows open slots and books one without overlaps. [Web code](/docs/registry/ui-recipes/booking-page).

### Availability editor [#availability-editor]

Weekly working hours and date overrides that set which slots can be booked. [Web code](/docs/registry/ui-recipes/availability-editor).

### Reminders list [#reminders-list]

The next events with the caller's reminders on each, to add a reminder at an offset before an event or remove one. [Web code](/docs/registry/ui-recipes/reminders-list).

## Content & files [#content--files]

### Grants editor [#grants-editor]

The grants on a drive item, inherited ones included, with a picker to share it with a member or team at a role. [Web code](/docs/registry/ui-recipes/grants-editor).

### Undo toast [#undo-toast]

A toast after a delete with a button that restores the record from the trash. [Web code](/docs/registry/ui-recipes/undo-toast).

### Drive explorer [#drive-explorer]

Folders and files with upload, rename, move and a breadcrumb trail, each file with its labels and the approval of its current version. [Web code](/docs/registry/ui-recipes/drive-explorer).

### Version history [#version-history]

The saved versions of a record with a diff and a button to restore one. [Web code](/docs/registry/ui-recipes/version-history).

### Trash bin [#trash-bin]

Deleted records with buttons to restore them or delete them for good. [Web code](/docs/registry/ui-recipes/trash-bin).

### Form builder [#form-builder]

An editor for form fields and a preview, with the responses in a table. [Web code](/docs/registry/ui-recipes/form-builder).

### Course outline [#course-outline]

A course's modules and lessons in rank order, with the caller's progress, certificate and an Enroll button. [Web code](/docs/registry/ui-recipes/course-outline).

### Lesson player [#lesson-player]

One lesson's content with a Mark as done button, or its quiz, which the database grades on submit. [Web code](/docs/registry/ui-recipes/lesson-player).

### CSV import wizard [#csv-import-wizard]

A stepper that uploads a CSV, maps its columns, validates the rows and imports them. [Web code](/docs/registry/ui-recipes/csv-import-wizard).

### Templates gallery [#templates-gallery]

Templates in a grid with a preview and a button to create a record from one. [Web code](/docs/registry/ui-recipes/templates-gallery).

### Document editor [#document-editor]

A rich text editor for a document with autosave, collaborators and a suggestion mode that editors accept or reject. [Web code](/docs/registry/ui-recipes/document-editor).

## AI [#ai]

### Chat with history [#chat-with-history]

An AI chat that stores every message, resumes a stored conversation and streams the reply. [Web code](/docs/registry/ui-recipes/chat-with-history).

### Knowledge base upload [#knowledge-base-upload]

A form that adds a document to the knowledge base, which chunks and embeds it for search. [Web code](/docs/registry/ui-recipes/knowledge-upload).

### Agent activity [#agent-activity]

The agent runs for the organization with their steps and the approvals that wait for a person. [Web code](/docs/registry/ui-recipes/agent-run-trace).

### AI task queue [#ai-task-queue]

A table of scheduled AI prompts with each task's next run and latest run status, plus pause and resume. [Web code](/docs/registry/ui-recipes/ai-task-queue).

### AI provider settings [#ai-provider-settings]

A settings card that lists the organization's own AI provider keys and models, adds a key to Vault and removes one. [Web code](/docs/registry/ui-recipes/ai-provider-settings).

## Automation [#automation]

### Workflow canvas [#workflow-canvas]

A node editor for a workflow definition that saves drafts, publishes versions and runs the workflow. [Web code](/docs/registry/ui-recipes/workflow-canvas).

### Workflow runs [#workflow-runs]

The latest workflow runs with their status, which update while a run is in progress. [Web code](/docs/registry/ui-recipes/workflow-runs).

### Connector credentials [#connector-credentials]

A sheet that stores a connector credential encrypted and revokes it, without reading the secret back. [Web code](/docs/registry/ui-recipes/connector-setup).

### Rule builder [#rule-builder]

A condition tree with field, operator and value rows, nested And and Or groups, drag to reorder and a fixed first condition, saved as a versioned rule. [Web code](/docs/registry/ui-recipes/rule-builder).

### Job runs table [#job-runs-table]

The latest background job runs with their status, attempts and error, and a button to retry a failed run. [Web code](/docs/registry/ui-recipes/job-runs-table).

## Developer platform [#developer-platform]

### API keys table [#api-keys-table]

The organization's API keys, with a form that creates one and shows its token once, and a revoke button per key. [Web and React Native code](/docs/registry/ui-recipes/api-keys-table).

### Webhook endpoints [#webhook-endpoints]

The organization's webhook endpoints with their recent deliveries and a retry button. [Web code](/docs/registry/ui-recipes/webhook-endpoints).

### Service principals table [#service-principals-table]

Machine identities with their scopes, last use and a button to disable one. [Web code](/docs/registry/ui-recipes/service-principals-table).

### Signing keys rotation [#signing-keys-rotation]

The active and previous signing keys with a button to rotate them. [Web code](/docs/registry/ui-recipes/signing-keys-rotation).

### Status page [#status-page]

A public status page: the overall status, each public component with its daily uptime, and the public incident updates. [Web code](/docs/registry/ui-recipes/status-page).

## Admin & compliance [#admin--compliance]

### Audit log table [#audit-log-table]

The audit trail, newest first, with filters by actor and action and older pages on demand. [Web and React Native code](/docs/registry/ui-recipes/audit-log-table).

### Support session banner [#support-session-banner]

A banner that tells the user a support agent is viewing the app as them, rendered on the server. [Web code](/docs/registry/ui-recipes/support-session-banner).

### Metrics dashboard [#metrics-dashboard]

A card per metric: the 30-day total against the 30 days before, with daily bars. [Web code](/docs/registry/ui-recipes/metrics-dashboard).

### Data export and delete [#data-export-and-delete]

Buttons to export the caller's data and to request account deletion, with the request status. [Web code](/docs/registry/ui-recipes/data-export-delete).

### Control posture [#control-posture]

One card per compliance framework with its passing, failing and not started controls, then the overdue evidence tasks. [Web code](/docs/registry/ui-recipes/control-posture).

## App tables [#app-tables]

### Record grid [#record-grid]

An app table as a data grid with search, filters, saved views and bulk actions on the selected rows, including Set field through `appTables.set`. [Web code](/docs/registry/ui-recipes/record-grid).

### Record form [#record-form]

A create form whose inputs follow each column: a length limit for text, a select for options, a date input and a searchable picker for references. [Web code](/docs/registry/ui-recipes/record-form).

### Record page [#record-page]

One row as a page: its fields, the rows that reference it, and comments and tags once the table is a subject. [Web code](/docs/registry/ui-recipes/record-page).

### Record board [#record-board]

An app table as a board with a column per option value, where dragging a card writes the value and its rank. [Web code](/docs/registry/ui-recipes/record-board).

### Record calendar [#record-calendar]

Rows on a month grid by a date column, with the month in the URL and a count of rows without a date. [Web code](/docs/registry/ui-recipes/record-calendar).

### Record timeline [#record-timeline]

Rows as bars from a start date to an end date over four weeks, with a lane per assignee. [Web code](/docs/registry/ui-recipes/record-timeline).

### Record Gantt chart [#record-gantt-chart]

A row per record grouped by its parent, with a bar for its planned dates and a marker for rows without dates. [Web code](/docs/registry/ui-recipes/record-gantt).

### Record history [#record-history]

Every version `subject.versions` kept of a row, with the fields each one changed and a restore per field. [Web code](/docs/registry/ui-recipes/record-history).

### Upsert import [#upsert-import]

Paste CSV, map its columns, preview the rows it creates and changes, then upsert them on one of the table's unique sets. [Web code](/docs/registry/ui-recipes/upsert-import).

# Inventory table

> Stock levels per product and location, with a form to adjust a count.

Source: https://bettersupabase.com/docs/registry/ui-recipes/inventory-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [inventory block](https://bettersupabase.com/blocks/inventory) must be installed (`npx better-supabase add inventory`). Its page on bettersupabase.com is [/recipes/inventory-table](https://bettersupabase.com/recipes/inventory-table).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/shop/components/inventory-table.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: stock per tracked variant and active location. */
export async function InventoryTable() {
  const session = await getSession();

  if (!can(session, "inventory.read")) {
    forbidden();
  }

  const [stock, t] = await Promise.all([
    getStockLevels(),
    getExtracted("shop"),
  ]);

  if (stock.kind === "no-organization") {
    return null;
  }

  const canManage = can(session, "inventory.manage");

  const locationName = new Map(
    stock.kind === "ok"
      ? stock.data.locations.map((location) => [location.id, location.name])
      : [],
  );

  return (
    <Card data-recipe="inventory-table">
      <CardHeader>
        <CardTitle>{t("Stock levels")}</CardTitle>
        <CardDescription>
          {t(
            "Units on hand, reserved by open orders and available to sell, per location.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {stock.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The stock levels didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : stock.data.locations.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <WarehouseIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No stock locations yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Stock is counted per location. Add one to start.")}
              </EmptyDescription>
            </EmptyHeader>
            {canManage ? (
              <EmptyContent>
                <CreateLocationButton />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : stock.data.levels.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <BoxesIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No products to count")}</EmptyTitle>
              <EmptyDescription>
                {t("Products from the catalog show up here with their stock.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Link href="/shop" className={buttonVariants({ size: "sm" })}>
                {t("Go to the catalog")}
              </Link>
            </EmptyContent>
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Product")}</TableHead>
                <TableHead>{t("Location")}</TableHead>
                <TableHead className="text-right">{t("On hand")}</TableHead>
                <TableHead className="text-right">{t("Reserved")}</TableHead>
                <TableHead className="text-right">{t("Available")}</TableHead>
                <TableHead>
                  <span className="sr-only">{t("Status")}</span>
                </TableHead>
                {canManage ? (
                  <TableHead>
                    <span className="sr-only">{t("Actions")}</span>
                  </TableHead>
                ) : null}
              </TableRow>
            </TableHeader>
            <TableBody>
              {stock.data.levels.map((level) => {
                const location = locationName.get(level.locationId) ?? "";

                return (
                  <TableRow key={`${level.variantId}:${level.locationId}`}>
                    <TableCell>
                      <div className="flex flex-col gap-1">
                        <span className="text-sm">{level.productTitle}</span>
                        <span className="font-mono text-xs text-muted-foreground">
                          {level.sku}
                        </span>
                      </div>
                    </TableCell>
                    <TableCell>{location}</TableCell>
                    <TableCell className="text-right tabular-nums">
                      {level.onHand}
                    </TableCell>
                    <TableCell className="text-right tabular-nums">
                      {level.reserved}
                    </TableCell>
                    <TableCell className="text-right tabular-nums">
                      {level.available}
                    </TableCell>
                    <TableCell>
                      {level.available <= 0 ? (
                        <Badge variant="destructive-light">
                          {t("Out of stock")}
                        </Badge>
                      ) : level.reorderPoint !== null &&
                        level.available <= level.reorderPoint ? (
                        <Badge variant="warning-light">{t("Low")}</Badge>
                      ) : (
                        <Badge variant="success-light">{t("In stock")}</Badge>
                      )}
                    </TableCell>
                    {canManage ? (
                      <TableCell className="text-right">
                        <AdjustStockDialog
                          variantId={level.variantId}
                          locationId={level.locationId}
                          label={`${level.productTitle} (${level.sku}), ${location}`}
                        />
                      </TableCell>
                    ) : null}
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}
```

# Invite dialog

> A form that invites a teammate by email with a role, then shows the token for the accept link.

Source: https://bettersupabase.com/docs/registry/ui-recipes/invite-dialog

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useInvitations` from `@/hooks/use-organizations`; the [invitations block](https://bettersupabase.com/blocks/invitations) must be installed (`npx better-supabase add invitations`). Its page on bettersupabase.com is [/recipes/invite-dialog](https://bettersupabase.com/recipes/invite-dialog).

Web, with shadcn components:

```bash
npx shadcn add button dialog field input select
```

```bash
npx shadcn add @better-supabase/use-organizations
```

```tsx title="apps/examples/nextjs/src/features/organization/components/invite-member-dialog.tsx"
"use client";

import type { SubmitEvent } from "react";

import { CopyIcon, UserPlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useState } from "react";
import { toast } from "sonner";
import * as v from "valibot";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { assignableRoles } from "@/features/user/user-permissions";
import { useInvitations } from "@/hooks/use-organizations";
import { Role } from "@/lib/claims";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import { invitationSent } from "../organization-actions";
import { useInviteLink } from "../use-invite-link";
import { useRoleLabel } from "../use-role-label";

/**
 * Invites a teammate through `invite_member` in the browser, then shows the
 * accept link: the example has no mailer.
 */
export function InviteMemberDialog({
  organizationId,
  myRole,
}: {
  organizationId: string;
  myRole: Role;
}) {
  const t = useExtracted("organization");
  const fieldId = useId();
  const roleLabel = useRoleLabel();
  const errorMessage = useErrorMessage();
  const inviteLink = useInviteLink();
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<Role>("member");

  const roles = assignableRoles(myRole).map((each) => ({
    value: each,
    label: roleLabel(each),
  }));

  // The server renders the pending list; `invitationSent` renders it again.
  const { invite, inviting, sent, error, clear } = useInvitations({
    organizationId,
    schema: blockConfig.organizations.api,
    onChanged: async ({ kind }) => {
      if (kind === "invited") {
        await invitationSent({});
      }
    },
  });

  const link = sent ? inviteLink(sent.token) : null;

  const message =
    error === undefined
      ? null
      : error.kind === "conflict"
        ? t("That person is already a member or invited.")
        : errorMessage(error);

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    await invite({ email: email.trim().toLowerCase(), role });
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        setEmail("");

        if (!next) {
          clear();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <UserPlusIcon />
        {t("Invite")}
      </DialogTrigger>
      <DialogContent>
        {link ? (
          <div className="grid gap-6">
            <DialogHeader>
              <DialogTitle>{t("Invitation created")}</DialogTitle>
              <DialogDescription>
                {t("Send this link. It works once and expires in seven days.")}
              </DialogDescription>
            </DialogHeader>
            <div className="flex gap-2">
              <Input
                value={link}
                readOnly
                aria-label={t("Invitation link")}
                data-testid="invite-link"
              />
              <Button
                variant="outline"
                size="icon"
                aria-label={t("Copy link")}
                onClick={() => {
                  void navigator.clipboard
                    .writeText(link)
                    .then(() => toast.success(t("Link copied")));
                }}
              >
                <CopyIcon />
              </Button>
            </div>
            <DialogFooter>
              <DialogClose render={<Button />}>{t("Done")}</DialogClose>
            </DialogFooter>
          </div>
        ) : (
          <form onSubmit={(event) => void submit(event)} className="grid gap-6">
            <DialogHeader>
              <DialogTitle>{t("Invite a teammate")}</DialogTitle>
              <DialogDescription>
                {t(
                  "They join with the role you pick. You can't give a role above your own.",
                )}
              </DialogDescription>
            </DialogHeader>
            <FieldGroup>
              <Field data-invalid={message ? true : undefined}>
                <FieldLabel htmlFor={`${fieldId}-invite-email`}>
                  {t("Email")}
                </FieldLabel>
                <Input
                  id={`${fieldId}-invite-email`}
                  name="email"
                  type="email"
                  value={email}
                  onChange={(event) => {
                    setEmail(event.target.value);
                  }}
                  required
                  autoComplete="off"
                />
                {message ? <FieldError>{message}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor={`${fieldId}-invite-role`}>
                  {t("Role")}
                </FieldLabel>
                <Select
                  value={role}
                  items={roles}
                  onValueChange={(value) => {
                    if (v.is(Role, value)) {
                      setRole(value);
                    }
                  }}
                >
                  <SelectTrigger id={`${fieldId}-invite-role`} className="w-40">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {roles.map((each) => (
                      <SelectItem key={each.value} value={each.value}>
                        {each.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </Field>
            </FieldGroup>
            <DialogFooter>
              <DialogClose render={<Button type="button" variant="outline" />}>
                {t("Cancel")}
              </DialogClose>
              <Button type="submit" disabled={inviting}>
                {t("Send invitation")}
              </Button>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

React Native, with `Button`, `Modal`, `Pressable`, `Text`, `TextInput`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-organizations
```

```tsx title="apps/examples/expo-powersync/src/components/invite-dialog.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import { Button, Modal, Pressable, Text, TextInput, View } from "react-native";

import { useInvitations } from "../hooks/use-organizations";
import { API_SCHEMA } from "../lib/blocks";

const ROLES = ["member", "admin"] as const;

/**
 * A button that opens a form to invite a teammate by email with a role, then
 * shows the token for the accept link (the example has no mailer).
 */
export function InviteDialog() {
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<(typeof ROLES)[number]>("member");

  const { invitations, invite, inviting, sent, error, clear } = useInvitations({
    organizationId: tenantOf(useAuth()),
    schema: API_SCHEMA,
  });

  const close = () => {
    setOpen(false);
    setEmail("");
    clear();
  };

  return (
    <View style={{ gap: 4 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>
        Pending invitations
      </Text>
      {invitations?.map((invitation) => (
        <Text key={invitation.id}>
          {invitation.email} ({invitation.role})
        </Text>
      ))}
      <Button
        title="Invite"
        onPress={() => {
          setOpen(true);
        }}
      />
      <Modal visible={open} animationType="slide" onRequestClose={close}>
        <View style={{ flex: 1, gap: 12, padding: 24 }}>
          {sent ? (
            <>
              <Text style={{ fontSize: 17, fontWeight: "600" }}>
                Invitation created
              </Text>
              <Text>Send this token in the accept link. It works once.</Text>
              <Text selectable style={{ fontFamily: "monospace" }}>
                {sent.token}
              </Text>
            </>
          ) : (
            <>
              <Text style={{ fontSize: 17, fontWeight: "600" }}>
                Invite a teammate
              </Text>
              <TextInput
                value={email}
                onChangeText={setEmail}
                placeholder="Email"
                keyboardType="email-address"
                autoCapitalize="none"
                autoComplete="off"
                style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
              />
              <View style={{ flexDirection: "row", gap: 8 }}>
                {ROLES.map((each) => (
                  <Pressable
                    key={each}
                    accessibilityRole="radio"
                    accessibilityState={{ checked: each === role }}
                    onPress={() => {
                      setRole(each);
                    }}
                    style={{
                      borderWidth: 1,
                      borderRadius: 16,
                      paddingHorizontal: 12,
                      paddingVertical: 4,
                      backgroundColor: each === role ? "#e5e5e5" : undefined,
                    }}
                  >
                    <Text>{each}</Text>
                  </Pressable>
                ))}
              </View>
              {error ? (
                <Text style={{ color: "#b91c1c" }}>{error.message}</Text>
              ) : null}
              <Button
                title="Send invitation"
                disabled={inviting || !email.trim()}
                onPress={() =>
                  void invite({ email: email.trim().toLowerCase(), role })
                }
              />
            </>
          )}
          <Button title={sent ? "Done" : "Cancel"} onPress={close} />
        </View>
      </Modal>
    </View>
  );
}
```

# Invoices table

> The organization's invoices with their status, amount and a link to the PDF.

Source: https://bettersupabase.com/docs/registry/ui-recipes/invoices-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [invoices block](https://bettersupabase.com/blocks/invoices) must be installed (`npx better-supabase add invoices`). Its page on bettersupabase.com is [/recipes/invoices-table](https://bettersupabase.com/recipes/invoices-table).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/billing/components/invoices-table.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/**
 * Render inside `<Suspense>`: the organization's invoices with their status,
 * total and what's still owed. Renders nothing without `invoices.read`, so the
 * billing page still shows the plan.
 */
export async function InvoicesTable() {
  const session = await getSession();

  if (!can(session, "invoices.read")) {
    return null;
  }

  const [list, t, format, locale] = await Promise.all([
    getInvoices(),
    getExtracted("billing"),
    getFormatter(),
    getLocale(),
  ]);

  if (list.kind === "no-organization") {
    return null;
  }

  const canWrite = can(session, "invoices.write");

  const status = (value: InvoiceStatus, overdue: boolean) => {
    if (overdue) {
      return <Badge variant="destructive-light">{t("Overdue")}</Badge>;
    }

    switch (value) {
      case "draft": {
        return <Badge variant="secondary">{t("Draft")}</Badge>;
      }

      case "issued": {
        return <Badge variant="info-light">{t("Issued")}</Badge>;
      }

      case "partially_paid": {
        return <Badge variant="warning-light">{t("Partly paid")}</Badge>;
      }

      case "paid": {
        return <Badge variant="success-light">{t("Paid")}</Badge>;
      }

      case "void": {
        return <Badge variant="outline">{t("Void")}</Badge>;
      }

      case "uncollectible": {
        return <Badge variant="destructive-light">{t("Uncollectible")}</Badge>;
      }

      default: {
        const unknown: never = value;

        return unknown;
      }
    }
  };

  const hasInvoices = list.kind === "ok" && list.invoices.length > 0;

  return (
    <Card data-recipe="invoices-table">
      <CardHeader>
        <CardTitle>{t("Invoices")}</CardTitle>
        <CardDescription>
          {t("Every invoice for this organization, newest first.")}
        </CardDescription>
        {hasInvoices && canWrite ? (
          <CardAction>
            <CreateDraftInvoiceDialog variant="outline" />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {list.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The invoices didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : list.invoices.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <FileTextIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No invoices yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Invoices show up here once they're created.")}
              </EmptyDescription>
            </EmptyHeader>
            {canWrite ? (
              <EmptyContent>
                <CreateDraftInvoiceDialog />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Invoice")}</TableHead>
                <TableHead>{t("Status")}</TableHead>
                <TableHead>{t("Due")}</TableHead>
                <TableHead className="text-right">{t("Total")}</TableHead>
                <TableHead className="text-right">{t("Owed")}</TableHead>
                {canWrite ? (
                  <TableHead>
                    <span className="sr-only">{t("Actions")}</span>
                  </TableHead>
                ) : null}
              </TableRow>
            </TableHeader>
            <TableBody>
              {list.invoices.map((invoice) => (
                <TableRow key={invoice.id}>
                  <TableCell>
                    <div className="flex flex-col gap-1">
                      <span className="text-sm">{invoice.title}</span>
                      <span className="font-mono text-xs text-muted-foreground">
                        {invoice.identifier ?? t("No number yet")}
                      </span>
                    </div>
                  </TableCell>
                  <TableCell>
                    {status(invoice.status, invoice.overdue)}
                  </TableCell>
                  <TableCell className="text-muted-foreground">
                    {invoice.dueOn
                      ? format.dateTime(new Date(invoice.dueOn), {
                          dateStyle: "medium",
                          timeZone: "UTC",
                        })
                      : t("Not issued")}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {formatMoney(invoice.total, locale)}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {formatMoney(invoice.balance, locale)}
                  </TableCell>
                  {canWrite ? (
                    <TableCell className="text-right">
                      {invoice.status === "draft" ? (
                        <IssueInvoiceButton invoiceId={invoice.id} />
                      ) : null}
                    </TableCell>
                  ) : null}
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}
```

# Job runs table

> The latest background job runs with their status, attempts and error, and a button to retry a failed run.

Source: https://bettersupabase.com/docs/registry/ui-recipes/job-runs-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [queue block](https://bettersupabase.com/blocks/queue) must be installed (`npx better-supabase add queue`). Its page on bettersupabase.com is [/recipes/job-runs-table](https://bettersupabase.com/recipes/job-runs-table).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/jobs/components/job-runs-table.tsx"
type JobRunsState =
  | { readonly kind: "ok"; readonly runs: readonly JobRunRow[] }
  | { readonly kind: "error" }
  | { readonly kind: "no-organization" };

async function loadJobRunsState(): Promise<JobRunsState> {
  try {
    const runs = await loadJobRuns();

    return runs ? { kind: "ok", runs } : { kind: "no-organization" };
  } catch (error) {
    unstable_rethrow(error);

    return { kind: "error" };
  }
}

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

type Translate = Awaited<ReturnType<typeof getExtracted>>;

function JobStatusBadge({ status, t }: { status: JobRunStatus; t: Translate }) {
  switch (status) {
    case "queued": {
      return <Badge variant="info-light">{t("Queued")}</Badge>;
    }

    case "scheduled": {
      return <Badge variant="secondary">{t("Scheduled")}</Badge>;
    }

    case "running": {
      return <Badge variant="primary-light">{t("Running")}</Badge>;
    }

    case "retrying": {
      return <Badge variant="warning-light">{t("Retrying")}</Badge>;
    }

    case "succeeded": {
      return <Badge variant="success-light">{t("Succeeded")}</Badge>;
    }

    case "dead": {
      return <Badge variant="destructive-light">{t("Failed")}</Badge>;
    }

    default: {
      const never: never = status;

      return never;
    }
  }
}

/**
 * Render inside `<Suspense>`: the organization's latest background jobs across
 * every queue, with a retry button on failed runs. Gated by `audit.read`, since
 * it reads with the database owner.
 */
export async function JobRunsTable() {
  const [session, t, format] = await Promise.all([
    getSession(),
    getExtracted("jobs"),
    getFormatter(),
  ]);

  if (!can(session, JOBS_PERMISSION)) {
    forbidden();
  }

  const state = await loadJobRunsState();

  if (state.kind === "no-organization") {
    return null;
  }

  const when = (iso: string) =>
    format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short" });

  return (
    <Card data-recipe="job-runs-table">
      <CardHeader>
        <CardTitle>{t("Job runs")}</CardTitle>
        <CardDescription>
          {t(
            "Background work queued for this organization, newest first. Failed jobs retry with backoff before they stop.",
          )}
        </CardDescription>
        {state.kind === "ok" && state.runs.length > 0 ? (
          <CardAction>
            <EnqueueTestJobButton variant="outline" />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {state.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The job runs didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : state.runs.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <ListChecksIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No job runs yet")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "Jobs are queued when the app hands work to the background, such as a webhook delivery or an import. Queue a test job to see one here.",
                )}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <EnqueueTestJobButton />
            </EmptyContent>
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Job")}</TableHead>
                <TableHead>{t("Status")}</TableHead>
                <TableHead>{t("Attempts")}</TableHead>
                <TableHead>{t("Queued")}</TableHead>
                <TableHead>{t("Finished")}</TableHead>
                <TableHead className="text-right">{t("Actions")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {state.runs.map((run) => (
                <TableRow key={`${run.queue}:${String(run.id)}`}>
                  <TableCell>
                    <div className="flex flex-col gap-1">
                      <span className="font-mono text-sm">{run.queue}</span>
                      <span
                        className="max-w-xs truncate text-xs text-muted-foreground"
                        title={run.lastError ?? undefined}
                      >
                        {run.lastError ?? `#${String(run.id)}`}
                      </span>
                    </div>
                  </TableCell>
                  <TableCell>
                    <JobStatusBadge status={run.status} t={t} />
                  </TableCell>
                  <TableCell className="tabular-nums">{run.attempts}</TableCell>
                  <TableCell className="text-xs text-muted-foreground">
                    {when(run.enqueuedAt)}
                  </TableCell>
                  <TableCell className="text-xs text-muted-foreground">
                    {run.finishedAt ? when(run.finishedAt) : "–"}
                  </TableCell>
                  <TableCell className="text-right">
                    {run.status === "dead" ? (
                      <RetryJobButton queue={run.queue} id={run.id} />
                    ) : null}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}

export function JobRunsTableSkeleton() {
  return <Skeleton className="h-40 rounded-xl" aria-busy="true" />;
}
```

# Kitchen display

> Open tickets per kitchen station, each line moved from queued to served, refreshed over Realtime.

Source: https://bettersupabase.com/docs/registry/ui-recipes/kitchen-display

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [orders block](https://bettersupabase.com/blocks/orders) must be installed (`npx better-supabase add orders`). Its page on bettersupabase.com is [/recipes/kitchen-display](https://bettersupabase.com/recipes/kitchen-display).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/shop/components/kitchen-display.tsx"
/**
 * Render inside `<Suspense>`: the open tickets at one station, or at every
 * station, oldest first. Each ticket moves its lines to the next state, and the
 * page refreshes when a line changes anywhere in the kitchen.
 */
export async function KitchenDisplay({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, params] = await Promise.all([getSession(), searchParams]);

  if (!can(session, "orders.read") && !can(session, "orders.prepare")) {
    forbidden();
  }

  const canPrepare =
    can(session, "orders.prepare") || can(session, "orders.manage");

  const [kitchen, t] = await Promise.all([
    getKitchen(
      v.is(StationParam, params["station"]) ? params["station"] : null,
    ),
    getExtracted("shop"),
  ]);

  if (kitchen.kind === "no-organization") {
    return null;
  }

  if (kitchen.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="kitchen-display">
        <TriangleAlertIcon />
        <AlertTitle>{t("The kitchen didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { organizationId, stations, stationId, tickets } = kitchen.data;

  return (
    <div data-recipe="kitchen-display" className="flex flex-col gap-4">
      <KitchenLive organizationId={organizationId} />
      {stations.length > 1 ? (
        <nav aria-label={t("Stations")} className="flex flex-wrap gap-1">
          <Link
            href="/shop/kitchen"
            aria-current={stationId === null ? "page" : undefined}
            className={buttonVariants({
              size: "sm",
              variant: stationId === null ? "secondary" : "ghost",
            })}
          >
            {t("All stations")}
          </Link>
          {stations.map((each) => (
            <Link
              key={each.id}
              href={`/shop/kitchen?station=${each.id}`}
              aria-current={stationId === each.id ? "page" : undefined}
              className={buttonVariants({
                size: "sm",
                variant: stationId === each.id ? "secondary" : "ghost",
              })}
            >
              {each.name}
            </Link>
          ))}
        </nav>
      ) : null}
      {tickets.length === 0 ? (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <ChefHatIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No open tickets")}</EmptyTitle>
            <EmptyDescription>
              {t(
                "Send a paid order to the kitchen and its lines show up here.",
              )}
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Link
              href="/shop/orders"
              className={buttonVariants({ size: "sm" })}
            >
              {t("Open orders")}
            </Link>
          </EmptyContent>
        </Empty>
      ) : (
        <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
          {tickets.map((ticket) => (
            <KitchenTicket
              key={ticket.orderId}
              ticket={ticket}
              stationId={stationId}
              canPrepare={canPrepare}
            />
          ))}
        </div>
      )}
    </div>
  );
}
```

# Knowledge base upload

> A form that adds a document to the knowledge base, which chunks and embeds it for search.

Source: https://bettersupabase.com/docs/registry/ui-recipes/knowledge-upload

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [knowledge block](https://bettersupabase.com/blocks/knowledge) must be installed (`npx better-supabase add knowledge`). Its page on bettersupabase.com is [/recipes/knowledge-upload](https://bettersupabase.com/recipes/knowledge-upload).

Web, with shadcn components:

```bash
npx shadcn add button field input textarea
```

```tsx title="apps/examples/nextjs/src/features/knowledge/components/add-document-form.tsx"
"use client";

import { useActionForm } from "better-supabase/react";
import { useExtracted } from "next-intl";
import { useId } from "react";

import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useErrorMessage } from "@/lib/use-error-message";

import { addDocument } from "../knowledge-actions";

export function AddDocumentForm() {
  const t = useExtracted("knowledge");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const form = useActionForm(addDocument);
  const error = form.error === undefined ? null : errorMessage(form.error);

  return (
    <form {...form.formProps} className="space-y-3">
      <Field>
        <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
        <Input id={`${fieldId}-title`} name="title" required maxLength={200} />
      </Field>
      <Field data-invalid={error ? true : undefined}>
        <FieldLabel htmlFor={`${fieldId}-text`}>{t("Text")}</FieldLabel>
        <Textarea
          id={`${fieldId}-text`}
          name="text"
          placeholder={t("Paste a policy, a guide or meeting notes")}
          required
          maxLength={100_000}
          rows={6}
        />
        {error ? <FieldError>{error}</FieldError> : null}
      </Field>
      <Button type="submit" size="sm" disabled={form.pending}>
        {t("Add document")}
      </Button>
    </form>
  );
}
```

# Lesson player

> One lesson's content with a Mark as done button, or its quiz, which the database grades on submit.

Source: https://bettersupabase.com/docs/registry/ui-recipes/lesson-player

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [courses block](https://bettersupabase.com/blocks/courses) must be installed (`npx better-supabase add courses`). Its page on bettersupabase.com is [/recipes/lesson-player](https://bettersupabase.com/recipes/lesson-player).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card checkbox field input select skeleton textarea
```

```tsx title="apps/examples/nextjs/src/features/courses/components/lesson-player.tsx"
import type { CourseLessonKind } from "better-supabase/blocks/courses";

import { ArrowLeftIcon, TriangleAlertIcon } from "lucide-react";
import { getExtracted } from "next-intl/server";
import { refresh } from "next/cache";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "@/i18n/navigation";

import { getLesson } from "../courses-queries";
import { CompleteLessonButton } from "./course-controls";
import { QuizForm } from "./quiz-form";

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: one lesson's content, or its quiz. */
export async function LessonPlayer({
  params,
}: {
  params: Promise<{ courseId: string; lessonId: string }>;
}) {
  const { courseId, lessonId } = await params;

  const [view, t] = await Promise.all([
    getLesson(lessonId),
    getExtracted("courses"),
  ]);

  const back = (
    <Link
      href={`/courses/${courseId}`}
      className={buttonVariants({
        variant: "ghost",
        size: "sm",
        className: "w-fit",
      })}
    >
      <ArrowLeftIcon />
      {t("Course outline")}
    </Link>
  );

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <div data-recipe="lesson-player" className="flex flex-col gap-4">
        {back}
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>{t("The lesson didn't load")}</AlertTitle>
          <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
          <AlertAction>
            <form action={retry}>
              <Button size="sm" variant="outline" type="submit">
                {t("Retry")}
              </Button>
            </form>
          </AlertAction>
        </Alert>
      </div>
    );
  }

  const lesson = view.data;

  const kindLabels: Record<CourseLessonKind, string> = {
    content: t("Reading"),
    video: t("Video"),
    quiz: t("Quiz"),
  };

  return (
    <div data-recipe="lesson-player" className="flex flex-col gap-4">
      {back}
      <Card>
        <CardHeader>
          <div className="flex flex-wrap items-center gap-2">
            <CardTitle>{lesson.title}</CardTitle>
            <Badge variant="outline">{kindLabels[lesson.kind]}</Badge>
            {lesson.done ? (
              <Badge variant="success-light">{t("Done")}</Badge>
            ) : null}
          </div>
          {lesson.lastScore === null ? null : (
            <CardDescription>
              {t("Last score: {score, number} of {max, number}", {
                score: lesson.lastScore.score,
                max: lesson.lastScore.max,
              })}
            </CardDescription>
          )}
        </CardHeader>
        {lesson.kind === "quiz" ? (
          lesson.quiz === null || lesson.quiz.length === 0 ? (
            <CardContent>
              <Alert variant="warning">
                <TriangleAlertIcon />
                <AlertTitle>{t("This quiz has no questions")}</AlertTitle>
                <AlertDescription>
                  {t("A manager publishes the quiz form first.")}
                </AlertDescription>
              </Alert>
            </CardContent>
          ) : (
            <QuizForm lessonId={lesson.id} fields={lesson.quiz} />
          )
        ) : (
          <>
            <CardContent className="flex flex-col gap-4">
              {lesson.text === null ? null : (
                <p className="text-sm whitespace-pre-line">{lesson.text}</p>
              )}
              {lesson.mediaUrl === null ? null : (
                <a
                  href={lesson.mediaUrl}
                  target="_blank"
                  rel="noreferrer"
                  className="text-sm text-primary transition-colors hover:text-primary/80"
                >
                  {t("Open the video")}
                </a>
              )}
            </CardContent>
            {lesson.done ? null : (
              <CardFooter className="flex justify-end">
                <CompleteLessonButton lessonId={lesson.id} />
              </CardFooter>
            )}
          </>
        )}
      </Card>
    </div>
  );
}

export function LessonPlayerSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

# Members table

> An organization's members with their roles, where a role change shows at once and a member can be removed.

Source: https://bettersupabase.com/docs/registry/ui-recipes/members-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useMembers` from `@/hooks/use-organizations`; the [organizations block](https://bettersupabase.com/blocks/organizations) must be installed (`npx better-supabase add organizations`). Its page on bettersupabase.com is [/recipes/members-table](https://bettersupabase.com/recipes/members-table).

Web, with shadcn components:

```bash
npx shadcn add avatar button select table
```

```bash
npx shadcn add @better-supabase/use-organizations
```

```tsx title="apps/examples/nextjs/src/features/organization/components/member-table.tsx"
"use client";

import { DbException } from "better-supabase";
import { UserMinusIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { toast } from "sonner";
import * as v from "valibot";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { RoleBadge } from "@/features/user/components/role-badge";
import { assignableRoles } from "@/features/user/user-permissions";
import { useMembers } from "@/hooks/use-organizations";
import { Role } from "@/lib/claims";
import { initials } from "@/lib/initials";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import type { Member } from "../organization-queries";

import { loadMembers, memberChanged } from "../organization-actions";
import { useRoleLabel } from "../use-role-label";

async function loadOrganizationMembers(): Promise<readonly Member[]> {
  const result = await loadMembers({});

  if (!result.ok) {
    throw new DbException(result.error);
  }

  return result.data;
}

/**
 * The organization's members. The server renders the rows; a role change shows
 * at once and goes through `update_member_role` in the browser.
 */
export function MemberTable({
  organizationId,
  initial,
  currentUserId,
  myRole,
  canUpdateRole,
  canRemove,
}: {
  organizationId: string;
  initial: readonly Member[];
  currentUserId: string;
  myRole: Role;
  canUpdateRole: boolean;
  canRemove: boolean;
}) {
  const t = useExtracted("organization");
  const roleLabel = useRoleLabel();
  const errorMessage = useErrorMessage();
  const format = useFormatter();

  const { members, pending, setRole, remove } = useMembers({
    organizationId,
    load: loadOrganizationMembers,
    initial,
    schema: blockConfig.organizations.api,
    onChanged: async ({ userId }) => {
      await memberChanged({ userId });
    },
  });

  const assignable = assignableRoles(myRole);

  const roleItems = assignable.map((role) => ({
    value: role,
    label: roleLabel(role),
  }));

  // Nobody changes their own role or an owner's here; ownership moves by transfer.
  const editable = (member: Member) =>
    member.userId !== currentUserId &&
    member.role !== "owner" &&
    assignable.includes(member.role);

  const busy = (member: Member) => pending.includes(member.userId);

  const changeRole = async (member: Member, role: Role) => {
    const result = await setRole(member.userId, role);

    if (result.ok) {
      toast.success(t("Role updated"));
    } else {
      toast.error(errorMessage(result.error));
    }
  };

  const removeMember = async (member: Member) => {
    const result = await remove(member.userId);

    if (result.ok) {
      toast.success(t("Member removed"));
    } else {
      toast.error(errorMessage(result.error));
    }
  };

  return (
    <Table data-testid="members">
      <TableHeader>
        <TableRow>
          <TableHead>{t("Member")}</TableHead>
          <TableHead>{t("Role")}</TableHead>
          <TableHead className="hidden sm:table-cell">{t("Joined")}</TableHead>
          <TableHead className="w-10" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {(members ?? initial).map((member) => (
          <TableRow
            key={member.userId}
            aria-busy={busy(member) ? true : undefined}
            className={busy(member) ? "opacity-50" : undefined}
          >
            <TableCell>
              <div className="flex items-center gap-3">
                <Avatar className="size-8">
                  {member.avatarUrl ? (
                    <AvatarImage src={member.avatarUrl} alt="" />
                  ) : null}
                  <AvatarFallback>
                    {initials(member.name, member.email)}
                  </AvatarFallback>
                </Avatar>
                <div className="grid leading-tight">
                  <span className="font-medium">
                    {member.name ?? member.email}
                    {member.userId === currentUserId ? (
                      <span className="font-normal text-muted-foreground">
                        {" "}
                        ({t("you")})
                      </span>
                    ) : null}
                  </span>
                  <span className="text-xs text-muted-foreground">
                    {member.email}
                  </span>
                </div>
              </div>
            </TableCell>
            <TableCell>
              {canUpdateRole && editable(member) ? (
                <Select
                  value={member.role}
                  items={roleItems}
                  disabled={busy(member)}
                  onValueChange={(value) => {
                    if (v.is(Role, value)) {
                      void changeRole(member, value);
                    }
                  }}
                >
                  <SelectTrigger
                    size="sm"
                    className="w-32"
                    aria-label={t("Role")}
                  >
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {roleItems.map((item) => (
                      <SelectItem key={item.value} value={item.value}>
                        {item.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              ) : (
                <RoleBadge role={member.role} />
              )}
            </TableCell>
            <TableCell className="hidden text-muted-foreground sm:table-cell">
              {member.joinedAt
                ? format.dateTime(new Date(member.joinedAt), {
                    dateStyle: "medium",
                  })
                : "–"}
            </TableCell>
            <TableCell>
              {canRemove && editable(member) ? (
                <Button
                  variant="ghost"
                  size="icon-sm"
                  disabled={busy(member)}
                  aria-label={t("Remove {name}", {
                    name: member.name ?? member.email ?? "",
                  })}
                  onClick={() => {
                    void removeMember(member);
                  }}
                >
                  <UserMinusIcon />
                </Button>
              ) : null}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

React Native, with `ActivityIndicator`, `Pressable`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-organizations
```

```tsx title="apps/examples/expo-powersync/src/components/members-table.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";

import { useMembers } from "../hooks/use-organizations";
import { API_SCHEMA } from "../lib/blocks";

/**
 * The active organization's members. Pressing a role switches it between member
 * and admin at once; the organizations module refuses a role above the
 * caller's, and the row shows the old role again.
 */
export function MembersTable() {
  const auth = useAuth();

  const { members, pending, error, setRole, remove } = useMembers({
    organizationId: tenantOf(auth),
    schema: API_SCHEMA,
  });

  if (members === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 4 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Members</Text>
      {members.map((member) => {
        const busy = pending.includes(member.userId);

        const editable =
          member.userId !== auth.user?.id && member.role !== "owner";

        const next = member.role === "admin" ? "member" : "admin";

        return (
          <View
            key={member.userId}
            accessibilityState={{ busy }}
            style={{
              flexDirection: "row",
              alignItems: "center",
              gap: 8,
              paddingVertical: 8,
              opacity: busy ? 0.5 : 1,
            }}
          >
            <Text style={{ flex: 1 }} numberOfLines={1}>
              {member.userId === auth.user?.id ? "You" : member.userId}
            </Text>
            <Pressable
              accessibilityRole="button"
              accessibilityHint={editable ? `Changes the role to ${next}` : ""}
              disabled={!editable || busy}
              onPress={() => void setRole(member.userId, next)}
            >
              <Text style={{ color: editable ? "#2563eb" : "#737373" }}>
                {member.role}
              </Text>
            </Pressable>
            {editable ? (
              <Pressable
                accessibilityRole="button"
                disabled={busy}
                onPress={() => void remove(member.userId)}
              >
                <Text style={{ color: "#b91c1c" }}>Remove</Text>
              </Pressable>
            ) : null}
          </View>
        );
      })}
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
    </View>
  );
}
```

# Message composer

> A message box that sends on Enter, signals typing, and uploads dropped, pasted or picked files onto the message.

Source: https://bettersupabase.com/docs/registry/ui-recipes/message-composer

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [messaging](https://bettersupabase.com/blocks/messaging), [attachments](https://bettersupabase.com/blocks/attachments) blocks must be installed (`npx better-supabase add messaging attachments`). Its page on bettersupabase.com is [/recipes/message-composer](https://bettersupabase.com/recipes/message-composer).

Web, with shadcn components:

```bash
npx shadcn add badge button label spinner textarea
```

```tsx title="apps/examples/nextjs/src/features/chat/components/message-composer.tsx"
"use client";

import type { Result } from "better-supabase";
import type { DragEvent, Ref } from "react";

import { useAction } from "better-supabase/react";
import { PaperclipIcon, SendIcon, XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useRef, useState } from "react";
import { toast } from "sonner";

import type { MessageDraft } from "@/hooks/use-messaging";

import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { useSupabase } from "@/lib/hooks";
import { useErrorMessage } from "@/lib/use-error-message";

import { confirmChatUpload, startChatUpload } from "../chat-actions";

interface PendingFile {
  readonly key: string;
  readonly name: string;
  /** Set once the bytes are uploaded and confirmed. */
  readonly id: string | null;
}

/** How often a held key press tells the others the caller is typing. */
const TYPING_EVERY_MS = 2000;

/**
 * Writes a message with files: drop them on the box, paste them, or pick them.
 * Each file uploads to the conversation first; `send` moves it onto the
 * message.
 */
export function MessageComposer({
  conversationId,
  placeholder,
  inputRef,
  onSend,
  onTyping,
}: {
  conversationId: string;
  placeholder: string;
  inputRef?: Ref<HTMLTextAreaElement>;
  onSend: (body: string, draft: MessageDraft) => Promise<Result<unknown>>;
  onTyping: (typing: boolean) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const supabase = useSupabase();
  const bodyId = useId();
  const picker = useRef<HTMLInputElement>(null);
  const lastPing = useRef(0);
  const [body, setBody] = useState("");
  const [files, setFiles] = useState<readonly PendingFile[]>([]);
  const [sending, setSending] = useState(false);

  const start = useAction(startChatUpload);
  const confirm = useAction(confirmChatUpload);

  const uploading = files.some((file) => file.id === null);

  const drop = (key: string) => {
    setFiles((current) => current.filter((file) => file.key !== key));
  };

  const upload = async (file: File) => {
    const key = crypto.randomUUID();
    setFiles((current) => [...current, { key, name: file.name, id: null }]);

    const started = await start.run({
      conversationId,
      name: file.name,
      mimeType: file.type,
      size: file.size,
    });

    if (!started.ok) {
      drop(key);
      toast.error(errorMessage(started.error));

      return;
    }

    const { id, bucket, path, token } = started.data;

    const sent = await supabase.storage
      .from(bucket)
      .uploadToSignedUrl(path, token, file, { contentType: file.type });

    const confirmed = sent.error ? undefined : await confirm.run({ id });

    if (!confirmed?.ok) {
      drop(key);
      toast.error(t("{name} did not upload. Try again.", { name: file.name }));

      return;
    }

    setFiles((current) =>
      current.map((each) => (each.key === key ? { ...each, id } : each)),
    );
  };

  const add = (list: FileList | null) => {
    for (const file of list ?? []) {
      void upload(file);
    }
  };

  const submit = async () => {
    const text = body.trim();

    if ((text === "" && files.length === 0) || uploading) {
      return;
    }

    setSending(true);

    const result = await onSend(text, {
      attachmentIds: files.flatMap((file) => (file.id ? [file.id] : [])),
    });

    setSending(false);

    if (result.ok) {
      setBody("");
      setFiles([]);
    } else {
      toast.error(errorMessage(result.error));
    }
  };

  return (
    <form
      className="flex flex-col gap-2"
      onSubmit={(event) => {
        event.preventDefault();
        void submit();
      }}
    >
      <Label htmlFor={bodyId} className="sr-only">
        {t("Message")}
      </Label>
      <Textarea
        ref={inputRef}
        id={bodyId}
        value={body}
        rows={2}
        maxLength={4000}
        placeholder={placeholder}
        onDragOver={(event: DragEvent) => {
          event.preventDefault();
        }}
        onDrop={(event: DragEvent) => {
          event.preventDefault();
          add(event.dataTransfer.files);
        }}
        onChange={(event) => {
          setBody(event.target.value);

          if (event.timeStamp - lastPing.current > TYPING_EVERY_MS) {
            lastPing.current = event.timeStamp;
            onTyping(true);
          }
        }}
        onPaste={(event) => {
          if (event.clipboardData.files.length > 0) {
            event.preventDefault();
            add(event.clipboardData.files);
          }
        }}
        onKeyDown={(event) => {
          if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault();
            void submit();
          }
        }}
      />
      {files.length > 0 ? (
        <ul className="flex flex-wrap gap-2" aria-live="polite">
          {files.map((file) => (
            <li key={file.key}>
              <Badge variant="outline">
                {file.id === null ? <Spinner /> : <PaperclipIcon aria-hidden />}
                {file.name}
                <button
                  type="button"
                  aria-label={t("Remove {name}", { name: file.name })}
                  className="text-muted-foreground transition-colors hover:text-foreground"
                  onClick={() => {
                    drop(file.key);
                  }}
                >
                  <XIcon aria-hidden />
                </button>
              </Badge>
            </li>
          ))}
        </ul>
      ) : null}
      <div className="flex items-center gap-2">
        <input
          ref={picker}
          type="file"
          multiple
          hidden
          onChange={(event) => {
            add(event.target.files);
            event.target.value = "";
          }}
        />
        <Button
          type="button"
          size="icon-sm"
          variant="ghost"
          aria-label={t("Attach files")}
          onClick={() => picker.current?.click()}
        >
          <PaperclipIcon aria-hidden />
        </Button>
        <span className="text-xs text-muted-foreground">
          {t("Drop or paste files to attach them.")}
        </span>
        <Button
          type="submit"
          size="sm"
          className="ml-auto"
          disabled={sending || uploading}
        >
          <SendIcon aria-hidden />
          {t("Send")}
        </Button>
      </div>
    </form>
  );
}
```

# Message list

> A conversation's messages over Realtime, with older pages on demand, who is typing, reply counts and the composer.

Source: https://bettersupabase.com/docs/registry/ui-recipes/message-list

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useMessages` from `@/hooks/use-messaging`; the [messaging](https://bettersupabase.com/blocks/messaging), [channels](https://bettersupabase.com/blocks/channels) blocks must be installed (`npx better-supabase add messaging channels`). Its page on bettersupabase.com is [/recipes/message-list](https://bettersupabase.com/recipes/message-list).

Web, with shadcn components:

```bash
npx shadcn add alert button card empty scroll-area skeleton
```

```bash
npx shadcn add @better-supabase/use-messaging
```

```tsx title="apps/examples/nextjs/src/features/chat/components/message-list.tsx"
"use client";

import type { Message } from "better-supabase/blocks/messaging";

import { MessagesSquareIcon, RotateCwIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useRef } from "react";

import { Alert, AlertAction, AlertTitle } from "@/components/reui/alert";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { useMessages } from "@/hooks/use-messaging";
import { useErrorMessage } from "@/lib/use-error-message";

import { MessageComposer } from "./message-composer";
import { MessageItem } from "./message-item";
import { PresenceAvatars } from "./presence-avatars";

/**
 * A channel's top-level messages, live over Realtime: older pages on demand,
 * who is typing, and the composer. Replies open in a thread.
 */
export function MessageList({
  conversationId,
  name,
  topic,
  onOpenThread,
}: {
  conversationId: string;
  name: string;
  topic?: string | undefined;
  onOpenThread: (root: Message) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const input = useRef<HTMLTextAreaElement>(null);

  const {
    messages,
    hasOlder,
    typing,
    error,
    send,
    setTyping,
    loadOlder,
    refresh,
  } = useMessages({ conversationId });

  return (
    <Card size="sm" className="min-h-0">
      <CardHeader>
        <CardTitle>#{name}</CardTitle>
        {topic ? <CardDescription>{topic}</CardDescription> : null}
        <CardAction>
          <PresenceAvatars
            subject={{ type: "conversation", id: conversationId }}
          />
        </CardAction>
      </CardHeader>
      <CardContent className="flex min-h-0 flex-1 flex-col gap-3">
        {error ? (
          <Alert variant="destructive" aria-live="polite">
            <AlertTitle>{errorMessage(error)}</AlertTitle>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void refresh()}
              >
                <RotateCwIcon aria-hidden />
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : null}
        {messages === undefined ? (
          <div
            className="flex flex-col gap-4"
            aria-busy="true"
            aria-live="polite"
          >
            <span className="sr-only">{t("Loading messages")}</span>
            <Skeleton className="h-12" />
            <Skeleton className="h-12" />
            <Skeleton className="h-12" />
          </div>
        ) : messages.length === 0 ? (
          <Empty className="flex-1">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <MessagesSquareIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No messages yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Say hello to start the conversation.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button
                type="button"
                size="sm"
                onClick={() => input.current?.focus()}
              >
                {t("Write a message")}
              </Button>
            </EmptyContent>
          </Empty>
        ) : (
          <ScrollArea className="min-h-0 flex-1">
            <div className="flex flex-col gap-4 pr-3">
              {hasOlder ? (
                <Button
                  type="button"
                  size="xs"
                  variant="ghost"
                  className="self-center"
                  onClick={() => void loadOlder()}
                >
                  {t("Load older messages")}
                </Button>
              ) : null}
              <ol className="flex flex-col gap-4" data-testid="messages">
                {messages.map((message) => (
                  <MessageItem
                    key={message.id}
                    message={message}
                    onOpenThread={onOpenThread}
                  />
                ))}
              </ol>
            </div>
          </ScrollArea>
        )}
      </CardContent>
      <CardFooter className="flex-col items-stretch gap-1">
        <p className="text-xs text-muted-foreground" aria-live="polite">
          {typing.length > 0
            ? t(
                "{count, plural, one {Someone is typing} other {# people are typing}}",
                { count: typing.length },
              )
            : null}
        </p>
        <MessageComposer
          conversationId={conversationId}
          inputRef={input}
          placeholder={t("Message #{name}", { name })}
          onSend={send}
          onTyping={setTyping}
        />
      </CardFooter>
    </Card>
  );
}
```

# Metrics dashboard

> A card per metric: the 30-day total against the 30 days before, with daily bars.

Source: https://bettersupabase.com/docs/registry/ui-recipes/metrics-dashboard

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [metrics block](https://bettersupabase.com/blocks/metrics) must be installed (`npx better-supabase add metrics`). Its page on bettersupabase.com is [/recipes/metrics-dashboard](https://bettersupabase.com/recipes/metrics-dashboard).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/metrics/components/metrics-dashboard.tsx"
import { toChartData } from "better-supabase/blocks/metrics";
import { tenantOf } from "better-supabase/next";
import { ChartColumnIcon, TriangleAlertIcon } from "lucide-react";
import { getExtracted, getFormatter } from "next-intl/server";
import { refresh } from "next/cache";
import { unstable_rethrow } from "next/navigation";
import { Temporal } from "temporal-polyfill";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { getSession } from "@/features/user/user-queries";
import { Link } from "@/i18n/navigation";
import { bsb } from "@/lib/supabase/server";
import { metrics } from "@/metrics";

const DAYS = 30;
const DAY_MS = 86_400_000;

type MetricKey = (typeof metrics)[number]["key"];

interface MetricPanel {
  readonly key: MetricKey;
  readonly kpi: Kpi;
  readonly rows: readonly MetricChartRow[];
}

type Dashboard =
  | { readonly kind: "ok"; readonly panels: readonly MetricPanel[] }
  | { readonly kind: "error" };

/**
 * Every metric in `src/metrics.ts` over the last 30 days: the total against the
 * 30 days before, and one point per day. Cached per session; the metrics module
 * checks `metrics.read` on each read. It throws on a failed read, so a failure
 * is never cached and Retry reads again.
 */
async function loadPanels(
  organizationId: string,
): Promise<readonly MetricPanel[]> {
  "use cache: private";
  const { db } = await bsb.cached();
  const until = Temporal.Now.instant();

  const range = {
    from: until.subtract({ milliseconds: DAYS * DAY_MS }),
    until,
  };

  const client = db.metrics;

  return Promise.all(
    metrics.map(async ({ key }) => {
      const [kpi, series] = await Promise.all([
        client
          .kpi(key, range, { organizationId, compare: "previous" })
          .orThrow(),
        client.series(key, range, { organizationId, grain: "day" }).orThrow(),
      ]);

      return { key, kpi, rows: toChartData(series) };
    }),
  );
}

async function loadDashboard(organizationId: string): Promise<Dashboard> {
  try {
    return { kind: "ok", panels: await loadPanels(organizationId) };
  } catch (error) {
    unstable_rethrow(error);

    return { kind: "error" };
  }
}

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: a card per defined metric, with its daily bars. */
export async function MetricsDashboard() {
  const organizationId = tenantOf(await getSession());

  if (!organizationId) {
    return null;
  }

  const [dashboard, t, format] = await Promise.all([
    loadDashboard(organizationId),
    getExtracted("metrics"),
    getFormatter(),
  ]);

  if (dashboard.kind === "error") {
    return (
      <Alert variant="destructive">
        <TriangleAlertIcon />
        <AlertTitle>{t("The metrics didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  if (dashboard.panels.every((panel) => (panel.kpi.value ?? 0) === 0)) {
    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <ChartColumnIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No data in the last 30 days")}</EmptyTitle>
          <EmptyDescription>
            {t("Add a customer and its metric shows up here.")}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <Link href="/customers" className={buttonVariants()}>
            {t("Add a customer")}
          </Link>
        </EmptyContent>
      </Empty>
    );
  }

  const labels: Record<MetricKey, string> = { customers: t("New customers") };

  return (
    <div className="grid gap-4 lg:grid-cols-2" data-testid="metrics-dashboard">
      {dashboard.panels.map((panel) => {
        const { change, trend, value, previous } = panel.kpi;
        const max = Math.max(1, ...panel.rows.map((row) => row.value ?? 0));

        return (
          <Card key={panel.key}>
            <CardHeader>
              <CardTitle>{labels[panel.key]}</CardTitle>
              <CardDescription>
                {t("{previous} in the 30 days before", {
                  previous: format.number(previous ?? 0),
                })}
              </CardDescription>
              {change === null || change === undefined ? null : (
                <CardAction>
                  <Badge
                    variant={
                      trend === "up"
                        ? "success-light"
                        : trend === "down"
                          ? "destructive-light"
                          : "secondary"
                    }
                  >
                    {format.number(change, {
                      style: "percent",
                      signDisplay: "exceptZero",
                    })}
                  </Badge>
                </CardAction>
              )}
            </CardHeader>
            <CardContent className="flex flex-col gap-4">
              <span className="text-3xl font-semibold tracking-tight tabular-nums">
                {format.number(value ?? 0)}
              </span>
              <div aria-hidden="true" className="flex h-24 items-end gap-0.5">
                {panel.rows.map((row) => (
                  <div
                    key={row.date}
                    title={`${format.dateTime(new Date(row.date), { dateStyle: "medium" })}: ${format.number(row.value ?? 0)}`}
                    className="h-full flex-1 origin-bottom scale-y-(--bar) bg-primary/80"
                    // SAFETY: React's CSSProperties has no custom properties; --bar is a number.
                    style={{ "--bar": (row.value ?? 0) / max } as CSSProperties}
                  />
                ))}
              </div>
            </CardContent>
          </Card>
        );
      })}
    </div>
  );
}

export function MetricsDashboardSkeleton() {
  return (
    <div className="grid gap-4 lg:grid-cols-2" aria-busy="true">
      {metrics.map(({ key }) => (
        <Skeleton key={key} className="h-64 rounded-xl" />
      ))}
    </div>
  );
}
```

# Two-factor setup

> A card that enrolls an authenticator app with a QR code, verifies the first code and removes the factor.

Source: https://bettersupabase.com/docs/registry/ui-recipes/mfa-setup

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [mfa block](https://bettersupabase.com/blocks/mfa) must be installed (`npx better-supabase add mfa`). Its page on bettersupabase.com is [/recipes/mfa-setup](https://bettersupabase.com/recipes/mfa-setup).

Web, with shadcn components:

```bash
npx shadcn add badge button card field input-otp
```

```tsx title="apps/examples/nextjs/src/features/user/components/two-factor-card.tsx"
"use client";

import { useSessionChange } from "better-supabase/next/client";
import { ShieldCheckIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import Image from "next/image";
import { useId, useState, useTransition } from "react";
import { toast } from "sonner";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp";
import { useSupabase } from "@/lib/hooks";

import type { AuthRead } from "../security-queries";

import { sessionChanged } from "../user-actions";

interface Enrollment {
  readonly factorId: string;
  readonly qrCode: string;
  readonly secret: string;
}

/**
 * TOTP through Supabase Auth MFA. The server reads the verified factor
 * (`getTotpFactorId`), so it is in the prefetched page; enrolling and verifying
 * run in the browser. A verified factor raises the session to `aal2`, which
 * `deleteMyAccount` and `DELETE /api/customers/:id` require.
 */
export function TwoFactorCard({
  initial,
}: {
  readonly initial: AuthRead<string | null>;
}) {
  const t = useExtracted("security");
  const fieldId = useId();
  const supabase = useSupabase();
  const changeSession = useSessionChange(sessionChanged);

  const refreshAndChange = useSessionChange(sessionChanged, undefined, {
    refreshToken: supabase,
  });

  const [factorId, setFactorId] = useState(initial.ok ? initial.value : null);
  const readError = initial.ok ? null : initial.error;
  const [enrollment, setEnrollment] = useState<Enrollment | null>(null);
  const [code, setCode] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  const enroll = () => {
    startTransition(async () => {
      setError(null);

      const { data, error: enrollError } = await supabase.auth.mfa.enroll({
        factorType: "totp",
        friendlyName: `Acme Cloud ${new Date().toISOString().slice(0, 10)}`,
      });

      if (enrollError) {
        setError(enrollError.message);

        return;
      }

      setEnrollment({
        factorId: data.id,
        qrCode: data.totp.qr_code,
        secret: data.totp.secret,
      });
    });
  };

  const verify = () => {
    if (!enrollment) {
      return;
    }

    startTransition(async () => {
      const { error: verifyError } = await supabase.auth.mfa.challengeAndVerify(
        { factorId: enrollment.factorId, code },
      );

      if (verifyError) {
        setError(t("That code didn't work. Try the next one."));

        return;
      }

      await changeSession();
      setFactorId(enrollment.factorId);
      setEnrollment(null);
      setCode("");
      toast.success(t("Two-factor authentication is on"));
    });
  };

  const remove = () => {
    if (!factorId) {
      return;
    }

    startTransition(async () => {
      const { error: removeError } = await supabase.auth.mfa.unenroll({
        factorId,
      });

      if (removeError) {
        toast.error(removeError.message);

        return;
      }

      await refreshAndChange();
      setFactorId(null);
      toast.success(t("Two-factor authentication is off"));
    });
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t("Two-factor authentication")}</CardTitle>
        <CardDescription>
          {t(
            "An authenticator app code at sign-in. Needed to delete your account.",
          )}
        </CardDescription>
        <CardAction>
          {readError ? null : factorId ? (
            <Badge data-testid="mfa-status">
              <ShieldCheckIcon />
              {t("On")}
            </Badge>
          ) : (
            <Badge variant="secondary" data-testid="mfa-status">
              {t("Off")}
            </Badge>
          )}
        </CardAction>
      </CardHeader>
      <CardContent className="space-y-4">
        {readError ? (
          <p className="text-sm text-destructive">{readError}</p>
        ) : enrollment ? (
          <div className="flex flex-col gap-6 sm:flex-row sm:items-start">
            {/* An SVG data URL from Supabase Auth: nothing to optimize. */}
            <Image
              src={enrollment.qrCode}
              width={160}
              height={160}
              unoptimized
              alt={t("QR code for your authenticator app")}
              className="rounded-lg bg-white p-2"
            />
            <div className="space-y-4">
              <Field data-invalid={error ? true : undefined}>
                <FieldLabel htmlFor={`${fieldId}-totp-code`}>
                  {t("Code from the app")}
                </FieldLabel>
                <InputOTP
                  id={`${fieldId}-totp-code`}
                  maxLength={6}
                  value={code}
                  onChange={setCode}
                >
                  <InputOTPGroup>
                    {Array.from({ length: 6 }, (_, index) => (
                      <InputOTPSlot key={index} index={index} />
                    ))}
                  </InputOTPGroup>
                </InputOTP>
                <FieldDescription>
                  {t("Or enter this key by hand: {secret}", {
                    secret: enrollment.secret,
                  })}
                </FieldDescription>
                {error ? <FieldError>{error}</FieldError> : null}
              </Field>
              <div className="flex gap-2">
                <Button
                  onClick={verify}
                  disabled={pending || code.length !== 6}
                >
                  {t("Verify")}
                </Button>
                <Button
                  variant="outline"
                  onClick={() => {
                    setEnrollment(null);
                  }}
                >
                  {t("Cancel")}
                </Button>
              </div>
            </div>
          </div>
        ) : factorId ? (
          <Button variant="outline" onClick={remove} disabled={pending}>
            {t("Turn off")}
          </Button>
        ) : (
          <>
            <Button onClick={enroll} disabled={pending}>
              {t("Set up")}
            </Button>
            {error ? <p className="text-sm text-destructive">{error}</p> : null}
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

# My work

> What waits on the caller across the installed blocks, from `loadMyWork`: assigned work, approvals, due reminders, mentions and SLA warnings, each linked to its record.

Source: https://bettersupabase.com/docs/registry/ui-recipes/my-work

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [subjects](https://bettersupabase.com/blocks/subjects), [tasks](https://bettersupabase.com/blocks/tasks), [approvals](https://bettersupabase.com/blocks/approvals), [reminders](https://bettersupabase.com/blocks/reminders), [comments](https://bettersupabase.com/blocks/comments) blocks must be installed (`npx better-supabase add subjects tasks approvals reminders comments`). Its page on bettersupabase.com is [/recipes/my-work](https://bettersupabase.com/recipes/my-work).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/my-work/components/my-work-list.tsx"
/**
 * Render inside `<Suspense>`: everything that waits on the caller, from
 * `loadMyWork`, newest first.
 */
export async function MyWorkList() {
  const [work, t, format] = await Promise.all([
    getMyWork(),
    getExtracted("myWork"),
    getFormatter(),
  ]);

  if (work.kind === "no-organization") {
    return null;
  }

  const kindLabel = (kind: MyWorkKind): string => {
    switch (kind) {
      case "assigned": {
        return t("Assigned");
      }

      case "approval": {
        return t("Approval");
      }

      case "reminder": {
        return t("Reminder");
      }

      case "mention": {
        return t("Mention");
      }

      case "sla": {
        return t("SLA warning");
      }

      default: {
        const never: never = kind;

        return never;
      }
    }
  };

  return (
    <Card data-recipe="my-work">
      <CardHeader>
        <CardTitle>{t("Waiting on you")}</CardTitle>
        <CardDescription>
          {t(
            "Assigned work, approvals, due reminders, mentions and SLA warnings.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {work.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("My work didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retryMyWork}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : work.data.rows.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <InboxIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing waiting on you")}</EmptyTitle>
              <EmptyDescription>
                {t("Work assigned to you and requests for you show up here.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Link
                href="/work"
                className={buttonVariants({ variant: "outline", size: "sm" })}
              >
                {t("Open the work board")}
              </Link>
            </EmptyContent>
          </Empty>
        ) : (
          <ul className="flex flex-col gap-1">
            {work.data.rows.map((row) => (
              <li key={row.key} className="flex items-center gap-2 text-sm">
                <Badge variant={BADGES[row.kind]}>{kindLabel(row.kind)}</Badge>
                {row.url === null ? (
                  <span className="truncate">{row.title}</span>
                ) : (
                  <Link
                    href={row.url}
                    className="truncate font-medium underline-offset-4 transition-colors hover:underline"
                  >
                    {row.title}
                  </Link>
                )}
                {row.at === null ? null : (
                  <time
                    dateTime={new Date(row.at).toISOString()}
                    className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums"
                  >
                    {format.relativeTime(row.at)}
                  </time>
                )}
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

# Notification bell

> A header button with the unread count, which updates when a notification arrives or is read.

Source: https://bettersupabase.com/docs/registry/ui-recipes/notification-bell

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useNotifications` from `@/hooks/use-notifications`; the [notifications block](https://bettersupabase.com/blocks/notifications) must be installed (`npx better-supabase add notifications`). Its page on bettersupabase.com is [/recipes/notification-bell](https://bettersupabase.com/recipes/notification-bell).

Web, with shadcn components:

```bash
npx shadcn add badge button
```

```bash
npx shadcn add @better-supabase/use-notifications @better-supabase/notifications-actions-next
```

```tsx title="apps/examples/nextjs/src/features/notifications/components/unread-badge.tsx"
"use client";

import type { NotificationCounts } from "better-supabase/blocks/notifications";

import { BellIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { useNotifications } from "@/hooks/use-notifications";
import { Link } from "@/i18n/navigation";
import { useAuth } from "@/lib/hooks";

import { loadNotificationCounts } from "../notifications-actions";

async function loadCounts(): Promise<readonly NotificationCounts[]> {
  const result = await loadNotificationCounts({});

  return result.ok ? [result.data] : [];
}

const unreadOf = (counts: readonly NotificationCounts[]): number =>
  counts[0]?.unread ?? 0;

/**
 * The unread count from the block's `counts()`, loaded again after each
 * broadcast on the caller's private topic `notifications:<user id>`.
 */
function useUnreadCount() {
  const auth = useAuth();
  const userId = auth.user?.id;

  const { items, count, status } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: loadCounts,
    count: unreadOf,
  });

  return { count: items === undefined ? undefined : count, status };
}

/**
 * The header badge. Part of the static shell, so it loads from the browser
 * instead of adding a database call to every page.
 */
export function UnreadBadge() {
  const t = useExtracted("notifications");
  const { count, status } = useUnreadCount();

  return (
    <Link
      href="/notifications"
      className={buttonVariants({ variant: "ghost", size: "sm" })}
      data-testid="unread-badge"
      data-status={status}
      aria-label={t("Notifications, {count, number} unread", {
        count: count ?? 0,
      })}
    >
      <BellIcon />
      <Badge
        variant={count ? "default" : "secondary"}
        className="tabular-nums"
        data-testid="unread-count"
      >
        {count ?? "–"}
      </Badge>
    </Link>
  );
}
```

React Native, with `Pressable`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-notifications @better-supabase/blocks
```

```tsx title="apps/examples/expo-powersync/src/components/notification-bell.tsx"
import type { NotificationCounts } from "better-supabase/blocks/notifications";

import { tenantOf, useAuth, useSupabase } from "better-supabase/react";
import { Pressable, Text, View } from "react-native";

import { useNotifications } from "../hooks/use-notifications";
import { blocks } from "../lib/blocks";

const unreadOf = (counts: readonly NotificationCounts[]): number =>
  counts[0]?.unread ?? 0;

/**
 * The unread count, loaded again after each broadcast on `notifications:<user
 * id>`.
 */
export function NotificationBell({
  onPress,
}: {
  readonly onPress: () => void;
}) {
  const auth = useAuth();
  const supabase = useSupabase();
  const tenant = tenantOf(auth);
  const userId = auth.user?.id;

  const { items, count } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: async () => {
      if (!tenant) {
        return [];
      }

      const result = await blocks(supabase).notifications.counts({ tenant });

      return result.ok ? [result.data] : [];
    },
    count: unreadOf,
  });

  return (
    <Pressable
      onPress={onPress}
      accessibilityRole="button"
      accessibilityLabel={`Notifications, ${String(count)} unread`}
      style={{ flexDirection: "row", alignItems: "center", gap: 8, padding: 8 }}
    >
      <Text>Notifications</Text>
      <View
        style={{
          minWidth: 24,
          borderRadius: 12,
          paddingHorizontal: 8,
          backgroundColor: count ? "#111" : "#e5e5e5",
        }}
      >
        <Text style={{ color: count ? "#fff" : "#111", textAlign: "center" }}>
          {items === undefined ? "–" : count}
        </Text>
      </View>
    </Pressable>
  );
}
```

# Notification list

> The caller's latest notifications with an unread marker, loaded again on every change.

Source: https://bettersupabase.com/docs/registry/ui-recipes/notification-list

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useNotifications` from `@/hooks/use-notifications`; the [notifications block](https://bettersupabase.com/blocks/notifications) must be installed (`npx better-supabase add notifications`). Its page on bettersupabase.com is [/recipes/notification-list](https://bettersupabase.com/recipes/notification-list).

Web, with shadcn components:

```bash
npx shadcn add badge empty skeleton
```

```bash
npx shadcn add @better-supabase/use-notifications @better-supabase/notifications-actions-next
```

```tsx title="apps/examples/nextjs/src/features/notifications/components/notification-list.tsx"
"use client";

import { BellIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";

import { Badge } from "@/components/ui/badge";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { useNotifications } from "@/hooks/use-notifications";
import { useAuth } from "@/lib/hooks";

import type { NotificationRow } from "../notification-types";

import { loadNotifications } from "../notifications-actions";

async function loadRows(): Promise<readonly NotificationRow[]> {
  const result = await loadNotifications({});

  return result.ok ? result.data : [];
}

/**
 * The caller's latest notifications, loaded again after each broadcast on their
 * private topic `notifications:<user id>`: a new one, or one marked read.
 */
export function NotificationList() {
  const t = useExtracted("notifications");
  const format = useFormatter();
  const auth = useAuth();
  const userId = auth.user?.id;

  const { items } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: loadRows,
  });

  if (items === undefined) {
    return <NotificationListSkeleton />;
  }

  if (items.length === 0) {
    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <BellIcon />
          </EmptyMedia>
          <EmptyTitle>{t("Nothing here yet")}</EmptyTitle>
          <EmptyDescription>
            {t("Send yourself a notification to see it arrive.")}
          </EmptyDescription>
        </EmptyHeader>
      </Empty>
    );
  }

  return (
    <ul className="divide-y rounded-xl border">
      {items.map((notification) => (
        <li
          key={notification.id}
          className="flex items-center gap-3 px-4 py-3 text-sm"
        >
          <span className="flex-1">{notification.title}</span>
          {notification.readAt ? null : <Badge>{t("New")}</Badge>}
          <span className="text-xs text-muted-foreground tabular-nums">
            {format.dateTime(new Date(notification.createdAt), {
              dateStyle: "medium",
              timeStyle: "short",
            })}
          </span>
        </li>
      ))}
    </ul>
  );
}

function NotificationListSkeleton() {
  return (
    <div className="space-y-2" aria-busy="true">
      {Array.from({ length: 4 }, (_, index) => (
        <Skeleton key={index} className="h-11 w-full" />
      ))}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `FlatList`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-notifications @better-supabase/blocks
```

```tsx title="apps/examples/expo-powersync/src/components/notification-list.tsx"
import { tenantOf, useAuth, useSupabase } from "better-supabase/react";
import { ActivityIndicator, FlatList, Text, View } from "react-native";

import { useNotifications } from "../hooks/use-notifications";
import { blocks } from "../lib/blocks";

/**
 * The caller's latest notifications, loaded again after each broadcast on their
 * private topic `notifications:<user id>`: a new one, or one marked read.
 */
export function NotificationList() {
  const auth = useAuth();
  const supabase = useSupabase();
  const tenant = tenantOf(auth);
  const userId = auth.user?.id;

  const { items } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: async () => {
      if (!tenant) {
        return [];
      }

      const result = await blocks(supabase).notifications.list({
        tenant,
        limit: 20,
      });

      return result.ok ? result.data : [];
    },
  });

  if (items === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      ListEmptyComponent={<Text>Nothing here yet</Text>}
      renderItem={({ item }) => (
        <View style={{ flexDirection: "row", gap: 8, paddingVertical: 12 }}>
          <Text style={{ flex: 1 }}>{item.data.title}</Text>
          {item.readAt ? null : <Text style={{ fontWeight: "600" }}>New</Text>}
          <Text style={{ color: "#737373" }}>
            {item.createdAt.toLocaleString(undefined, {
              dateStyle: "medium",
              timeStyle: "short",
            })}
          </Text>
        </View>
      )}
    />
  );
}
```

# Onboarding checklist

> The organization's getting-started steps with progress, hidden once every step is done.

Source: https://bettersupabase.com/docs/registry/ui-recipes/onboarding-checklist

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useOnboarding` from `@/hooks/use-onboarding`; the [onboarding block](https://bettersupabase.com/blocks/onboarding) must be installed (`npx better-supabase add onboarding`). Its page on bettersupabase.com is [/recipes/onboarding-checklist](https://bettersupabase.com/recipes/onboarding-checklist).

Web, with shadcn components:

```bash
npx shadcn add button card progress skeleton
```

```bash
npx shadcn add @better-supabase/use-onboarding @better-supabase/onboarding-checklist
```

```tsx title="apps/examples/nextjs/src/features/dashboard/components/onboarding-card.tsx"
"use client";

import { tenantOf } from "better-supabase/react";
import { CheckCircle2Icon, CircleIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { gettingStarted } from "@/features/onboarding/checklist";
import { useOnboarding } from "@/hooks/use-onboarding";
import { Link } from "@/i18n/navigation";
import { useAuth } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";

/** The API schema `gen` wrote for the onboarding block. */
const API_SCHEMA = blockConfig.onboarding.api;

/** The organization's getting-started checklist; hidden once every step is done. */
export function OnboardingCard() {
  const t = useExtracted("dashboard");
  const organizationId = tenantOf(useAuth()) ?? null;

  const { progress } = useOnboarding(gettingStarted, {
    organizationId,
    schema: API_SCHEMA,
  });

  if (!organizationId) {
    return null;
  }

  if (progress === undefined) {
    return <OnboardingCardSkeleton />;
  }

  if (progress.done) {
    return null;
  }

  const titles = {
    customer: t("Add your first customer"),
    invite: t("Invite a teammate"),
    "api-key": t("Create an API key"),
    plan: t("Review your plan"),
  };

  return (
    <Card data-testid="onboarding">
      <CardHeader>
        <CardTitle>{t("Get started")}</CardTitle>
        <CardDescription>
          {t("{completed, number} of {total, number} steps done", {
            completed: progress.completed,
            total: progress.total,
          })}
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <Progress value={(progress.completed / progress.total) * 100} />
        <ul className="divide-y">
          {progress.steps.map((step) => (
            <li key={step.id} className="flex items-center gap-3 py-2">
              {step.completed ? (
                <CheckCircle2Icon className="size-4 text-primary" />
              ) : (
                <CircleIcon className="size-4 text-muted-foreground" />
              )}
              <span
                className={
                  step.completed
                    ? "flex-1 text-muted-foreground line-through"
                    : "flex-1"
                }
              >
                {titles[step.id]}
              </span>
              {step.completed ? null : (
                <Link
                  href={step.href ?? "/"}
                  className={buttonVariants({ variant: "outline", size: "sm" })}
                >
                  {t("Start")}
                </Link>
              )}
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  );
}

export function OnboardingCardSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

React Native, with `ActivityIndicator`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-onboarding @better-supabase/onboarding-checklist
```

```tsx title="apps/examples/expo-powersync/src/components/onboarding-checklist.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Text, View } from "react-native";

import { useOnboarding } from "../hooks/use-onboarding";
import { API_SCHEMA } from "../lib/blocks";
import { gettingStarted } from "../lib/onboarding";

/** The organization's getting-started checklist; hidden once every step is done. */
export function OnboardingChecklist() {
  const organizationId = tenantOf(useAuth()) ?? null;

  const { progress } = useOnboarding(gettingStarted, {
    organizationId,
    schema: API_SCHEMA,
  });

  if (!organizationId) {
    return null;
  }

  if (progress === undefined) {
    return <ActivityIndicator />;
  }

  if (progress.done) {
    return null;
  }

  return (
    <View style={{ gap: 8, padding: 16, borderWidth: 1, borderRadius: 12 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Get started</Text>
      <Text style={{ color: "#737373" }}>
        {progress.completed} of {progress.total} steps done
      </Text>
      <View style={{ height: 6, borderRadius: 3, backgroundColor: "#e5e5e5" }}>
        <View
          style={{
            height: 6,
            borderRadius: 3,
            backgroundColor: "#111",
            width: `${(progress.completed / progress.total) * 100}%`,
          }}
        />
      </View>
      {progress.steps.map((step) => (
        <Text
          key={step.id}
          style={
            step.completed
              ? { color: "#737373", textDecorationLine: "line-through" }
              : undefined
          }
        >
          {step.completed ? "✓" : "○"} {step.title}
        </Text>
      ))}
    </View>
  );
}
```

# Order detail

> An order with its lines, totals, payment state and fulfillment steps.

Source: https://bettersupabase.com/docs/registry/ui-recipes/order-detail

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [orders block](https://bettersupabase.com/blocks/orders) must be installed (`npx better-supabase add orders`). Its page on bettersupabase.com is [/recipes/order-detail](https://bettersupabase.com/recipes/order-detail).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/shop/components/order-detail.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/**
 * Render inside `<Suspense>`: a placed order, or the caller's draft (an open
 * cart) with the same id, with its lines, totals and next steps.
 */
export async function OrderDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const [{ id }, session] = await Promise.all([params, getSession()]);

  if (!can(session, "orders.read")) {
    forbidden();
  }

  const [detail, t, format, locale] = await Promise.all([
    getOrderDetail(id),
    getExtracted("shop"),
    getFormatter(),
    getLocale(),
  ]);

  if (detail.kind === "not-found") {
    notFound();
  }

  if (detail.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="order-detail">
        <TriangleAlertIcon />
        <AlertTitle>{t("The order didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const view = detail.data;
  const canManage = can(session, "orders.manage");
  const amount = (value: Money) => formatMoney(value, locale);

  const problemLabel = (problem: string) => {
    switch (problem) {
      case "shipping_required": {
        return t("Pick a shipping rate first.");
      }

      case "shipping_unavailable": {
        return t("No shipping rate covers this address.");
      }

      case "promotion_unavailable": {
        return t("A promotion code no longer applies.");
      }

      default: {
        return problem;
      }
    }
  };

  const totals: readonly (readonly [string, Money])[] = [
    [t("Subtotal"), view.subtotal],
    ...(view.discountTotal.amount > 0n
      ? [[t("Discount"), negateMoney(view.discountTotal)] as const]
      : []),
    ...(view.shippingTotal.amount > 0n
      ? [[t("Shipping"), view.shippingTotal] as const]
      : []),
    ...(view.kind === "order" && view.taxTotal.amount > 0n
      ? [[t("Tax"), view.taxTotal] as const]
      : []),
  ];

  return (
    <div data-recipe="order-detail" className="grid gap-6 lg:grid-cols-3">
      <div className="flex flex-col gap-6 lg:col-span-2">
        <div className="flex flex-col gap-1">
          <div className="flex items-center gap-2">
            <h1 className="text-2xl font-semibold tracking-tight">
              {view.kind === "order"
                ? t("Order {number}", { number: view.number })
                : t("Draft order")}
            </h1>
            <OrderStatusBadge
              status={view.kind === "order" ? view.status : "draft"}
            />
          </div>
          <p className="text-sm text-muted-foreground">
            {view.kind === "order"
              ? t("Placed {date}", {
                  date: format.dateTime(new Date(view.placedAt), {
                    dateStyle: "medium",
                    timeStyle: "short",
                  }),
                })
              : t(
                  "Not placed yet. Prices follow the catalog until you place it.",
                )}
          </p>
        </div>
        <Card>
          <CardHeader>
            <CardTitle>{t("Lines")}</CardTitle>
          </CardHeader>
          <CardContent>
            {view.lines.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <ShoppingCartIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("This draft is empty")}</EmptyTitle>
                  <EmptyDescription>
                    {t("Add a product from the orders page.")}
                  </EmptyDescription>
                </EmptyHeader>
              </Empty>
            ) : (
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>{t("Product")}</TableHead>
                    <TableHead className="text-right">
                      {t("Quantity")}
                    </TableHead>
                    <TableHead className="text-right">
                      {t("Unit price")}
                    </TableHead>
                    <TableHead className="text-right">{t("Total")}</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {view.lines.map((line) => (
                    <TableRow key={line.id}>
                      <TableCell>
                        <div className="flex flex-col gap-1">
                          <span className="text-sm">{line.title}</span>
                          <span className="font-mono text-xs text-muted-foreground">
                            {line.sku}
                          </span>
                        </div>
                      </TableCell>
                      <TableCell className="text-right tabular-nums">
                        {line.quantity}
                      </TableCell>
                      <TableCell className="text-right tabular-nums">
                        {amount(line.unitAmount)}
                      </TableCell>
                      <TableCell className="text-right tabular-nums">
                        {amount(line.total)}
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
                <TableFooter>
                  {totals.map(([label, value]) => (
                    <TableRow key={label}>
                      <TableCell colSpan={3} className="text-muted-foreground">
                        {label}
                      </TableCell>
                      <TableCell className="text-right tabular-nums">
                        {amount(value)}
                      </TableCell>
                    </TableRow>
                  ))}
                  <TableRow>
                    <TableCell colSpan={3} className="font-medium">
                      {t("Total")}
                    </TableCell>
                    <TableCell className="text-right font-medium tabular-nums">
                      {amount(view.total)}
                    </TableCell>
                  </TableRow>
                  {view.kind === "order" ? (
                    <TableRow>
                      <TableCell colSpan={3} className="text-muted-foreground">
                        {t("Paid")}
                      </TableCell>
                      <TableCell className="text-right tabular-nums">
                        {amount(view.paidTotal)}
                      </TableCell>
                    </TableRow>
                  ) : null}
                </TableFooter>
              </Table>
            )}
          </CardContent>
        </Card>
      </div>
      <div className="flex flex-col gap-6">
        <Card>
          <CardHeader>
            <CardTitle>{t("Next step")}</CardTitle>
            <CardDescription>
              {view.kind === "draft"
                ? t(
                    "Placing the order reserves its stock and fixes its prices.",
                  )
                : t("Without a payments provider, record the payment by hand.")}
            </CardDescription>
          </CardHeader>
          <CardContent className="flex flex-col gap-3">
            {view.kind === "draft" && view.problems.length > 0 ? (
              <Alert variant="warning">
                <TriangleAlertIcon />
                <AlertTitle>
                  {t("Check this draft before placing it")}
                </AlertTitle>
                <AlertDescription>
                  {view.problems.map(problemLabel).join(" ")}
                </AlertDescription>
              </Alert>
            ) : null}
            {canManage ? (
              view.kind === "draft" ? (
                <PlaceOrderButton
                  cartId={view.id}
                  disabled={view.lines.length === 0}
                />
              ) : (
                <OrderTransitions
                  orderId={view.id}
                  status={view.status}
                  fulfillments={view.fulfillments}
                />
              )
            ) : (
              <p className="text-sm text-muted-foreground">
                {t("Ask an admin to move this order along.")}
              </p>
            )}
            {view.kind === "order" && view.fulfillments.length > 0 ? (
              <ul className="flex flex-col gap-2">
                {view.fulfillments.map((fulfillment, index) => (
                  <li
                    key={fulfillment.id}
                    className="flex items-center justify-between gap-2 text-sm"
                  >
                    <span>{t("Shipment {n, number}", { n: index + 1 })}</span>
                    {fulfillment.status === "delivered" ? (
                      <Badge variant="success-light">{t("Delivered")}</Badge>
                    ) : fulfillment.status === "shipped" ? (
                      <Badge variant="info-light">{t("Shipped")}</Badge>
                    ) : fulfillment.status === "cancelled" ? (
                      <Badge variant="destructive-light">
                        {t("Cancelled")}
                      </Badge>
                    ) : (
                      <Badge variant="secondary">{t("Packing")}</Badge>
                    )}
                  </li>
                ))}
              </ul>
            ) : null}
          </CardContent>
        </Card>
        <Card>
          <CardHeader>
            <CardTitle>{t("Promotions")}</CardTitle>
          </CardHeader>
          <CardContent>
            {view.kind === "draft" && canManage ? (
              <PromotionCodeField
                cartId={view.id}
                applied={view.promotions.flatMap((promotion) =>
                  promotion.code
                    ? [
                        {
                          code: promotion.code,
                          name: promotion.name,
                          discount: promotion.discount,
                        },
                      ]
                    : [],
                )}
                canCreate={can(session, "promotions.manage")}
              />
            ) : view.promotions.length === 0 ? (
              <p className="text-sm text-muted-foreground">
                {t("No promotion was applied.")}
              </p>
            ) : (
              <ul className="flex flex-col gap-2">
                {view.promotions.map((promotion) => (
                  <li
                    key={promotion.code ?? promotion.name}
                    className="flex items-center justify-between gap-2 text-sm"
                  >
                    <span className="flex items-center gap-2">
                      {promotion.code ? (
                        <Badge variant="success-light" className="font-mono">
                          {promotion.code}
                        </Badge>
                      ) : null}
                      <span className="text-muted-foreground">
                        {promotion.name}
                      </span>
                    </span>
                    <span className="tabular-nums">
                      {amount(negateMoney(promotion.discount))}
                    </span>
                  </li>
                ))}
              </ul>
            )}
          </CardContent>
        </Card>
      </div>
    </div>
  );
}
```

# Order state board

> Today's kitchen orders in preparation-state columns; dragging an order forward moves all of its lines.

Source: https://bettersupabase.com/docs/registry/ui-recipes/order-state-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [orders block](https://bettersupabase.com/blocks/orders) must be installed (`npx better-supabase add orders`). Its page on bettersupabase.com is [/recipes/order-state-board](https://bettersupabase.com/recipes/order-state-board).

Web, with shadcn components:

```bash
npx shadcn add badge card @reui/kanban
```

```tsx title="apps/examples/nextjs/src/features/shop/components/order-state-board.tsx"
/**
 * The orders sent to the kitchen today, in a column for the state of their
 * least advanced line. Dragging a card to a later column moves every line of
 * the order up to that state; a move back or within a column puts the card
 * back. The parent keys it by the cards' states, so a refresh starts it from
 * the saved board.
 */
export function OrderStateBoard({
  cards,
  canPrepare,
}: {
  cards: readonly BoardCardView[];
  canPrepare: boolean;
}) {
  const t = useExtracted("shop");
  const errorMessage = useErrorMessage();
  const label = usePreparationLabel();
  const [columns, setColumns] = useState(() => columnsOf(cards));
  const bump = useAction(bumpTicket);
  const byId = new Map(cards.map((card) => [card.orderId, card]));

  return (
    <div data-recipe="order-state-board">
      <Kanban
        value={columns}
        onValueChange={setColumns}
        getItemValue={(card) => card.orderId}
        onValueCommit={(_value, meta) => {
          if (meta.kind !== "item") {
            return;
          }

          const from = v.parse(StateSchema, meta.activeContainer);
          const to = v.parse(StateSchema, meta.overContainer);

          if (
            PREPARATION_STATES.indexOf(to) <= PREPARATION_STATES.indexOf(from)
          ) {
            setColumns(meta.previousValue);

            if (from !== to) {
              toast.warning(t("An order only moves forward on this board."));
            }

            return;
          }

          void bump
            .run({
              orderId: String(meta.event.active.id),
              state: to,
              stationId: null,
            })
            .then((result) => {
              if (!result.ok) {
                setColumns(meta.previousValue);
                toast.error(errorMessage(result.error));
              }
            });
        }}
      >
        <KanbanBoard className="flex overflow-x-auto">
          {PREPARATION_STATES.map((state) => {
            const column = columns[state] ?? [];
            const count = column.length;

            return (
              <KanbanColumn
                key={state}
                value={state}
                className="w-72 shrink-0 gap-3 rounded-lg bg-muted/50 p-3"
              >
                <div className="flex items-center justify-between gap-2">
                  <Badge variant={preparationBadgeVariant(state)}>
                    {label(state)}
                  </Badge>
                  <Badge variant="outline">{count}</Badge>
                </div>
                <KanbanColumnContent value={state} className="min-h-16">
                  {column.map((card) => (
                    <KanbanItem
                      key={card.orderId}
                      value={card.orderId}
                      disabled={!canPrepare || bump.pending}
                    >
                      <KanbanItemHandle cursor={canPrepare}>
                        <OrderCard card={card} />
                      </KanbanItemHandle>
                    </KanbanItem>
                  ))}
                  {count === 0 ? (
                    <p className="text-xs text-muted-foreground">
                      {t("No orders here.")}
                    </p>
                  ) : null}
                </KanbanColumnContent>
              </KanbanColumn>
            );
          })}
        </KanbanBoard>
        <KanbanOverlay>
          {({ value, variant }) => {
            const card =
              variant === "item" ? byId.get(String(value)) : undefined;

            return card ? <OrderCard card={card} /> : null;
          }}
        </KanbanOverlay>
      </Kanban>
    </div>
  );
}
```

# Organization switcher

> A menu of the user's organizations that switches the active one and refreshes the session for its tenant claim.

Source: https://bettersupabase.com/docs/registry/ui-recipes/org-switcher

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useOrganizations` from `@/hooks/use-organizations`; the [organizations block](https://bettersupabase.com/blocks/organizations) must be installed (`npx better-supabase add organizations`). Its page on bettersupabase.com is [/recipes/org-switcher](https://bettersupabase.com/recipes/org-switcher).

Web, with shadcn components:

```bash
npx shadcn add dropdown-menu sidebar
```

```bash
npx shadcn add @better-supabase/use-organizations
```

```tsx title="apps/examples/nextjs/src/features/organization/components/organization-switcher-menu.tsx"
"use client";

import { DbException } from "better-supabase";
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useState, useTransition } from "react";
import { toast } from "sonner";

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { useOrganizations } from "@/hooks/use-organizations";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import type { OrganizationSummary } from "../organization-queries";

import { loadMyOrganizations } from "../organization-actions";
import { useRefreshSession } from "../use-refresh-session";
import { useRoleLabel } from "../use-role-label";
import { CreateOrganizationDialog } from "./create-organization-dialog";

function Mark({ name }: { name: string }) {
  return (
    <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground">
      {name.slice(0, 1).toUpperCase()}
    </div>
  );
}

async function loadOrganizations(): Promise<readonly OrganizationSummary[]> {
  const result = await loadMyOrganizations({});

  if (!result.ok) {
    throw new DbException(result.error);
  }

  return result.data;
}

/**
 * The organization menu. The server renders it with its rows; switching goes
 * through `switch_organization`, refreshes the session for the new tenant
 * claim, then re-renders the server tree.
 */
export function OrganizationSwitcherMenu({
  initial,
  activeId: serverActiveId,
}: {
  initial: readonly OrganizationSummary[];
  activeId: string | null;
}) {
  const t = useExtracted("organization");
  const roleLabel = useRoleLabel();
  const errorMessage = useErrorMessage();
  const refreshSession = useRefreshSession();
  const [pending, startTransition] = useTransition();
  const [creating, setCreating] = useState(false);

  const { organizations, active, activeId, switchTo } = useOrganizations({
    load: loadOrganizations,
    initial,
    activeId: serverActiveId,
    schema: blockConfig.organizations.api,
    onSwitched: () => refreshSession("/"),
  });

  const choose = (organization: OrganizationSummary) => {
    startTransition(async () => {
      const result = await switchTo(organization.id);

      if (!result.ok) {
        toast.error(errorMessage(result.error));

        return;
      }

      toast.success(t("Switched to {name}", { name: organization.name }));
    });
  };

  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger
          disabled={pending}
          render={
            <SidebarMenuButton
              size="lg"
              className="data-popup-open:bg-sidebar-accent"
              aria-label={t("Switch organization")}
            />
          }
        >
          <Mark name={active?.name ?? "?"} />
          <div className="grid flex-1 text-left text-sm leading-tight">
            <span
              className="truncate font-medium"
              data-testid="organization-name"
            >
              {active?.name ?? t("No organization")}
            </span>
            <span className="truncate text-xs text-muted-foreground capitalize">
              {active ? active.plan : t("Create one to start")}
            </span>
          </div>
          <ChevronsUpDownIcon className="ml-auto" />
        </DropdownMenuTrigger>
        <DropdownMenuContent className="w-64" align="start" side="bottom">
          <DropdownMenuGroup>
            <DropdownMenuLabel>{t("Organizations")}</DropdownMenuLabel>
            {(organizations ?? initial).map((organization) => (
              <DropdownMenuItem
                key={organization.id}
                className="gap-2"
                onClick={() => {
                  if (organization.id !== activeId) {
                    choose(organization);
                  }
                }}
              >
                <Mark name={organization.name} />
                <div className="grid flex-1 leading-tight">
                  <span className="truncate">{organization.name}</span>
                  <span className="text-xs text-muted-foreground">
                    {roleLabel(organization.role)}
                  </span>
                </div>
                {organization.id === activeId ? <CheckIcon /> : null}
              </DropdownMenuItem>
            ))}
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuItem
            onClick={() => {
              setCreating(true);
            }}
          >
            <PlusIcon />
            {t("Create organization")}
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
      <CreateOrganizationDialog open={creating} onOpenChange={setCreating} />
    </>
  );
}
```

React Native, with `ActivityIndicator`, `Pressable`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-organizations
```

```tsx title="apps/examples/expo-powersync/src/components/organization-switcher.tsx"
import { ActivityIndicator, Pressable, Text, View } from "react-native";

import { useOrganizations } from "../hooks/use-organizations";
import { API_SCHEMA } from "../lib/blocks";

/**
 * The caller's organizations. Pressing one switches to it, refreshes the
 * session so the token carries the new tenant, and loads the list again.
 */
export function OrganizationSwitcher() {
  const { organizations, activeId, switching, error, switchTo } =
    useOrganizations({ schema: API_SCHEMA });

  if (organizations === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 4 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Organizations</Text>
      {organizations.map((organization) => (
        <Pressable
          key={organization.id}
          accessibilityRole="button"
          accessibilityState={{
            selected: organization.id === activeId,
            busy: organization.id === switching,
          }}
          disabled={switching !== undefined || organization.id === activeId}
          onPress={() => void switchTo(organization.id)}
          style={{ flexDirection: "row", gap: 8, paddingVertical: 8 }}
        >
          <Text style={{ width: 16 }}>
            {organization.id === activeId ? "✓" : ""}
          </Text>
          <Text style={{ flex: 1 }}>{organization.name}</Text>
          <Text style={{ color: "#737373" }}>{organization.role}</Text>
        </Pressable>
      ))}
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
    </View>
  );
}
```

# Plan picker

> The plans with their prices and a checkout button each. Checkout starts in a server action or API route that holds the Stripe key, and the client opens the URL it returns.

Source: https://bettersupabase.com/docs/registry/ui-recipes/plan-picker

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `usePlanPicker` from `@/hooks/use-billing`; the [billing block](https://bettersupabase.com/blocks/billing) must be installed (`npx better-supabase add billing`). Its page on bettersupabase.com is [/recipes/plan-picker](https://bettersupabase.com/recipes/plan-picker).

Web, with shadcn components:

```bash
npx shadcn add button card
```

```bash
npx shadcn add @better-supabase/use-billing
```

```tsx title="apps/examples/nextjs/src/features/billing/components/plan-picker.tsx"
"use client";

import type { Money } from "better-supabase/commerce";

import { DbException } from "better-supabase";
import { formatMoney } from "better-supabase/commerce";
import { useExtracted, useLocale } from "next-intl";
import { toast } from "sonner";

import type { PlanCatalog } from "@/hooks/use-billing";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { usePlanPicker } from "@/hooks/use-billing";
import { useErrorMessage } from "@/lib/use-error-message";

import type { PlanOption } from "../billing-queries";

import { loadPlanCatalog, startCheckout } from "../billing-actions";

async function loadCatalog(): Promise<PlanCatalog<PlanOption>> {
  const result = await loadPlanCatalog({});

  if (!result.ok) {
    throw new DbException(result.error);
  }

  return result.data;
}

/**
 * The plans with a checkout button each. The server renders the catalog;
 * `choose` runs the `startCheckout` server action, which talks to Stripe, and
 * sends the browser to the Checkout URL it returns.
 */
export function PlanPicker({
  initial,
  canManage,
}: {
  initial: PlanCatalog<PlanOption>;
  canManage: boolean;
}) {
  const t = useExtracted("billing");
  const locale = useLocale();
  const errorMessage = useErrorMessage();

  const { plans, current, pending, choose } = usePlanPicker({
    load: loadCatalog,
    initial,
    checkout: (plan) => startCheckout({ plan }),
    open: (url) => {
      window.location.assign(url);
    },
  });

  const price = (value: Money) =>
    value.amount === 0n
      ? t("Free")
      : t("{price} per month", { price: formatMoney(value, locale) });

  const featureLabel = ({ key, value }: PlanOption["features"][number]) => {
    switch (key) {
      case "seats": {
        return t("{seats} seats", { seats: String(value) });
      }

      case "exports": {
        return t("CSV exports");
      }

      case "audit": {
        return t("Audit log");
      }

      case "sso": {
        return t("Single sign-on");
      }

      default: {
        return key;
      }
    }
  };

  return (
    <div className="grid gap-4 md:grid-cols-3">
      {(plans ?? initial.plans).map((plan) => {
        const active = plan.key === current;

        return (
          <Card key={plan.key} size="sm" data-testid="plan">
            <CardHeader>
              <CardTitle>{plan.name}</CardTitle>
              <CardDescription>{price(plan.price)}</CardDescription>
            </CardHeader>
            <CardContent>
              <ul className="space-y-1 text-sm text-muted-foreground">
                {plan.features.map((feature) => (
                  <li key={feature.key}>{featureLabel(feature)}</li>
                ))}
              </ul>
            </CardContent>
            <CardFooter>
              <Button
                className="w-full"
                variant={active ? "secondary" : "default"}
                data-testid={active ? "active-plan" : undefined}
                disabled={active || !canManage || pending !== undefined}
                onClick={() => {
                  void choose(plan.key).then((started) => {
                    if (!started.ok) {
                      toast.error(errorMessage(started.error));
                    }
                  });
                }}
              >
                {active
                  ? t("Current plan")
                  : pending === plan.key
                    ? t("Opening checkout")
                    : t("Switch")}
              </Button>
            </CardFooter>
          </Card>
        );
      })}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Pressable`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-billing
```

```tsx title="apps/examples/expo-powersync/src/components/plan-picker.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import * as WebBrowser from "expo-web-browser";
import { ActivityIndicator, Pressable, Text, View } from "react-native";

import { usePlanPicker } from "../hooks/use-billing";
import { loadPlanCatalog, startCheckout } from "../lib/billing";

const price = (cents: number) =>
  cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)} per month`;

/**
 * The plans with a checkout button each. `choose` posts to the app's own API
 * route, which talks to Stripe, and opens the Checkout URL it returns in the
 * system browser.
 */
export function PlanPicker() {
  const organizationId = tenantOf(useAuth());

  const { plans, current, pending, error, choose } = usePlanPicker({
    load: () =>
      organizationId
        ? loadPlanCatalog(organizationId)
        : Promise.resolve({ plans: [], current: undefined }),
    checkout: startCheckout,
    open: async (url) => {
      await WebBrowser.openBrowserAsync(url);
    },
  });

  if (plans === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 8 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Plans</Text>
      {plans.map((plan) => {
        const active = plan.key === current;

        return (
          <Pressable
            key={plan.key}
            accessibilityRole="button"
            accessibilityState={{
              selected: active,
              busy: pending === plan.key,
            }}
            disabled={active || pending !== undefined}
            onPress={() => void choose(plan.key)}
            style={{
              flexDirection: "row",
              gap: 8,
              padding: 12,
              borderWidth: 1,
              borderRadius: 12,
              borderColor: active ? "#111" : "#e5e5e5",
            }}
          >
            <Text style={{ flex: 1, fontWeight: "600" }}>{plan.name}</Text>
            <Text style={{ color: "#737373" }}>{price(plan.priceCents)}</Text>
            <Text>
              {active ? "Current" : pending === plan.key ? "Opening" : "Switch"}
            </Text>
          </Pressable>
        );
      })}
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
    </View>
  );
}
```

# Post board

> A feedback board's posts ranked by votes, with an upvote toggle, the roadmap status, moderation state and a New post dialog.

Source: https://bettersupabase.com/docs/registry/ui-recipes/post-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [posts block](https://bettersupabase.com/blocks/posts) must be installed (`npx better-supabase add posts`). Its page on bettersupabase.com is [/recipes/post-board](https://bettersupabase.com/recipes/post-board).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input skeleton textarea
```

```tsx title="apps/examples/nextjs/src/features/community/components/post-board.tsx"
const statusVariants = {
  open: "outline",
  under_review: "info-light",
  planned: "primary-light",
  in_progress: "warning-light",
  done: "success-light",
  closed: "secondary",
} as const satisfies Record<PostStatus, string>;

const moderationVariants = {
  visible: null,
  pending: "warning-light",
  hidden: "destructive-light",
  removed: "destructive-light",
} as const satisfies Record<PostModeration, string | null>;

/** Render inside `<Suspense>`: the board's posts, top voted first. */
export async function PostBoard() {
  const [view, session, t] = await Promise.all([
    getBoard(),
    getSession(),
    getExtracted("community"),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <Alert variant="destructive">
        <TriangleAlertIcon />
        <AlertTitle>{t("The board didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const board = view.data;

  if (board === null) {
    const manage = can(session, "posts.manage");

    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <MessageSquareTextIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No board yet")}</EmptyTitle>
          <EmptyDescription>
            {manage
              ? t("Open a feedback board where members post and vote on ideas.")
              : t("An admin opens the board your team posts on.")}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          {manage ? (
            <CreateBoardButton />
          ) : (
            <Link href="/" className={buttonVariants({ variant: "outline" })}>
              {t("Back to the dashboard")}
            </Link>
          )}
        </EmptyContent>
      </Empty>
    );
  }

  const write = can(session, "posts.write");

  const statusLabels: Record<PostStatus, string> = {
    open: t("Open"),
    under_review: t("Under review"),
    planned: t("Planned"),
    in_progress: t("In progress"),
    done: t("Done"),
    closed: t("Closed"),
  };

  const moderationLabels: Record<PostModeration, string> = {
    visible: t("Visible"),
    pending: t("Awaiting review"),
    hidden: t("Hidden"),
    removed: t("Removed"),
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between gap-2">
        <h2 className="text-sm font-medium">{board.name}</h2>
        {write ? <NewPostDialog boardId={board.id} /> : null}
      </div>
      {board.posts.length === 0 ? (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <MessageSquareIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No posts yet")}</EmptyTitle>
            <EmptyDescription>
              {t("Post the first idea for others to vote on.")}
            </EmptyDescription>
          </EmptyHeader>
          {write ? (
            <EmptyContent>
              <NewPostDialog boardId={board.id} />
            </EmptyContent>
          ) : null}
        </Empty>
      ) : (
        <ul className="flex flex-col gap-3">
          {board.posts.map((post) => {
            const moderation = moderationVariants[post.moderation];

            return (
              <li key={post.id}>
                <Card size="sm">
                  <CardContent className="flex items-start gap-3">
                    <VoteButton
                      postId={post.id}
                      score={post.score}
                      voted={post.vote > 0}
                      disabled={post.moderation !== "visible"}
                    />
                    <div className="flex min-w-0 flex-1 flex-col gap-1">
                      <div className="flex flex-wrap items-center gap-2">
                        {post.pinned ? (
                          <PinIcon
                            aria-label={t("Pinned")}
                            className="size-4 text-muted-foreground"
                          />
                        ) : null}
                        <span className="text-sm font-medium">
                          {post.title}
                        </span>
                        <Badge variant={statusVariants[post.status]}>
                          {statusLabels[post.status]}
                        </Badge>
                        {moderation === null ? null : (
                          <Badge variant={moderation}>
                            {moderationLabels[post.moderation]}
                          </Badge>
                        )}
                      </div>
                      {post.body ? (
                        <p className="line-clamp-2 text-sm text-muted-foreground">
                          {post.body}
                        </p>
                      ) : null}
                      <div className="flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
                        <span className="flex items-center gap-1">
                          <MessageSquareIcon aria-hidden className="size-3.5" />
                          {t("{count} replies", {
                            count: String(post.replyCount),
                          })}
                        </span>
                        {post.locked ? (
                          <span className="flex items-center gap-1">
                            <LockIcon aria-hidden className="size-3.5" />
                            {t("Locked")}
                          </span>
                        ) : null}
                      </div>
                    </div>
                  </CardContent>
                </Card>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}
```

# Presence avatars

> Who else has a record open (a channel, a comment thread's record or a document), on the record's presence topic.

Source: https://bettersupabase.com/docs/registry/ui-recipes/presence-avatars

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `usePresence` from `better-supabase/react`; the [messaging block](https://bettersupabase.com/blocks/messaging) must be installed (`npx better-supabase add messaging`). Its page on bettersupabase.com is [/recipes/presence-avatars](https://bettersupabase.com/recipes/presence-avatars).

Web, with shadcn components:

```bash
npx shadcn add avatar
```

```tsx title="apps/examples/nextjs/src/features/chat/components/presence-avatars.tsx"
"use client";

import { usePresence } from "better-supabase/react";
import {
  subjectPresenceTopic,
  subjectPresenceValues,
} from "better-supabase/realtime";
import { useExtracted } from "next-intl";
import * as v from "valibot";

import {
  Avatar,
  AvatarFallback,
  AvatarGroup,
  AvatarGroupCount,
} from "@/components/ui/avatar";
import { useSession } from "@/lib/hooks";

const SHOWN = 4;

/** States that fail the schema never reach `members`. */
const viewers = subjectPresenceTopic(
  v.object({ userId: v.string(), name: v.string() }),
);

/**
 * Who else has a record open: a channel, a comment thread's record or a
 * document. The subjects module lets anyone who reads the record join its
 * presence topic. States come from the clients, so they only label avatars.
 */
export function PresenceAvatars({
  subject,
}: {
  subject: { readonly type: string; readonly id: string };
}) {
  const t = useExtracted("chat");
  const session = useSession();
  const me = session.kind === "user" ? session.user : null;

  const { members } = usePresence(
    viewers,
    subjectPresenceValues(me ? subject : null),
    { state: me ? { userId: me.id, name: me.email ?? t("Someone") } : null },
  );

  const others = new Map<string, string>();

  for (const { state } of members) {
    if (state.userId !== me?.id) {
      others.set(state.userId, state.name);
    }
  }

  if (others.size === 0) {
    return null;
  }

  const people = [...others];

  return (
    <AvatarGroup
      aria-label={t(
        "{count, plural, one {# other viewer} other {# other viewers}}",
        { count: people.length },
      )}
    >
      {people.slice(0, SHOWN).map(([userId, name]) => (
        <Avatar key={userId} size="sm" title={name}>
          <AvatarFallback>{name.charAt(0).toUpperCase()}</AvatarFallback>
        </Avatar>
      ))}
      {people.length > SHOWN ? (
        <AvatarGroupCount>+{people.length - SHOWN}</AvatarGroupCount>
      ) : null}
    </AvatarGroup>
  );
}
```

# Product catalog grid

> Products in a grid with prices, variants and a filter by collection.

Source: https://bettersupabase.com/docs/registry/ui-recipes/product-catalog-grid

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [catalog block](https://bettersupabase.com/blocks/catalog) must be installed (`npx better-supabase add catalog`). Its page on bettersupabase.com is [/recipes/product-catalog-grid](https://bettersupabase.com/recipes/product-catalog-grid).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/shop/components/product-catalog-grid.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: the organization's products as cards. */
export async function ProductCatalogGrid() {
  const session = await getSession();

  if (!can(session, "catalog.read")) {
    forbidden();
  }

  const [products, t, locale] = await Promise.all([
    getProducts(),
    getExtracted("shop"),
    getLocale(),
  ]);

  if (products.kind === "no-organization") {
    return null;
  }

  const canManage = can(session, "catalog.manage");

  const status = (product: Product) => {
    switch (product.status) {
      case "active": {
        return <Badge variant="success-light">{t("Active")}</Badge>;
      }

      case "draft": {
        return <Badge variant="secondary">{t("Draft")}</Badge>;
      }

      case "archived": {
        return <Badge variant="warning-light">{t("Archived")}</Badge>;
      }

      default: {
        const unknown: never = product.status;

        return unknown;
      }
    }
  };

  return (
    <div data-recipe="product-catalog-grid" className="flex flex-col gap-4">
      {products.kind === "error" ? (
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>{t("The products didn't load")}</AlertTitle>
          <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
          <AlertAction>
            <form action={retry}>
              <Button size="sm" variant="outline" type="submit">
                {t("Retry")}
              </Button>
            </form>
          </AlertAction>
        </Alert>
      ) : products.data.length === 0 ? (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <PackageIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No products yet")}</EmptyTitle>
            <EmptyDescription>
              {t("Add a product with a price to start taking orders.")}
            </EmptyDescription>
          </EmptyHeader>
          {canManage ? (
            <EmptyContent>
              <CreateProductDialog />
            </EmptyContent>
          ) : null}
        </Empty>
      ) : (
        <>
          <div className="flex items-center justify-between gap-2">
            <p className="text-sm text-muted-foreground">
              {t("{count, plural, one {# product} other {# products}}", {
                count: products.data.length,
              })}
            </p>
            <div className="flex items-center gap-2">
              {can(session, "promotions.manage") ? (
                <CreatePromotionCodeDialog />
              ) : null}
              {canManage ? <CreateProductDialog variant="outline" /> : null}
            </div>
          </div>
          <ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
            {products.data.map((product) => (
              <li key={product.id}>
                <Card>
                  <CardHeader>
                    <CardTitle>{product.title}</CardTitle>
                    <CardDescription className="font-mono">
                      {product.sku}
                    </CardDescription>
                    <CardAction>{status(product)}</CardAction>
                  </CardHeader>
                  <CardContent className="flex items-end justify-between gap-2">
                    <span className="text-sm font-semibold tabular-nums">
                      {product.price
                        ? formatMoney(product.price, locale)
                        : t("No price")}
                    </span>
                    <span className="text-xs text-muted-foreground">
                      {product.available === null
                        ? t("Not stocked")
                        : t("{count, number} available", {
                            count: product.available,
                          })}
                    </span>
                  </CardContent>
                </Card>
              </li>
            ))}
          </ul>
        </>
      )}
    </div>
  );
}
```

# Profile form

> A form that updates the caller's display name through a server action and shows field errors.

Source: https://bettersupabase.com/docs/registry/ui-recipes/profile-form

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [profiles block](https://bettersupabase.com/blocks/profiles) must be installed (`npx better-supabase add profiles`). Its page on bettersupabase.com is [/recipes/profile-form](https://bettersupabase.com/recipes/profile-form).

Web, with shadcn components:

```bash
npx shadcn add button field input
```

```tsx title="apps/examples/nextjs/src/features/user/components/profile-form.tsx"
"use client";

import { useActionForm } from "better-supabase/react";
import { useExtracted } from "next-intl";
import { useId } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { useErrorMessage } from "@/lib/use-error-message";

import { updateProfile } from "../user-actions";

export function ProfileForm({
  fullName,
  email,
}: {
  fullName: string | null;
  email: string | null;
}) {
  const t = useExtracted("user");
  const fieldId = useId();
  const errorMessage = useErrorMessage();

  const form = useActionForm(updateProfile, {
    resetOnSuccess: false,
    onSuccess: () => {
      toast.success(t("Profile saved"));
    },
  });

  const error = form.error === undefined ? null : errorMessage(form.error);

  return (
    <form {...form.formProps} className="space-y-6">
      <FieldGroup>
        <Field data-invalid={error ? true : undefined}>
          <FieldLabel htmlFor={`${fieldId}-profile-name`}>
            {t("Full name")}
          </FieldLabel>
          <Input
            id={`${fieldId}-profile-name`}
            name="fullName"
            defaultValue={fullName ?? ""}
            autoComplete="name"
            required
            maxLength={120}
          />
          {error ? <FieldError>{error}</FieldError> : null}
        </Field>
        <Field>
          <FieldLabel htmlFor={`${fieldId}-profile-email`}>
            {t("Email")}
          </FieldLabel>
          <Input
            id={`${fieldId}-profile-email`}
            value={email ?? ""}
            disabled
            readOnly
          />
          <FieldDescription>
            {t("Your sign-in address. It comes from Supabase Auth.")}
          </FieldDescription>
        </Field>
      </FieldGroup>
      <Button type="submit" disabled={form.pending}>
        {t("Save")}
      </Button>
    </form>
  );
}
```

# Promotion code field

> A field that checks a promotion code and shows the discount it gives.

Source: https://bettersupabase.com/docs/registry/ui-recipes/promotion-code-field

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [promotions block](https://bettersupabase.com/blocks/promotions) must be installed (`npx better-supabase add promotions`). Its page on bettersupabase.com is [/recipes/promotion-code-field](https://bettersupabase.com/recipes/promotion-code-field).

Web, with shadcn components:

```bash
npx shadcn add badge button dialog field input
```

```tsx title="apps/examples/nextjs/src/features/shop/components/promotion-code-field.tsx"
export interface AppliedCode {
  readonly code: string;
  readonly name: string;
  readonly discount: Money;
}

/**
 * Applies a promotion code to a draft order's cart. The block checks the code
 * (active, in its window, under its limits) and the cart's totals come back
 * with the discount, which the list below the field shows.
 */
export function PromotionCodeField({
  cartId,
  applied,
  canCreate,
}: {
  cartId: string;
  applied: readonly AppliedCode[];
  canCreate: boolean;
}) {
  const t = useExtracted("shop");
  const locale = useLocale();
  const fieldId = useId();
  const errorMessage = useErrorMessage();

  const form = useActionForm(applyPromotionCode, {
    onSuccess: (code) => {
      toast.success(t("{code} applied", { code }));
    },
  });

  const remove = useAction(removePromotionCode, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const error = form.error === undefined ? null : errorMessage(form.error);

  return (
    <div data-recipe="promotion-code-field" className="flex flex-col gap-3">
      <form {...form.formProps}>
        <input type="hidden" name="cartId" value={cartId} />
        <Field data-invalid={error ? true : undefined}>
          <FieldLabel htmlFor={`${fieldId}-code`}>
            {t("Promotion code")}
          </FieldLabel>
          <div className="flex gap-2">
            <Input
              id={`${fieldId}-code`}
              name="code"
              required
              maxLength={32}
              autoComplete="off"
              placeholder="WELCOME10"
              className="font-mono uppercase"
            />
            <Button type="submit" variant="outline" disabled={form.pending}>
              {t("Apply")}
            </Button>
          </div>
          {error ? <FieldError>{error}</FieldError> : null}
        </Field>
      </form>
      {applied.length > 0 ? (
        <ul className="flex flex-col gap-2">
          {applied.map((each) => (
            <li
              key={each.code}
              className="flex items-center justify-between gap-2 text-sm"
            >
              <span className="flex items-center gap-2">
                <Badge variant="success-light" className="font-mono">
                  {each.code}
                </Badge>
                <span className="text-muted-foreground">{each.name}</span>
              </span>
              <span className="flex items-center gap-1">
                <span className="tabular-nums">
                  {formatMoney(negateMoney(each.discount), locale)}
                </span>
                <Button
                  size="icon-sm"
                  variant="ghost"
                  aria-label={t("Remove {code}", { code: each.code })}
                  disabled={remove.pending}
                  onClick={() => {
                    void remove.run({ cartId, code: each.code });
                  }}
                >
                  <XIcon />
                </Button>
              </span>
            </li>
          ))}
        </ul>
      ) : (
        <div className="flex flex-wrap items-center justify-between gap-2">
          <p className="text-xs text-muted-foreground">
            {t("No code applied.")}
          </p>
          {canCreate ? <CreatePromotionCodeDialog /> : null}
        </div>
      )}
    </div>
  );
}

/** Creates a percentage promotion with one code, to try the field with. */
export function CreatePromotionCodeDialog() {
  const t = useExtracted("shop");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);

  const form = useActionForm(createPromotionCode, {
    onSuccess: (code) => {
      toast.success(t("{code} is ready to use", { code }));
      setOpen(false);
    },
  });

  const error = form.error === undefined ? null : errorMessage(form.error);

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        form.reset();
      }}
    >
      <DialogTrigger render={<Button size="sm" variant="outline" />}>
        <TicketPercentIcon />
        {t("New promotion code")}
      </DialogTrigger>
      <DialogContent>
        <form {...form.formProps} className="grid gap-6">
          <DialogHeader>
            <DialogTitle>{t("New promotion code")}</DialogTitle>
            <DialogDescription>
              {t("A percentage off the whole order, with one code to enter.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field data-invalid={error ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-new-code`}>
                {t("Code")}
              </FieldLabel>
              <Input
                id={`${fieldId}-new-code`}
                name="code"
                required
                maxLength={32}
                autoComplete="off"
                defaultValue="WELCOME10"
                className="font-mono uppercase"
              />
              {error ? <FieldError>{error}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-percent`}>
                {t("Percent off")}
              </FieldLabel>
              <Input
                id={`${fieldId}-percent`}
                name="percent"
                required
                inputMode="decimal"
                defaultValue="10"
                className="w-24"
              />
              <FieldDescription>
                {t("Above 0 and at most 100, such as 12.5.")}
              </FieldDescription>
            </Field>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={form.pending}>
              {t("Create code")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

# Push opt-in

> A card that registers this browser for web push and removes it again.

Source: https://bettersupabase.com/docs/registry/ui-recipes/push-opt-in

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [push block](https://bettersupabase.com/blocks/push) must be installed (`npx better-supabase add push`). Its page on bettersupabase.com is [/recipes/push-opt-in](https://bettersupabase.com/recipes/push-opt-in).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/push/components/push-opt-in.tsx"
"use client";

import { createPush, rpcTransport } from "better-supabase/blocks/push";
import { webPushToken } from "better-supabase/client";
import { BellOffIcon, BellRingIcon, TriangleAlertIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useEffect, useState, useTransition } from "react";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { useSupabase } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";

/**
 * The worker in `public/` that shows the pushes; it must be served from the
 * site root.
 */
const WORKER_URL = "/push-sw.js";
const PUSH_GUIDE = "https://bettersupabase.com/docs/blocks/push";

const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;

type PushState =
  | { readonly kind: "checking" }
  | { readonly kind: "unsupported" }
  | { readonly kind: "unconfigured" }
  | { readonly kind: "denied" }
  | { readonly kind: "off" }
  | { readonly kind: "on"; readonly token: string }
  | { readonly kind: "error" };

function supportsPush(): boolean {
  return (
    "serviceWorker" in navigator &&
    "PushManager" in window &&
    "Notification" in window
  );
}

/** What the browser already has, without asking the user anything. */
async function currentState(): Promise<PushState> {
  if (!supportsPush()) {
    return { kind: "unsupported" };
  }

  if (!VAPID_PUBLIC_KEY) {
    return { kind: "unconfigured" };
  }

  if (Notification.permission === "denied") {
    return { kind: "denied" };
  }

  const registration =
    await navigator.serviceWorker.getRegistration(WORKER_URL);

  const subscription = await registration?.pushManager.getSubscription();

  return subscription
    ? { kind: "on", token: JSON.stringify(subscription) }
    : { kind: "off" };
}

/**
 * Asks for web push permission and stores this browser's push subscription as
 * one of the caller's devices in the push module (`register_push_device`), or
 * removes it again. The device row belongs to the signed-in user, so a sender
 * can reach every browser they turned on.
 */
export function PushOptIn() {
  const t = useExtracted("push");
  const supabase = useSupabase();
  const [state, setState] = useState<PushState>({ kind: "checking" });
  const [pending, startTransition] = useTransition();

  const devices = createPush({
    transport: rpcTransport(supabase),
    schema: blockConfig.push.api,
  });

  const read = () => {
    currentState().then(setState, () => {
      setState({ kind: "error" });
    });
  };

  const check = () => {
    setState({ kind: "checking" });
    read();
  };

  useEffect(read, []);

  const turnOn = () => {
    startTransition(async () => {
      if (!VAPID_PUBLIC_KEY) {
        return;
      }

      try {
        await navigator.serviceWorker.register(WORKER_URL);

        const token = await webPushToken({
          vapidPublicKey: VAPID_PUBLIC_KEY,
        }).token();

        if (token === null) {
          setState(
            Notification.permission === "denied"
              ? { kind: "denied" }
              : { kind: "off" },
          );

          return;
        }

        const saved = await devices.register({
          token,
          platform: "web",
          provider: "webpush",
        });

        setState(saved.ok ? { kind: "on", token } : { kind: "error" });
      } catch {
        setState({ kind: "error" });
      }
    });
  };

  const turnOff = (token: string) => {
    startTransition(async () => {
      const removed = await devices.unregister(token);

      if (!removed.ok) {
        setState({ kind: "error" });

        return;
      }

      const registration =
        await navigator.serviceWorker.getRegistration(WORKER_URL);

      await (await registration?.pushManager.getSubscription())?.unsubscribe();
      setState({ kind: "off" });
    });
  };

  return (
    <Card data-testid="push-opt-in" data-recipe="push-opt-in">
      <CardHeader>
        <CardTitle>{t("Push notifications")}</CardTitle>
        <CardDescription>
          {t("Get a notification on this device when something needs you.")}
        </CardDescription>
        {state.kind === "on" ? (
          <CardAction>
            <Badge variant="success-light">{t("On")}</Badge>
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        <PushStateBody
          state={state}
          pending={pending}
          onTurnOn={turnOn}
          onTurnOff={turnOff}
          onRetry={check}
        />
      </CardContent>
    </Card>
  );
}

function PushStateBody({
  state,
  pending,
  onTurnOn,
  onTurnOff,
  onRetry,
}: {
  state: PushState;
  pending: boolean;
  onTurnOn: () => void;
  onTurnOff: (token: string) => void;
  onRetry: () => void;
}) {
  const t = useExtracted("push");

  switch (state.kind) {
    case "checking": {
      return (
        <div className="flex items-center gap-3" aria-busy="true">
          <Skeleton className="h-4 flex-1" />
          <Skeleton className="h-8 w-24" />
        </div>
      );
    }

    case "unsupported": {
      return (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <BellOffIcon />
            </EmptyMedia>
            <EmptyTitle>{t("This browser has no push support")}</EmptyTitle>
            <EmptyDescription>
              {t(
                "Open the app in a browser that supports web push, or install it to your home screen on iOS.",
              )}
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <a
              href={PUSH_GUIDE}
              className={buttonVariants({ variant: "outline" })}
            >
              {t("Read the push guide")}
            </a>
          </EmptyContent>
        </Empty>
      );
    }

    case "unconfigured": {
      return (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <BellOffIcon />
            </EmptyMedia>
            <EmptyTitle>{t("Push isn't set up yet")}</EmptyTitle>
            <EmptyDescription>
              {t(
                "Set NEXT_PUBLIC_VAPID_PUBLIC_KEY to your VAPID public key to turn on web push.",
              )}
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <a
              href={PUSH_GUIDE}
              className={buttonVariants({ variant: "outline" })}
            >
              {t("Read the push guide")}
            </a>
          </EmptyContent>
        </Empty>
      );
    }

    case "denied": {
      return (
        <p className="text-sm text-muted-foreground">
          {t(
            "Notifications are blocked for this site. Allow them in your browser's site settings, then reload the page.",
          )}
        </p>
      );
    }

    case "off": {
      return (
        <div className="flex flex-wrap items-center justify-between gap-3">
          <p className="text-sm text-muted-foreground">
            {t("Your browser asks for permission first.")}
          </p>
          <Button onClick={onTurnOn} disabled={pending}>
            <BellRingIcon />
            {t("Turn on")}
          </Button>
        </div>
      );
    }

    case "on": {
      return (
        <div className="flex flex-wrap items-center justify-between gap-3">
          <p className="text-sm text-muted-foreground">
            {t("This browser gets your push notifications.")}
          </p>
          <Button
            variant="outline"
            onClick={() => {
              onTurnOff(state.token);
            }}
            disabled={pending}
          >
            {t("Turn off")}
          </Button>
        </div>
      );
    }

    case "error": {
      return (
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>{t("Push couldn't be updated")}</AlertTitle>
          <AlertDescription>
            {t("Check your connection and try again.")}
          </AlertDescription>
          <AlertAction>
            <Button size="sm" variant="outline" onClick={onRetry}>
              {t("Retry")}
            </Button>
          </AlertAction>
        </Alert>
      );
    }

    default: {
      const unknown: never = state;

      return unknown;
    }
  }
}
```

# Quote builder

> A quote with line items, discounts and totals in minor units, sent for acceptance.

Source: https://bettersupabase.com/docs/registry/ui-recipes/quote-builder

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [quotes block](https://bettersupabase.com/blocks/quotes) must be installed (`npx better-supabase add quotes`). Its page on bettersupabase.com is [/recipes/quote-builder](https://bettersupabase.com/recipes/quote-builder).

Web, with shadcn components:

```bash
npx shadcn add card skeleton table
```

```tsx title="apps/examples/nextjs/src/features/crm/components/quote-builder.tsx"
/**
 * Render inside `<Suspense>`: a quote's line items and totals. A draft (or a
 * quote with changes requested) is editable; the status decides what comes
 * next: send, record the customer's answer, or revise.
 */
export async function QuoteBuilder({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const [{ id }, session, t, locale] = await Promise.all([
    params,
    getSession(),
    getExtracted("crm"),
    getLocale(),
  ]);

  if (!can(session, "quotes.read")) {
    forbidden();
  }

  const loaded = await settle(() => loadQuote(id));

  if (loaded.kind === "no-organization") {
    return null;
  }

  if (loaded.kind === "error") {
    return <CrmLoadError title={t("The quote didn't load")} />;
  }

  const quote = loaded.data;

  if (!quote) {
    notFound();
  }

  const canWrite = can(session, "quotes.write");

  const editable =
    canWrite &&
    (quote.status === "draft" || quote.status === "changes_requested");

  const totals = [
    { label: t("Subtotal"), value: quote.subtotal },
    { label: t("Discount"), value: quote.discountTotal },
    { label: t("Tax"), value: quote.taxTotal },
  ];

  return (
    <Card data-recipe="quote-builder">
      <CardHeader>
        <CardTitle>{quote.title}</CardTitle>
        <CardDescription className="flex flex-wrap items-center gap-2">
          <span className="tabular-nums">
            {t("{identifier} rev. {revision, number}", {
              identifier: quote.identifier,
              revision: quote.revision,
            })}
          </span>
          <QuoteStatusBadge status={quote.status} />
          {quote.validUntil ? (
            <span>
              {t("Valid until {date}", {
                date: Temporal.PlainDate.from(quote.validUntil).toLocaleString(
                  locale,
                  { dateStyle: "medium" },
                ),
              })}
            </span>
          ) : null}
        </CardDescription>
        {canWrite ? (
          <CardAction>
            <QuoteStatusActions
              quoteId={quote.id}
              status={quote.status}
              hasLines={quote.lines.length > 0}
            />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {editable ? (
          <QuoteLinesEditor
            key={quote.lines
              .map((line) => `${line.description}:${line.unitAmount.amount}`)
              .join(",")}
            quoteId={quote.id}
            currency={quote.currency}
            initialLines={quote.lines}
          />
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Description")}</TableHead>
                <TableHead className="text-right">{t("Qty")}</TableHead>
                <TableHead className="text-right">{t("Unit price")}</TableHead>
                <TableHead className="text-right">{t("Discount %")}</TableHead>
                <TableHead className="text-right">{t("Tax %")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {quote.lines.length === 0 ? (
                <TableRow>
                  <TableCell
                    colSpan={5}
                    className="text-center text-sm text-muted-foreground"
                  >
                    {t("This quote has no lines.")}
                  </TableCell>
                </TableRow>
              ) : null}
              {quote.lines.map((line) => (
                <TableRow key={line.position}>
                  <TableCell>{line.description}</TableCell>
                  <TableCell className="text-right tabular-nums">
                    {line.quantity}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {formatMoney(line.unitAmount, locale)}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {line.discountBps / 100}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {line.taxRateBps / 100}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
      <CardFooter className="justify-end">
        <dl className="grid w-full max-w-xs grid-cols-[1fr_auto] gap-x-6 gap-y-1 text-sm">
          {totals.map((total) => (
            <div key={total.label} className="contents">
              <dt className="text-muted-foreground">{total.label}</dt>
              <dd className="text-right tabular-nums">
                {formatMoney(total.value, locale)}
              </dd>
            </div>
          ))}
          <dt className="font-semibold">{t("Total")}</dt>
          <dd className="text-right font-semibold tabular-nums">
            {formatMoney(quote.total, locale)}
          </dd>
        </dl>
      </CardFooter>
    </Card>
  );
}
```

# Reactions bar

> Emoji reactions under a record with counts, which toggle the caller's own reaction.

Source: https://bettersupabase.com/docs/registry/ui-recipes/reactions-bar

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [reactions block](https://bettersupabase.com/blocks/reactions) must be installed (`npx better-supabase add reactions`). Its page on bettersupabase.com is [/recipes/reactions-bar](https://bettersupabase.com/recipes/reactions-bar).

Web, with shadcn components:

```bash
npx shadcn add alert button card skeleton
```

```tsx title="apps/examples/nextjs/src/features/customers/components/reactions-bar.tsx"
const EMOJIS = ["👍", "🎉", "❤️", "👀", "🚀"] as const;

/**
 * Quick reactions on the customer. The counts load in the browser and update
 * when anyone in the organization reacts (the `reactions` topic).
 */
export function ReactionsBar({
  customerId,
  canReact,
}: {
  customerId: string;
  canReact: boolean;
}) {
  const t = useExtracted("customers");
  const [busy, setBusy] = useState<string | null>(null);

  const subject = { type: "customer", id: customerId };

  const { reactions, error, toggle, refresh } = useReactions({
    subjects: [subject],
  });

  const counts = new Map(
    (reactions?.[subjectKey(subject)] ?? []).map((each) => [each.emoji, each]),
  );

  const total = [...counts.values()].reduce((sum, each) => sum + each.count, 0);

  return (
    <Card size="sm" data-recipe="reactions-bar">
      <CardHeader>
        <CardTitle>{t("Reactions")}</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        {error ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The reactions didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <Button
                size="sm"
                variant="outline"
                onClick={() => {
                  void refresh();
                }}
              >
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : null}
        {reactions === undefined ? (
          error ? null : (
            <div className="flex gap-2">
              {EMOJIS.map((emoji) => (
                <Skeleton key={emoji} className="h-8 w-12" />
              ))}
            </div>
          )
        ) : (
          <div className="flex flex-col gap-2">
            <fieldset className="flex flex-wrap gap-2">
              <legend className="sr-only">{t("React to this customer")}</legend>
              {EMOJIS.map((emoji) => {
                const count = counts.get(emoji);

                return (
                  <Button
                    key={emoji}
                    size="sm"
                    variant={count?.reacted ? "secondary" : "outline"}
                    aria-pressed={count?.reacted ?? false}
                    disabled={!canReact || busy !== null}
                    onClick={() => {
                      setBusy(emoji);
                      void toggle(subject, emoji).finally(() => {
                        setBusy(null);
                      });
                    }}
                  >
                    <span aria-hidden>{emoji}</span>
                    <span className="tabular-nums">{count?.count ?? 0}</span>
                  </Button>
                );
              })}
            </fieldset>
            {total === 0 ? (
              <p className="text-xs text-muted-foreground">
                {canReact
                  ? t("No reactions yet. Pick one to be the first.")
                  : t("No reactions yet.")}
              </p>
            ) : null}
          </div>
        )}
      </CardContent>
    </Card>
  );
}
```

# Record board

> An app table as a board with a column per option value, where dragging a card writes the value and its rank.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [rank block](https://bettersupabase.com/blocks/rank) must be installed (`npx better-supabase add rank`). Its page on bettersupabase.com is [/recipes/record-board](https://bettersupabase.com/recipes/record-board).

Web, with shadcn components:

```bash
npx shadcn add badge @reui/kanban
```

```tsx title="apps/examples/nextjs/src/features/projects/components/task-board.tsx"
type Columns = Record<string, TaskRow[]>;

/** One column per option of the select column; rows arrive in rank order. */
function columnsOf(rows: readonly TaskRow[]): Columns {
  const columns: Columns = Object.fromEntries(
    TASK_STATUSES.map((status) => [status, []]),
  );

  for (const row of rows) {
    columns[row.status]?.push(row);
  }

  return columns;
}

function TaskCard({
  task,
  assignee,
}: {
  task: TaskRow;
  assignee: string | null;
}) {
  return (
    <Card size="sm">
      <CardContent className="flex flex-col gap-2">
        <span className="text-sm font-medium">{task.title}</span>
        <div className="flex flex-wrap items-center gap-1">
          {task.dueOn === null ? null : (
            <Badge variant="outline">{task.dueOn}</Badge>
          )}
          {assignee === null ? null : (
            <Badge variant="secondary">{assignee}</Badge>
          )}
        </div>
      </CardContent>
    </Card>
  );
}

/**
 * Tasks in a column per status. A drop calls `rank.move` with the cards on
 * either side and the column, so the status and the order save together. A
 * refused move puts the card back.
 */
export function TaskBoard({
  rows,
  members,
  canMove,
}: {
  rows: readonly TaskRow[];
  members: readonly MemberOption[];
  canMove: boolean;
}) {
  const t = useExtracted("projects");
  const errorMessage = useErrorMessage();
  const statusLabel = useStatusLabel();
  const [columns, setColumns] = useState(() => columnsOf(rows));
  const move = useAction(moveTask);
  const byId = new Map(rows.map((row) => [row.id, row]));
  const memberName = new Map(members.map((each) => [each.userId, each.name]));

  const assigneeOf = (task: TaskRow) =>
    task.assigneeId === null ? null : (memberName.get(task.assigneeId) ?? null);

  return (
    <div data-recipe="record-board">
      <Kanban
        value={columns}
        onValueChange={setColumns}
        getItemValue={(item) => item.id}
        onValueCommit={(value, meta) => {
          const status = TASK_STATUSES.find(
            (each) => each === meta.overContainer,
          );

          if (meta.kind !== "item" || status === undefined) {
            return;
          }

          const column = value[status] ?? [];

          void move
            .run({
              id: String(meta.event.active.id),
              status,
              previousId: column[meta.overIndex - 1]?.id ?? null,
              nextId: column[meta.overIndex + 1]?.id ?? null,
            })
            .then((result) => {
              if (!result.ok) {
                setColumns(meta.previousValue);
                toast.error(errorMessage(result.error));
              }
            });
        }}
      >
        <KanbanBoard className="flex overflow-x-auto">
          {TASK_STATUSES.map((status) => {
            const tasks = columns[status] ?? [];

            return (
              <KanbanColumn
                key={status}
                value={status}
                className="w-72 shrink-0 gap-3 rounded-lg bg-muted/50 p-3"
              >
                <div className="flex items-center justify-between gap-2">
                  <Badge variant={statusBadgeVariant(status)}>
                    {statusLabel(status)}
                  </Badge>
                  <Badge variant="outline">{tasks.length}</Badge>
                </div>
                <KanbanColumnContent value={status} className="min-h-16">
                  {tasks.map((task) => (
                    <KanbanItem
                      key={task.id}
                      value={task.id}
                      disabled={!canMove || move.pending}
                    >
                      <KanbanItemHandle cursor={canMove}>
                        <TaskCard task={task} assignee={assigneeOf(task)} />
                      </KanbanItemHandle>
                    </KanbanItem>
                  ))}
                  {tasks.length === 0 ? (
                    <p className="text-xs text-muted-foreground">
                      {t("Drop a task here.")}
                    </p>
                  ) : null}
                </KanbanColumnContent>
              </KanbanColumn>
            );
          })}
        </KanbanBoard>
        <KanbanOverlay>
          {({ value, variant }) => {
            const task =
              variant === "item" ? byId.get(String(value)) : undefined;

            return task ? (
              <TaskCard task={task} assignee={assigneeOf(task)} />
            ) : null;
          }}
        </KanbanOverlay>
      </Kanban>
    </div>
  );
}
```

# Record calendar

> Rows on a month grid by a date column, with the month in the URL and a count of rows without a date.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-calendar

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [calendar block](https://bettersupabase.com/blocks/calendar) must be installed (`npx better-supabase add calendar`). Its page on bettersupabase.com is [/recipes/record-calendar](https://bettersupabase.com/recipes/record-calendar).

Web, with shadcn components:

```bash
npx shadcn add badge button card
```

```tsx title="apps/examples/nextjs/src/features/projects/components/task-calendar.tsx"
const SHOWN_PER_DAY = 3;

/**
 * Tasks on a month grid by their due date. The month lives in the URL
 * (`?month=YYYY-MM`), so the arrows are links and the page stays a server
 * component.
 */
export async function TaskCalendar({
  rows,
  first,
  today,
  monthHref,
}: {
  rows: readonly TaskRow[];
  first: Temporal.PlainDate;
  today: Temporal.PlainDate;
  monthHref: (month: string) => string;
}) {
  const [t, format] = await Promise.all([
    getExtracted("projects"),
    getFormatter(),
  ]);

  const weeks = weeksOf(first);
  const byDay = new Map<string, TaskRow[]>();

  for (const row of rows) {
    if (row.dueOn !== null) {
      byDay.set(row.dueOn, [...(byDay.get(row.dueOn) ?? []), row]);
    }
  }

  const label = (date: Temporal.PlainDate, options: DateTimeFormatOptions) =>
    format.dateTime(new Date(`${date.toString()}T12:00:00Z`), {
      ...options,
      timeZone: "UTC",
    });

  const unscheduled = rows.filter((row) => row.dueOn === null).length;

  const monthOf = (date: Temporal.PlainDate) =>
    date.toPlainYearMonth().toString();

  return (
    <Card data-recipe="record-calendar">
      <CardHeader>
        <CardTitle>
          {label(first, { month: "long", year: "numeric" })}
        </CardTitle>
        <CardDescription>
          {t(
            "{count, plural, =0 {Every task has a due date.} one {# task has no due date.} other {# tasks have no due date.}}",
            { count: unscheduled },
          )}
        </CardDescription>
        <CardAction className="flex items-center gap-2">
          <Link
            href={monthHref(monthOf(first.subtract({ months: 1 })))}
            aria-label={t("Previous month")}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
          >
            <ChevronLeftIcon />
          </Link>
          <Link
            href={monthHref(monthOf(today))}
            className={buttonVariants({ variant: "outline", size: "sm" })}
          >
            {t("Today")}
          </Link>
          <Link
            href={monthHref(monthOf(first.add({ months: 1 })))}
            aria-label={t("Next month")}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
          >
            <ChevronRightIcon />
          </Link>
        </CardAction>
      </CardHeader>
      <CardContent>
        <div className="overflow-x-auto">
          <div className="min-w-[42rem] overflow-hidden rounded-lg border">
            <div
              aria-hidden="true"
              className="grid grid-cols-7 border-b bg-muted/50"
            >
              {weeks[0]?.map((day) => (
                <span
                  key={day.toString()}
                  className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
                >
                  {label(day, { weekday: "short" })}
                </span>
              ))}
            </div>
            <ol
              aria-label={label(first, { month: "long", year: "numeric" })}
              className="grid grid-cols-7"
            >
              {weeks.flat().map((day) => {
                const key = day.toString();
                const tasks = byDay.get(key) ?? [];
                const outside = day.month !== first.month;

                return (
                  <li
                    key={key}
                    className={cn(
                      "flex min-h-24 flex-col gap-1 border-e border-b p-1.5 [&:nth-child(7n)]:border-e-0",
                      outside && "bg-muted/30",
                    )}
                  >
                    <span className="sr-only">
                      {label(day, { dateStyle: "full" })}
                    </span>
                    <span
                      aria-hidden="true"
                      className={cn(
                        "text-xs",
                        outside ? "text-muted-foreground" : "font-medium",
                      )}
                    >
                      {day.equals(today) ? (
                        <Badge variant="default">{day.day}</Badge>
                      ) : (
                        day.day
                      )}
                    </span>
                    {tasks.slice(0, SHOWN_PER_DAY).map((task) => (
                      <Link
                        key={task.id}
                        href={`/projects/${task.projectId}`}
                        title={task.title}
                        className={cn(
                          "truncate rounded-sm bg-primary/10 px-1.5 py-0.5 text-xs text-foreground transition-colors hover:bg-primary/20",
                          task.status === "done" &&
                            "text-muted-foreground line-through",
                        )}
                      >
                        {task.title}
                      </Link>
                    ))}
                    {tasks.length > SHOWN_PER_DAY ? (
                      <span className="text-xs text-muted-foreground">
                        {t("{count} more", {
                          count: String(tasks.length - SHOWN_PER_DAY),
                        })}
                      </span>
                    ) : null}
                  </li>
                );
              })}
            </ol>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}
```

# Record form

> A create form whose inputs follow each column: a length limit for text, a select for options, a date input and a searchable picker for references.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-form

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [saved-views block](https://bettersupabase.com/blocks/saved-views) must be installed (`npx better-supabase add saved-views`). Its page on bettersupabase.com is [/recipes/record-form](https://bettersupabase.com/recipes/record-form).

Web, with shadcn components:

```bash
npx shadcn add button combobox dialog field input select
```

```tsx title="apps/examples/nextjs/src/features/projects/components/task-form-dialog.tsx"
interface ReferenceOption {
  readonly value: string;
  readonly label: string;
}

/**
 * A searchable picker for a foreign key, such as `tasks.project_id`. It posts
 * the id in a hidden input, so the form stays a plain server action form.
 */
function ReferenceCombobox({
  id,
  name,
  options,
  defaultValue,
  placeholder,
  empty,
}: {
  id: string;
  name: string;
  options: readonly ReferenceOption[];
  defaultValue: string | null;
  placeholder: string;
  empty: string;
}) {
  const [value, setValue] = useState<ReferenceOption | null>(
    () => options.find((option) => option.value === defaultValue) ?? null,
  );

  return (
    <>
      <input type="hidden" name={name} value={value?.value ?? ""} />
      <Combobox
        items={options}
        value={value}
        onValueChange={setValue}
        itemToStringLabel={(option: ReferenceOption) => option.label}
        isItemEqualToValue={(a: ReferenceOption, b: ReferenceOption) =>
          a.value === b.value
        }
      >
        <ComboboxInput id={id} placeholder={placeholder} showClear />
        <ComboboxContent>
          <ComboboxEmpty>{empty}</ComboboxEmpty>
          <ComboboxList>
            {(option: ReferenceOption) => (
              <ComboboxItem key={option.value} value={option}>
                {option.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </>
  );
}

/**
 * The create form for a task. Each column gets the input its type and format
 * call for: text with its `max`, a select for `options`, a date input for
 * `date`, and a combobox for each reference.
 */
export function TaskFormDialog({
  projects,
  members,
  defaultProjectId,
}: {
  projects: readonly ProjectOption[];
  members: readonly MemberOption[];
  defaultProjectId: string | null;
}) {
  const t = useExtracted("projects");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const statusLabel = useStatusLabel();
  const [open, setOpen] = useState(false);
  const [status, setStatus] = useState<TaskStatus>("todo");

  const form = useActionForm(createTask, {
    onSuccess: () => {
      toast.success(t("Task created"));
      setOpen(false);
    },
  });

  const error = form.error === undefined ? null : errorMessage(form.error);

  const statusItems = TASK_STATUSES.map((value) => ({
    value,
    label: statusLabel(value),
  }));

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (!next) {
          form.reset();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <PlusIcon />
        {t("New task")}
      </DialogTrigger>
      <DialogContent>
        <form {...form.formProps} className="grid gap-6">
          <DialogHeader>
            <DialogTitle>{t("New task")}</DialogTitle>
            <DialogDescription>
              {t("It goes to the end of its status column.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field data-invalid={error ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
              <Input
                id={`${fieldId}-title`}
                name="title"
                required
                maxLength={200}
                autoComplete="off"
              />
              {error ? <FieldError>{error}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-project`}>
                {t("Project")}
              </FieldLabel>
              <ReferenceCombobox
                id={`${fieldId}-project`}
                name="projectId"
                options={projects.map((project) => ({
                  value: project.id,
                  label: project.name,
                }))}
                defaultValue={defaultProjectId}
                placeholder={t("Pick a project")}
                empty={t("No projects match.")}
              />
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-status`}>
                {t("Status")}
              </FieldLabel>
              <input type="hidden" name="status" value={status} />
              <Select
                value={status}
                items={statusItems}
                onValueChange={(value) => {
                  setStatus(
                    TASK_STATUSES.find((each) => each === value) ?? "todo",
                  );
                }}
              >
                <SelectTrigger id={`${fieldId}-status`}>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {statusItems.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-assignee`}>
                {t("Assignee")}
              </FieldLabel>
              <ReferenceCombobox
                id={`${fieldId}-assignee`}
                name="assigneeId"
                options={members.map((member) => ({
                  value: member.userId,
                  label: member.name,
                }))}
                defaultValue={null}
                placeholder={t("Unassigned")}
                empty={t("No members match.")}
              />
            </Field>
            <div className="grid grid-cols-2 gap-4">
              <Field>
                <FieldLabel htmlFor={`${fieldId}-starts`}>
                  {t("Starts on")}
                </FieldLabel>
                <Input id={`${fieldId}-starts`} name="startsOn" type="date" />
              </Field>
              <Field>
                <FieldLabel htmlFor={`${fieldId}-due`}>
                  {t("Due on")}
                </FieldLabel>
                <Input id={`${fieldId}-due`} name="dueOn" type="date" />
              </Field>
            </div>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={form.pending}>
              {t("Create task")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

# Record Gantt chart

> A row per record grouped by its parent, with a bar for its planned dates and a marker for rows without dates.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-gantt

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [calendar block](https://bettersupabase.com/blocks/calendar) must be installed (`npx better-supabase add calendar`). Its page on bettersupabase.com is [/recipes/record-gantt](https://bettersupabase.com/recipes/record-gantt).

Web, with shadcn components:

```bash
npx shadcn add badge button card
```

```tsx title="apps/examples/nextjs/src/features/projects/components/task-gantt.tsx"
const BAR = {
  todo: "bg-muted-foreground/30",
  doing: "bg-primary/60",
  done: "bg-primary/25",
} as const satisfies Record<TaskStatus, string>;

/**
 * One row per task under its project, with a bar from `startsOn` to `dueOn`.
 * Tasks without dates stay in the list with an empty track, so the plan shows
 * what still needs scheduling.
 */
export async function TaskGantt({
  rows,
  projects,
  from,
  today,
  windowHref,
}: {
  rows: readonly TaskRow[];
  projects: readonly ProjectOption[];
  from: Temporal.PlainDate;
  today: Temporal.PlainDate;
  windowHref: (from: string) => string;
}) {
  const t = await getExtracted("projects");

  const groups = projects
    .map((project) => ({
      project,
      tasks: rows.filter((row) => row.projectId === project.id),
    }))
    .filter((group) => group.tasks.length > 0);

  return (
    <Card data-recipe="record-gantt">
      <CardHeader>
        <CardTitle>{t("Gantt")}</CardTitle>
        <CardDescription>
          {t("Every task by project, with its planned dates.")}
        </CardDescription>
        <CardAction>
          <WindowNav from={from} today={today} windowHref={windowHref} />
        </CardAction>
      </CardHeader>
      <CardContent>
        <div className="overflow-x-auto">
          <div className="min-w-[60rem] overflow-hidden rounded-lg border">
            <div className="grid grid-cols-[14rem_1fr]">
              <span className="border-e border-b bg-muted/50" />
              <WindowDays from={from} today={today} />
              {groups.map(({ project, tasks }) => (
                <div key={project.id} className="contents">
                  <Link
                    href={`/projects/${project.id}`}
                    className="col-span-2 border-b bg-muted/30 px-2 py-1.5 text-sm font-medium hover:underline"
                  >
                    {project.name}
                  </Link>
                  {tasks.map((task) => {
                    const span = spanOf(task, from);

                    return (
                      <div key={task.id} className="contents">
                        <span className="flex items-center justify-between gap-2 border-e border-b px-2 py-1.5 ps-4">
                          <span className="truncate text-sm">{task.title}</span>
                          {span === null &&
                          task.startsOn === null &&
                          task.dueOn === null ? (
                            <Badge variant="outline">{t("No dates")}</Badge>
                          ) : null}
                        </span>
                        <div className="grid grid-cols-28 items-center border-b">
                          {span === null ? null : (
                            <span
                              title={t("{start} to {end}", {
                                start: task.startsOn ?? task.dueOn ?? "",
                                end: task.dueOn ?? task.startsOn ?? "",
                              })}
                              className={`[grid-column:var(--span-start)/span_var(--span-length)] h-4 rounded-sm ${BAR[task.status]}`}
                              // SAFETY: React's CSSProperties has no custom properties; both are numbers.
                              style={
                                {
                                  "--span-start": span.start + 1,
                                  "--span-length": span.length,
                                } as CSSProperties
                              }
                            />
                          )}
                        </div>
                      </div>
                    );
                  })}
                </div>
              ))}
            </div>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}
```

# Record grid

> An app table as a data grid with search, filters, saved views and bulk actions on the selected rows, including Set field through `appTables.set`.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-grid

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [saved-views block](https://bettersupabase.com/blocks/saved-views) must be installed (`npx better-supabase add saved-views`). Its page on bettersupabase.com is [/recipes/record-grid](https://bettersupabase.com/recipes/record-grid).

Web, with shadcn components:

```bash
npx shadcn add badge button dialog input select @reui/data-grid @reui/filters
```

A copy of `@reui/data-grid` installed from ReUI 2.8.0 or earlier memoizes its context on `recordCount` but not on the rows, so an edit that keeps the row count (a status change, a renamed task) doesn't repaint the cells. Reinstall it with `npx shadcn add @reui/data-grid --overwrite`, or add `table.options.data` to the dependency list of the `value` memo in `DataGridProvider`.

```tsx title="apps/examples/nextjs/src/features/projects/components/task-grid.tsx"
const STATUS_FIELD = "status";
const PROJECT_FIELD = "project";
const ONLY_IS = [{ value: "is", label: "is" }];

/** The URL's status and project filters as a ReUI filter query. */
function toFilterQuery(query: TaskQuery): FilterQuery<string> {
  return createFilterQuery([
    ...(query.status === null
      ? []
      : [
          createFilterRule({
            id: STATUS_FIELD,
            path: [STATUS_FIELD],
            operator: "is",
            value: query.status,
          }),
        ]),
    ...(query.projectId === null
      ? []
      : [
          createFilterRule({
            id: PROJECT_FIELD,
            path: [PROJECT_FIELD],
            operator: "is",
            value: query.projectId,
          }),
        ]),
  ]);
}

/** The one value a filter rule holds, or null once it is cleared. */
function valueOf(query: FilterQuery<string>, field: string): string | null {
  const condition = flattenFilterConditions(query).find(
    (each) => each.field === field,
  );

  const value = v.safeParse(v.string(), condition?.values[0]);

  return value.success ? value.output : null;
}

/**
 * The search, the filters and the saved views every task screen shares. They
 * live in the URL, so a saved view is the screen plus its search string.
 */
export function TaskToolbar({
  query,
  projects,
  views,
  canSaveViews,
}: {
  query: TaskQuery;
  projects: readonly ProjectOption[];
  views: readonly TaskViewOption[];
  canSaveViews: boolean;
}) {
  const t = useExtracted("projects");
  const router = useRouter();
  const pathname = usePathname();
  const statusLabel = useStatusLabel();
  const errorMessage = useErrorMessage();
  const [navigating, startTransition] = useTransition();
  const [search, setSearch] = useState(query.q);

  const removeView = useAction(deleteTaskView, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const go = (next: TaskQuery) => {
    startTransition(() => {
      router.replace(`${pathname}${taskSearch(next)}`);
    });
  };

  const fields: FilterField<string>[] = [
    {
      id: STATUS_FIELD,
      label: t("Status"),
      type: "select",
      operators: ONLY_IS,
      options: TASK_STATUSES.map((status) => ({
        value: status,
        label: statusLabel(status),
      })),
    },
    {
      id: PROJECT_FIELD,
      label: t("Project"),
      type: "select",
      operators: ONLY_IS,
      options: projects.map((project) => ({
        value: project.id,
        label: project.name,
      })),
    },
  ];

  const activeView = views.find(
    (view) => taskSearch(view.query) === taskSearch(query),
  );

  return (
    <div className="flex flex-wrap items-center gap-2">
      <form
        className="flex items-center gap-2"
        onSubmit={(event) => {
          event.preventDefault();
          go({ ...query, q: search.trim() });
        }}
      >
        <Input
          type="search"
          className="w-56"
          value={search}
          placeholder={t("Search tasks")}
          aria-label={t("Search tasks")}
          onChange={(event) => {
            setSearch(event.target.value);
          }}
        />
        <Button
          type="submit"
          size="icon-sm"
          variant="outline"
          aria-label={t("Search")}
          disabled={navigating}
        >
          <SearchIcon />
        </Button>
      </form>
      <Filters
        size="sm"
        fields={fields}
        query={toFilterQuery(query)}
        showClear
        onQueryChange={(next) => {
          const status = TASK_STATUSES.find(
            (each) => each === valueOf(next, STATUS_FIELD),
          );

          go({
            ...query,
            status: status ?? null,
            projectId: valueOf(next, PROJECT_FIELD),
          });
        }}
      />
      <div className="ml-auto flex items-center gap-2">
        {views.length === 0 ? null : (
          <Select
            value={activeView?.id ?? null}
            items={views.map((view) => ({ value: view.id, label: view.name }))}
            onValueChange={(value) => {
              const view = views.find((each) => each.id === value);

              if (view) {
                setSearch(view.query.q);
                go(view.query);
              }
            }}
          >
            <SelectTrigger
              size="sm"
              className="w-44"
              aria-label={t("Saved views")}
            >
              <SelectValue placeholder={t("Saved views")} />
            </SelectTrigger>
            <SelectContent>
              {views.map((view) => (
                <SelectItem key={view.id} value={view.id}>
                  {view.name}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        )}
        {activeView ? (
          <Button
            size="icon-sm"
            variant="outline"
            aria-label={t("Delete view {name}", { name: activeView.name })}
            disabled={removeView.pending}
            onClick={() => {
              void removeView.run({ id: activeView.id });
            }}
          >
            <Trash2Icon />
          </Button>
        ) : null}
        {canSaveViews ? <SaveTaskViewDialog query={query} /> : null}
      </div>
    </div>
  );
}

function SaveTaskViewDialog({ query }: { query: TaskQuery }) {
  const t = useExtracted("projects");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const save = useAction(saveTaskView);
  const error = save.error === undefined ? null : errorMessage(save.error);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger render={<Button size="sm" variant="outline" />}>
        <BookmarkIcon />
        {t("Save view")}
      </DialogTrigger>
      <DialogContent>
        <form
          className="grid gap-6"
          onSubmit={(event) => {
            event.preventDefault();
            void save.run({ name, ...query }).then((result) => {
              if (result.ok) {
                toast.success(t("View saved"));
                setName("");
                setOpen(false);
              }
            });
          }}
        >
          <DialogHeader>
            <DialogTitle>{t("Save view")}</DialogTitle>
            <DialogDescription>
              {t("Keeps the screen, the search and the filters.")}
            </DialogDescription>
          </DialogHeader>
          <Field data-invalid={error ? true : undefined}>
            <FieldLabel htmlFor={`${fieldId}-name`}>{t("Name")}</FieldLabel>
            <Input
              id={`${fieldId}-name`}
              value={name}
              required
              maxLength={80}
              autoComplete="off"
              onChange={(event) => {
                setName(event.target.value);
              }}
            />
            {error ? <FieldError>{error}</FieldError> : null}
          </Field>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={save.pending}>
              {t("Save")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

const UNASSIGNED = "unassigned";

type SetField = "due_on" | "assignee_id";

/**
 * Sets one field on the selected tasks with the bulk block's `appTables.set`.
 * An empty value clears the field.
 */
function SetFieldDialog({
  ids,
  members,
  onDone,
}: {
  ids: readonly string[];
  members: readonly MemberOption[];
  onDone: () => void;
}) {
  const t = useExtracted("projects");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const [field, setField] = useState<SetField>("due_on");
  const [dueOn, setDueOn] = useState("");
  const [assignee, setAssignee] = useState(UNASSIGNED);

  const set = useAction(setTaskField, {
    onSuccess: (count) => {
      toast.success(
        t("{count, plural, one {# task} other {# tasks}} changed", { count }),
      );
      setOpen(false);
      onDone();
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const fields = [
    { value: "due_on", label: t("Due date") },
    { value: "assignee_id", label: t("Assignee") },
  ] as const satisfies readonly { value: SetField; label: string }[];

  const people = [
    { value: UNASSIGNED, label: t("Unassigned") },
    ...members.map((member) => ({ value: member.userId, label: member.name })),
  ];

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger render={<Button size="sm" variant="outline" />}>
        {t("Set field")}
      </DialogTrigger>
      <DialogContent>
        <form
          className="grid gap-6"
          onSubmit={(event) => {
            event.preventDefault();
            void set.run({
              ids: [...ids],
              values:
                field === "due_on"
                  ? { due_on: dueOn === "" ? null : dueOn }
                  : { assignee_id: assignee === UNASSIGNED ? null : assignee },
            });
          }}
        >
          <DialogHeader>
            <DialogTitle>
              {t(
                "Set a field on {count, plural, one {# task} other {# tasks}}",
                { count: ids.length },
              )}
            </DialogTitle>
            <DialogDescription>
              {t("Leave the value empty to clear the field.")}
            </DialogDescription>
          </DialogHeader>
          <div className="grid gap-4">
            <Field>
              <FieldLabel htmlFor={`${fieldId}-field`}>{t("Field")}</FieldLabel>
              <Select
                value={field}
                items={fields}
                onValueChange={(value) => {
                  const picked = fields.find((each) => each.value === value);

                  if (picked) {
                    setField(picked.value);
                  }
                }}
              >
                <SelectTrigger id={`${fieldId}-field`} className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {fields.map((each) => (
                    <SelectItem key={each.value} value={each.value}>
                      {each.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            {field === "due_on" ? (
              <Field>
                <FieldLabel htmlFor={`${fieldId}-due`}>
                  {t("Due date")}
                </FieldLabel>
                <Input
                  id={`${fieldId}-due`}
                  type="date"
                  value={dueOn}
                  onChange={(event) => {
                    setDueOn(event.target.value);
                  }}
                />
              </Field>
            ) : (
              <Field>
                <FieldLabel htmlFor={`${fieldId}-assignee`}>
                  {t("Assignee")}
                </FieldLabel>
                <Select
                  value={assignee}
                  items={people}
                  onValueChange={(value) => {
                    const person = people.find((each) => each.value === value);

                    if (person) {
                      setAssignee(person.value);
                    }
                  }}
                >
                  <SelectTrigger id={`${fieldId}-assignee`} className="w-full">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {people.map((person) => (
                      <SelectItem key={person.value} value={person.value}>
                        {person.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </Field>
            )}
          </div>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={set.pending}>
              {t("Apply")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

type GridRow = TaskRow & {
  readonly projectName: string;
  readonly assigneeName: string;
};

type GridCell = CellContext<DataGridFeatures, GridRow>;

const SELECT_COLUMN = {
  id: "select",
  header: () => <DataGridTableRowSelectAll />,
  cell: ({ row }) => <DataGridTableRowSelect row={row} />,
  size: 40,
} satisfies ColumnDef<DataGridFeatures, GridRow>;

function TitleCell({ row }: GridCell) {
  return (
    <Link
      href={`/projects/${row.original.projectId}`}
      className="font-medium hover:underline"
    >
      {row.original.title}
    </Link>
  );
}

function StatusCell({ row }: GridCell) {
  return <TaskStatusBadge status={row.original.status} />;
}

/**
 * Tasks in a ReUI data grid. The checkboxes feed a bulk bar that sets the
 * status of the selected rows, sets another field through the bulk block, or
 * deletes them in one statement.
 */
export function TaskGrid({
  rows,
  projects,
  members,
  canWrite,
}: {
  rows: readonly TaskRow[];
  projects: readonly ProjectOption[];
  members: readonly MemberOption[];
  canWrite: boolean;
}) {
  const t = useExtracted("projects");
  const statusLabel = useStatusLabel();
  const errorMessage = useErrorMessage();
  const [selection, setSelection] = useState<RowSelectionState>({});
  const projectName = new Map(projects.map((each) => [each.id, each.name]));
  const memberName = new Map(members.map((each) => [each.userId, each.name]));

  const bulk = useAction(runTaskBulkAction, {
    onSuccess: ({ count }) => {
      toast.success(
        t("{count, plural, one {# task} other {# tasks}} changed", { count }),
      );
      setSelection({});
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const columns: ColumnDef<DataGridFeatures, GridRow>[] = [
    ...(canWrite ? [SELECT_COLUMN] : []),
    { accessorKey: "title", header: t("Title"), cell: TitleCell, size: 280 },
    { accessorKey: "status", header: t("Status"), cell: StatusCell, size: 120 },
    { accessorKey: "projectName", header: t("Project"), size: 180 },
    { accessorKey: "assigneeName", header: t("Assignee"), size: 160 },
    { accessorKey: "dueOn", header: t("Due"), size: 120 },
  ];

  const data = rows.map((row) => ({
    ...row,
    projectName: projectName.get(row.projectId) ?? "",
    assigneeName:
      row.assigneeId === null
        ? t("Unassigned")
        : (memberName.get(row.assigneeId) ?? ""),
  }));

  const table = useTable({
    features: dataGridFeatures,
    columns,
    data,
    getRowId: (row) => row.id,
    state: { rowSelection: selection },
    enableRowSelection: canWrite,
    onRowSelectionChange: setSelection,
  });

  const ids = rows.filter((row) => selection[row.id]).map((row) => row.id);

  return (
    <div className="flex flex-col gap-3" data-recipe="record-grid">
      {ids.length > 0 ? (
        <div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/50 p-2">
          <span className="text-sm">
            {t("{count, plural, one {# selected} other {# selected}}", {
              count: ids.length,
            })}
          </span>
          {TASK_STATUSES.map((status) => (
            <Button
              key={status}
              size="sm"
              variant="outline"
              disabled={bulk.pending}
              onClick={() => {
                void bulk.run({ kind: "status", ids, status });
              }}
            >
              {t("Mark {status}", { status: statusLabel(status) })}
            </Button>
          ))}
          <SetFieldDialog
            ids={ids}
            members={members}
            onDone={() => {
              setSelection({});
            }}
          />
          <Button
            size="sm"
            variant="destructive"
            className="ml-auto"
            disabled={bulk.pending}
            onClick={() => {
              void bulk.run({ kind: "delete", ids });
            }}
          >
            <Trash2Icon />
            {t("Delete")}
          </Button>
        </div>
      ) : null}
      <DataGrid table={table} recordCount={rows.length}>
        <DataGridContainer>
          <DataGridScrollArea>
            <DataGridTable />
          </DataGridScrollArea>
        </DataGridContainer>
      </DataGrid>
    </div>
  );
}
```

# Record history

> Every version `subject.versions` kept of a row, with the fields each one changed and a restore per field.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-history

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [versions block](https://bettersupabase.com/blocks/versions) must be installed (`npx better-supabase add versions`). Its page on bettersupabase.com is [/recipes/record-history](https://bettersupabase.com/recipes/record-history).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty
```

```tsx title="apps/examples/nextjs/src/features/projects/components/project-history.tsx"
interface FieldChange {
  readonly field: ProjectHistoryField;
  readonly before: string | undefined;
  readonly after: string | undefined;
}

/**
 * The fields a version changed from the one before it; all of them for the
 * first.
 */
function changesOf(
  version: ProjectVersion,
  previous: ProjectVersion | undefined,
): readonly FieldChange[] {
  return PROJECT_HISTORY_FIELDS.flatMap((field) => {
    const after = version.fields[field];
    const before = previous?.fields[field];

    return previous !== undefined && before === after
      ? []
      : [{ field, before, after }];
  });
}

/**
 * The project's history: every version the snapshot trigger kept, with the
 * fields it changed. Restore writes one field of an older version back and
 * leaves the others as they are.
 */
export function ProjectHistory({
  projectId,
  versions,
  canSave,
  canRestore,
}: {
  projectId: string;
  versions: readonly ProjectVersion[];
  canSave: boolean;
  canRestore: boolean;
}) {
  const t = useExtracted("projects");

  return (
    <Card size="sm" data-recipe="record-history">
      <CardHeader>
        <CardTitle>{t("History")}</CardTitle>
        <CardDescription>
          {t("Each change to the name, budget or currency.")}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {versions.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No history yet")}</EmptyTitle>
              <EmptyDescription>
                {t("The next change to this project appears here.")}
              </EmptyDescription>
            </EmptyHeader>
            {canSave ? (
              <EmptyContent>
                <SaveVersionButton projectId={projectId} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <ol className="flex flex-col divide-y" data-testid="project-history">
            {versions.map((version, index) => (
              <VersionEntry
                key={version.id}
                projectId={projectId}
                version={version}
                changes={changesOf(version, versions[index + 1])}
                canRestore={canRestore && index > 0}
              />
            ))}
          </ol>
        )}
      </CardContent>
    </Card>
  );
}

function VersionEntry({
  projectId,
  version,
  changes,
  canRestore,
}: {
  projectId: string;
  version: ProjectVersion;
  changes: readonly FieldChange[];
  canRestore: boolean;
}) {
  const t = useExtracted("projects");
  const format = useFormatter();
  const locale = useLocale();
  const errorMessage = useErrorMessage();

  const restore = useAction(restoreProjectFields, {
    onSuccess: () => {
      toast.success(t("Field restored"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const label = {
    name: t("Name"),
    budget_minor: t("Budget"),
    currency: t("Currency"),
  } as const satisfies Record<ProjectHistoryField, string>;

  const source = {
    manual: t("Saved"),
    auto: t("Edited"),
    restore: t("Restored"),
  } as const satisfies Record<ProjectVersion["source"], string>;

  const shown = (field: ProjectHistoryField, value: string | undefined) => {
    if (value === undefined) {
      return t("Empty");
    }

    return field === "budget_minor"
      ? formatMoney(
          money(BigInt(value), version.fields.currency ?? "EUR"),
          locale,
        )
      : value;
  };

  return (
    <li className="flex flex-col gap-2 py-3">
      <div className="flex items-center gap-2">
        <span className="text-sm font-medium">
          {t("Version {number}", { number: String(version.number) })}
        </span>
        <Badge variant="secondary">{source[version.source]}</Badge>
        <span className="ml-auto text-xs text-muted-foreground">
          {format.dateTime(version.createdAt, {
            dateStyle: "medium",
            timeStyle: "short",
          })}
        </span>
      </div>
      <dl className="flex flex-col gap-1">
        {changes.map((change) => (
          <div key={change.field} className="flex items-center gap-2 text-sm">
            <dt className="w-20 shrink-0 text-muted-foreground">
              {label[change.field]}
            </dt>
            <dd className="flex min-w-0 flex-1 items-center gap-1">
              {change.before === undefined ? null : (
                <>
                  <span className="truncate text-muted-foreground line-through">
                    {shown(change.field, change.before)}
                  </span>
                  <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground" />
                </>
              )}
              <span className="truncate">
                {shown(change.field, change.after)}
              </span>
            </dd>
            {canRestore ? (
              <Button
                size="xs"
                variant="ghost"
                disabled={restore.pending}
                aria-label={t("Restore {field} from version {number}", {
                  field: label[change.field],
                  number: String(version.number),
                })}
                onClick={() => {
                  void restore.run({
                    projectId,
                    versionId: version.id,
                    fields: [change.field],
                  });
                }}
              >
                {t("Restore")}
              </Button>
            ) : null}
          </div>
        ))}
      </dl>
    </li>
  );
}

function SaveVersionButton({ projectId }: { projectId: string }) {
  const t = useExtracted("projects");
  const errorMessage = useErrorMessage();

  const save = useAction(snapshotProjectVersions, {
    onSuccess: () => {
      toast.success(t("Version saved"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <Button
      size="sm"
      variant="outline"
      disabled={save.pending}
      onClick={() => {
        void save.run({ id: projectId, input: {} });
      }}
    >
      <HistoryIcon />
      {t("Save a version")}
    </Button>
  );
}
```

# Record page

> One row as a page: its fields, the rows that reference it, and comments and tags once the table is a subject.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-page

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [comments](https://bettersupabase.com/blocks/comments), [tags](https://bettersupabase.com/blocks/tags) blocks must be installed (`npx better-supabase add comments tags`). Its page on bettersupabase.com is [/recipes/record-page](https://bettersupabase.com/recipes/record-page).

Web, with shadcn components:

```bash
npx shadcn add badge card skeleton
```

```tsx title="apps/examples/nextjs/src/features/projects/components/project-page.tsx"
/**
 * Render inside `<Suspense>`: one project as a record page. Its fields, its
 * tasks through the `project_id` reference, and the comments and tags that
 * reach it because `projects` is a subject.
 */
export async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const [{ id }, session] = await Promise.all([params, getSession()]);
  const organizationId = tenantOf(session);

  if (!organizationId) {
    notFound();
  }

  const [project, tasks, members, history, t, format, locale] =
    await Promise.all([
      settle(() => getProject(id)),
      settle(() => getTasks({ q: "", status: null, projectId: id })),
      settle(() => getMemberOptions(organizationId)),
      settle(() => getProjectHistory(id)),
      getExtracted("projects"),
      getFormatter(),
      getLocale(),
    ]);

  if (project.kind !== "ok" || tasks.kind !== "ok" || members.kind !== "ok") {
    return <ProjectLoadError title={t("The project didn't load")} />;
  }

  const record = project.data;

  if (record === undefined) {
    notFound();
  }

  const nameOf = (userId: string | null) =>
    userId === null
      ? t("Unassigned")
      : (members.data.find((member) => member.userId === userId)?.name ??
        userId);

  const subject = { type: PROJECT_SUBJECT, id: record.id };

  return (
    <div className="grid items-start gap-6 lg:grid-cols-3">
      <div className="flex flex-col gap-6 lg:col-span-2">
        <div className="flex flex-col gap-1">
          <h1 className="text-2xl font-semibold tracking-tight">
            {record.name}
          </h1>
          <p className="text-sm text-muted-foreground">
            {t("Created {date}", {
              date: format.dateTime(new Date(record.createdAt), {
                dateStyle: "long",
              }),
            })}
          </p>
        </div>

        <Card>
          <CardHeader>
            <CardTitle>{t("Tasks")}</CardTitle>
            <CardDescription>
              {t("{count, plural, one {# task} other {# tasks}}", {
                count: tasks.data.length,
              })}
            </CardDescription>
            <CardAction className="flex items-center gap-2">
              <TaskImportAction projectId={record.id} />
              <TaskFormDialog
                projects={[{ id: record.id, name: record.name }]}
                members={members.data}
                defaultProjectId={record.id}
              />
            </CardAction>
          </CardHeader>
          <CardContent>
            {tasks.data.length === 0 ? (
              <p className="text-sm text-muted-foreground">
                {t("No tasks yet. Add the first one with New task.")}
              </p>
            ) : (
              <ul className="flex flex-col divide-y">
                {tasks.data.map((task) => (
                  <li
                    key={task.id}
                    className="flex items-center justify-between gap-3 py-2"
                  >
                    <div className="flex min-w-0 flex-col">
                      <span className="truncate text-sm font-medium">
                        {task.title}
                      </span>
                      <span className="text-xs text-muted-foreground">
                        {nameOf(task.assigneeId)}
                        {task.dueOn
                          ? ` · ${t("due {date}", {
                              date: format.dateTime(
                                new Date(`${task.dueOn}T00:00:00Z`),
                                { dateStyle: "medium", timeZone: "UTC" },
                              ),
                            })}`
                          : null}
                      </span>
                    </div>
                    <TaskStatusBadge status={task.status} />
                  </li>
                ))}
              </ul>
            )}
          </CardContent>
        </Card>

        <Card data-recipe="comment-thread">
          <CardHeader>
            <CardTitle>{t("Comments")}</CardTitle>
          </CardHeader>
          <CardContent>
            <CommentThread
              subject={subject}
              authors={Object.fromEntries(
                members.data.map((member) => [member.userId, member.name]),
              )}
              canComment={can(session, "messages.send")}
            />
          </CardContent>
        </Card>
      </div>

      <div className="flex flex-col gap-6">
        <Card size="sm">
          <CardHeader>
            <CardTitle>{t("Details")}</CardTitle>
          </CardHeader>
          <CardContent>
            <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
              <dt className="text-muted-foreground">{t("Budget")}</dt>
              <dd>
                {formatMoney(
                  money(record.budgetMinor, record.currency),
                  locale,
                )}
              </dd>
              <dt className="text-muted-foreground">{t("Lead")}</dt>
              <dd>{nameOf(record.leadId)}</dd>
              <dt className="text-muted-foreground">{t("Open tasks")}</dt>
              <dd>
                <Badge variant="outline">
                  {tasks.data.filter((task) => task.status !== "done").length}
                </Badge>
              </dd>
            </dl>
          </CardContent>
        </Card>
        {history.kind === "ok" ? (
          <ProjectHistory
            projectId={record.id}
            versions={history.data}
            canSave={can(session, "versions.create")}
            canRestore={can(session, "versions.restore")}
          />
        ) : (
          <ProjectLoadError title={t("The history didn't load")} />
        )}
        <Card size="sm" data-recipe="tag-input">
          <CardHeader>
            <CardTitle>{t("Tags")}</CardTitle>
          </CardHeader>
          <CardContent>
            <TagInput subject={subject} />
          </CardContent>
        </Card>
        <Link
          href={`/projects?view=board&project=${record.id}`}
          className="text-sm text-muted-foreground transition-colors hover:text-foreground"
        >
          {t("Open this project's board")}
        </Link>
      </div>
    </div>
  );
}
```

# Record timeline

> Rows as bars from a start date to an end date over four weeks, with a lane per assignee.

Source: https://bettersupabase.com/docs/registry/ui-recipes/record-timeline

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [calendar block](https://bettersupabase.com/blocks/calendar) must be installed (`npx better-supabase add calendar`). Its page on bettersupabase.com is [/recipes/record-timeline](https://bettersupabase.com/recipes/record-timeline).

Web, with shadcn components:

```bash
npx shadcn add button card
```

```tsx title="apps/examples/nextjs/src/features/projects/components/task-timeline.tsx"
const UNASSIGNED = "";

/**
 * Tasks as bars from `startsOn` to `dueOn`, one lane per assignee, over a
 * four-week window that lives in the URL (`?from=YYYY-MM-DD`).
 */
export async function TaskTimeline({
  rows,
  members,
  from,
  today,
  windowHref,
}: {
  rows: readonly TaskRow[];
  members: readonly MemberOption[];
  from: Temporal.PlainDate;
  today: Temporal.PlainDate;
  windowHref: (from: string) => string;
}) {
  const t = await getExtracted("projects");
  const lanes = new Map<string, TaskRow[]>();

  for (const row of rows) {
    if (spanOf(row, from) !== null) {
      const lane = row.assigneeId ?? UNASSIGNED;

      lanes.set(lane, [...(lanes.get(lane) ?? []), row]);
    }
  }

  const laneName = (id: string) =>
    id === UNASSIGNED
      ? t("Unassigned")
      : (members.find((member) => member.userId === id)?.name ?? id);

  return (
    <Card data-recipe="record-timeline">
      <CardHeader>
        <CardTitle>{t("Timeline")}</CardTitle>
        <CardDescription>
          {t(
            "Tasks with a start or due date in these four weeks, by assignee.",
          )}
        </CardDescription>
        <CardAction>
          <WindowNav from={from} today={today} windowHref={windowHref} />
        </CardAction>
      </CardHeader>
      <CardContent>
        <div className="overflow-x-auto">
          <div className="min-w-[56rem] overflow-hidden rounded-lg border">
            <div className="grid grid-cols-[10rem_1fr]">
              <span className="border-e border-b bg-muted/50" />
              <WindowDays from={from} today={today} />
              {lanes.size === 0 ? (
                <p className="col-span-2 p-4 text-sm text-muted-foreground">
                  {t("No tasks fall in these weeks.")}
                </p>
              ) : null}
              {[...lanes].map(([lane, tasks]) => (
                <div key={lane} className="contents">
                  <span className="truncate border-e border-b p-2 text-sm font-medium">
                    {laneName(lane)}
                  </span>
                  <ol className="grid grid-cols-28 gap-y-1 border-b py-1.5">
                    {tasks.map((task) => {
                      const span = spanOf(task, from);

                      return span === null ? null : (
                        <li
                          key={task.id}
                          className="[grid-column:var(--span-start)/span_var(--span-length)]"
                          // SAFETY: React's CSSProperties has no custom properties; both are numbers.
                          style={
                            {
                              "--span-start": span.start + 1,
                              "--span-length": span.length,
                            } as CSSProperties
                          }
                        >
                          <Link
                            href={`/projects/${task.projectId}`}
                            title={task.title}
                            className={cn(
                              "block truncate rounded-sm bg-primary/10 px-1.5 py-0.5 text-xs transition-colors hover:bg-primary/20",
                              task.status === "done" &&
                                "text-muted-foreground line-through",
                            )}
                          >
                            {task.title}
                          </Link>
                        </li>
                      );
                    })}
                  </ol>
                </div>
              ))}
            </div>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}
```

# Related items panel

> The records linked to a record, with a picker to add or remove a link.

Source: https://bettersupabase.com/docs/registry/ui-recipes/related-items-panel

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [relations block](https://bettersupabase.com/blocks/relations) must be installed (`npx better-supabase add relations`). Its page on bettersupabase.com is [/recipes/related-items-panel](https://bettersupabase.com/recipes/related-items-panel).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty field select
```

```tsx title="apps/examples/nextjs/src/features/customers/components/related-items-panel.tsx"
/** Customers linked to this one (the `relates` kind), and a picker to link more. */
export function RelatedItemsPanel({
  customerId,
  relations,
  canManage,
}: {
  customerId: string;
  relations: Loaded<Relations>;
  canManage: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="related-items-panel">
      <CardHeader>
        <CardTitle>{t("Related customers")}</CardTitle>
        <CardDescription>
          {t("Link customers that belong together, such as a parent company.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {relations.kind === "error" ? (
          <RecordLoadError title={t("The related customers didn't load")} />
        ) : (
          <>
            {relations.data.links.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <LinkIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No related customers")}</EmptyTitle>
                  <EmptyDescription>
                    {canManage
                      ? t("Pick a customer below to link it.")
                      : t("Nobody linked a customer yet.")}
                  </EmptyDescription>
                </EmptyHeader>
              </Empty>
            ) : (
              <RelatedList
                customerId={customerId}
                links={relations.data.links}
                canManage={canManage}
              />
            )}
            {canManage ? (
              <LinkForm
                customerId={customerId}
                options={relations.data.options.filter(
                  (option) =>
                    !relations.data.links.some(
                      (link) =>
                        link.subjectType === "customer" &&
                        link.subjectId === option.id,
                    ),
                )}
              />
            ) : null}
          </>
        )}
      </CardContent>
    </Card>
  );
}

function RelatedList({
  customerId,
  links,
  canManage,
}: {
  customerId: string;
  links: readonly RelatedCustomer[];
  canManage: boolean;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const unlink = useAction(unlinkCustomerRelations, {
    onSuccess: () => {
      toast.success(t("Link removed"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <ul className="flex flex-col divide-y" data-testid="related-customers">
      {links.map((link) => (
        <li
          key={link.id}
          className="flex items-center justify-between gap-2 py-2"
        >
          <div className="flex min-w-0 items-center gap-2">
            <SubjectLink
              subject={{ type: link.subjectType, id: link.subjectId }}
            />
            {link.kind === "relates" ? null : (
              <Badge variant="outline">{link.kind}</Badge>
            )}
          </div>
          {canManage ? (
            <Button
              size="icon-sm"
              variant="ghost"
              aria-label={t("Remove link")}
              disabled={unlink.pendingInputs.some(
                (input) =>
                  !(input instanceof FormData) &&
                  input.input.target.id === link.subjectId,
              )}
              onClick={() => {
                void unlink.run({
                  id: customerId,
                  input: {
                    kind: link.kind,
                    target: { type: link.subjectType, id: link.subjectId },
                  },
                });
              }}
            >
              <UnlinkIcon />
            </Button>
          ) : null}
        </li>
      ))}
    </ul>
  );
}

function LinkForm({
  customerId,
  options,
}: {
  customerId: string;
  options: readonly { readonly id: string; readonly name: string }[];
}) {
  const t = useExtracted("customers");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [targetId, setTargetId] = useState<string | null>(null);

  const link = useAction(linkCustomerRelations, {
    onSuccess: () => {
      setTargetId(null);
      toast.success(t("Customer linked"));
    },
  });

  const items = options.map((option) => ({
    value: option.id,
    label: option.name,
  }));

  if (items.length === 0) {
    return null;
  }

  return (
    <form
      className="flex items-end gap-2"
      onSubmit={(event) => {
        event.preventDefault();

        if (targetId) {
          void link.run({
            id: customerId,
            input: {
              kind: "relates",
              target: { type: "customer", id: targetId },
            },
          });
        }
      }}
    >
      <Field data-invalid={link.error ? true : undefined}>
        <FieldLabel htmlFor={`${fieldId}-related`}>
          {t("Link a customer")}
        </FieldLabel>
        <Select
          value={targetId}
          items={items}
          onValueChange={(value) => {
            setTargetId(value);
          }}
        >
          <SelectTrigger id={`${fieldId}-related`} className="w-full">
            <SelectValue placeholder={t("Pick a customer")} />
          </SelectTrigger>
          <SelectContent>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        {link.error ? (
          <FieldError>{errorMessage(link.error)}</FieldError>
        ) : null}
      </Field>
      <Button
        type="submit"
        variant="outline"
        disabled={!targetId || link.pending}
      >
        {t("Link")}
      </Button>
    </form>
  );
}

/** The related customers card while its rows load. */
export function RelatedItemsPanelSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

# Reminders list

> The next events with the caller's reminders on each, to add a reminder at an offset before an event or remove one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/reminders-list

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [reminders block](https://bettersupabase.com/blocks/reminders) must be installed (`npx better-supabase add reminders`). Its page on bettersupabase.com is [/recipes/reminders-list](https://bettersupabase.com/recipes/reminders-list).

Web, with shadcn components:

```bash
npx shadcn add card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/calendar/components/reminders-list.tsx"
/**
 * The next events with the caller's reminders on each. A reminder fires its
 * offset before every occurrence of the event, once per occurrence.
 */
export async function RemindersList() {
  const session = await getSession();

  if (!can(session, "reminders.manage")) {
    return null;
  }

  const [t, format, upcoming] = await Promise.all([
    getExtracted("calendar"),
    getFormatter(),
    settle(loadUpcomingReminders),
    connection(),
  ]);

  if (upcoming.kind === "no-organization") {
    return null;
  }

  const calendars = upcoming.kind === "ok" ? upcoming.data.calendars : [];

  const defaultDate = Temporal.Now.plainDateISO(
    calendars[0]?.tz ?? "UTC",
  ).toString();

  return (
    <Card data-recipe="reminders-list">
      <CardHeader>
        <CardTitle>{t("Upcoming reminders")}</CardTitle>
        <CardDescription>
          {t("Get a notification before the events of the next two weeks.")}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {upcoming.kind === "error" ? (
          <CalendarLoadError title={t("Your reminders didn't load.")} />
        ) : upcoming.data.events.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <BellIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No events coming up")}</EmptyTitle>
              <EmptyDescription>
                {t("Add an event, then set a reminder for it here.")}
              </EmptyDescription>
            </EmptyHeader>
            {calendars.length > 0 && can(session, "calendar.write") ? (
              <EmptyContent>
                <CreateEventDialog
                  calendars={calendars}
                  defaultDate={defaultDate}
                />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <ul className="flex flex-col gap-2">
            {upcoming.data.events.map((event) => (
              <EventReminders
                key={event.id}
                eventId={event.id}
                title={event.title}
                when={format.dateTime(
                  new Date(event.startsAt),
                  event.allDay
                    ? { dateStyle: "medium" }
                    : { dateStyle: "medium", timeStyle: "short" },
                )}
                reminders={event.reminders}
              />
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

# Rule builder

> A condition tree with field, operator and value rows, nested And and Or groups, drag to reorder and a fixed first condition, saved as a versioned rule.

Source: https://bettersupabase.com/docs/registry/ui-recipes/rule-builder

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useRuleBuilder` from `@/hooks/use-automations`; the [automations block](https://bettersupabase.com/blocks/automations) must be installed (`npx better-supabase add automations`). Its page on bettersupabase.com is [/recipes/rule-builder](https://bettersupabase.com/recipes/rule-builder).

Web, with shadcn components:

```bash
npx shadcn add alert button card empty field input label select skeleton switch
```

```bash
npx shadcn add @better-supabase/use-automations
```

```tsx title="apps/examples/nextjs/src/features/rules/components/rule-builder.tsx"
"use client";

import type { DragEndEvent } from "@dnd-kit/core";
import type { RuleField } from "better-supabase/blocks/automations";
import type {
  FilterCombinator,
  FilterGroupNode,
  FilterNode,
  FilterQuery,
  FilterRule,
} from "better-supabase/list";
import type { CSSProperties, ReactNode } from "react";

import {
  closestCenter,
  DndContext,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { contains, ruleOperators } from "better-supabase/blocks/automations";
import { FILTER_OPERATOR_LABELS } from "better-supabase/list";
import {
  GripVerticalIcon,
  ListFilterIcon,
  LockIcon,
  PlusIcon,
  TriangleAlertIcon,
  XIcon,
} from "lucide-react";
import { useExtracted } from "next-intl";
import { createContext, use, useId, useMemo, useState } from "react";
import * as v from "valibot";

import type { RuleBuilderState } from "@/hooks/use-automations";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useStatusLabel } from "@/features/customers/use-status-label";
import { useRuleBuilder } from "@/hooks/use-automations";
import { useRouter } from "@/i18n/navigation";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import {
  initialConditions,
  LOCKED_CONDITION,
  RULE_SUBJECT,
  RULE_TRIGGER,
} from "../rule-fields";
import { rulesChanged } from "../rules-actions";

const LOCKED = [LOCKED_CONDITION];

/** Groups nest at most this deep, below the `RULE_LIMITS` depth of 6. */
const MAX_GROUP_DEPTH = 3;

interface Editor {
  readonly builder: RuleBuilderState;
  readonly fields: ReadonlyMap<string, RuleField>;
  /** The first problem the check found on each node, by node id. */
  readonly issues: ReadonlyMap<string, string>;
  readonly readOnly: boolean;
}

const EditorContext = createContext<Editor | null>(null);

function useEditor(): Editor {
  const editor = use(EditorContext);

  if (!editor) {
    throw new Error("Render rule editor parts inside <RuleBuilder>");
  }

  return editor;
}

/** The node an issue path such as `["filter", "rules", 1, "value"]` names. */
function nodeAt(
  tree: FilterQuery,
  path: readonly PropertyKey[] | undefined,
): string {
  let node: FilterNode = tree;

  for (let index = 1; index < (path?.length ?? 0); index += 1) {
    const position = path?.[index + 1];

    if (
      path?.[index] !== "rules" ||
      node.type !== "group" ||
      !Number.isInteger(position)
    ) {
      break;
    }

    const child: FilterNode | undefined = node.rules[Number(position)];

    if (child === undefined) {
      break;
    }

    node = child;
    index += 1;
  }

  return node.id;
}

/**
 * The "When" editor of one customer rule: conditions on the catalog fields in
 * nested And/Or groups, reordered by their handles, with the first condition
 * locked. Saves through the browser client as the signed-in user.
 */
export function RuleBuilder({
  organizationId,
  rule,
  canEdit,
  canPublish,
}: {
  organizationId: string;
  /** The rule to edit; `null` starts a new one. */
  rule: string | null;
  canEdit: boolean;
  canPublish: boolean;
}) {
  const t = useExtracted("rules");
  const router = useRouter();
  const errorMessage = useErrorMessage();
  const nameId = useId();
  const enabledId = useId();
  const [enabledEdit, setEnabledEdit] = useState<boolean | undefined>();

  const builder = useRuleBuilder({
    organizationId,
    rule,
    subjectType: RULE_SUBJECT,
    trigger: RULE_TRIGGER,
    initial: initialConditions,
    locked: LOCKED,
    schema: blockConfig.automations.api,
  });

  const enabled = enabledEdit ?? builder.rule?.enabled ?? false;

  const fields = useMemo(
    () => new Map((builder.fields ?? []).map((field) => [field.id, field])),
    [builder.fields],
  );

  const issues = useMemo(() => {
    const byNode = new Map<string, string>();

    for (const issue of builder.issues) {
      const id = nodeAt(builder.conditions, issue.path);

      if (!byNode.has(id)) {
        byNode.set(id, issue.message);
      }
    }

    return byNode;
  }, [builder.issues, builder.conditions]);

  const editor = useMemo(
    () => ({ builder, fields, issues, readOnly: !canEdit }),
    [builder, fields, issues, canEdit],
  );

  if (builder.fields === undefined) {
    return builder.error ? (
      <Alert variant="destructive">
        <TriangleAlertIcon />
        <AlertTitle>{t("The rule didn't load")}</AlertTitle>
        <AlertDescription>{errorMessage(builder.error)}</AlertDescription>
        <AlertAction>
          <Button
            type="button"
            size="sm"
            variant="outline"
            onClick={() => {
              void builder.refresh();
            }}
          >
            {t("Retry")}
          </Button>
        </AlertAction>
      </Alert>
    ) : (
      <RuleBuilderSkeleton />
    );
  }

  const enabledChanged =
    enabledEdit !== undefined && enabledEdit !== builder.rule?.enabled;

  const save = async () => {
    const saved = await builder.save(
      enabledChanged ? { enabled: enabledEdit } : {},
    );

    if (saved === undefined) {
      return;
    }

    setEnabledEdit(undefined);
    await rulesChanged({});

    if (rule === null) {
      router.replace(`/rules/${saved.id}`);
    }
  };

  return (
    <EditorContext value={editor}>
      <Card data-recipe="rule-builder">
        <CardHeader>
          <CardTitle>{t("When a lead is saved")}</CardTitle>
          <CardDescription>
            {t(
              "The rule matches when its conditions hold for the saved customer.",
            )}
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-6">
          <Field>
            <FieldLabel htmlFor={nameId}>{t("Name")}</FieldLabel>
            <Input
              id={nameId}
              value={builder.name}
              maxLength={200}
              disabled={!canEdit}
              placeholder={t("Follow up on large leads")}
              onChange={(event) => {
                builder.setName(event.target.value);
              }}
            />
          </Field>
          {builder.conditions.rules.length === 0 ? (
            <EmptyConditions />
          ) : (
            <GroupEditor group={builder.conditions} depth={1} />
          )}
          {builder.error ? (
            <Alert variant="destructive" aria-live="polite">
              <TriangleAlertIcon />
              <AlertTitle>{t("The rule wasn't saved")}</AlertTitle>
              <AlertDescription>{errorMessage(builder.error)}</AlertDescription>
            </Alert>
          ) : null}
        </CardContent>
        {canEdit ? (
          <CardFooter className="flex flex-wrap items-center justify-between gap-3">
            <div className="flex items-center gap-2">
              <Switch
                id={enabledId}
                checked={enabled}
                disabled={!canPublish || builder.saving}
                onCheckedChange={setEnabledEdit}
              />
              <Label htmlFor={enabledId} className="text-sm font-normal">
                {canPublish
                  ? t("Enabled")
                  : t("Enabled (needs publish rights)")}
              </Label>
            </div>
            <div className="flex items-center gap-2">
              <Button
                type="button"
                size="sm"
                variant="outline"
                disabled={(!builder.dirty && !enabledChanged) || builder.saving}
                onClick={() => {
                  builder.reset();
                  setEnabledEdit(undefined);
                }}
              >
                {t("Discard")}
              </Button>
              <Button
                type="button"
                size="sm"
                data-testid="save-rule"
                disabled={
                  builder.saving ||
                  builder.name.trim() === "" ||
                  builder.issues.length > 0 ||
                  (!builder.dirty && !enabledChanged)
                }
                onClick={() => {
                  void save();
                }}
              >
                {builder.saving ? t("Saving") : t("Save rule")}
              </Button>
            </div>
          </CardFooter>
        ) : null}
      </Card>
    </EditorContext>
  );
}

function EmptyConditions() {
  const t = useExtracted("rules");
  const { builder, readOnly } = useEditor();

  return (
    <Empty className="border">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <ListFilterIcon />
        </EmptyMedia>
        <EmptyTitle>{t("No conditions")}</EmptyTitle>
        <EmptyDescription>
          {t("Without conditions the rule matches every saved customer.")}
        </EmptyDescription>
      </EmptyHeader>
      {readOnly ? null : (
        <EmptyContent>
          <Button
            type="button"
            size="sm"
            onClick={() => {
              builder.addRule(undefined, builder.fields?.[0]?.id);
            }}
          >
            <PlusIcon />
            {t("Add condition")}
          </Button>
        </EmptyContent>
      )}
    </Empty>
  );
}

function GroupEditor({
  group,
  depth,
}: {
  group: FilterGroupNode;
  depth: number;
}) {
  const t = useExtracted("rules");
  const { builder, readOnly } = useEditor();
  const locked = new Set(builder.locked);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const onDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id || locked.has(String(over.id))) {
      return;
    }

    const index = group.rules.findIndex((child) => child.id === over.id);

    if (index !== -1) {
      builder.move(String(active.id), group.id, index);
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={onDragEnd}
      >
        <SortableContext
          items={group.rules.map((child) => child.id)}
          strategy={verticalListSortingStrategy}
        >
          <ol className="flex flex-col gap-2">
            {group.rules.map((child, index) => (
              <SortableNode
                key={child.id}
                node={child}
                disabled={readOnly || locked.has(child.id)}
              >
                <Connective group={group} index={index} />
                {child.type === "rule" ? (
                  <ConditionRow rule={child} />
                ) : (
                  <NestedGroup group={child} depth={depth + 1} />
                )}
              </SortableNode>
            ))}
          </ol>
        </SortableContext>
      </DndContext>
      {readOnly ? null : (
        <div className="flex flex-wrap gap-2">
          <Button
            type="button"
            size="sm"
            variant="ghost"
            onClick={() => {
              builder.addRule(group.id, builder.fields?.[0]?.id);
            }}
          >
            <PlusIcon />
            {t("Add condition")}
          </Button>
          {depth < MAX_GROUP_DEPTH ? (
            <Button
              type="button"
              size="sm"
              variant="ghost"
              onClick={() => {
                const added = builder.addGroup(
                  group.id,
                  group.combinator === "and" ? "or" : "and",
                );

                builder.addRule(added, builder.fields?.[0]?.id);
              }}
            >
              <PlusIcon />
              {t("Add group")}
            </Button>
          ) : null}
        </div>
      )}
    </div>
  );
}

function SortableNode({
  node,
  disabled,
  children,
}: {
  node: FilterNode;
  disabled: boolean;
  children: ReactNode;
}) {
  const t = useExtracted("rules");
  const { builder } = useEditor();
  const locked = builder.locked.includes(node.id);

  const {
    attributes,
    listeners,
    setNodeRef,
    setActivatorNodeRef,
    transform,
    isDragging,
  } = useSortable({ id: node.id, disabled });

  // SAFETY: React's CSSProperties has no custom properties; it is a CSS transform.
  const style = {
    "--drag-transform": CSS.Transform.toString(transform) ?? "none",
  } as CSSProperties;

  return (
    <li
      ref={setNodeRef}
      style={style}
      data-dragging={isDragging || undefined}
      className="flex [transform:var(--drag-transform)] items-start gap-2 transition-transform duration-200 data-dragging:opacity-60 data-dragging:transition-none motion-reduce:transition-none"
    >
      {locked ? (
        <span
          className="flex size-8 shrink-0 items-center justify-center text-muted-foreground"
          title={t("This condition is fixed")}
        >
          <LockIcon className="size-4" aria-hidden />
          <span className="sr-only">{t("This condition is fixed")}</span>
        </span>
      ) : (
        <Button
          ref={setActivatorNodeRef}
          type="button"
          size="icon-sm"
          variant="ghost"
          disabled={disabled}
          aria-label={t("Reorder")}
          className="cursor-grab"
          {...attributes}
          {...listeners}
        >
          <GripVerticalIcon />
        </Button>
      )}
      {children}
    </li>
  );
}

/** "Where" before the first child; the group's And or Or before the rest. */
function Connective({
  group,
  index,
}: {
  group: FilterGroupNode;
  index: number;
}) {
  const t = useExtracted("rules");
  const { builder, readOnly } = useEditor();

  const label = (combinator: FilterCombinator) =>
    combinator === "and" ? t("And") : t("Or");

  if (index === 0) {
    return (
      <span className="flex h-8 w-20 shrink-0 items-center text-sm text-muted-foreground">
        {t("Where")}
      </span>
    );
  }

  if (index > 1 || readOnly) {
    return (
      <span className="flex h-8 w-20 shrink-0 items-center text-sm text-muted-foreground">
        {label(group.combinator)}
      </span>
    );
  }

  return (
    <Select
      value={group.combinator}
      items={[
        { value: "and", label: label("and") },
        { value: "or", label: label("or") },
      ]}
      onValueChange={(value) => {
        if (value === "and" || value === "or") {
          builder.setCombinator(group.id, value);
        }
      }}
    >
      <SelectTrigger size="sm" className="w-20" aria-label={t("Combine with")}>
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="and">{label("and")}</SelectItem>
        <SelectItem value="or">{label("or")}</SelectItem>
      </SelectContent>
    </Select>
  );
}

function NestedGroup({
  group,
  depth,
}: {
  group: FilterGroupNode;
  depth: number;
}) {
  const t = useExtracted("rules");
  const { builder, readOnly } = useEditor();

  const removable =
    !readOnly && !builder.locked.some((id) => contains(group, id));

  return (
    <div className="flex min-w-0 flex-1 items-start gap-2 border-l-2 pl-3">
      <div className="min-w-0 flex-1">
        <GroupEditor group={group} depth={depth} />
      </div>
      {removable ? (
        <Button
          type="button"
          size="icon-sm"
          variant="ghost"
          aria-label={t("Remove group")}
          onClick={() => {
            builder.remove(group.id);
          }}
        >
          <XIcon />
        </Button>
      ) : null}
    </div>
  );
}

function useFieldLabel(): (field: RuleField) => string {
  const t = useExtracted("rules");

  return (field) => {
    switch (field.id) {
      case "status": {
        return t("Status");
      }

      case "name": {
        return t("Name");
      }

      case "kvk": {
        return t("KvK number");
      }

      case "createdAt": {
        return t("Created");
      }

      default: {
        return field.label;
      }
    }
  };
}

function useOperatorLabel(): (operator: string) => string {
  const t = useExtracted("rules");

  return (operator) => {
    switch (operator) {
      case "is": {
        return t("is");
      }

      case "is_not": {
        return t("is not");
      }

      case "contains": {
        return t("contains");
      }

      case "not_contains": {
        return t("does not contain");
      }

      case "starts_with": {
        return t("starts with");
      }

      case "empty": {
        return t("is empty");
      }

      case "not_empty": {
        return t("is not empty");
      }

      case "is_before": {
        return t("is before");
      }

      case "is_after": {
        return t("is after");
      }

      default: {
        return FILTER_OPERATOR_LABELS[operator] ?? operator;
      }
    }
  };
}

function ConditionRow({ rule }: { rule: FilterRule }) {
  const t = useExtracted("rules");
  const { builder, fields, issues, readOnly } = useEditor();
  const fieldLabel = useFieldLabel();
  const operatorLabel = useOperatorLabel();
  const field = fields.get(rule.path.join("."));
  const operators = field ? ruleOperators(field) : [];
  const spec = operators.find((entry) => entry.value === rule.operator);
  const disabled = readOnly || builder.locked.includes(rule.id);
  const issue = issues.get(rule.id);

  return (
    <div className="flex min-w-0 flex-1 flex-col gap-1">
      <div className="flex min-w-0 flex-wrap items-center gap-2">
        <Select
          value={field?.id ?? null}
          disabled={disabled}
          items={(builder.fields ?? []).map((entry) => ({
            value: entry.id,
            label: fieldLabel(entry),
          }))}
          onValueChange={(value) => {
            if (value !== null) {
              builder.setField(rule.id, value);
            }
          }}
        >
          <SelectTrigger size="sm" className="w-40" aria-label={t("Field")}>
            <SelectValue placeholder={t("Field")} />
          </SelectTrigger>
          <SelectContent>
            {(builder.fields ?? []).map((entry) => (
              <SelectItem key={entry.id} value={entry.id}>
                {fieldLabel(entry)}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Select
          value={spec?.value ?? null}
          disabled={disabled || field === undefined}
          items={operators.map((entry) => ({
            value: entry.value,
            label: operatorLabel(entry.value),
          }))}
          onValueChange={(value) => {
            if (value !== null) {
              builder.setOperator(rule.id, value);
            }
          }}
        >
          <SelectTrigger size="sm" className="w-40" aria-label={t("Operator")}>
            <SelectValue placeholder={t("Operator")} />
          </SelectTrigger>
          <SelectContent>
            {operators.map((entry) => (
              <SelectItem key={entry.value} value={entry.value}>
                {operatorLabel(entry.value)}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        {field && spec?.arity === "one" ? (
          <ValueControl
            field={field}
            value={valueText(field, rule)}
            disabled={disabled}
            onChange={(text) => {
              builder.setValue(rule.id, valueOf(field, text));
            }}
          />
        ) : null}
        {disabled ? null : (
          <Button
            type="button"
            size="icon-sm"
            variant="ghost"
            aria-label={t("Remove condition")}
            onClick={() => {
              builder.remove(rule.id);
            }}
          >
            <XIcon />
          </Button>
        )}
      </div>
      {issue ? <FieldError>{issue}</FieldError> : null}
    </div>
  );
}

const Text = v.fallback(v.string(), "");

const NumberText = v.fallback(
  v.pipe(v.number(), v.finite(), v.transform(String)),
  "",
);

const BooleanText = v.fallback(
  v.pipe(
    v.union([v.boolean(), v.picklist(["true", "false"])]),
    v.transform(String),
  ),
  "",
);

const DayText = v.fallback(
  v.pipe(
    v.object({ date: v.string() }),
    v.transform((value) => value.date),
  ),
  "",
);

/** A condition's value as the text its control shows; `""` when unset. */
function valueText(field: RuleField, rule: FilterRule): string {
  switch (field.type) {
    case "number": {
      return v.parse(NumberText, rule.value);
    }

    case "date": {
      return v.parse(DayText, rule.value);
    }

    case "boolean": {
      return v.parse(BooleanText, rule.value);
    }

    case "text":
    case "select":
    case "multiselect":
    case "relation": {
      return v.parse(Text, rule.value);
    }

    default: {
      const never: never = field.type;

      return String(never);
    }
  }
}

/** The value a control's text stands for; `undefined` clears it. */
function valueOf(field: RuleField, text: string): FilterRule["value"] {
  if (text === "" && field.type !== "text") {
    return undefined;
  }

  switch (field.type) {
    case "number": {
      return Number(text);
    }

    case "date": {
      return { date: text };
    }

    case "text":
    case "boolean":
    case "select":
    case "multiselect":
    case "relation": {
      return text;
    }

    default: {
      const never: never = field.type;

      return String(never);
    }
  }
}

/** One value for the field's type: text, a number, a day or an option. */
function ValueControl({
  field,
  value,
  disabled,
  onChange,
}: {
  field: RuleField;
  value: string;
  disabled: boolean;
  onChange: (text: string) => void;
}) {
  const t = useExtracted("rules");
  const statusLabel = useStatusLabel();

  switch (field.type) {
    case "select":
    case "multiselect":
    case "relation":
    case "boolean": {
      const options =
        field.type === "boolean"
          ? [
              { value: "true", label: t("Yes") },
              { value: "false", label: t("No") },
            ]
          : (field.options ?? []).map((option) => ({
              value: option.value,
              label:
                field.id === "status"
                  ? statusLabel(option.value)
                  : option.label,
            }));

      return (
        <Select
          value={value === "" ? null : value}
          disabled={disabled}
          items={options}
          onValueChange={(next) => {
            if (next !== null) {
              onChange(next);
            }
          }}
        >
          <SelectTrigger size="sm" className="w-40" aria-label={t("Value")}>
            <SelectValue placeholder={t("Value")} />
          </SelectTrigger>
          <SelectContent>
            {options.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      );
    }

    case "number":
    case "date":
    case "text": {
      return (
        <Input
          type={field.type}
          className={field.type === "text" ? "w-48" : "w-40"}
          aria-label={t("Value")}
          disabled={disabled}
          value={value}
          onChange={(event) => {
            onChange(event.target.value);
          }}
        />
      );
    }

    default: {
      const never: never = field.type;

      return String(never);
    }
  }
}

export function RuleBuilderSkeleton() {
  return (
    <Card aria-busy="true">
      <CardHeader>
        <Skeleton className="h-5 w-40" />
        <Skeleton className="h-4 w-72" />
      </CardHeader>
      <CardContent className="flex flex-col gap-6">
        <Skeleton className="h-9 w-full" />
        <div className="flex flex-col gap-2">
          {[0, 1, 2].map((row) => (
            <Skeleton key={row} className="h-8 w-full" />
          ))}
        </div>
      </CardContent>
    </Card>
  );
}
```

# Sequence builder

> Each sequence's email, wait and task steps in order, with its enrollment counts, an Add step dialog and an Activate or Pause button.

Source: https://bettersupabase.com/docs/registry/ui-recipes/sequence-builder

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [sequences block](https://bettersupabase.com/blocks/sequences) must be installed (`npx better-supabase add sequences`). Its page on bettersupabase.com is [/recipes/sequence-builder](https://bettersupabase.com/recipes/sequence-builder).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input select skeleton
```

```tsx title="apps/examples/nextjs/src/features/sequences/components/sequence-builder.tsx"
const statusVariants = {
  draft: "secondary",
  active: "success-light",
  paused: "warning-light",
  archived: "outline",
} as const satisfies Record<SequenceStatus, string>;

const stepIcons = {
  email: MailIcon,
  wait: ClockIcon,
  task: ListChecksIcon,
} as const satisfies Record<SequenceStep["kind"], LucideIcon>;

/** Render inside `<Suspense>`: the organization's sequences and their steps. */
export async function SequenceBuilder() {
  const [view, session, t] = await Promise.all([
    getSequences(),
    getSession(),
    getExtracted("sequences"),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <Alert variant="destructive">
        <TriangleAlertIcon />
        <AlertTitle>{t("The sequences didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const manage = can(session, "sequences.manage");

  if (view.data.length === 0) {
    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <SendIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No sequences yet")}</EmptyTitle>
          <EmptyDescription>
            {manage
              ? t(
                  "Create a sample sequence, then activate it to enroll contacts.",
                )
              : t(
                  "An admin sets up the sequences your team enrolls contacts in.",
                )}
          </EmptyDescription>
        </EmptyHeader>
        {manage ? (
          <EmptyContent>
            <SampleSequenceButton />
          </EmptyContent>
        ) : null}
      </Empty>
    );
  }

  const statusLabels: Record<SequenceStatus, string> = {
    draft: t("Draft"),
    active: t("Active"),
    paused: t("Paused"),
    archived: t("Archived"),
  };

  const stepLabel = (step: SequenceStep): string => {
    switch (step.kind) {
      case "email": {
        return step.subject;
      }

      case "wait": {
        return t("Wait {minutes} minutes", { minutes: String(step.minutes) });
      }

      case "task": {
        return step.title;
      }

      default: {
        const unknown: never = step;

        return unknown;
      }
    }
  };

  return (
    <div className="grid gap-4 lg:grid-cols-2">
      {view.data.map((sequence) => (
        <Card key={sequence.id}>
          <CardHeader>
            <div className="flex items-center gap-2">
              <CardTitle>{sequence.name}</CardTitle>
              <Badge variant={statusVariants[sequence.status]}>
                {statusLabels[sequence.status]}
              </Badge>
            </div>
            {manage && sequence.status !== "archived" ? (
              <CardAction className="flex gap-2">
                <AddStepDialog sequenceId={sequence.id} />
                <SequenceStatusButton
                  sequenceId={sequence.id}
                  active={sequence.status === "active"}
                />
              </CardAction>
            ) : null}
          </CardHeader>
          <CardContent>
            {sequence.steps.length === 0 ? (
              <p className="text-sm text-muted-foreground">
                {t("No steps yet.")}
              </p>
            ) : (
              <ol className="flex flex-col gap-2">
                {sequence.steps.map((step, index) => {
                  const Icon = stepIcons[step.kind];

                  return (
                    <li
                      key={step.id}
                      className="flex items-center gap-2 text-sm"
                    >
                      <span className="w-5 text-xs text-muted-foreground tabular-nums">
                        {index + 1}
                      </span>
                      <Icon
                        aria-hidden
                        className="size-4 text-muted-foreground"
                      />
                      <span className="truncate">{stepLabel(step)}</span>
                    </li>
                  );
                })}
              </ol>
            )}
          </CardContent>
          <CardFooter className="gap-4 text-xs text-muted-foreground tabular-nums">
            <span>
              {t("{count} active", { count: String(sequence.counts.active) })}
            </span>
            <span>
              {t("{count} completed", {
                count: String(sequence.counts.completed),
              })}
            </span>
            <span>
              {t("{count} replied", { count: String(sequence.counts.replied) })}
            </span>
          </CardFooter>
        </Card>
      ))}
    </div>
  );
}
```

# Service principals table

> Machine identities with their scopes, last use and a button to disable one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/service-principals-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [service-accounts block](https://bettersupabase.com/blocks/service-accounts) must be installed (`npx better-supabase add service-accounts`). Its page on bettersupabase.com is [/recipes/service-principals-table](https://bettersupabase.com/recipes/service-principals-table).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/service-principals/components/service-principals-table.tsx"
type PrincipalsState =
  | { readonly kind: "ok"; readonly principals: readonly ServicePrincipalRow[] }
  | { readonly kind: "error" }
  | { readonly kind: "no-organization" };

async function loadPrincipalsState(): Promise<PrincipalsState> {
  try {
    const principals = await loadServicePrincipals();

    return principals
      ? { kind: "ok", principals }
      : { kind: "no-organization" };
  } catch (error) {
    unstable_rethrow(error);

    return { kind: "error" };
  }
}

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/**
 * Render inside `<Suspense>`: the machine identities workflows and webhooks run
 * as, with their role. Members with `service_principals.manage` add and disable
 * them. Members without `service_principals.read` see nothing.
 */
export async function ServicePrincipalsTable() {
  const [session, t, format] = await Promise.all([
    getSession(),
    getExtracted("servicePrincipals"),
    getFormatter(),
  ]);

  if (!can(session, "service_principals.read")) {
    return null;
  }

  const state = await loadPrincipalsState();

  if (state.kind === "no-organization") {
    return null;
  }

  const manage = can(session, "service_principals.manage");

  const roles = new Map([
    ["owner", t("Owner")],
    ["admin", t("Admin")],
    ["member", t("Member")],
  ]);

  return (
    <Card data-recipe="service-principals-table">
      <CardHeader>
        <CardTitle>{t("Service principals")}</CardTitle>
        <CardDescription>
          {t(
            "Machine identities that workflows and webhooks run as. They can't sign in.",
          )}
        </CardDescription>
        {manage && state.kind === "ok" ? (
          <CardAction>
            <CreateServicePrincipalDialog />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {state.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The service principals didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : state.principals.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <BotIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No service principals yet")}</EmptyTitle>
              <EmptyDescription>
                {manage
                  ? t("Add one so automations don't run as a person.")
                  : t("An admin can add one for automations.")}
              </EmptyDescription>
            </EmptyHeader>
            {manage ? (
              <EmptyContent>
                <CreateServicePrincipalDialog />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Name")}</TableHead>
                <TableHead>{t("Role")}</TableHead>
                <TableHead>{t("Status")}</TableHead>
                <TableHead>{t("Created")}</TableHead>
                {manage ? (
                  <TableHead className="text-right">{t("Actions")}</TableHead>
                ) : null}
              </TableRow>
            </TableHeader>
            <TableBody>
              {state.principals.map((principal) => (
                <TableRow key={principal.id}>
                  <TableCell>
                    <div className="flex items-center gap-2">
                      <span className="text-sm font-medium">
                        {principal.name}
                      </span>
                      {principal.isDefault ? (
                        <Badge variant="primary-light">{t("Default")}</Badge>
                      ) : null}
                    </div>
                  </TableCell>
                  <TableCell className="text-sm">
                    {roles.get(principal.role) ?? principal.role}
                  </TableCell>
                  <TableCell>
                    {principal.disabledAt ? (
                      <Badge variant="secondary">{t("Disabled")}</Badge>
                    ) : (
                      <Badge variant="success-light">{t("Active")}</Badge>
                    )}
                  </TableCell>
                  <TableCell className="text-xs text-muted-foreground">
                    {format.dateTime(new Date(principal.createdAt), {
                      dateStyle: "medium",
                    })}
                  </TableCell>
                  {manage ? (
                    <TableCell className="text-right">
                      {principal.disabledAt ? null : (
                        <DisableServicePrincipalButton
                          id={principal.id}
                          name={principal.name}
                        />
                      )}
                    </TableCell>
                  ) : null}
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}

export function ServicePrincipalsTableSkeleton() {
  return <Skeleton className="h-40 rounded-xl" aria-busy="true" />;
}
```

# Sessions list

> The caller's signed-in devices with the last activity and a button to end each session.

Source: https://bettersupabase.com/docs/registry/ui-recipes/sessions-list

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [sessions block](https://bettersupabase.com/blocks/sessions) must be installed (`npx better-supabase add sessions`). Its page on bettersupabase.com is [/recipes/sessions-list](https://bettersupabase.com/recipes/sessions-list).

Web, with shadcn components:

```bash
npx shadcn add alert-dialog badge button card
```

```tsx title="apps/examples/nextjs/src/features/sessions/components/sessions-list.tsx"
/**
 * The caller's signed-in session with its sign-in method and time, and the
 * sessions on other devices (`list_my_sessions`). Each one can be ended on its
 * own (`end_my_session`), or all of them at once (`signOut({ scope: "others"
 * })`).
 */
export function SessionsList({
  session,
  others,
}: {
  session: CurrentSession;
  others: readonly OtherSession[];
}) {
  const t = useExtracted("sessions");
  const format = useFormatter();
  const supabase = useSupabase();
  const router = useRouter();
  const changeSession = useSessionChange(sessionChanged, router);
  const [othersOpen, setOthersOpen] = useState(false);
  const [endingOthers, startEndingOthers] = useTransition();
  const [endingThis, startEndingThis] = useTransition();

  const methodLabel = (method: string | null) => {
    switch (method) {
      case "password": {
        return t("Password");
      }

      case "otp":
      case "magiclink": {
        return t("Email link");
      }

      case "oauth": {
        return t("Social sign-in");
      }

      case "sso/saml": {
        return t("Single sign-on");
      }

      case "totp": {
        return t("Authenticator app");
      }

      case null: {
        return t("Unknown method");
      }

      default: {
        return method;
      }
    }
  };

  const signedIn = session.signedInAt
    ? format.dateTime(new Date(session.signedInAt), {
        dateStyle: "medium",
        timeStyle: "short",
      })
    : null;

  const endOthers = () => {
    startEndingOthers(async () => {
      const { error } = await supabase.auth.signOut({ scope: "others" });

      if (error) {
        toast.error(t("Signing out failed. Try again."));

        return;
      }

      setOthersOpen(false);
      toast.success(t("Signed out of your other devices"));
    });
  };

  const endThis = () => {
    startEndingThis(async () => {
      const { error } = await supabase.auth.signOut({ scope: "local" });

      if (error) {
        toast.error(t("Signing out failed. Try again."));

        return;
      }

      await changeSession("/login");
    });
  };

  return (
    <Card data-recipe="sessions-list">
      <CardHeader>
        <CardTitle>{t("Sessions")}</CardTitle>
        <CardDescription>
          {t(
            "The devices you are signed in on. End a session if you lost the device or signed in somewhere you no longer use.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <div className="flex flex-wrap items-center gap-3 rounded-lg border p-3">
          <MonitorSmartphoneIcon
            aria-hidden="true"
            className="size-5 text-muted-foreground"
          />
          <div className="flex min-w-0 flex-1 flex-col gap-1">
            <div className="flex flex-wrap items-center gap-2">
              <span className="text-sm font-medium">{t("This device")}</span>
              <Badge variant="success-light">{t("Active now")}</Badge>
              {session.aal === "aal2" ? (
                <Badge variant="info-light">{t("Two-factor")}</Badge>
              ) : null}
            </div>
            <span className="text-xs text-muted-foreground">
              {signedIn
                ? t("{method}, signed in {date}", {
                    date: signedIn,
                    method: methodLabel(session.method),
                  })
                : methodLabel(session.method)}
            </span>
          </div>
          <Button
            type="button"
            variant="outline"
            size="sm"
            disabled={endingThis}
            onClick={endThis}
          >
            <LogOutIcon />
            {t("Sign out")}
          </Button>
        </div>
        {others.length === 0 ? (
          <p className="text-sm text-muted-foreground">
            {t("You are not signed in on any other device.")}
          </p>
        ) : (
          <ul className="flex flex-col gap-2">
            {others.map((other) => (
              <OtherSessionRow key={other.id} session={other} />
            ))}
          </ul>
        )}
        {others.length > 1 ? (
          <div className="flex flex-wrap items-center justify-between gap-2">
            <p className="text-sm text-muted-foreground">
              {t("Other devices stay signed in until you end their sessions.")}
            </p>
            <AlertDialog
              open={othersOpen}
              onOpenChange={(open) => {
                if (!endingOthers) {
                  setOthersOpen(open);
                }
              }}
            >
              <AlertDialogTrigger
                render={<Button type="button" variant="outline" size="sm" />}
              >
                {t("Sign out other devices")}
              </AlertDialogTrigger>
              <AlertDialogContent>
                <AlertDialogHeader>
                  <AlertDialogTitle>
                    {t("Sign out of your other devices?")}
                  </AlertDialogTitle>
                  <AlertDialogDescription>
                    {t(
                      "Every session except this one ends. Those devices have to sign in again.",
                    )}
                  </AlertDialogDescription>
                </AlertDialogHeader>
                <AlertDialogFooter>
                  <AlertDialogCancel disabled={endingOthers}>
                    {t("Cancel")}
                  </AlertDialogCancel>
                  <AlertDialogAction
                    type="button"
                    variant="destructive"
                    disabled={endingOthers}
                    onClick={endOthers}
                  >
                    {t("Sign out others")}
                  </AlertDialogAction>
                </AlertDialogFooter>
              </AlertDialogContent>
            </AlertDialog>
          </div>
        ) : null}
      </CardContent>
    </Card>
  );
}

function OtherSessionRow({ session }: { session: OtherSession }) {
  const t = useExtracted("sessions");
  const format = useFormatter();
  const errorMessage = useErrorMessage();

  const end = useAction(endSession, {
    onSuccess: () => {
      toast.success(t("Session ended"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const lastActive = format.dateTime(new Date(session.lastActiveAt), {
    dateStyle: "medium",
    timeStyle: "short",
  });

  return (
    <li className="flex flex-wrap items-center gap-3 rounded-lg border p-3">
      <MonitorIcon
        aria-hidden="true"
        className="size-5 text-muted-foreground"
      />
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <span className="truncate text-sm font-medium">
          {session.userAgent ?? t("Unknown device")}
        </span>
        <span className="text-xs text-muted-foreground">
          {session.ip
            ? t("Last active {date} from {ip}", {
                date: lastActive,
                ip: session.ip,
              })
            : t("Last active {date}", { date: lastActive })}
        </span>
      </div>
      <Button
        type="button"
        variant="outline"
        size="sm"
        disabled={end.pending}
        onClick={() => {
          void end.run({ id: session.id });
        }}
      >
        {t("End session")}
      </Button>
    </li>
  );
}
```

# Settings layout

> Settings pages generated from the settings definition: a section per scope and a control per key, picked by the kind its schema declares.

Source: https://bettersupabase.com/docs/registry/ui-recipes/settings-layout

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useSettings` from `@/hooks/use-settings`; the [settings block](https://bettersupabase.com/blocks/settings) must be installed (`npx better-supabase add settings`). Its page on bettersupabase.com is [/recipes/settings-layout](https://bettersupabase.com/recipes/settings-layout).

Web, with shadcn components:

```bash
npx shadcn add card field input select skeleton switch
```

```bash
npx shadcn add @better-supabase/use-settings
```

```tsx title="apps/examples/nextjs/src/features/settings/components/settings-layout.tsx"
"use client";

import type { SettingEntries } from "better-supabase/blocks/settings";

import { tenantOf } from "better-supabase/react";
import { useExtracted } from "next-intl";
import { useId } from "react";
import { toast } from "sonner";

import type { SettingField, SettingsState } from "@/hooks/use-settings";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useSettings } from "@/hooks/use-settings";
import { useAuth } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import { settings } from "../settings-definition";

/** The API schema `gen` wrote for the settings block. */
const API_SCHEMA = blockConfig.settings.api;

/** What a control produces; the key's schema validates it before it is stored. */
type ControlValue = string | number | boolean;

/** The control for one field, picked by the kind its schema declares. */
function SettingControl({
  id,
  field,
  value,
  disabled,
  onChange,
}: {
  id: string;
  field: SettingField;
  value: unknown;
  disabled: boolean;
  onChange: (value: ControlValue) => void;
}) {
  switch (field.kind) {
    case "boolean": {
      return (
        <Switch
          id={id}
          checked={value === true}
          disabled={disabled}
          onCheckedChange={(checked: boolean) => {
            onChange(checked);
          }}
        />
      );
    }

    case "choice": {
      const items = field.choices.map((choice) => ({
        value: String(choice),
        label: String(choice),
      }));

      return (
        <Select
          value={String(value)}
          items={items}
          disabled={disabled}
          onValueChange={(next) => {
            const choice = field.choices.find((each) => String(each) === next);

            if (choice !== undefined) {
              onChange(choice);
            }
          }}
        >
          <SelectTrigger id={id} className="w-48 capitalize">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {items.map((item) => (
              <SelectItem
                key={item.value}
                value={item.value}
                className="capitalize"
              >
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      );
    }

    case "number":
    case "text":
    case "json": {
      return (
        <Input
          id={id}
          className="w-48"
          type={field.kind === "number" ? "number" : "text"}
          defaultValue={field.kind === "json" ? "" : String(value ?? "")}
          disabled={disabled || field.kind === "json"}
          onBlur={(event) => {
            const next = event.currentTarget.value;
            onChange(field.kind === "number" ? Number(next) : next);
          }}
        />
      );
    }

    default: {
      const unknown: never = field.kind;

      return unknown;
    }
  }
}

/** One scope of the definition as a card: a row per key, saved on change. */
function SettingsSection<E extends SettingEntries>({
  title,
  description,
  editable,
  state,
}: {
  title: string;
  description: string;
  editable: boolean;
  state: SettingsState<E>;
}) {
  const t = useExtracted("settings");
  const errorMessage = useErrorMessage();
  const baseId = useId();

  return (
    <Card>
      <CardHeader>
        <CardTitle>{title}</CardTitle>
        <CardDescription>{description}</CardDescription>
      </CardHeader>
      <CardContent>
        {state.values === undefined ? (
          <Skeleton className="h-16" aria-busy="true" />
        ) : (
          <FieldGroup>
            {state.fields.map((field) => (
              <Field key={field.key} orientation="horizontal">
                <FieldContent>
                  <FieldLabel htmlFor={`${baseId}-${field.key}`}>
                    {field.title ?? field.key}
                  </FieldLabel>
                  {field.description ? (
                    <FieldDescription>{field.description}</FieldDescription>
                  ) : null}
                  {editable ? null : (
                    <FieldDescription>
                      {t("Only admins can change this.")}
                    </FieldDescription>
                  )}
                </FieldContent>
                <SettingControl
                  id={`${baseId}-${field.key}`}
                  field={field}
                  value={state.values?.[field.key]}
                  disabled={!editable || state.saving === field.key}
                  onChange={(value) => {
                    void state.setField(field.key, value).then((saved) => {
                      if (!saved.ok) {
                        toast.error(errorMessage(saved.error));
                      }
                    });
                  }}
                />
              </Field>
            ))}
          </FieldGroup>
        )}
      </CardContent>
    </Card>
  );
}

/**
 * The preferences page, generated from `settings-definition.ts`: a card per
 * scope and a control per key, each saved through the settings module.
 */
export function SettingsLayout({
  canEditOrganization,
}: {
  canEditOrganization: boolean;
}) {
  const t = useExtracted("settings");
  const organizationId = tenantOf(useAuth()) ?? null;
  const user = useSettings(settings, { scope: "user", schema: API_SCHEMA });

  const organization = useSettings(settings, {
    scope: "organization",
    organizationId,
    schema: API_SCHEMA,
  });

  return (
    <div className="space-y-6" data-testid="settings-layout">
      <SettingsSection
        title={t("Your preferences")}
        description={t("Only you see these.")}
        editable
        state={user}
      />
      {organizationId ? (
        <SettingsSection
          title={t("Organization defaults")}
          description={t("Shared by everyone in the organization.")}
          editable={canEditOrganization}
          state={organization}
        />
      ) : null}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Pressable`, `ScrollView`, `Switch`, `Text`, `TextInput`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-settings
```

```tsx title="apps/examples/expo-powersync/src/components/settings-layout.tsx"
import type { SettingEntries } from "better-supabase/blocks/settings";

import { tenantOf, useAuth } from "better-supabase/react";
import {
  ActivityIndicator,
  Pressable,
  ScrollView,
  Switch,
  Text,
  TextInput,
  View,
} from "react-native";

import type { SettingField, SettingsState } from "../hooks/use-settings";

import { useSettings } from "../hooks/use-settings";
import { API_SCHEMA } from "../lib/blocks";
import { settings } from "../lib/settings";

/** What a control produces; the key's schema validates it before it is stored. */
type ControlValue = string | number | boolean;

/** The control for one field, picked by the kind its schema declares. */
function SettingControl({
  field,
  value,
  disabled,
  onChange,
}: {
  readonly field: SettingField;
  readonly value: unknown;
  readonly disabled: boolean;
  readonly onChange: (value: ControlValue) => void;
}) {
  switch (field.kind) {
    case "boolean": {
      return (
        <Switch
          value={value === true}
          disabled={disabled}
          onValueChange={onChange}
        />
      );
    }

    case "choice": {
      return (
        <View style={{ flexDirection: "row", gap: 6 }}>
          {field.choices.map((choice) => (
            <Pressable
              key={String(choice)}
              accessibilityRole="radio"
              accessibilityState={{ checked: choice === value, disabled }}
              disabled={disabled}
              onPress={() => {
                onChange(choice);
              }}
              style={{
                paddingHorizontal: 10,
                paddingVertical: 6,
                borderWidth: 1,
                borderRadius: 8,
                borderColor: choice === value ? "#111" : "#e5e5e5",
              }}
            >
              <Text>{String(choice)}</Text>
            </Pressable>
          ))}
        </View>
      );
    }

    case "number":
    case "text":
    case "json": {
      return (
        <TextInput
          defaultValue={field.kind === "json" ? "" : String(value ?? "")}
          editable={!disabled && field.kind !== "json"}
          keyboardType={field.kind === "number" ? "numeric" : "default"}
          onEndEditing={(event) => {
            const next = event.nativeEvent.text;
            onChange(field.kind === "number" ? Number(next) : next);
          }}
          style={{ minWidth: 120, borderWidth: 1, borderRadius: 8, padding: 6 }}
        />
      );
    }

    default: {
      const unknown: never = field.kind;

      return unknown;
    }
  }
}

/** One scope of the definition: a row per key, saved on change. */
function SettingsSection<E extends SettingEntries>({
  title,
  state,
}: {
  readonly title: string;
  readonly state: SettingsState<E>;
}) {
  if (state.values === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 12 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>{title}</Text>
      {state.fields.map((field) => (
        <View key={field.key} style={{ flexDirection: "row", gap: 12 }}>
          <View style={{ flex: 1 }}>
            <Text>{field.title ?? field.key}</Text>
            {field.description ? (
              <Text style={{ color: "#737373" }}>{field.description}</Text>
            ) : null}
          </View>
          <SettingControl
            field={field}
            value={state.values?.[field.key]}
            disabled={state.saving === field.key}
            onChange={(value) => void state.setField(field.key, value)}
          />
        </View>
      ))}
      {state.error ? (
        <Text style={{ color: "#b91c1c" }}>{state.error.message}</Text>
      ) : null}
    </View>
  );
}

/** The preferences screen, generated from the settings definition. */
export function SettingsLayout() {
  const organizationId = tenantOf(useAuth()) ?? null;
  const user = useSettings(settings, { scope: "user", schema: API_SCHEMA });

  const organization = useSettings(settings, {
    scope: "organization",
    organizationId,
    schema: API_SCHEMA,
  });

  return (
    <ScrollView contentContainerStyle={{ gap: 24, padding: 16 }}>
      <SettingsSection title="Your preferences" state={user} />
      {organizationId ? (
        <SettingsSection title="Organization defaults" state={organization} />
      ) : null}
    </ScrollView>
  );
}
```

# Share link dialog

> A dialog that creates an expiring link to a record and revokes it.

Source: https://bettersupabase.com/docs/registry/ui-recipes/share-link-dialog

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [share-links block](https://bettersupabase.com/blocks/share-links) must be installed (`npx better-supabase add share-links`). Its page on bettersupabase.com is [/recipes/share-link-dialog](https://bettersupabase.com/recipes/share-link-dialog).

Web, with shadcn components:

```bash
npx shadcn add alert-dialog badge button card dialog empty field input select
```

```tsx title="apps/examples/nextjs/src/features/customers/components/share-link-dialog.tsx"
type ExpiryDays = 1 | 7 | 30 | 0;

const isExpiry = (value: unknown): value is ExpiryDays =>
  value === 1 || value === 7 || value === 30 || value === 0;

type LinkState = "active" | "expired" | "revoked";

const LINK_VARIANT = {
  active: "success-light",
  expired: "warning-light",
  revoked: "secondary",
} as const satisfies Record<LinkState, string>;

function stateOf(link: ShareLinkRow): LinkState {
  if (link.revokedAt !== null) {
    return "revoked";
  }

  return link.expired ? "expired" : "active";
}

/** View links to the customer: create one, see who can still open them, revoke. */
export function ShareLinkDialog({
  customerId,
  links,
  canCreate,
}: {
  customerId: string;
  links: Loaded<readonly ShareLinkRow[]>;
  canCreate: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="share-link-dialog">
      <CardHeader>
        <CardTitle>{t("Share links")}</CardTitle>
        {canCreate && links.kind === "ok" && links.data.length > 0 ? (
          <CardAction>
            <CreateShareLink customerId={customerId} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {links.kind === "error" ? (
          <RecordLoadError title={t("The share links didn't load")} />
        ) : links.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Link2Icon />
              </EmptyMedia>
              <EmptyTitle>{t("No share links")}</EmptyTitle>
              <EmptyDescription>
                {t("A link lets someone outside the team view this customer.")}
              </EmptyDescription>
            </EmptyHeader>
            {canCreate ? (
              <EmptyContent>
                <CreateShareLink customerId={customerId} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <ShareLinkList
            customerId={customerId}
            links={links.data}
            canRevoke={canCreate}
          />
        )}
      </CardContent>
    </Card>
  );
}

function ShareLinkList({
  customerId,
  links,
  canRevoke,
}: {
  customerId: string;
  links: readonly ShareLinkRow[];
  canRevoke: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();

  const label: Record<LinkState, string> = {
    active: t("Active"),
    expired: t("Expired"),
    revoked: t("Revoked"),
  };

  return (
    <ul className="flex flex-col divide-y" data-testid="share-links">
      {links.map((link) => {
        const state = stateOf(link);

        return (
          <li
            key={link.id}
            className="flex items-center justify-between gap-2 py-2"
          >
            <div className="flex min-w-0 flex-col gap-1">
              <div className="flex items-center gap-2">
                <Badge variant={LINK_VARIANT[state]}>{label[state]}</Badge>
                <span className="text-sm">{t("View only")}</span>
              </div>
              <span className="text-xs text-muted-foreground">
                {link.expiresAt === null
                  ? t("Never expires")
                  : t("Expires {date}", {
                      date: format.dateTime(link.expiresAt, {
                        dateStyle: "medium",
                        timeStyle: "short",
                      }),
                    })}
              </span>
            </div>
            {canRevoke && state === "active" ? (
              <RevokeShareLink id={link.id} customerId={customerId} />
            ) : null}
          </li>
        );
      })}
    </ul>
  );
}

function CreateShareLink({ customerId }: { customerId: string }) {
  const t = useExtracted("customers");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const [expiresInDays, setExpiresInDays] = useState<ExpiryDays>(7);
  const [url, setUrl] = useState<string | null>(null);

  const create = useAction(createShareLink, {
    onSuccess: ({ token }) => {
      setUrl(`${window.location.origin}/share/${token}`);
    },
  });

  const expiries = [
    { value: 1, label: t("1 day") },
    { value: 7, label: t("7 days") },
    { value: 30, label: t("30 days") },
    { value: 0, label: t("Never") },
  ];

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (!next) {
          setUrl(null);
          create.reset();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <Link2Icon />
        {t("Create link")}
      </DialogTrigger>
      <DialogContent>
        {url ? (
          <div className="grid gap-6">
            <DialogHeader>
              <DialogTitle>{t("Link created")}</DialogTitle>
              <DialogDescription>
                {t(
                  "Copy it now. It is shown once; revoke it here to close it.",
                )}
              </DialogDescription>
            </DialogHeader>
            <div className="flex gap-2">
              <Input
                value={url}
                readOnly
                aria-label={t("Share link")}
                data-testid="share-link-url"
              />
              <Button
                variant="outline"
                size="icon"
                aria-label={t("Copy link")}
                onClick={() => {
                  void navigator.clipboard
                    .writeText(url)
                    .then(() => toast.success(t("Link copied")));
                }}
              >
                <CopyIcon />
              </Button>
            </div>
            <DialogFooter>
              <DialogClose render={<Button />}>{t("Done")}</DialogClose>
            </DialogFooter>
          </div>
        ) : (
          <form
            className="grid gap-6"
            onSubmit={(event) => {
              event.preventDefault();
              void create.run({ customerId, expiresInDays });
            }}
          >
            <DialogHeader>
              <DialogTitle>{t("Share this customer")}</DialogTitle>
              <DialogDescription>
                {t(
                  "Anyone with the link can view the customer until it expires.",
                )}
              </DialogDescription>
            </DialogHeader>
            <Field data-invalid={create.error ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-expiry`}>
                {t("Expires after")}
              </FieldLabel>
              <Select
                value={expiresInDays}
                items={expiries}
                onValueChange={(value) => {
                  if (isExpiry(value)) {
                    setExpiresInDays(value);
                  }
                }}
              >
                <SelectTrigger id={`${fieldId}-expiry`} className="w-40">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {expiries.map((each) => (
                    <SelectItem key={each.value} value={each.value}>
                      {each.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              {create.error ? (
                <FieldError>{errorMessage(create.error)}</FieldError>
              ) : null}
            </Field>
            <DialogFooter>
              <DialogClose render={<Button type="button" variant="outline" />}>
                {t("Cancel")}
              </DialogClose>
              <Button type="submit" disabled={create.pending}>
                {t("Create link")}
              </Button>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  );
}

function RevokeShareLink({
  id,
  customerId,
}: {
  id: string;
  customerId: string;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const revoke = useAction(revokeCustomerShareLinks, {
    onSuccess: () => {
      toast.success(t("Link revoked"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={
          <Button size="sm" variant="outline" disabled={revoke.pending} />
        }
      >
        {t("Revoke")}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{t("Revoke this link?")}</AlertDialogTitle>
          <AlertDialogDescription>
            {t("Anyone who has it can no longer open the customer.")}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
          <AlertDialogAction
            variant="destructive"
            onClick={() => {
              void revoke.run({ id: customerId, input: { id } });
            }}
          >
            {t("Revoke")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

/** The share links card while its rows load. */
export function ShareLinkDialogSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

# Shipment tracking

> Shipments with their status and the tracking timeline of the selected one, newest event first.

Source: https://bettersupabase.com/docs/registry/ui-recipes/shipment-tracking

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [shipments block](https://bettersupabase.com/blocks/shipments) must be installed (`npx better-supabase add shipments`). Its page on bettersupabase.com is [/recipes/shipment-tracking](https://bettersupabase.com/recipes/shipment-tracking).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input skeleton table
```

```tsx title="apps/examples/nextjs/src/features/field/components/shipment-tracking.tsx"
/**
 * Render inside `<Suspense>`: the latest shipments with their status, and the
 * tracking timeline of the selected one, newest event first. `?shipment=<id>`
 * picks the shipment.
 */
export async function ShipmentTracking({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, format, query] = await Promise.all([
    getSession(),
    getExtracted("field"),
    getFormatter(),
    searchParams,
    connection(),
  ]);

  if (!can(session, "shipments.read")) {
    forbidden();
  }

  const picked = v.safeParse(ShipmentParam, query["shipment"]);

  const tracking = await settle(() =>
    loadShipmentTracking(picked.success ? picked.output : undefined),
  );

  if (tracking.kind === "no-organization") {
    return null;
  }

  const newShipment = can(session, "shipments.manage") ? (
    <NewShipmentDialog />
  ) : null;

  const selected = tracking.kind === "ok" ? tracking.data.selected : null;

  return (
    <div data-recipe="shipment-tracking" className="flex flex-col gap-6">
      <Card>
        <CardHeader>
          <CardTitle>{t("Shipments")}</CardTitle>
          <CardDescription>
            {t("The status follows the latest tracking event.")}
          </CardDescription>
          {newShipment ? <CardAction>{newShipment}</CardAction> : null}
        </CardHeader>
        <CardContent>
          {tracking.kind === "error" ? (
            <FieldLoadError title={t("The shipments didn't load")} />
          ) : tracking.data.shipments.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <PackageIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No shipments yet")}</EmptyTitle>
                <EmptyDescription>
                  {t("Create a shipment, then buy its label from a carrier.")}
                </EmptyDescription>
              </EmptyHeader>
              {newShipment ? <EmptyContent>{newShipment}</EmptyContent> : null}
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Reference")}</TableHead>
                  <TableHead>{t("Recipient")}</TableHead>
                  <TableHead>{t("Destination")}</TableHead>
                  <TableHead>{t("Tracking number")}</TableHead>
                  <TableHead>{t("Status")}</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {tracking.data.shipments.map((shipment) => (
                  <TableRow
                    key={shipment.id}
                    data-state={
                      shipment.id === selected?.id ? "selected" : undefined
                    }
                  >
                    <TableCell>
                      <Link
                        href={`/field/shipments?shipment=${shipment.id}`}
                        className="text-sm font-medium underline-offset-4 transition-colors hover:underline"
                        aria-current={
                          shipment.id === selected?.id ? "true" : undefined
                        }
                      >
                        {shipment.reference}
                      </Link>
                    </TableCell>
                    <TableCell className="text-sm">
                      {shipment.recipient}
                    </TableCell>
                    <TableCell className="text-sm text-muted-foreground">
                      {shipment.destination}
                    </TableCell>
                    <TableCell className="text-sm tabular-nums">
                      {shipment.trackingNumber ?? ""}
                    </TableCell>
                    <TableCell>
                      <ShipmentStatusBadge status={shipment.status} />
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
      {tracking.kind === "ok" && selected ? (
        <Card>
          <CardHeader>
            <CardTitle>
              {t("Tracking for {reference}", { reference: selected.reference })}
            </CardTitle>
            <CardDescription>
              {selected.carrier
                ? t("Events from {carrier}, newest first.", {
                    carrier: selected.carrier,
                  })
                : t("Events show once the shipment has a label.")}
            </CardDescription>
          </CardHeader>
          <CardContent>
            {tracking.data.timeline.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <TruckIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No tracking events yet")}</EmptyTitle>
                  <EmptyDescription>
                    {t(
                      "The carrier's webhook adds events as the package moves.",
                    )}
                  </EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <Link
                    href={`/field/shipments?shipment=${selected.id}`}
                    className="text-sm font-medium underline-offset-4 transition-colors hover:underline"
                  >
                    {t("Check again")}
                  </Link>
                </EmptyContent>
              </Empty>
            ) : (
              <ol className="flex flex-col divide-y">
                {tracking.data.timeline.map((step) => (
                  <li
                    key={step.id}
                    className="flex flex-wrap items-center gap-3 py-3"
                  >
                    <ShipmentStatusBadge status={step.status} />
                    <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                      <span className="text-sm">
                        {step.description ?? step.location ?? ""}
                      </span>
                      {step.description && step.location ? (
                        <span className="text-xs text-muted-foreground">
                          {step.location}
                        </span>
                      ) : null}
                    </div>
                    <span className="text-xs text-muted-foreground tabular-nums">
                      {format.dateTime(new Date(step.occurredAt), {
                        dateStyle: "medium",
                        timeStyle: "short",
                      })}
                    </span>
                  </li>
                ))}
              </ol>
            )}
          </CardContent>
        </Card>
      ) : null}
    </div>
  );
}
```

# Signing keys rotation

> The active and previous signing keys with a button to rotate them.

Source: https://bettersupabase.com/docs/registry/ui-recipes/signing-keys-rotation

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [signing-keys block](https://bettersupabase.com/blocks/signing-keys) must be installed (`npx better-supabase add signing-keys`). Its page on bettersupabase.com is [/recipes/signing-keys-rotation](https://bettersupabase.com/recipes/signing-keys-rotation).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/signing-keys/components/signing-keys-rotation.tsx"
type SigningKeysState =
  | { readonly kind: "ok"; readonly overview: SigningKeysOverview }
  | { readonly kind: "error" }
  | { readonly kind: "no-organization" };

async function loadSigningKeysState(): Promise<SigningKeysState> {
  try {
    const overview = await loadSigningKeys();

    return overview ? { kind: "ok", overview } : { kind: "no-organization" };
  } catch (error) {
    unstable_rethrow(error);

    return { kind: "error" };
  }
}

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/**
 * Render inside `<Suspense>`: the organization's active signing key and the
 * retired ones still published, with a rotate button. The keys are service-role
 * only, so this checks `organization.update` before it reads.
 */
export async function SigningKeysRotation() {
  const [session, t] = await Promise.all([
    getSession(),
    getExtracted("signingKeys"),
  ]);

  if (!can(session, SIGNING_KEYS_PERMISSION)) {
    forbidden();
  }

  const state = await loadSigningKeysState();

  if (state.kind === "no-organization") {
    return null;
  }

  const rows =
    state.kind === "ok"
      ? [
          ...(state.overview.active
            ? [{ ...state.overview.active, active: true }]
            : []),
          ...state.overview.previous.map((key) => ({ ...key, active: false })),
        ]
      : [];

  return (
    <Card data-recipe="signing-keys-rotation">
      <CardHeader>
        <CardTitle>{t("Signing keys")}</CardTitle>
        <CardDescription>
          {t(
            "The keys that sign this organization's tokens. Private keys stay in Vault; receivers check signatures against the published JWKS.",
          )}
        </CardDescription>
        {rows.length > 0 ? (
          <CardAction>
            <RotateSigningKeyButton first={false} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {state.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The signing keys didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : rows.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <KeyRoundIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No signing key yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Create one before the organization signs any tokens.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <RotateSigningKeyButton first />
            </EmptyContent>
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Key ID")}</TableHead>
                <TableHead>{t("Algorithm")}</TableHead>
                <TableHead>{t("Status")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {rows.map((key) => (
                <TableRow key={key.kid}>
                  <TableCell className="font-mono text-xs">{key.kid}</TableCell>
                  <TableCell className="text-sm">{key.algorithm}</TableCell>
                  <TableCell>
                    {key.active ? (
                      <Badge variant="success-light">{t("Active")}</Badge>
                    ) : (
                      <Badge variant="secondary">{t("Previous")}</Badge>
                    )}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}

export function SigningKeysRotationSkeleton() {
  return <Skeleton className="h-40 rounded-xl" aria-busy="true" />;
}
```

# SLA badges

> A badge on a record with the time left before its SLA is breached.

Source: https://bettersupabase.com/docs/registry/ui-recipes/sla-badges

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [sla block](https://bettersupabase.com/blocks/sla) must be installed (`npx better-supabase add sla`). Its page on bettersupabase.com is [/recipes/sla-badges](https://bettersupabase.com/recipes/sla-badges).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty
```

```tsx title="apps/examples/nextjs/src/features/customers/components/sla-badges.tsx"
const VARIANT = {
  running: "info-light",
  paused: "secondary",
  met: "success-light",
  breached: "destructive-light",
} as const satisfies Record<SlaClockRow["state"], string>;

/**
 * The customer's SLA clocks as badges. Without clocks, the empty state starts
 * them; with no policy for customers, a caller who manages SLAs gets a default
 * policy first.
 */
export function SlaBadges({
  customerId,
  clocks,
  canCreatePolicy,
}: {
  customerId: string;
  clocks: Loaded<readonly SlaClockRow[]>;
  canCreatePolicy: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();
  const errorMessage = useErrorMessage();

  const start = useAction(startCustomerSla, {
    onSuccess: (count) => {
      if (count === 0) {
        toast.error(t("No SLA policy covers customers yet."));
      } else {
        toast.success(t("SLA clocks started"));
      }
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const metric = {
    first_response: t("First response"),
    next_response: t("Next response"),
    resolution: t("Resolution"),
  } as const satisfies Record<SlaClockRow["metric"], string>;

  const state = {
    running: t("On track"),
    paused: t("Paused"),
    met: t("Met"),
    breached: t("Breached"),
  } as const satisfies Record<SlaClockRow["state"], string>;

  const timing = (clock: SlaClockRow): string => {
    if (clock.state === "met") {
      return t("Done");
    }

    if (clock.secondsLeft === null || clock.state === "paused") {
      return t("Due {date}", {
        date: format.dateTime(clock.dueAt, {
          dateStyle: "medium",
          timeStyle: "short",
        }),
      });
    }

    const minutes = Math.round(Math.abs(clock.secondsLeft) / 60);

    const span =
      minutes >= 1440
        ? format.number(Math.round(minutes / 1440), {
            style: "unit",
            unit: "day",
            unitDisplay: "long",
          })
        : minutes >= 60
          ? format.number(Math.round(minutes / 60), {
              style: "unit",
              unit: "hour",
              unitDisplay: "long",
            })
          : format.number(minutes, {
              style: "unit",
              unit: "minute",
              unitDisplay: "long",
            });

    return clock.secondsLeft < 0
      ? t("{span} overdue", { span })
      : t("{span} left", { span });
  };

  return (
    <Card size="sm" data-recipe="sla-badges">
      <CardHeader>
        <CardTitle>{t("SLA")}</CardTitle>
      </CardHeader>
      <CardContent>
        {clocks.kind === "error" ? (
          <RecordLoadError title={t("The SLA clocks didn't load")} />
        ) : clocks.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <TimerIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No SLA clocks")}</EmptyTitle>
              <EmptyDescription>
                {canCreatePolicy
                  ? t(
                      "Start the clocks. Without a policy for customers, a default one is created: first response in 4 hours, resolution in 3 days.",
                    )
                  : t("Start the clocks of the policy that covers customers.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button
                size="sm"
                disabled={start.pending}
                onClick={() => {
                  void start.run({ customerId });
                }}
              >
                <TimerIcon />
                {t("Start SLA clocks")}
              </Button>
            </EmptyContent>
          </Empty>
        ) : (
          <ul className="flex flex-col gap-2" data-testid="sla-clocks">
            {clocks.data.map((clock) => (
              <li
                key={clock.id}
                className="flex items-center justify-between gap-2"
              >
                <div className="flex min-w-0 flex-col gap-1">
                  <span className="text-sm font-medium">
                    {metric[clock.metric]}
                  </span>
                  <span className="text-xs text-muted-foreground">
                    {timing(clock)}
                  </span>
                </div>
                <Badge variant={VARIANT[clock.state]}>
                  {state[clock.state]}
                </Badge>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}

/** The SLA card while its clocks load. */
export function SlaBadgesSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

# SSO connection settings

> The organization's SAML or OIDC connection with its domains and the role new members get.

Source: https://bettersupabase.com/docs/registry/ui-recipes/sso-settings

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [sso block](https://bettersupabase.com/blocks/sso) must be installed (`npx better-supabase add sso`). Its page on bettersupabase.com is [/recipes/sso-settings](https://bettersupabase.com/recipes/sso-settings).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/sso/components/sso-settings.tsx"
/**
 * The organization's email domains (claimed with a TXT record, then verified)
 * and its SAML connection. A verified domain sets the role new users at it join
 * with and whether they must sign in with SSO.
 */
export async function SsoSettings() {
  if (!can(await getSession(), "sso.manage")) {
    forbidden();
  }

  const [t, format, result] = await Promise.all([
    getExtracted("sso"),
    getFormatter(),
    loadSettings(),
  ]);

  if (result.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="sso-settings">
        <TriangleAlertIcon />
        <AlertTitle>{t("SSO settings could not be loaded")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={refreshSso}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  if (result.data === null) {
    return (
      <p className="text-sm text-muted-foreground" data-recipe="sso-settings">
        {t("Pick an organization to manage its single sign-on.")}
      </p>
    );
  }

  const { domains, connections } = result.data;

  const verifiedDomains = domains
    .filter((domain) => domain.verified)
    .map((domain) => domain.domain);

  return (
    <div className="flex flex-col gap-6" data-recipe="sso-settings">
      <Card id="sso-domains">
        <CardHeader>
          <CardTitle>{t("Domains")}</CardTitle>
          <CardDescription>
            {t(
              "Claim the email domains your company owns. Publish the TXT record at your DNS host, then verify the domain.",
            )}
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-4">
          {domains.length === 0 ? (
            <Empty>
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <GlobeIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No domains yet")}</EmptyTitle>
                <EmptyDescription>
                  {t("Add your company's email domain to start.")}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                <AddDomainForm />
              </EmptyContent>
            </Empty>
          ) : (
            <>
              <ul className="flex flex-col divide-y rounded-lg border">
                {domains.map((domain) => (
                  <li key={domain.id} className="flex flex-col gap-3 p-3">
                    <div className="flex flex-wrap items-center justify-between gap-2">
                      <div className="flex items-center gap-2">
                        <span className="text-sm font-medium">
                          {domain.domain}
                        </span>
                        {domain.verified ? (
                          <Badge variant="success-light">{t("Verified")}</Badge>
                        ) : (
                          <Badge variant="warning-light">
                            {t("Not verified")}
                          </Badge>
                        )}
                      </div>
                      <DomainActions domain={domain} />
                    </div>
                    {domain.verified ? null : (
                      <dl className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-[auto_1fr] sm:gap-x-3">
                        <dt>{t("TXT name")}</dt>
                        <dd className="font-mono break-all text-foreground">
                          {domain.recordName}
                        </dd>
                        <dt>{t("TXT value")}</dt>
                        <dd className="font-mono break-all text-foreground">
                          {domain.recordValue}
                        </dd>
                      </dl>
                    )}
                  </li>
                ))}
              </ul>
              <AddDomainForm />
            </>
          )}
        </CardContent>
      </Card>
      <Card>
        <CardHeader>
          <CardTitle>{t("SAML connection")}</CardTitle>
          <CardDescription>
            {t(
              "Members at your verified domains sign in through your identity provider, such as Okta or Microsoft Entra ID.",
            )}
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-4">
          {connections.length === 0 ? (
            <Empty>
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <KeyRoundIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No SAML connection")}</EmptyTitle>
                <EmptyDescription>
                  {verifiedDomains.length === 0
                    ? t(
                        "Verify a domain first, then connect your identity provider.",
                      )
                    : t(
                        "Connect your identity provider with its metadata URL.",
                      )}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                {verifiedDomains.length === 0 ? (
                  <a
                    href="#sso-domains"
                    className={buttonVariants({ variant: "outline" })}
                  >
                    {t("Go to domains")}
                  </a>
                ) : (
                  <AddSamlForm verifiedDomains={verifiedDomains} />
                )}
              </EmptyContent>
            </Empty>
          ) : (
            <ul className="flex flex-col divide-y rounded-lg border">
              {connections.map((connection) => (
                <li
                  key={connection.id}
                  className="flex flex-wrap items-center justify-between gap-2 p-3"
                >
                  <div className="flex min-w-0 flex-col gap-1">
                    <div className="flex flex-wrap items-center gap-2">
                      <span className="text-sm font-medium">
                        {t("SAML 2.0")}
                      </span>
                      {connection.domains.map((domain) => (
                        <Badge key={domain} variant="outline">
                          {domain}
                        </Badge>
                      ))}
                    </div>
                    <span className="truncate text-xs text-muted-foreground">
                      {connection.metadataUrl ?? t("Metadata uploaded as XML")}
                      {" · "}
                      {t("Added {date}", {
                        date: format.dateTime(new Date(connection.createdAt), {
                          dateStyle: "medium",
                        }),
                      })}
                    </span>
                  </div>
                  <RemoveConnectionButton id={connection.id} />
                </li>
              ))}
            </ul>
          )}
        </CardContent>
      </Card>
    </div>
  );
}
```

# Status page

> A public status page: the overall status, each public component with its daily uptime, and the public incident updates.

Source: https://bettersupabase.com/docs/registry/ui-recipes/status-page

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [incidents block](https://bettersupabase.com/blocks/incidents) must be installed (`npx better-supabase add incidents`). Its page on bettersupabase.com is [/recipes/status-page](https://bettersupabase.com/recipes/status-page).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/status/components/status-page.tsx"
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

type BadgeVariant = ComponentProps<typeof Badge>["variant"];

interface StatusLook {
  readonly badge: BadgeVariant;
  /** The background of an uptime cell. */
  readonly cell: string;
}

function statusLook(status: ComponentStatus): StatusLook {
  switch (status) {
    case "operational": {
      return { badge: "success-light", cell: "bg-success" };
    }

    case "maintenance": {
      return { badge: "info-light", cell: "bg-info" };
    }

    case "degraded": {
      return { badge: "warning-light", cell: "bg-warning" };
    }

    case "partial_outage": {
      return { badge: "warning-light", cell: "bg-warning" };
    }

    case "major_outage": {
      return { badge: "destructive-light", cell: "bg-destructive" };
    }

    default: {
      const never: never = status;

      return never;
    }
  }
}

function OverallAlert({
  status,
  title,
}: {
  status: ComponentStatus;
  title: string;
}) {
  switch (status) {
    case "operational": {
      return (
        <Alert variant="success">
          <CircleCheckIcon />
          <AlertTitle>{title}</AlertTitle>
        </Alert>
      );
    }

    case "maintenance": {
      return (
        <Alert variant="info">
          <WrenchIcon />
          <AlertTitle>{title}</AlertTitle>
        </Alert>
      );
    }

    case "degraded":
    case "partial_outage": {
      return (
        <Alert variant="warning">
          <TriangleAlertIcon />
          <AlertTitle>{title}</AlertTitle>
        </Alert>
      );
    }

    case "major_outage": {
      return (
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>{title}</AlertTitle>
        </Alert>
      );
    }

    default: {
      const never: never = status;

      return never;
    }
  }
}

/**
 * The public status page published at `slug`: the overall status, each public
 * component with its uptime, then the public incidents with their updates.
 * Anyone can open it; the incidents block returns only fields marked public.
 */
export async function StatusPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const [{ slug }, t, format] = await Promise.all([
    params,
    getExtracted("status"),
    getFormatter(),
  ]);

  const page = await settle(() => loadStatusPage(slug));

  if (page.kind === "missing") {
    notFound();
  }

  if (page.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="status-page">
        <TriangleAlertIcon />
        <AlertTitle>{t("The status page didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { data } = page;

  const labels: Record<ComponentStatus, string> = {
    operational: t("Operational"),
    maintenance: t("Maintenance"),
    degraded: t("Degraded performance"),
    partial_outage: t("Partial outage"),
    major_outage: t("Major outage"),
  };

  const overall: Record<ComponentStatus, string> = {
    operational: t("All systems operational"),
    maintenance: t("Scheduled maintenance in progress"),
    degraded: t("Some systems are affected"),
    partial_outage: t("Some systems are affected"),
    major_outage: t("Major outage"),
  };

  const updates: Record<IncidentStatus, string> = {
    investigating: t("Investigating"),
    identified: t("Identified"),
    monitoring: t("Monitoring"),
    resolved: t("Resolved"),
  };

  const day = (date: string) =>
    format.dateTime(new Date(`${date}T12:00:00Z`), {
      dateStyle: "medium",
      timeZone: "UTC",
    });

  const when = (iso: string) =>
    format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short" });

  return (
    <div className="flex flex-col gap-6" data-recipe="status-page">
      <div className="flex flex-col gap-1">
        <h1 className="text-2xl font-semibold tracking-tight">{data.title}</h1>
        {data.description ? (
          <p className="text-sm text-muted-foreground">{data.description}</p>
        ) : null}
      </div>
      <OverallAlert status={data.status} title={overall[data.status]} />
      <Card>
        <CardHeader>
          <CardTitle>{t("Components")}</CardTitle>
          <CardDescription>
            {t("Uptime over the last {days, number} days.", {
              days: UPTIME_DAYS,
            })}
          </CardDescription>
        </CardHeader>
        <CardContent>
          {data.components.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <ActivityIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No components yet")}</EmptyTitle>
                <EmptyDescription>
                  {t(
                    "Components appear here once the team marks them public. Check back later.",
                  )}
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          ) : (
            <ul className="flex flex-col gap-6">
              {data.components.map((component) => {
                const look = statusLook(component.status);

                return (
                  <li key={component.id} className="flex flex-col gap-2">
                    <div className="flex items-center justify-between gap-2">
                      <span className="text-sm font-medium">
                        {component.name}
                      </span>
                      <Badge variant={look.badge}>
                        {labels[component.status]}
                      </Badge>
                    </div>
                    <div className="flex h-8 gap-px" aria-hidden="true">
                      {component.uptime.map((entry) => (
                        <span
                          key={entry.date}
                          className={`flex-1 rounded-sm ${statusLook(entry.status).cell}`}
                          title={`${day(entry.date)}: ${labels[entry.status]}`}
                        />
                      ))}
                    </div>
                    <span className="text-xs text-muted-foreground tabular-nums">
                      {t("{percent, number}% uptime", {
                        percent: component.uptimePercent,
                      })}
                    </span>
                  </li>
                );
              })}
            </ul>
          )}
        </CardContent>
      </Card>
      <Card>
        <CardHeader>
          <CardTitle>{t("Incidents")}</CardTitle>
          <CardDescription>
            {t(
              "Open incidents and those resolved in the last {days, number} days.",
              { days: UPTIME_DAYS },
            )}
          </CardDescription>
        </CardHeader>
        <CardContent>
          {data.incidents.length === 0 ? (
            <p className="text-sm text-muted-foreground">
              {t("No incidents reported.")}
            </p>
          ) : (
            <ul className="flex flex-col gap-6">
              {data.incidents.map((incident) => (
                <li key={incident.id} className="flex flex-col gap-3">
                  <div className="flex flex-wrap items-center justify-between gap-2">
                    <span className="text-sm font-medium">
                      {incident.title}
                    </span>
                    <Badge
                      variant={
                        incident.status === "resolved"
                          ? "success-light"
                          : "warning-light"
                      }
                    >
                      {updates[incident.status]}
                    </Badge>
                  </div>
                  {incident.components.length > 0 ? (
                    <p className="text-xs text-muted-foreground">
                      {t("Affects {components}", {
                        components: incident.components
                          .map((component) => component.name)
                          .join(", "),
                      })}
                    </p>
                  ) : null}
                  <ol className="flex flex-col gap-3 border-l pl-4">
                    {incident.updates.map((update) => (
                      <li
                        key={update.createdAt}
                        className="flex flex-col gap-1"
                      >
                        <span className="text-xs text-muted-foreground">
                          {updates[update.status]}
                          {" · "}
                          {when(update.createdAt)}
                        </span>
                        <p className="text-sm">{update.body}</p>
                      </li>
                    ))}
                  </ol>
                </li>
              ))}
            </ul>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

export function StatusPageSkeleton() {
  return (
    <div className="flex flex-col gap-6" aria-busy="true">
      <Skeleton className="h-12 rounded-xl" />
      <Skeleton className="h-64 rounded-xl" />
    </div>
  );
}
```

# Subject chip

> A compact badge with a subject's type and title that links to its page.

Source: https://bettersupabase.com/docs/registry/ui-recipes/subject-chip

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useSubject` from `@/hooks/use-subjects`; the [subjects block](https://bettersupabase.com/blocks/subjects) must be installed (`npx better-supabase add subjects`). Its page on bettersupabase.com is [/recipes/subject-chip](https://bettersupabase.com/recipes/subject-chip).

Web, with shadcn components:

```bash
npx shadcn add skeleton
```

```bash
npx shadcn add @better-supabase/use-subjects
```

```tsx title="apps/examples/nextjs/src/features/subjects/components/subject-chip.tsx"
"use client";

import type { RecordRef } from "better-supabase/blocks/subjects";

import { useExtracted } from "next-intl";

import { Badge } from "@/components/reui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useSubject } from "@/hooks/use-subjects";
import { Link } from "@/i18n/navigation";

/**
 * A compact badge for a subject: its type and title, linking to its page. Rows
 * that each render a chip share one `subject_titles` call.
 */
export function SubjectChip({ subject }: { subject: RecordRef }) {
  const t = useExtracted("subjects");
  const { subject: resolved, error } = useSubject(subject);

  if (resolved === undefined && !error) {
    return <Skeleton className="h-5 w-24" />;
  }

  const label = resolved?.title ?? t("Unavailable");

  const content = (
    <>
      <span className="text-muted-foreground">{subject.type}</span>
      <span className="truncate">{label}</span>
    </>
  );

  return resolved?.url === undefined ? (
    <Badge variant="outline">{content}</Badge>
  ) : (
    <Badge
      variant="outline"
      className="transition-colors hover:bg-accent"
      render={<Link href={resolved.url} />}
    >
      {content}
    </Badge>
  );
}
```

# Subject link

> A link to any subject type, titled and routed by subject_titles, with plain text for a record the caller can't read.

Source: https://bettersupabase.com/docs/registry/ui-recipes/subject-link

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useSubject` from `@/hooks/use-subjects`; the [subjects block](https://bettersupabase.com/blocks/subjects) must be installed (`npx better-supabase add subjects`). Its page on bettersupabase.com is [/recipes/subject-link](https://bettersupabase.com/recipes/subject-link).

Web, with shadcn components:

```bash
npx shadcn add button skeleton
```

```bash
npx shadcn add @better-supabase/use-subjects
```

```tsx title="apps/examples/nextjs/src/features/subjects/components/subject-link.tsx"
"use client";

import type { RecordRef } from "better-supabase/blocks/subjects";

import { RotateCwIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useSubject } from "@/hooks/use-subjects";
import { Link } from "@/i18n/navigation";

/**
 * A link to any subject in `config.records`, titled and routed by
 * `subject_titles`. The links rendered in one pass share one round trip, and a
 * subject the caller can't read shows as plain text.
 */
export function SubjectLink({ subject }: { subject: RecordRef }) {
  const t = useExtracted("subjects");
  const { subject: resolved, error, refresh } = useSubject(subject);

  if (error) {
    return (
      <span className="flex items-center gap-1 text-sm text-muted-foreground">
        {t("The title didn't load")}
        <Button
          size="icon-xs"
          variant="ghost"
          aria-label={t("Retry")}
          onClick={() => {
            void refresh();
          }}
        >
          <RotateCwIcon />
        </Button>
      </span>
    );
  }

  if (resolved === undefined) {
    return <Skeleton className="h-4 w-32" />;
  }

  if (resolved === null) {
    return (
      <span className="truncate text-sm text-muted-foreground">
        {t("A record you can't open")}
      </span>
    );
  }

  const title = resolved.title ?? t("Untitled");

  return resolved.url === undefined ? (
    <span className="truncate text-sm font-medium">{title}</span>
  ) : (
    <Link
      href={resolved.url}
      className="truncate text-sm font-medium underline-offset-4 transition-colors hover:underline"
    >
      {title}
    </Link>
  );
}
```

# Subject timeline

> One record's comments, files and versions merged newest first, from `bsb.record(...).timeline`, skipping the bindings the caller can't read.

Source: https://bettersupabase.com/docs/registry/ui-recipes/subject-timeline

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [comments](https://bettersupabase.com/blocks/comments), [attachments](https://bettersupabase.com/blocks/attachments), [versions](https://bettersupabase.com/blocks/versions) blocks must be installed (`npx better-supabase add comments attachments versions`). Its page on bettersupabase.com is [/recipes/subject-timeline](https://bettersupabase.com/recipes/subject-timeline).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty
```

```tsx title="apps/examples/nextjs/src/features/customers/components/customer-timeline.tsx"
/**
 * The customer's history from `customerRecord.timeline`: comments, files and
 * versions in one list, newest first.
 */
export async function CustomerTimeline({
  timeline,
  authors,
}: {
  timeline: Loaded<readonly TimelineRow[]>;
  authors: Readonly<Record<string, string>>;
}) {
  const [t, format] = await Promise.all([
    getExtracted("customers"),
    getFormatter(),
  ]);

  const kindLabel = (kind: string): string => {
    switch (kind) {
      case "comment": {
        return t("Comment");
      }

      case "attachment": {
        return t("File");
      }

      case "version": {
        return t("Version");
      }

      default: {
        return kind;
      }
    }
  };

  return (
    <Card size="sm" data-recipe="record-timeline">
      <CardHeader>
        <CardTitle>{t("Timeline")}</CardTitle>
        <CardDescription>
          {t("Comments, files and versions, newest first.")}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {timeline.kind === "error" ? (
          <RecordLoadError title={t("The timeline didn't load")} />
        ) : timeline.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing happened yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Comments, files and versions show up here.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <a
                href="#comments"
                className={buttonVariants({ variant: "outline", size: "sm" })}
              >
                {t("Write a comment")}
              </a>
            </EmptyContent>
          </Empty>
        ) : (
          <ol className="flex flex-col gap-3">
            {timeline.data.map((entry) => (
              <li key={entry.id} className="flex items-center gap-2 text-sm">
                <Badge variant="outline">{kindLabel(entry.kind)}</Badge>
                <span className="truncate">
                  {(entry.actor === null ? undefined : authors[entry.actor]) ??
                    t("Someone")}
                </span>
                <time
                  dateTime={new Date(entry.at).toISOString()}
                  className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums"
                >
                  {format.relativeTime(entry.at)}
                </time>
              </li>
            ))}
          </ol>
        )}
      </CardContent>
    </Card>
  );
}
```

# Support session banner

> A banner that tells the user a support agent is viewing the app as them, rendered on the server.

Source: https://bettersupabase.com/docs/registry/ui-recipes/support-session-banner

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [impersonation block](https://bettersupabase.com/blocks/impersonation) must be installed (`npx better-supabase add impersonation`). Its page on bettersupabase.com is [/recipes/support-session-banner](https://bettersupabase.com/recipes/support-session-banner).

Web, with shadcn components:

```bash
npx shadcn add alert
```

```tsx title="apps/examples/nextjs/src/features/user/components/impersonation-banner.tsx"
import { LifeBuoyIcon } from "lucide-react";
import { getExtracted } from "next-intl/server";

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";

import { getSession } from "../user-queries";

/**
 * Shown during a support session (`bsb.startSupport`): staff see the app as the
 * user, and the audit log records who acted. Render inside `<Suspense>`.
 */
export async function ImpersonationBanner() {
  const [session, t] = await Promise.all([getSession(), getExtracted("user")]);

  if (session.kind !== "user" || !session.impersonator) {
    return null;
  }

  const { id, reason } = session.impersonator;

  return (
    <Alert variant="destructive" data-testid="impersonation">
      <LifeBuoyIcon />
      <AlertTitle>{t("Support session")}</AlertTitle>
      <AlertDescription>
        {reason
          ? t("Signed in by support ({id}): {reason}", { id, reason })
          : t("Signed in by support ({id}).", { id })}
      </AlertDescription>
    </Alert>
  );
}
```

# Tag input

> A record's tags with suggestions, where typing a name adds a tag or creates it.

Source: https://bettersupabase.com/docs/registry/ui-recipes/tag-input

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useTagInput` from `@/hooks/use-tags`; the [tags block](https://bettersupabase.com/blocks/tags) must be installed (`npx better-supabase add tags`). Its page on bettersupabase.com is [/recipes/tag-input](https://bettersupabase.com/recipes/tag-input).

Web, with shadcn components:

```bash
npx shadcn add badge button input skeleton
```

```bash
npx shadcn add @better-supabase/use-tags
```

```tsx title="apps/examples/nextjs/src/features/customers/components/tag-input.tsx"
"use client";

import type { SubmitEvent } from "react";

import { XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useTagInput } from "@/hooks/use-tags";
import { useErrorMessage } from "@/lib/use-error-message";

/**
 * A record's tags. Typing a name adds the organization's tag of the subject's
 * kind with that name, or creates it for callers with `tags.manage`.
 */
export function TagInput({
  subject,
}: {
  subject: { readonly type: string; readonly id: string };
}) {
  const t = useExtracted("customers");
  const listId = useId();
  const errorMessage = useErrorMessage();
  const [name, setName] = useState("");

  const { tags, available, pending, error, create, remove } = useTagInput({
    subject,
    kind: subject.type,
  });

  const applied = new Set(tags?.map((tag) => tag.id));
  const suggestions = available?.filter((tag) => !applied.has(tag.id)) ?? [];

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (!name.trim()) {
      return;
    }

    if ((await create(name)).ok) {
      setName("");
    }
  };

  if (tags === undefined) {
    return <Skeleton className="h-16" />;
  }

  return (
    <div className="space-y-3">
      <ul className="flex flex-wrap gap-1.5" data-testid="tags">
        {tags.length === 0 ? (
          <li className="text-sm text-muted-foreground">{t("No tags yet.")}</li>
        ) : null}
        {tags.map((tag) => (
          <li key={tag.id}>
            <Badge
              variant="secondary"
              className={pending.includes(tag.id) ? "opacity-50" : undefined}
            >
              {tag.name}
              <button
                type="button"
                aria-label={t("Remove {name}", { name: tag.name })}
                disabled={pending.includes(tag.id)}
                onClick={() => {
                  void remove(tag.id);
                }}
              >
                <XIcon className="size-3" />
              </button>
            </Badge>
          </li>
        ))}
      </ul>
      <form onSubmit={(event) => void submit(event)} className="flex gap-2">
        <Input
          value={name}
          onChange={(event) => {
            setName(event.target.value);
          }}
          list={listId}
          maxLength={80}
          placeholder={t("Add a tag")}
          aria-label={t("Tag")}
        />
        <datalist id={listId}>
          {suggestions.map((tag) => (
            <option key={tag.id} value={tag.name}>
              {tag.name}
            </option>
          ))}
        </datalist>
        <Button type="submit" variant="outline" disabled={!name.trim()}>
          {t("Add")}
        </Button>
      </form>
      {error ? (
        <p className="text-sm text-destructive">{errorMessage(error)}</p>
      ) : null}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `Button`, `Pressable`, `Text`, `TextInput`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-tags
```

```tsx title="apps/examples/expo-powersync/src/components/tag-input.tsx"
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  Pressable,
  Text,
  TextInput,
  View,
} from "react-native";

import { useTagInput } from "../hooks/use-tags";

/**
 * A customer's tags as chips that remove themselves when pressed, the
 * organization's other tags as suggestions, and a field that adds or creates
 * one by name.
 */
export function TagInput({ customerId }: { readonly customerId: string }) {
  const [name, setName] = useState("");

  const { tags, available, pending, error, add, remove, create } = useTagInput({
    subject: { type: "customer", id: customerId },
    kind: "customer",
  });

  if (tags === undefined) {
    return <ActivityIndicator />;
  }

  const applied = new Set(tags.map((tag) => tag.id));
  const suggestions = (available ?? []).filter((tag) => !applied.has(tag.id));

  const chip = (label: string, busy: boolean, onPress: () => void) => (
    <Pressable
      key={label}
      accessibilityRole="button"
      disabled={busy}
      onPress={onPress}
      style={{
        borderWidth: 1,
        borderRadius: 16,
        paddingHorizontal: 10,
        paddingVertical: 2,
        opacity: busy ? 0.5 : 1,
      }}
    >
      <Text>{label}</Text>
    </Pressable>
  );

  return (
    <View style={{ gap: 8 }}>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        {tags.length === 0 ? <Text>No tags yet.</Text> : null}
        {tags.map((tag) =>
          chip(`${tag.name} ×`, pending.includes(tag.id), () => {
            void remove(tag.id);
          }),
        )}
      </View>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        {suggestions.map((tag) =>
          chip(`+ ${tag.name}`, pending.includes(tag.id), () => {
            void add(tag.id);
          }),
        )}
      </View>
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Add a tag"
        maxLength={80}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
      />
      <Button
        title="Add"
        disabled={!name.trim()}
        onPress={() =>
          void create(name).then((result) => {
            if (result.ok) {
              setName("");
            }
          })
        }
      />
    </View>
  );
}
```

# Targets progress

> Sales targets for the period with the progress toward each one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/targets-progress

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [goals block](https://bettersupabase.com/blocks/goals) must be installed (`npx better-supabase add goals`). Its page on bettersupabase.com is [/recipes/targets-progress](https://bettersupabase.com/recipes/targets-progress).

Web, with shadcn components:

```bash
npx shadcn add badge card empty progress skeleton
```

```tsx title="apps/examples/nextjs/src/features/crm/components/targets-progress.tsx"
/**
 * Render inside `<Suspense>`: the targets running today and how far along they
 * are.
 */
export async function TargetsProgress() {
  const [session, t, format, locale] = await Promise.all([
    getSession(),
    getExtracted("crm"),
    getFormatter(),
    getLocale(),
  ]);

  if (!can(session, "targets.read")) {
    return null;
  }

  const targets = await settle(loadTargets);

  if (targets.kind === "no-organization") {
    return null;
  }

  const canManage = can(session, "targets.manage");

  const amountOf = (target: TargetRow, value: bigint) =>
    target.currency === null
      ? format.number(value)
      : formatMoney(money(value, target.currency), locale);

  return (
    <Card data-recipe="targets-progress">
      <CardHeader>
        <CardTitle>{t("Targets")}</CardTitle>
        <CardDescription>
          {t("Won deals against this period's goals.")}
        </CardDescription>
        {canManage && targets.kind === "ok" && targets.data.length > 0 ? (
          <CardAction>
            <CreateTargetDialog variant="outline" />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {targets.kind === "error" ? (
          <CrmLoadError title={t("The targets didn't load")} />
        ) : targets.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <TargetIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No targets running")}</EmptyTitle>
              <EmptyDescription>
                {canManage
                  ? t("Set a goal for the amount or number of deals won.")
                  : t("An admin sets the sales targets.")}
              </EmptyDescription>
            </EmptyHeader>
            {canManage ? (
              <EmptyContent>
                <CreateTargetDialog />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <ul className="grid gap-6 md:grid-cols-2">
            {targets.data.map((target) => (
              <li key={target.id} className="flex flex-col gap-2">
                <Progress
                  value={Math.min(target.attainment * 100, 100)}
                  aria-label={target.name}
                >
                  <ProgressLabel>{target.name}</ProgressLabel>
                  {target.attainment >= 1 ? (
                    <Badge variant="success-light" className="ml-auto">
                      {t("Reached")}
                    </Badge>
                  ) : (
                    <span className="ml-auto text-sm text-muted-foreground tabular-nums">
                      {format.number(target.attainment, {
                        style: "percent",
                        maximumFractionDigits: 0,
                      })}
                    </span>
                  )}
                </Progress>
                <span className="text-xs text-muted-foreground tabular-nums">
                  {t("{progress} of {goal}, {start} to {end}", {
                    progress: amountOf(target, target.progress),
                    goal: amountOf(target, target.goal),
                    start: Temporal.PlainDate.from(
                      target.periodStart,
                    ).toLocaleString(locale, { dateStyle: "medium" }),
                    end: Temporal.PlainDate.from(
                      target.periodEnd,
                    ).toLocaleString(locale, { dateStyle: "medium" }),
                  })}
                </span>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

# Team picker

> A select of the caller's teams in the active organization, to scope a page or assign work.

Source: https://bettersupabase.com/docs/registry/ui-recipes/team-picker

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [teams block](https://bettersupabase.com/blocks/teams) must be installed (`npx better-supabase add teams`). Its page on bettersupabase.com is [/recipes/team-picker](https://bettersupabase.com/recipes/team-picker).

Web, with shadcn components:

```bash
npx shadcn add button empty field input select
```

```tsx title="apps/examples/nextjs/src/features/work/components/team-picker.tsx"
/**
 * Scopes the page to one of the organization's teams through `?team=`. With no
 * teams yet, it offers to create one when the caller holds `teams.manage`.
 */
export function TeamPicker({
  teams,
  teamId,
  canManage,
}: {
  teams: readonly TeamOption[];
  teamId: string | null;
  canManage: boolean;
}) {
  const t = useExtracted("work");
  const fieldId = useId();
  const router = useRouter();
  const pathname = usePathname();
  const errorMessage = useErrorMessage();
  const [switching, startTransition] = useTransition();

  const form = useActionForm(createTeam, {
    onSuccess: () => {
      toast.success(t("Team created"));
    },
  });

  if (teams.length === 0) {
    const error = form.error === undefined ? null : errorMessage(form.error);

    return (
      <Empty className="border" data-recipe="team-picker">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <UsersIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No teams yet")}</EmptyTitle>
          <EmptyDescription>
            {canManage
              ? t("Create a team to group work and filter the board by it.")
              : t("An admin can create teams to group work.")}
          </EmptyDescription>
        </EmptyHeader>
        {canManage ? (
          <EmptyContent>
            <form {...form.formProps} className="flex w-full items-end gap-2">
              <Field data-invalid={error ? true : undefined}>
                <FieldLabel htmlFor={`${fieldId}-team-name`}>
                  {t("Team name")}
                </FieldLabel>
                <Input
                  id={`${fieldId}-team-name`}
                  name="name"
                  required
                  maxLength={80}
                  autoComplete="off"
                />
                {error ? <FieldError>{error}</FieldError> : null}
              </Field>
              <Button type="submit" disabled={form.pending}>
                {t("Create team")}
              </Button>
            </form>
          </EmptyContent>
        ) : null}
      </Empty>
    );
  }

  const items = [
    { value: ALL_TEAMS, label: t("All teams") },
    ...teams.map((team) => ({ value: team.id, label: team.name })),
  ];

  return (
    <div className="flex items-center gap-2" data-recipe="team-picker">
      <span className="text-sm text-muted-foreground" id={`${fieldId}-label`}>
        {t("Team")}
      </span>
      <Select
        value={teamId ?? ALL_TEAMS}
        items={items}
        disabled={switching}
        onValueChange={(value) => {
          const next = value ?? ALL_TEAMS;

          startTransition(() => {
            router.replace(
              next === ALL_TEAMS
                ? pathname
                : `${pathname}?team=${encodeURIComponent(next)}`,
            );
          });
        }}
      >
        <SelectTrigger className="w-48" aria-labelledby={`${fieldId}-label`}>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {items.map((item) => (
            <SelectItem key={item.value} value={item.value}>
              {item.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}
```

# Technician day

> A technician's stops for the day in route order, each with the button that moves it on.

Source: https://bettersupabase.com/docs/registry/ui-recipes/technician-day

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [dispatch block](https://bettersupabase.com/blocks/dispatch) must be installed (`npx better-supabase add dispatch`). Its page on bettersupabase.com is [/recipes/technician-day](https://bettersupabase.com/recipes/technician-day).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/field/components/technician-day.tsx"
/**
 * Render inside `<Suspense>`: the caller's stops for a day in route order, each
 * with the button that moves it on. `?day=YYYY-MM-DD` picks the day.
 */
export async function TechnicianDay({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, format, query] = await Promise.all([
    getSession(),
    getExtracted("field"),
    getFormatter(),
    searchParams,
    connection(),
  ]);

  if (!can(session, "dispatch.work")) {
    forbidden();
  }

  const day = dayOf(query["day"]);
  const plan = await settle(() => loadTechnicianDay(day.toString()));

  if (plan.kind === "no-organization") {
    return null;
  }

  const title = format.dateTime(new Date(`${day.toString()}T12:00:00Z`), {
    dateStyle: "full",
    timeZone: "UTC",
  });

  const time = (instant: string | null) =>
    instant ? format.dateTime(new Date(instant), { timeStyle: "short" }) : null;

  return (
    <Card data-recipe="technician-day">
      <CardHeader>
        <CardTitle>{title}</CardTitle>
        <CardDescription>
          {plan.kind === "ok" && plan.data.planned
            ? t("Your stops in the order of the planned route.")
            : t("Your stops by start time.")}
        </CardDescription>
        <CardAction className="flex items-center gap-2">
          <Link
            href={`/field/day?day=${day.subtract({ days: 1 }).toString()}`}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
            aria-label={t("Previous day")}
          >
            <ChevronLeftIcon />
          </Link>
          <Link
            href={`/field/day?day=${day.add({ days: 1 }).toString()}`}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
            aria-label={t("Next day")}
          >
            <ChevronRightIcon />
          </Link>
        </CardAction>
      </CardHeader>
      <CardContent>
        {plan.kind === "error" ? (
          <FieldLoadError title={t("Your day didn't load")} />
        ) : plan.data.stops.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <RouteIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No stops on this day")}</EmptyTitle>
              <EmptyDescription>
                {t("Work orders scheduled for you show here.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Link
                href={`/field/day?day=${day.add({ days: 1 }).toString()}`}
                className={buttonVariants({ variant: "outline" })}
              >
                {t("Show the next day")}
              </Link>
            </EmptyContent>
          </Empty>
        ) : (
          <ol className="flex flex-col divide-y">
            {plan.data.stops.map(({ position, workOrder }) => {
              const next = nextStep(workOrder.status);
              const from = time(workOrder.startsAt);
              const to = time(workOrder.endsAt);

              return (
                <li
                  key={workOrder.id}
                  className="flex flex-wrap items-center gap-3 py-3"
                >
                  <Badge variant="outline" className="tabular-nums">
                    {position}
                  </Badge>
                  <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                    <span className="text-sm font-medium">
                      {workOrder.title}
                    </span>
                    <span className="text-xs text-muted-foreground tabular-nums">
                      {[
                        from && to ? t("{from} to {to}", { from, to }) : from,
                        workOrder.address,
                      ]
                        .filter(Boolean)
                        .join(" · ")}
                    </span>
                  </div>
                  <WorkOrderStatusBadge status={workOrder.status} />
                  {next ? (
                    <AdvanceWorkOrderButton id={workOrder.id} to={next} />
                  ) : null}
                </li>
              );
            })}
          </ol>
        )}
      </CardContent>
    </Card>
  );
}
```

# Templates gallery

> Templates in a grid with a preview and a button to create a record from one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/templates-gallery

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [templates block](https://bettersupabase.com/blocks/templates) must be installed (`npx better-supabase add templates`). Its page on bettersupabase.com is [/recipes/templates-gallery](https://bettersupabase.com/recipes/templates-gallery).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/templates/components/templates-gallery.tsx"
import { LayoutTemplateIcon, TriangleAlertIcon } from "lucide-react";
import { getExtracted, getFormatter } from "next-intl/server";
import { refresh } from "next/cache";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { can } from "@/features/user/user-permissions";
import { getSession } from "@/features/user/user-queries";

import { getTemplates } from "../templates-queries";
import { CreateTemplateDialog } from "./create-template-dialog";
import { UseTemplateDialog } from "./use-template-dialog";

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** The New template button, for members who may manage templates. */
export async function TemplateManagerActions() {
  const session = await getSession();

  return can(session, "templates.manage") ? <CreateTemplateDialog /> : null;
}

/** Render inside `<Suspense>`: the customer templates, each with Use template. */
export async function TemplatesGallery() {
  const [view, session, t, format] = await Promise.all([
    getTemplates(),
    getSession(),
    getExtracted("templates"),
    getFormatter(),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  const canApply =
    can(session, "templates.apply") && can(session, "customers.write");

  if (view.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="templates-gallery">
        <TriangleAlertIcon />
        <AlertTitle>{t("The templates didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  if (view.templates.length === 0) {
    return (
      <Empty className="border" data-recipe="templates-gallery">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <LayoutTemplateIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No templates yet")}</EmptyTitle>
          <EmptyDescription>
            {t(
              "A template holds the values a new customer starts with, so the team sets them up the same way.",
            )}
          </EmptyDescription>
        </EmptyHeader>
        {can(session, "templates.manage") ? (
          <EmptyContent>
            <CreateTemplateDialog />
          </EmptyContent>
        ) : null}
      </Empty>
    );
  }

  return (
    <div
      className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
      data-recipe="templates-gallery"
    >
      {view.templates.map((template) => (
        <Card key={template.id}>
          <CardHeader>
            <CardTitle>{template.name}</CardTitle>
            <CardDescription>
              {t("Updated {date}", {
                date: format.dateTime(new Date(template.updatedAt), {
                  dateStyle: "medium",
                }),
              })}
            </CardDescription>
          </CardHeader>
          <CardContent className="flex flex-wrap items-center gap-2">
            {template.status === null ? null : (
              <Badge variant="info-light">
                {template.status === "active" ? t("Active") : t("Lead")}
              </Badge>
            )}
            {template.publishedVersion === null ? (
              <Badge variant="warning-light">{t("Not published")}</Badge>
            ) : (
              <Badge variant="outline">
                {t("Version {version}", {
                  version: String(template.publishedVersion),
                })}
              </Badge>
            )}
            {template.scope === "system" ? (
              <Badge variant="secondary">{t("Built in")}</Badge>
            ) : null}
            {template.isDefault ? (
              <Badge variant="success-light">{t("Default")}</Badge>
            ) : null}
          </CardContent>
          <CardFooter>
            <UseTemplateDialog
              templateId={template.id}
              templateName={template.name}
              disabled={!canApply || template.publishedVersion === null}
            />
          </CardFooter>
        </Card>
      ))}
    </div>
  );
}

export function TemplatesGallerySkeleton() {
  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
      <Skeleton className="h-44 rounded-xl" />
      <Skeleton className="h-44 rounded-xl" />
      <Skeleton className="h-44 rounded-xl" />
    </div>
  );
}
```

# Thread panel

> A thread beside the channel: the root message, its replies live over Realtime, and a composer that replies into it.

Source: https://bettersupabase.com/docs/registry/ui-recipes/thread-panel

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useMessages` from `@/hooks/use-messaging`; the [messaging](https://bettersupabase.com/blocks/messaging), [channels](https://bettersupabase.com/blocks/channels) blocks must be installed (`npx better-supabase add messaging channels`). Its page on bettersupabase.com is [/recipes/thread-panel](https://bettersupabase.com/recipes/thread-panel).

Web, with shadcn components:

```bash
npx shadcn add alert button card empty scroll-area separator skeleton
```

```bash
npx shadcn add @better-supabase/use-messaging
```

```tsx title="apps/examples/nextjs/src/features/chat/components/thread-panel.tsx"
"use client";

import type { Message } from "better-supabase/blocks/messaging";

import { MessageSquareIcon, RotateCwIcon, XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useRef } from "react";

import { Alert, AlertAction, AlertTitle } from "@/components/reui/alert";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useMessages } from "@/hooks/use-messaging";
import { useErrorMessage } from "@/lib/use-error-message";

import { MessageComposer } from "./message-composer";
import { MessageItem } from "./message-item";

/** A thread: its root message, the replies, and a composer that replies into it. */
export function ThreadPanel({
  root,
  onClose,
}: {
  /** The top-level message the thread hangs off. */
  root: Message;
  onClose: () => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const input = useRef<HTMLTextAreaElement>(null);

  const thread = useMessages({
    conversationId: root.conversationId,
    threadId: root.id,
  });

  return (
    <Card size="sm" className="min-h-0">
      <CardHeader>
        <CardTitle>{t("Thread")}</CardTitle>
        <CardAction>
          <Button
            type="button"
            size="icon-sm"
            variant="ghost"
            aria-label={t("Close the thread")}
            onClick={onClose}
          >
            <XIcon aria-hidden />
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent className="flex min-h-0 flex-1 flex-col gap-3">
        {thread.error ? (
          <Alert variant="destructive" aria-live="polite">
            <AlertTitle>{errorMessage(thread.error)}</AlertTitle>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void thread.refresh()}
              >
                <RotateCwIcon aria-hidden />
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : null}
        {thread.messages === undefined ? (
          <div
            className="flex flex-col gap-4"
            aria-busy="true"
            aria-live="polite"
          >
            <span className="sr-only">{t("Loading the thread")}</span>
            <Skeleton className="h-12" />
            <Skeleton className="h-12" />
          </div>
        ) : (
          <ScrollArea className="min-h-0 flex-1">
            <div className="flex flex-col gap-4 pr-3">
              <ol>
                <MessageItem message={root} />
              </ol>
              <Separator />
              {thread.messages.length === 0 ? (
                <Empty>
                  <EmptyHeader>
                    <EmptyMedia variant="icon">
                      <MessageSquareIcon />
                    </EmptyMedia>
                    <EmptyTitle>{t("No replies yet")}</EmptyTitle>
                    <EmptyDescription>
                      {t("Replies stay here, out of the channel.")}
                    </EmptyDescription>
                  </EmptyHeader>
                  <EmptyContent>
                    <Button
                      type="button"
                      size="sm"
                      onClick={() => input.current?.focus()}
                    >
                      {t("Reply")}
                    </Button>
                  </EmptyContent>
                </Empty>
              ) : (
                <ol className="flex flex-col gap-4">
                  {thread.messages.map((message) => (
                    <MessageItem key={message.id} message={message} />
                  ))}
                </ol>
              )}
            </div>
          </ScrollArea>
        )}
      </CardContent>
      <CardFooter className="flex-col items-stretch">
        <MessageComposer
          conversationId={root.conversationId}
          inputRef={input}
          placeholder={t("Reply in the thread")}
          onSend={thread.send}
          onTyping={thread.setTyping}
        />
      </CardFooter>
    </Card>
  );
}
```

# Timesheet

> A week of time entries per project with a running timer and totals.

Source: https://bettersupabase.com/docs/registry/ui-recipes/timesheet

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [time-tracking block](https://bettersupabase.com/blocks/time-tracking) must be installed (`npx better-supabase add time-tracking`). Its page on bettersupabase.com is [/recipes/timesheet](https://bettersupabase.com/recipes/timesheet).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/people/components/timesheet.tsx"
/**
 * Render inside `<Suspense>`: the caller's week as one row per project with
 * hours per day and totals, a running timer, and Submit once the week is done.
 * `?week=YYYY-MM-DD` picks the week.
 */
export async function Timesheet({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, format, query] = await Promise.all([
    getSession(),
    getExtracted("people"),
    getFormatter(),
    searchParams,
    connection(),
  ]);

  if (!can(session, "time_tracking.track")) {
    forbidden();
  }

  const monday = mondayOf(query["week"]);
  const week = monday.toString();
  const sheet = await settle(() => loadTimesheet(week));

  if (sheet.kind === "no-organization") {
    return null;
  }

  const dayLabel = (date: string) =>
    format.dateTime(new Date(`${date}T12:00:00Z`), {
      weekday: "short",
      day: "numeric",
      timeZone: "UTC",
    });

  const range = format.dateTimeRange(
    new Date(`${week}T12:00:00Z`),
    new Date(`${monday.add({ days: 6 }).toString()}T12:00:00Z`),
    { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" },
  );

  const locked =
    sheet.kind === "ok" &&
    (sheet.data.status === "submitted" || sheet.data.status === "approved");

  const logTime = locked ? null : <LogTimeDialog week={week} />;

  return (
    <Card data-recipe="timesheet">
      <CardHeader>
        <CardTitle>{t("Week of {range}", { range })}</CardTitle>
        <CardDescription>
          {t("Hours per project and day, in {zone}.", { zone: TIME_ZONE })}
        </CardDescription>
        <CardAction className="flex items-center gap-2">
          <Link
            href={`/people/time?week=${monday.subtract({ weeks: 1 }).toString()}`}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
            aria-label={t("Previous week")}
          >
            <ChevronLeftIcon />
          </Link>
          <Link
            href={`/people/time?week=${monday.add({ weeks: 1 }).toString()}`}
            className={buttonVariants({ variant: "outline", size: "icon-sm" })}
            aria-label={t("Next week")}
          >
            <ChevronRightIcon />
          </Link>
        </CardAction>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {sheet.kind === "error" ? (
          <PeopleLoadError title={t("The timesheet didn't load")} />
        ) : (
          <>
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div className="flex items-center gap-2">
                <StatusBadge
                  status={sheet.data.status}
                  labels={{
                    open: t("Open"),
                    submitted: t("Submitted"),
                    approved: t("Approved"),
                    rejected: t("Rejected"),
                  }}
                />
                <span className="text-sm text-muted-foreground tabular-nums">
                  {t("{total} logged, {billable} billable", {
                    total: hours(sheet.data.seconds),
                    billable: hours(sheet.data.billableSeconds),
                  })}
                </span>
              </div>
              <div className="flex flex-wrap items-center gap-2">
                {locked ? null : (
                  <TimerControls
                    running={
                      sheet.data.running
                        ? {
                            project: sheet.data.running.note ?? "",
                            since: format.dateTime(
                              new Date(sheet.data.running.startedAt),
                              { timeStyle: "short" },
                            ),
                          }
                        : null
                    }
                  />
                )}
                {sheet.data.rows.length > 0 ? logTime : null}
                {locked || sheet.data.rows.length === 0 ? null : (
                  <SubmitWeekButton week={week} />
                )}
              </div>
            </div>
            {sheet.data.rows.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <ClockIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("Nothing logged this week")}</EmptyTitle>
                  <EmptyDescription>
                    {locked
                      ? t("This week is locked.")
                      : t(
                          "Log a block of time or start the timer on a project.",
                        )}
                  </EmptyDescription>
                </EmptyHeader>
                {logTime ? <EmptyContent>{logTime}</EmptyContent> : null}
              </Empty>
            ) : (
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>{t("Project")}</TableHead>
                    {sheet.data.days.map((day) => (
                      <TableHead key={day.date} className="text-right">
                        {dayLabel(day.date)}
                      </TableHead>
                    ))}
                    <TableHead className="text-right">{t("Total")}</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {sheet.data.rows.map((row) => (
                    <TableRow key={row.id}>
                      <TableCell>
                        <div className="flex items-center gap-2">
                          <span className="text-sm font-medium">
                            {row.label === "" ? t("No project") : row.label}
                          </span>
                          {row.billable ? (
                            <Badge variant="outline">{t("Billable")}</Badge>
                          ) : null}
                        </div>
                      </TableCell>
                      {row.days.map((seconds, index) => (
                        <TableCell
                          // oxlint-disable-next-line react-doctor/no-array-index-as-key -- the columns are a fixed week keyed by date; the index only covers a missing day
                          key={sheet.data.days[index]?.date ?? index}
                          className="text-right tabular-nums"
                        >
                          {seconds === 0 ? "" : hours(seconds)}
                        </TableCell>
                      ))}
                      <TableCell className="text-right font-medium tabular-nums">
                        {hours(row.seconds)}
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
                <TableFooter>
                  <TableRow>
                    <TableCell>{t("Total")}</TableCell>
                    {sheet.data.days.map((day) => (
                      <TableCell
                        key={day.date}
                        className="text-right tabular-nums"
                      >
                        {hours(day.seconds)}
                      </TableCell>
                    ))}
                    <TableCell className="text-right tabular-nums">
                      {hours(sheet.data.seconds)}
                    </TableCell>
                  </TableRow>
                </TableFooter>
              </Table>
            )}
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

# Trash bin

> Deleted records with buttons to restore them or delete them for good.

Source: https://bettersupabase.com/docs/registry/ui-recipes/trash-bin

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [trash block](https://bettersupabase.com/blocks/trash) must be installed (`npx better-supabase add trash`). Its page on bettersupabase.com is [/recipes/trash-bin](https://bettersupabase.com/recipes/trash-bin).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/trash/components/trash-bin.tsx"
import { FolderOpenIcon, Trash2Icon, TriangleAlertIcon } from "lucide-react";
import { getExtracted, getFormatter } from "next-intl/server";
import { refresh } from "next/cache";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { can } from "@/features/user/user-permissions";
import { getSession } from "@/features/user/user-queries";
import { Link } from "@/i18n/navigation";

import { getTrash } from "../trash-queries";
import { TrashItemActions } from "./trash-item-actions";

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/**
 * Render inside `<Suspense>`: what was deleted, with Restore and Delete
 * forever.
 */
export async function TrashBin() {
  const [view, session, t, format] = await Promise.all([
    getTrash(),
    getSession(),
    getExtracted("trash"),
    getFormatter(),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  const kinds = new Map([
    ["customer", t("Customer")],
    ["drive_node", t("Drive item")],
  ]);

  const canPurge = can(session, "trash.manage");

  return (
    <Card data-recipe="trash-bin">
      <CardHeader>
        <CardTitle>{t("Deleted items")}</CardTitle>
        <CardDescription>
          {t(
            "Restore an item to put it back where it was, with everything deleted along with it.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {view.kind === "error" ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The trash didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <form action={retry}>
                <Button size="sm" variant="outline" type="submit">
                  {t("Retry")}
                </Button>
              </form>
            </AlertAction>
          </Alert>
        ) : view.items.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Trash2Icon />
              </EmptyMedia>
              <EmptyTitle>{t("The trash is empty")}</EmptyTitle>
              <EmptyDescription>
                {t("Items you delete in the drive or elsewhere show up here.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Link href="/drive" className={buttonVariants()}>
                <FolderOpenIcon />
                {t("Open the drive")}
              </Link>
            </EmptyContent>
          </Empty>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Item")}</TableHead>
                <TableHead>{t("Type")}</TableHead>
                <TableHead>{t("Deleted")}</TableHead>
                <TableHead>{t("Removed for good")}</TableHead>
                <TableHead className="text-right">{t("Actions")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {view.items.map((item) => {
                const title = item.title ?? t("Untitled");

                return (
                  <TableRow key={item.id}>
                    <TableCell className="font-medium">
                      <div className="flex items-center gap-2">
                        {title}
                        {item.count > 1 ? (
                          <span className="text-xs text-muted-foreground">
                            {t("and {count, number} more", {
                              count: item.count - 1,
                            })}
                          </span>
                        ) : null}
                      </div>
                    </TableCell>
                    <TableCell>
                      <Badge variant="secondary">
                        {kinds.get(item.subjectType) ?? item.subjectType}
                      </Badge>
                    </TableCell>
                    <TableCell className="text-muted-foreground">
                      {format.dateTime(new Date(item.trashedAt), {
                        dateStyle: "medium",
                        timeStyle: "short",
                      })}
                    </TableCell>
                    <TableCell className="text-muted-foreground">
                      {item.purgeAfter === null
                        ? t("Never")
                        : format.dateTime(new Date(item.purgeAfter), {
                            dateStyle: "medium",
                          })}
                    </TableCell>
                    <TableCell>
                      <TrashItemActions
                        id={item.id}
                        title={title}
                        canPurge={canPurge}
                      />
                    </TableCell>
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}

export function TrashBinSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

# Undo toast

> A toast after a delete with a button that restores the record from the trash.

Source: https://bettersupabase.com/docs/registry/ui-recipes/undo-toast

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [trash block](https://bettersupabase.com/blocks/trash) must be installed (`npx better-supabase add trash`). Its page on bettersupabase.com is [/recipes/undo-toast](https://bettersupabase.com/recipes/undo-toast).

Web, with shadcn components:

```bash
npx shadcn add sonner
```

```tsx title="apps/examples/nextjs/src/features/trash/components/undo-toast.tsx"
"use client";

import { useAction } from "better-supabase/react";
import { useExtracted } from "next-intl";
import { toast } from "sonner";

import { useErrorMessage } from "@/lib/use-error-message";

import { restoreTrashItem } from "../trash-actions";

export interface TrashedRecord {
  /** The trash item the delete returned. */
  readonly trashItemId: string;
  readonly name: string;
}

/**
 * Returns `show`, which confirms a delete with a toast whose Undo button
 * restores the record's whole trash batch.
 */
export function useUndoToast() {
  const t = useExtracted("trash");
  const errorMessage = useErrorMessage();

  const restore = useAction(restoreTrashItem, {
    onSuccess: () => {
      toast.success(t("Restored"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return function show({ trashItemId, name }: TrashedRecord) {
    toast(
      <span data-recipe="undo-toast">
        {t("{name} moved to the trash", { name })}
      </span>,
      {
        action: {
          label: t("Undo"),
          onClick: () => {
            void restore.run({ id: trashItemId });
          },
        },
      },
    );
  };
}
```

# Upsert import

> Paste CSV, map its columns, preview the rows it creates and changes, then upsert them on one of the table's unique sets.

Source: https://bettersupabase.com/docs/registry/ui-recipes/upsert-import

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [imports block](https://bettersupabase.com/blocks/imports) must be installed (`npx better-supabase add imports`). Its page on bettersupabase.com is [/recipes/upsert-import](https://bettersupabase.com/recipes/upsert-import).

Web, with shadcn components:

```bash
npx shadcn add alert badge button dialog field select skeleton textarea
```

```tsx title="apps/examples/nextjs/src/features/projects/components/task-import-dialog.tsx"
const SKIP = "skip";

type Choice = TaskImportField | typeof SKIP;

function isChoice(value: unknown): value is Choice {
  return value === SKIP || TASK_IMPORT_FIELDS.some((field) => field === value);
}

/** The mapped columns, without the skipped ones. */
function mappedOnly(
  mapping: Readonly<Record<string, Choice>>,
): Record<string, TaskImportField> {
  return Object.fromEntries(
    Object.entries(mapping).flatMap(([header, choice]) =>
      choice === SKIP ? [] : [[header, choice]],
    ),
  );
}

/**
 * Imports tasks into the project from pasted CSV: map the columns, preview what
 * the file creates and changes, then run it. A task with the same title in the
 * project is updated instead of added twice.
 */
export function TaskImportDialog({ projectId }: { projectId: string }) {
  const t = useExtracted("projects");
  const id = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const [csv, setCsv] = useState("");
  const [mapping, setMapping] = useState<Record<string, Choice>>({});
  const preview = useAction(previewTaskImport);

  const run = useAction(runTaskImport, {
    onSuccess: ({ status, imported }) => {
      if (status === "completed") {
        toast.success(
          t("{count, plural, one {# task} other {# tasks}} imported", {
            count: imported,
          }),
        );
        setOpen(false);
        setCsv("");
        setMapping({});
        preview.reset();
      } else {
        toast.error(t("The file has rows that need fixing."));
      }
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const [headers = []] = csv.trim() === "" ? [] : parseCsv(csv);
  const titleMapped = Object.values(mapping).includes("title");

  const label = {
    title: t("Title"),
    status: t("Status"),
    dueOn: t("Due date"),
  } as const satisfies Record<TaskImportField, string>;

  const choices = [
    { value: SKIP, label: t("Skip") },
    ...TASK_IMPORT_FIELDS.map((field) => ({
      value: field,
      label: label[field],
    })),
  ];

  const changeCsv = (text: string) => {
    setCsv(text);
    preview.reset();

    const [first = []] = text.trim() === "" ? [] : parseCsv(text);
    const suggested = suggestMapping(first, TASK_IMPORT_FIELDS);

    setMapping(
      Object.fromEntries(
        first.map((header) => {
          const field = suggested[header];

          return [header, isChoice(field) ? field : SKIP];
        }),
      ),
    );
  };

  const input = { projectId, csv, mapping: mappedOnly(mapping) };

  const runPreview = () => {
    void preview.run(input);
  };

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger render={<Button size="sm" variant="outline" />}>
        <UploadIcon />
        {t("Import")}
      </DialogTrigger>
      <DialogContent data-recipe="upsert-import">
        <DialogHeader>
          <DialogTitle>{t("Import tasks")}</DialogTitle>
          <DialogDescription>
            {t(
              "Paste CSV with a title column. A title the project already has updates that task.",
            )}
          </DialogDescription>
        </DialogHeader>
        <div className="flex flex-col gap-4">
          <Field>
            <FieldLabel htmlFor={`${id}-csv`}>{t("CSV")}</FieldLabel>
            <Textarea
              id={`${id}-csv`}
              rows={5}
              value={csv}
              placeholder={"title,status,due\nWrite the brief,todo,2026-11-02"}
              onChange={(event) => {
                changeCsv(event.target.value);
              }}
            />
          </Field>
          {headers.length > 0 ? (
            <div className="grid gap-3 sm:grid-cols-2">
              {headers.map((header) => (
                <Field key={header}>
                  <FieldLabel htmlFor={`${id}-${header}`}>{header}</FieldLabel>
                  <Select
                    value={mapping[header] ?? SKIP}
                    items={choices}
                    onValueChange={(value) => {
                      if (isChoice(value)) {
                        setMapping((current) => ({
                          ...current,
                          [header]: value,
                        }));
                        preview.reset();
                      }
                    }}
                  >
                    <SelectTrigger
                      id={`${id}-${header}`}
                      size="sm"
                      className="w-full"
                    >
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      {choices.map((choice) => (
                        <SelectItem key={choice.value} value={choice.value}>
                          {choice.label}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </Field>
              ))}
            </div>
          ) : null}
          {preview.error ? (
            <Alert variant="destructive">
              <TriangleAlertIcon />
              <AlertTitle>{t("The preview didn't load")}</AlertTitle>
              <AlertDescription>{errorMessage(preview.error)}</AlertDescription>
              <AlertAction>
                <Button size="sm" variant="outline" onClick={runPreview}>
                  {t("Retry")}
                </Button>
              </AlertAction>
            </Alert>
          ) : preview.pending ? (
            <Skeleton className="h-8" />
          ) : preview.data ? (
            <div className="flex flex-col gap-2" data-testid="import-preview">
              <div className="flex flex-wrap items-center gap-2">
                <Badge variant="success-light">
                  {t("{count, plural, one {# new} other {# new}}", {
                    count: preview.data.created,
                  })}
                </Badge>
                <Badge variant="info-light">
                  {t("{count, plural, one {# changed} other {# changed}}", {
                    count: preview.data.changed,
                  })}
                </Badge>
                <Badge variant="secondary">
                  {t("{count, plural, one {# unchanged} other {# unchanged}}", {
                    count: preview.data.unchanged,
                  })}
                </Badge>
                {preview.data.errors.length > 0 ? (
                  <Badge variant="destructive-light">
                    {t("{count, plural, one {# error} other {# errors}}", {
                      count: preview.data.errors.length,
                    })}
                  </Badge>
                ) : null}
              </div>
              {preview.data.errors.slice(0, 5).map((error) => (
                <p
                  key={`${error.row}:${error.message}`}
                  className="text-xs text-destructive"
                >
                  {t("Row {row}: {message}", {
                    row: String(error.row),
                    message: error.message,
                  })}
                </p>
              ))}
            </div>
          ) : null}
        </div>
        <DialogFooter>
          <DialogClose render={<Button type="button" variant="outline" />}>
            {t("Cancel")}
          </DialogClose>
          {preview.data && preview.data.errors.length === 0 ? (
            <Button
              disabled={run.pending}
              onClick={() => {
                void run.run(input);
              }}
            >
              {t("Import")}
            </Button>
          ) : (
            <Button
              disabled={!titleMapped || preview.pending}
              onClick={runPreview}
            >
              {t("Preview")}
            </Button>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

# Usage meter

> This period's usage per meter against the plan's quota, with the reset date.

Source: https://bettersupabase.com/docs/registry/ui-recipes/usage-meter

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useUsageMeter` from `@/hooks/use-usage`; the [usage block](https://bettersupabase.com/blocks/usage) must be installed (`npx better-supabase add usage`). Its page on bettersupabase.com is [/recipes/usage-meter](https://bettersupabase.com/recipes/usage-meter).

Web, with shadcn components:

```bash
npx shadcn add card progress skeleton
```

```bash
npx shadcn add @better-supabase/use-usage
```

```tsx title="apps/examples/nextjs/src/features/dashboard/components/usage-card.tsx"
"use client";

import { tenantOf } from "better-supabase/react";
import { useExtracted, useFormatter } from "next-intl";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useUsageMeter } from "@/hooks/use-usage";
import { useAuth } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";

/** The API schema `gen` wrote for the usage block. */
const API_SCHEMA = blockConfig.usage.api;

/** This period's usage per meter against the plan's quota. */
export function UsageCard() {
  const t = useExtracted("dashboard");
  const format = useFormatter();
  const organizationId = tenantOf(useAuth()) ?? null;

  const { meters } = useUsageMeter({
    organizationId,
    meters: ["api.calls", "customers.created"],
    schema: API_SCHEMA,
  });

  if (!organizationId) {
    return null;
  }

  if (meters === undefined) {
    return <UsageCardSkeleton />;
  }

  const labels = new Map([
    ["api.calls", t("API calls")],
    ["customers.created", t("Customers added")],
  ]);

  return (
    <Card data-testid="usage">
      <CardHeader>
        <CardTitle>{t("Usage this period")}</CardTitle>
        <CardDescription>
          {t("Counted by the usage module, limited by your plan.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-5">
        {meters.map((meter) => (
          <div key={meter.meter} className="space-y-2">
            <div className="flex items-baseline justify-between gap-2 text-sm">
              <span className="font-medium">
                {labels.get(meter.meter) ?? meter.meter}
              </span>
              <span
                className={
                  meter.exceeded
                    ? "text-destructive tabular-nums"
                    : "text-muted-foreground tabular-nums"
                }
              >
                {meter.limit === undefined
                  ? t("{used} used, no limit", {
                      used: format.number(meter.used),
                    })
                  : t("{used} of {limit}", {
                      used: format.number(meter.used),
                      limit: format.number(meter.limit),
                    })}
              </span>
            </div>
            <Progress value={(meter.fraction ?? 0) * 100} />
            <p className="text-xs text-muted-foreground">
              {t("Resets {date}", {
                date: format.dateTime(
                  new Date(meter.resetsAt.epochMilliseconds),
                  { dateStyle: "medium" },
                ),
              })}
            </p>
          </div>
        ))}
      </CardContent>
    </Card>
  );
}

export function UsageCardSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

React Native, with `ActivityIndicator`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/use-usage
```

```tsx title="apps/examples/expo-powersync/src/components/usage-meter.tsx"
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Text, View } from "react-native";

import { useUsageMeter } from "../hooks/use-usage";
import { API_SCHEMA } from "../lib/blocks";

const labels = new Map([
  ["api.calls", "API calls"],
  ["customers.created", "Customers added"],
]);

/** This period's usage per meter against the plan's quota. */
export function UsageMeter() {
  const organizationId = tenantOf(useAuth()) ?? null;

  const { meters } = useUsageMeter({
    organizationId,
    meters: ["api.calls", "customers.created"],
    schema: API_SCHEMA,
  });

  if (!organizationId) {
    return null;
  }

  if (meters === undefined) {
    return <ActivityIndicator />;
  }

  return (
    <View style={{ gap: 12, padding: 16, borderWidth: 1, borderRadius: 12 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Usage this period</Text>
      {meters.map((meter) => (
        <View key={meter.meter} style={{ gap: 4 }}>
          <View style={{ flexDirection: "row" }}>
            <Text style={{ flex: 1 }}>
              {labels.get(meter.meter) ?? meter.meter}
            </Text>
            <Text style={{ color: meter.exceeded ? "#b91c1c" : "#737373" }}>
              {meter.limit === undefined
                ? `${meter.used} used, no limit`
                : `${meter.used} of ${meter.limit}`}
            </Text>
          </View>
          <View
            style={{ height: 6, borderRadius: 3, backgroundColor: "#e5e5e5" }}
          >
            <View
              style={{
                height: 6,
                borderRadius: 3,
                backgroundColor: meter.exceeded ? "#b91c1c" : "#111",
                width: `${(meter.fraction ?? 0) * 100}%`,
              }}
            />
          </View>
          <Text style={{ color: "#737373", fontSize: 12 }}>
            Resets{" "}
            {meter.resetsAt.toLocaleString(undefined, { dateStyle: "medium" })}
          </Text>
        </View>
      ))}
    </View>
  );
}
```

# Version history

> The saved versions of a record with a diff and a button to restore one.

Source: https://bettersupabase.com/docs/registry/ui-recipes/version-history

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [versions block](https://bettersupabase.com/blocks/versions) must be installed (`npx better-supabase add versions`). Its page on bettersupabase.com is [/recipes/version-history](https://bettersupabase.com/recipes/version-history).

Web, with shadcn components:

```bash
npx shadcn add alert alert-dialog badge button card dialog empty skeleton
```

```tsx title="apps/examples/nextjs/src/features/customers/components/version-history.tsx"
/**
 * Saved versions of the customer's name and KvK number, newest first. Each one
 * compares with the version before it and restores as a new version.
 */
export function VersionHistory({
  customerId,
  versions,
  canSave,
  canRestore,
}: {
  customerId: string;
  versions: Loaded<readonly VersionRow[]>;
  canSave: boolean;
  canRestore: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="version-history">
      <CardHeader>
        <CardTitle>{t("Version history")}</CardTitle>
        <CardDescription>
          {t("Snapshots of the name and KvK number.")}
        </CardDescription>
        {canSave && versions.kind === "ok" && versions.data.length > 0 ? (
          <CardAction>
            <SaveVersionButton customerId={customerId} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {versions.kind === "error" ? (
          <RecordLoadError title={t("The versions didn't load")} />
        ) : versions.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No versions yet")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "Save a version to keep the customer's details as they are now.",
                )}
              </EmptyDescription>
            </EmptyHeader>
            {canSave ? (
              <EmptyContent>
                <SaveVersionButton customerId={customerId} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <VersionList
            customerId={customerId}
            versions={versions.data}
            canRestore={canRestore}
          />
        )}
      </CardContent>
    </Card>
  );
}

function VersionList({
  customerId,
  versions,
  canRestore,
}: {
  customerId: string;
  versions: readonly VersionRow[];
  canRestore: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();

  const source = {
    manual: t("Saved"),
    auto: t("Automatic"),
    restore: t("Restored"),
  } as const satisfies Record<VersionRow["source"], string>;

  return (
    <ol className="flex flex-col divide-y" data-testid="versions">
      {versions.map((version, index) => {
        const previous = versions[index + 1];

        return (
          <li
            key={version.id}
            className="flex items-center justify-between gap-2 py-2"
          >
            <div className="flex min-w-0 flex-col gap-1">
              <div className="flex items-center gap-2">
                <span className="text-sm font-medium">
                  {t("Version {number}", { number: String(version.number) })}
                </span>
                <Badge variant="secondary">{source[version.source]}</Badge>
              </div>
              <span className="truncate text-xs text-muted-foreground">
                {version.label ? `${version.label} · ` : ""}
                {format.dateTime(version.createdAt, {
                  dateStyle: "medium",
                  timeStyle: "short",
                })}
              </span>
            </div>
            <div className="flex items-center gap-1">
              {previous ? (
                <CompareDialog
                  fromId={previous.id}
                  toId={version.id}
                  fromNumber={previous.number}
                  toNumber={version.number}
                />
              ) : null}
              {canRestore && index > 0 ? (
                <RestoreButton
                  customerId={customerId}
                  versionId={version.id}
                  number={version.number}
                />
              ) : null}
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function SaveVersionButton({ customerId }: { customerId: string }) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const save = useAction(snapshotCustomerVersions, {
    onSuccess: () => {
      toast.success(t("Version saved"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <Button
      size="sm"
      variant="outline"
      disabled={save.pending}
      onClick={() => {
        void save.run({ id: customerId, input: {} });
      }}
    >
      <HistoryIcon />
      {t("Save a version")}
    </Button>
  );
}

function CompareDialog({
  fromId,
  toId,
  fromNumber,
  toNumber,
}: {
  fromId: string;
  toId: string;
  fromNumber: number;
  toNumber: number;
}) {
  const t = useExtracted("customers");
  const compare = useAction(compareCustomerVersions);

  const load = () => {
    void compare.run({ fromId, toId });
  };

  return (
    <Dialog
      onOpenChange={(open) => {
        if (open) {
          load();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" variant="ghost" />}>
        {t("Compare")}
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {t("Version {from} to {to}", {
              from: String(fromNumber),
              to: String(toNumber),
            })}
          </DialogTitle>
          <DialogDescription>
            {t("What changed between the two versions.")}
          </DialogDescription>
        </DialogHeader>
        {compare.error ? (
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The comparison didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <Button size="sm" variant="outline" onClick={load}>
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : compare.data === undefined || compare.pending ? (
          <div className="flex flex-col gap-2">
            <Skeleton className="h-8" />
            <Skeleton className="h-8" />
          </div>
        ) : compare.data.length === 0 ? (
          <p className="text-sm text-muted-foreground">
            {t("The two versions are the same.")}
          </p>
        ) : (
          <ul className="flex flex-col divide-y" data-testid="version-diff">
            {compare.data.map((operation) => (
              <li
                key={`${operation.op}:${operation.path}`}
                className="flex items-center gap-2 py-2 text-sm"
              >
                <ChangeBadge operation={operation} />
                <code className="text-xs">{operation.path}</code>
                {"value" in operation ? (
                  <span className="truncate">
                    {JSON.stringify(operation.value)}
                  </span>
                ) : null}
              </li>
            ))}
          </ul>
        )}
        <DialogFooter>
          <DialogClose render={<Button />}>{t("Done")}</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

function ChangeBadge({ operation }: { operation: JsonPatchOperation }) {
  const t = useExtracted("customers");

  switch (operation.op) {
    case "add": {
      return <Badge variant="success-light">{t("Added")}</Badge>;
    }

    case "remove": {
      return <Badge variant="destructive-light">{t("Removed")}</Badge>;
    }

    case "replace": {
      return <Badge variant="info-light">{t("Changed")}</Badge>;
    }

    case "move":
    case "copy":
    case "test": {
      return <Badge variant="secondary">{operation.op}</Badge>;
    }

    default: {
      const unhandled: never = operation;

      return unhandled;
    }
  }
}

function RestoreButton({
  customerId,
  versionId,
  number,
}: {
  customerId: string;
  versionId: string;
  number: number;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const restore = useAction(restoreCustomerVersion, {
    onSuccess: () => {
      toast.success(t("Version restored"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={<Button size="sm" variant="ghost" disabled={restore.pending} />}
      >
        {t("Restore")}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>
            {t("Restore version {number}?", { number: String(number) })}
          </AlertDialogTitle>
          <AlertDialogDescription>
            {t(
              "The customer's name and KvK number go back to this version. The current details stay in the history.",
            )}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
          <AlertDialogAction
            onClick={() => {
              void restore.run({ customerId, versionId });
            }}
          >
            {t("Restore")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

/** The version history card while its rows load. */
export function VersionHistorySkeleton() {
  return <RecordCardSkeleton rows={3} />;
}
```

# Waitlist signup

> An email form that joins the waitlist and shows the position in line.

Source: https://bettersupabase.com/docs/registry/ui-recipes/waitlist-signup

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [waitlist block](https://bettersupabase.com/blocks/waitlist) must be installed (`npx better-supabase add waitlist`). Its page on bettersupabase.com is [/recipes/waitlist-signup](https://bettersupabase.com/recipes/waitlist-signup).

Web, with shadcn components:

```bash
npx shadcn add badge button card field input
```

```tsx title="apps/examples/nextjs/src/features/waitlist/components/waitlist-signup.tsx"
/**
 * Joins the waitlist with an email address and shows the place in line. Joining
 * again with the same address shows the same place.
 */
export function WaitlistSignup() {
  const t = useExtracted("waitlist");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const form = useActionForm(joinWaitlist);

  const error =
    form.error === undefined
      ? null
      : form.error.kind === "validation"
        ? t("Enter a valid email address.")
        : errorMessage(form.error);

  if (form.data) {
    const { position, status } = form.data;

    return (
      <Card data-recipe="waitlist-signup">
        <CardHeader className="text-center">
          <ListOrderedIcon
            aria-hidden="true"
            className="mx-auto size-8 text-primary"
          />
          <CardTitle>
            {status === "waiting" && position !== null
              ? t("You're number {position} in line", {
                  position: String(position),
                })
              : t("You're on the list")}
          </CardTitle>
          <CardDescription>
            {status === "waiting"
              ? t("We'll email you when your spot opens up.")
              : t("Your spot is open. Create your account to get started.")}
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col items-center gap-4">
          <Badge
            variant={status === "waiting" ? "info-light" : "success-light"}
          >
            {status === "waiting" ? t("Waiting") : t("Approved")}
          </Badge>
          {status === "waiting" ? (
            <Button
              type="button"
              variant="outline"
              onClick={() => {
                form.reset();
              }}
            >
              {t("Add another address")}
            </Button>
          ) : (
            <Link href="/signup" className={buttonVariants()}>
              {t("Create your account")}
            </Link>
          )}
        </CardContent>
      </Card>
    );
  }

  return (
    <Card data-recipe="waitlist-signup">
      <CardHeader className="text-center">
        <CardTitle>{t("Join the waitlist")}</CardTitle>
        <CardDescription>
          {t("Acme Cloud opens to new teams in batches. Save your spot.")}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form {...form.formProps}>
          <FieldGroup>
            <Field data-invalid={error ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-email`}>{t("Email")}</FieldLabel>
              <Input
                id={`${fieldId}-email`}
                name="email"
                type="email"
                autoComplete="email"
                required
                maxLength={320}
                aria-invalid={error ? true : undefined}
              />
              {error ? <FieldError>{error}</FieldError> : null}
            </Field>
            <Button type="submit" className="w-full" disabled={form.pending}>
              {t("Join the waitlist")}
            </Button>
            <p className="text-center text-sm text-muted-foreground">
              {t("Already have an account?")}{" "}
              <Link
                href="/login"
                className="text-foreground underline underline-offset-4"
              >
                {t("Sign in")}
              </Link>
            </p>
          </FieldGroup>
        </form>
      </CardContent>
    </Card>
  );
}
```

# Webhook endpoints

> The organization's webhook endpoints with their recent deliveries and a retry button.

Source: https://bettersupabase.com/docs/registry/ui-recipes/webhook-endpoints

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [webhooks-out block](https://bettersupabase.com/blocks/webhooks-out) must be installed (`npx better-supabase add webhooks-out`). Its page on bettersupabase.com is [/recipes/webhook-endpoints](https://bettersupabase.com/recipes/webhook-endpoints).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton table
```

```tsx title="apps/examples/nextjs/src/features/webhooks/components/webhook-endpoints.tsx"
type WebhooksState =
  | { readonly kind: "ok"; readonly overview: WebhooksOverview }
  | { readonly kind: "error" }
  | { readonly kind: "no-organization" };

async function loadWebhooksState(): Promise<WebhooksState> {
  try {
    const overview = await loadWebhooks();

    return overview ? { kind: "ok", overview } : { kind: "no-organization" };
  } catch (error) {
    unstable_rethrow(error);

    return { kind: "error" };
  }
}

// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

type Translate = Awaited<ReturnType<typeof getExtracted>>;

function DeliveryStatusBadge({
  status,
  t,
}: {
  status: WebhookStatus;
  t: Translate;
}) {
  switch (status) {
    case "pending": {
      return <Badge variant="info-light">{t("Pending")}</Badge>;
    }

    case "delivering": {
      return <Badge variant="info-light">{t("Delivering")}</Badge>;
    }

    case "succeeded": {
      return <Badge variant="success-light">{t("Delivered")}</Badge>;
    }

    case "retrying": {
      return <Badge variant="warning-light">{t("Retrying")}</Badge>;
    }

    case "dead": {
      return <Badge variant="destructive-light">{t("Failed")}</Badge>;
    }

    case "canceled": {
      return <Badge variant="secondary">{t("Canceled")}</Badge>;
    }

    default: {
      const never: never = status;

      return never;
    }
  }
}

function EndpointStatusBadge({
  endpoint,
  t,
}: {
  endpoint: WebhookEndpointRow;
  t: Translate;
}) {
  if (!endpoint.enabled) {
    return <Badge variant="secondary">{t("Off")}</Badge>;
  }

  return endpoint.failingSince ? (
    <Badge variant="warning-light">{t("Failing")}</Badge>
  ) : (
    <Badge variant="success-light">{t("Active")}</Badge>
  );
}

/**
 * Render inside `<Suspense>`: the organization's webhook endpoints and their
 * latest deliveries. Members with `webhooks.manage` add, toggle, test and
 * delete endpoints, and retry finished deliveries.
 */
export async function WebhookEndpoints() {
  const [session, state, t, format] = await Promise.all([
    getSession(),
    loadWebhooksState(),
    getExtracted("webhooks"),
    getFormatter(),
  ]);

  if (!can(session, "webhooks.read")) {
    forbidden();
  }

  if (state.kind === "no-organization") {
    return null;
  }

  const manage = can(session, "webhooks.manage");

  const firstEnabled =
    state.kind === "ok"
      ? state.overview.endpoints.find((endpoint) => endpoint.enabled)
      : undefined;

  const when = (iso: string) =>
    format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short" });

  return (
    <div data-recipe="webhook-endpoints" className="flex flex-col gap-6">
      <Card data-testid="webhook-endpoints">
        <CardHeader>
          <CardTitle>{t("Webhook endpoints")}</CardTitle>
          <CardDescription>
            {t(
              "URLs that receive this organization's events, signed with a secret only you and the receiver know.",
            )}
          </CardDescription>
          {manage && state.kind === "ok" ? (
            <CardAction>
              <CreateWebhookDialog />
            </CardAction>
          ) : null}
        </CardHeader>
        <CardContent>
          {state.kind === "error" ? (
            <Alert variant="destructive">
              <TriangleAlertIcon />
              <AlertTitle>{t("The webhooks didn't load")}</AlertTitle>
              <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
              <AlertAction>
                <form action={retry}>
                  <Button size="sm" variant="outline" type="submit">
                    {t("Retry")}
                  </Button>
                </form>
              </AlertAction>
            </Alert>
          ) : state.overview.endpoints.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <WebhookIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No endpoints yet")}</EmptyTitle>
                <EmptyDescription>
                  {manage
                    ? t("Add a URL to start receiving events.")
                    : t("An admin can add a URL to receive events.")}
                </EmptyDescription>
              </EmptyHeader>
              {manage ? (
                <EmptyContent>
                  <CreateWebhookDialog />
                </EmptyContent>
              ) : null}
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Endpoint")}</TableHead>
                  <TableHead>{t("Events")}</TableHead>
                  <TableHead>{t("Status")}</TableHead>
                  {manage ? (
                    <TableHead className="text-right">{t("Actions")}</TableHead>
                  ) : null}
                </TableRow>
              </TableHeader>
              <TableBody>
                {state.overview.endpoints.map((endpoint) => (
                  <TableRow key={endpoint.id}>
                    <TableCell>
                      <div className="flex flex-col gap-1">
                        <span className="text-sm font-medium">
                          {endpoint.name}
                        </span>
                        <span className="max-w-xs truncate font-mono text-xs text-muted-foreground">
                          {endpoint.url}
                        </span>
                      </div>
                    </TableCell>
                    <TableCell>
                      <div className="flex flex-wrap gap-1">
                        {endpoint.eventTypes.map((type) => (
                          <Badge
                            key={type}
                            variant="outline"
                            className="font-mono"
                          >
                            {type}
                          </Badge>
                        ))}
                      </div>
                    </TableCell>
                    <TableCell>
                      <EndpointStatusBadge endpoint={endpoint} t={t} />
                    </TableCell>
                    {manage ? (
                      <TableCell>
                        <WebhookEndpointActions
                          id={endpoint.id}
                          name={endpoint.name}
                          enabled={endpoint.enabled}
                        />
                      </TableCell>
                    ) : null}
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
      {state.kind === "ok" ? (
        <Card data-testid="webhook-deliveries">
          <CardHeader>
            <CardTitle>{t("Recent deliveries")}</CardTitle>
            <CardDescription>
              {t(
                "The last 20 events sent to your endpoints. Failed deliveries retry with backoff.",
              )}
            </CardDescription>
          </CardHeader>
          <CardContent>
            {state.overview.deliveries.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <SendIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No deliveries yet")}</EmptyTitle>
                  <EmptyDescription>
                    {firstEnabled
                      ? t(
                          "Send a test event to {name} to check that it receives them.",
                          { name: firstEnabled.name },
                        )
                      : t("Deliveries show up here once an endpoint is on.")}
                  </EmptyDescription>
                </EmptyHeader>
                {manage && firstEnabled ? (
                  <EmptyContent>
                    <SendTestEventButton id={firstEnabled.id} />
                  </EmptyContent>
                ) : null}
              </Empty>
            ) : (
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>{t("Event")}</TableHead>
                    <TableHead>{t("Status")}</TableHead>
                    <TableHead>{t("Attempts")}</TableHead>
                    <TableHead>{t("Response")}</TableHead>
                    <TableHead>{t("Created")}</TableHead>
                    {manage ? (
                      <TableHead className="text-right">
                        {t("Actions")}
                      </TableHead>
                    ) : null}
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {state.overview.deliveries.map((delivery) => (
                    <TableRow key={delivery.id}>
                      <TableCell>
                        <div className="flex flex-col gap-1">
                          <span className="font-mono text-sm">
                            {delivery.eventType}
                          </span>
                          <span className="text-xs text-muted-foreground">
                            {delivery.endpointName}
                          </span>
                        </div>
                      </TableCell>
                      <TableCell>
                        <DeliveryStatusBadge status={delivery.status} t={t} />
                      </TableCell>
                      <TableCell className="tabular-nums">
                        {delivery.attempt}
                      </TableCell>
                      <TableCell>
                        <span
                          className="font-mono text-xs text-muted-foreground"
                          title={delivery.lastError ?? undefined}
                        >
                          {delivery.responseStatus ?? "–"}
                        </span>
                      </TableCell>
                      <TableCell className="text-xs text-muted-foreground">
                        {when(delivery.createdAt)}
                      </TableCell>
                      {manage ? (
                        <TableCell className="text-right">
                          {delivery.status === "dead" ||
                          delivery.status === "canceled" ||
                          delivery.status === "retrying" ? (
                            <RedeliverButton id={delivery.id} />
                          ) : null}
                        </TableCell>
                      ) : null}
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            )}
          </CardContent>
        </Card>
      ) : null}
    </div>
  );
}

export function WebhookEndpointsSkeleton() {
  return (
    <div className="flex flex-col gap-6" aria-busy="true">
      <Skeleton className="h-48 rounded-xl" />
      <Skeleton className="h-64 rounded-xl" />
    </div>
  );
}
```

# Work item board

> Work items in state columns, ordered by rank and moved through allowed transitions.

Source: https://bettersupabase.com/docs/registry/ui-recipes/work-item-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [tasks](https://bettersupabase.com/blocks/tasks), [state-machine](https://bettersupabase.com/blocks/state-machine), [rank](https://bettersupabase.com/blocks/rank) blocks must be installed (`npx better-supabase add tasks state-machine rank`). Its page on bettersupabase.com is [/recipes/work-item-board](https://bettersupabase.com/recipes/work-item-board).

Web, with shadcn components:

```bash
npx shadcn add badge card @reui/kanban
```

```tsx title="apps/examples/nextjs/src/features/work/components/work-item-board.tsx"
/**
 * Work items in the columns of the workflow's stages, each column labelled with
 * the state its stage stands for. Dragging to another column changes the stage,
 * which the pipelines trigger checks against the allowed transitions; dragging
 * within a column saves the order through the rank block. A refused move puts
 * the card back. The parent keys it by the items' positions, so a server
 * refresh starts it from the saved board.
 */
export function WorkItemBoard({
  stages,
  items,
  canMove,
}: {
  stages: readonly StageColumn[];
  items: readonly WorkItemRow[];
  canMove: boolean;
}) {
  const t = useExtracted("work");
  const errorMessage = useErrorMessage();
  const stateLabel = useStateLabel();
  const workErrorHint = useWorkErrorHint();
  const [columns, setColumns] = useState(() => columnsOf(stages, items));
  const move = useAction(moveWorkItem);
  const byId = new Map(items.map((item) => [item.id, item]));

  return (
    <div data-recipe="work-item-board">
      <Kanban
        value={columns}
        onValueChange={setColumns}
        getItemValue={(item) => item.id}
        onValueCommit={(value, meta) => {
          if (meta.kind !== "item") {
            return;
          }

          const column = value[meta.overContainer] ?? [];
          const previous = column[meta.overIndex - 1];
          const next = column[meta.overIndex + 1];

          void move
            .run({
              id: String(meta.event.active.id),
              stageId: meta.overContainer,
              stageChanged: meta.activeContainer !== meta.overContainer,
              previousId: previous?.id ?? null,
              nextId: next?.id ?? null,
            })
            .then((result) => {
              if (!result.ok) {
                setColumns(meta.previousValue);
                toast.error(
                  workErrorHint(result.error.hint) ??
                    errorMessage(result.error),
                );

                return;
              }

              if (!result.data.ordered) {
                toast.warning(
                  t("Moved, but its place in the column wasn't saved."),
                );
              }
            });
        }}
      >
        <KanbanBoard className="flex overflow-x-auto">
          {stages.map((stage) => {
            const state = stateOf(stage.category);
            const count = columns[stage.id]?.length ?? 0;

            return (
              <KanbanColumn
                key={stage.id}
                value={stage.id}
                className="w-72 shrink-0 gap-3 rounded-lg bg-muted/50 p-3"
              >
                <div className="flex items-start justify-between gap-2">
                  <div className="flex min-w-0 flex-col gap-1">
                    <span className="truncate text-sm font-medium">
                      {stage.name}
                    </span>
                    {state === null ? null : (
                      <Badge variant={stateBadgeVariant(state)}>
                        {stateLabel(state)}
                      </Badge>
                    )}
                  </div>
                  <Badge
                    variant={
                      stage.wipLimit !== null && count > stage.wipLimit
                        ? "destructive-light"
                        : "outline"
                    }
                  >
                    {stage.wipLimit === null
                      ? count
                      : t("{count, number} of {limit, number}", {
                          count,
                          limit: stage.wipLimit,
                        })}
                  </Badge>
                </div>
                <KanbanColumnContent value={stage.id} className="min-h-16">
                  {(columns[stage.id] ?? []).map((item) => (
                    <KanbanItem
                      key={item.id}
                      value={item.id}
                      disabled={!canMove || move.pending}
                    >
                      <KanbanItemHandle cursor={canMove}>
                        <WorkItemCard item={item} />
                      </KanbanItemHandle>
                    </KanbanItem>
                  ))}
                  {count === 0 ? (
                    <p className="text-xs text-muted-foreground">
                      {t("Drop an item here.")}
                    </p>
                  ) : null}
                </KanbanColumnContent>
              </KanbanColumn>
            );
          })}
        </KanbanBoard>
        <KanbanOverlay>
          {({ value, variant }) => {
            const item =
              variant === "item" ? byId.get(String(value)) : undefined;

            return item ? <WorkItemCard item={item} /> : null;
          }}
        </KanbanOverlay>
      </Kanban>
    </div>
  );
}
```

# Work item grid

> A data grid of work items with filters, sorting and saved views.

Source: https://bettersupabase.com/docs/registry/ui-recipes/work-item-grid

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [tasks](https://bettersupabase.com/blocks/tasks), [saved-views](https://bettersupabase.com/blocks/saved-views) blocks must be installed (`npx better-supabase add tasks saved-views`). Its page on bettersupabase.com is [/recipes/work-item-grid](https://bettersupabase.com/recipes/work-item-grid).

Web, with shadcn components:

```bash
npx shadcn add badge button checkbox dialog field input select table
```

```tsx title="apps/examples/nextjs/src/features/work/components/work-item-grid.tsx"
/**
 * Work items as a table. The search, state filter and sort live in the URL and
 * can be saved as named views. State and assignee edit in place, and the
 * checkboxes feed the bulk action bar.
 */
export function WorkItemGrid({
  rows,
  stages,
  members,
  views,
  query,
  canWrite,
  canBulk,
}: {
  rows: readonly WorkItemRow[];
  stages: readonly StageColumn[];
  members: readonly MemberOption[];
  views: readonly SavedViewOption[];
  query: WorkGridQuery;
  canWrite: boolean;
  canBulk: boolean;
}) {
  const t = useExtracted("work");
  const router = useRouter();
  const pathname = usePathname();
  const errorMessage = useErrorMessage();
  const workErrorHint = useWorkErrorHint();
  const stateLabel = useStateLabel();
  const priorityLabel = usePriorityLabel();
  const sortLabel = useSortLabel();
  const [navigating, startTransition] = useTransition();
  const [search, setSearch] = useState(query.q);
  const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());

  const onEditError = (error: DbError) => {
    toast.error(workErrorHint(error.hint) ?? errorMessage(error));
  };

  const stage = useAction(setWorkItemStage, { onError: onEditError });
  const assign = useAction(setWorkItemAssignee, { onError: onEditError });

  const removeView = useAction(deleteWorkView, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const go = (next: WorkGridQuery) => {
    startTransition(() => {
      router.replace(`${pathname}${workGridSearch(next)}`);
    });
  };

  const visibleIds = new Set(rows.map((row) => row.id));
  const selection = [...selected].filter((id) => visibleIds.has(id));
  const selectedRows = rows.filter((row) => selected.has(row.id));
  const allSelected = rows.length > 0 && selection.length === rows.length;

  const stateItems = [
    { value: ANY, label: t("Any state") },
    ...WORK_ITEM_STATES.map((state) => ({
      value: state,
      label: stateLabel(state),
    })),
  ];

  const sortItems = WORK_ITEM_SORTS.map((sort) => ({
    value: sort,
    label: sortLabel(sort),
  }));

  const stageItems = stages.map((each) => ({
    value: each.id,
    label: each.name,
  }));

  const memberItems = [
    { value: UNASSIGNED, label: t("Unassigned") },
    ...members.map((member) => ({ value: member.userId, label: member.name })),
  ];

  const viewItems = views.map((view) => ({ value: view.id, label: view.name }));

  const activeView = views.find(
    (view) => workGridSearch(view.query) === workGridSearch(query),
  );

  return (
    <div className="flex flex-col gap-4" data-recipe="work-item-grid">
      <div className="flex flex-wrap items-center gap-2">
        <form
          className="flex items-center gap-2"
          onSubmit={(event) => {
            event.preventDefault();
            go({ ...query, q: search.trim() });
          }}
        >
          <Input
            type="search"
            className="w-56"
            value={search}
            placeholder={t("Search titles")}
            aria-label={t("Search titles")}
            onChange={(event) => {
              setSearch(event.target.value);
            }}
          />
          <Button
            type="submit"
            size="icon-sm"
            variant="outline"
            aria-label={t("Search")}
            disabled={navigating}
          >
            <SearchIcon />
          </Button>
        </form>
        <Select
          value={query.state ?? ANY}
          items={stateItems}
          onValueChange={(value) => {
            const state = WORK_ITEM_STATES.find((each) => each === value);

            go({ ...query, state: state ?? null });
          }}
        >
          <SelectTrigger size="sm" className="w-36" aria-label={t("State")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {stateItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Select
          value={query.sort}
          items={sortItems}
          onValueChange={(value) => {
            const sort = WORK_ITEM_SORTS.find((each) => each === value);

            go({ ...query, sort: sort ?? "newest" });
          }}
        >
          <SelectTrigger size="sm" className="w-36" aria-label={t("Sort")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {sortItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <div className="ml-auto flex items-center gap-2">
          {views.length === 0 ? null : (
            <Select
              value={activeView?.id ?? null}
              items={viewItems}
              onValueChange={(value) => {
                const view = views.find((each) => each.id === value);

                if (view) {
                  setSearch(view.query.q);
                  go(view.query);
                }
              }}
            >
              <SelectTrigger
                size="sm"
                className="w-44"
                aria-label={t("Saved views")}
              >
                <SelectValue placeholder={t("Saved views")} />
              </SelectTrigger>
              <SelectContent>
                {viewItems.map((item) => (
                  <SelectItem key={item.value} value={item.value}>
                    {item.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          )}
          {activeView ? (
            <Button
              size="icon-sm"
              variant="ghost"
              aria-label={t("Delete view {name}", { name: activeView.name })}
              disabled={removeView.pending}
              onClick={() => {
                void removeView.run({ id: activeView.id });
              }}
            >
              <Trash2Icon />
            </Button>
          ) : (
            <SaveViewDialog query={query} />
          )}
        </div>
      </div>
      {canBulk ? (
        <BulkActionBar
          selected={selectedRows}
          onSettled={(failedIds) => {
            setSelected(new Set(failedIds));
          }}
        />
      ) : null}
      <Table>
        <TableHeader>
          <TableRow>
            {canBulk ? (
              <TableHead className="w-8">
                <Checkbox
                  checked={allSelected}
                  indeterminate={selection.length > 0 && !allSelected}
                  aria-label={t("Select all")}
                  onCheckedChange={(checked) => {
                    setSelected(
                      checked ? new Set(rows.map((row) => row.id)) : new Set(),
                    );
                  }}
                />
              </TableHead>
            ) : null}
            <TableHead>{t("ID")}</TableHead>
            <TableHead>{t("Title")}</TableHead>
            <TableHead>{t("State")}</TableHead>
            <TableHead>{t("Priority")}</TableHead>
            <TableHead>{t("Assignee")}</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((row) => {
            const pendingStage = stage.pendingInputs.find(
              (input) => input.id === row.id,
            );

            const pendingAssignee = assign.pendingInputs.find(
              (input) => input.id === row.id,
            );

            const assignee = pendingAssignee
              ? (pendingAssignee.userId ?? UNASSIGNED)
              : (row.assigneeIds[0] ?? UNASSIGNED);

            return (
              <TableRow
                key={row.id}
                data-state={selected.has(row.id) ? "selected" : undefined}
              >
                {canBulk ? (
                  <TableCell>
                    <Checkbox
                      checked={selected.has(row.id)}
                      aria-label={t("Select {identifier}", {
                        identifier: row.identifier,
                      })}
                      onCheckedChange={(checked) => {
                        setSelected((current) => {
                          const next = new Set(current);

                          if (checked) {
                            next.add(row.id);
                          } else {
                            next.delete(row.id);
                          }

                          return next;
                        });
                      }}
                    />
                  </TableCell>
                ) : null}
                <TableCell className="text-muted-foreground tabular-nums">
                  {row.identifier}
                </TableCell>
                <TableCell className="font-medium">{row.title}</TableCell>
                <TableCell>
                  {canWrite && stages.length > 0 ? (
                    <Select
                      value={pendingStage?.stageId ?? row.stageId}
                      items={stageItems}
                      disabled={pendingStage !== undefined}
                      onValueChange={(value) => {
                        if (value !== null && value !== row.stageId) {
                          void stage.run({ id: row.id, stageId: value });
                        }
                      }}
                    >
                      <SelectTrigger
                        size="sm"
                        className="w-40"
                        aria-label={t("State of {identifier}", {
                          identifier: row.identifier,
                        })}
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {stageItems.map((item) => (
                          <SelectItem key={item.value} value={item.value}>
                            {item.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  ) : (
                    <Badge variant={stateBadgeVariant(row.state)}>
                      {stateLabel(row.state)}
                    </Badge>
                  )}
                </TableCell>
                <TableCell className="text-muted-foreground">
                  {priorityLabel(row.priority)}
                </TableCell>
                <TableCell>
                  {canWrite ? (
                    <Select
                      value={assignee}
                      items={memberItems}
                      disabled={pendingAssignee !== undefined}
                      onValueChange={(value) => {
                        void assign.run({
                          id: row.id,
                          userId:
                            value === null || value === UNASSIGNED
                              ? null
                              : value,
                        });
                      }}
                    >
                      <SelectTrigger
                        size="sm"
                        className="w-44"
                        aria-label={t("Assignee of {identifier}", {
                          identifier: row.identifier,
                        })}
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {memberItems.map((item) => (
                          <SelectItem key={item.value} value={item.value}>
                            {item.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  ) : (
                    <span className="text-sm text-muted-foreground">
                      {members.find((member) => member.userId === assignee)
                        ?.name ?? t("Unassigned")}
                    </span>
                  )}
                </TableCell>
              </TableRow>
            );
          })}
        </TableBody>
      </Table>
    </div>
  );
}
```

# Workflow canvas

> A node editor for a workflow definition that saves drafts, publishes versions and runs the workflow.

Source: https://bettersupabase.com/docs/registry/ui-recipes/workflow-canvas

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useWorkflowCanvasRun` from `@/hooks/use-workflow-editor`; the [workflow-editor](https://bettersupabase.com/blocks/workflow-editor), [workflows](https://bettersupabase.com/blocks/workflows) blocks must be installed (`npx better-supabase add workflow-editor workflows`). Its page on bettersupabase.com is [/recipes/workflow-canvas](https://bettersupabase.com/recipes/workflow-canvas).

Web, with shadcn components:

```bash
npx shadcn add badge button input select
```

```bash
npx shadcn add @better-supabase/use-workflow-editor
```

```tsx title="apps/examples/nextjs/src/features/workflows/builder/components/workflow-canvas.tsx"
"use client";

import type { Connection } from "@xyflow/react";
import type { WorkflowNodeKind } from "better-supabase/blocks/workflow-editor";

import {
  addEdge,
  Background,
  Controls,
  ReactFlow,
  useEdgesState,
  useNodesState,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useAction } from "better-supabase/react";
import { PlayIcon, PlusIcon, SaveIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useWorkflowCanvasRun } from "@/hooks/use-workflow-editor";
import { useWorkflowRuns } from "@/hooks/use-workflows";
import { useErrorMessage } from "@/lib/use-error-message";

import type { BuilderDetail } from "../builder-queries";
import type { GraphFlowNode, GraphNodeData } from "./graph-node";

import { isFinished, statusVariant } from "../../workflow-labels";
import {
  decideNode,
  publishGraph,
  runDefinition,
  saveDraft,
} from "../builder-actions";
import { CredentialSheet } from "./credential-sheet";
import { fromFlow, nodeTypes, toFlow } from "./graph-node";
import { NodePanel } from "./node-panel";
import { PublishDialog } from "./publish-dialog";
import { TriggerSheet } from "./trigger-sheet";

const shortId = () => crypto.randomUUID().slice(0, 8);

/**
 * The canvas: a palette from the step library, the graph on React Flow, an
 * inspector for the selected node, and the status of a run's nodes on top, live
 * over `workflow-run:<id>`.
 */
export function WorkflowCanvas({ detail }: { detail: BuilderDetail }) {
  const t = useExtracted("workflows");
  const format = useFormatter();
  const errorMessage = useErrorMessage();
  const [initial] = useState(() => toFlow(detail.graph));

  const [nodes, setNodes, onNodesChange] = useNodesState<GraphFlowNode>(
    initial.nodes,
  );

  const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
  const [selected, setSelected] = useState<string | null>(null);
  const [runId, setRunId] = useState<string | null>(null);
  const [input, setInput] = useState("");

  const { runs } = useWorkflowRuns({
    tenant: detail.tenant,
    schema: "api",
    limit: 50,
  });

  const overlay = useWorkflowCanvasRun(runId, { schema: "api" });

  const definitionRuns = (runs ?? []).filter(
    (run) => run.attributes["bsb.definition"] === detail.definition.id,
  );

  const graph = fromFlow(nodes, edges);
  const draft = { nodes: [...graph.nodes], edges: [...graph.edges] };

  const onError = (error: Parameters<typeof errorMessage>[0]) => {
    toast.error(errorMessage(error));
  };

  const save = useAction(saveDraft, {
    onError,
    onSuccess: () => {
      toast.success(t("Draft saved"));
    },
  });

  const publish = useAction(publishGraph, {
    onError,
    onSuccess: (version) => {
      toast.success(
        t("Version {version} published", { version: String(version) }),
      );
    },
  });

  const run = useAction(runDefinition, {
    onError,
    onSuccess: (id) => {
      setRunId(id);
    },
  });

  const decide = useAction(decideNode, { onError });

  const addNode = (kind: WorkflowNodeKind, step?: string, title?: string) => {
    const id = `${step ?? kind}-${shortId()}`.replaceAll(".", "-");
    const lowest = Math.max(0, ...nodes.map((node) => node.position.y));

    const data: GraphNodeData = {
      kind,
      label: title ?? kind,
      config: kind === "sleep" ? { duration: "1m" } : {},
    };

    setNodes((current) => [
      ...current,
      {
        id,
        type: "graph",
        position: { x: 0, y: lowest + 120 },
        data: step === undefined ? data : { ...data, step },
      },
    ]);
    setSelected(id);
  };

  const onConnect = (connection: Connection) => {
    setEdges((current) =>
      addEdge(
        connection.sourceHandle
          ? {
              ...connection,
              id: `e-${shortId()}`,
              label: connection.sourceHandle,
            }
          : { ...connection, id: `e-${shortId()}` },
        current,
      ),
    );
  };

  const updateNode = (id: string, data: Partial<GraphNodeData>) => {
    setNodes((current) =>
      current.map((node) =>
        node.id === id ? { ...node, data: { ...node.data, ...data } } : node,
      ),
    );
  };

  const removeNode = (id: string) => {
    setNodes((current) => current.filter((node) => node.id !== id));
    setEdges((current) =>
      current.filter((edge) => edge.source !== id && edge.target !== id),
    );
    setSelected(null);
  };

  const shown =
    runId === null
      ? nodes
      : nodes.map((node) => {
          const status = overlay.nodes[node.id]?.status;

          return status === undefined
            ? node
            : { ...node, data: { ...node.data, status } };
        });

  const selectedNode = nodes.find((node) => node.id === selected);
  const runOpen = overlay.run !== undefined && !isFinished(overlay.run.status);
  const definition = detail.definition.id;

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <Button
          variant="outline"
          size="sm"
          disabled={save.pending}
          onClick={() => {
            void save.run({ definition, graph: draft });
          }}
        >
          <SaveIcon />
          {t("Save draft")}
        </Button>
        <PublishDialog
          published={detail.published}
          graph={graph}
          pending={publish.pending}
          onPublish={() => {
            void publish.run({ definition, graph: draft });
          }}
        />
        <TriggerSheet definition={definition} triggers={detail.triggers} />
        <CredentialSheet credentials={detail.credentials} />
        <span className="grow" />
        <Input
          value={input}
          onChange={(event) => {
            setInput(event.target.value);
          }}
          placeholder={t("Input text")}
          aria-label={t("Input text")}
          className="w-56"
        />
        <Button
          size="sm"
          disabled={run.pending || detail.published === null}
          onClick={() => {
            void run.run({ definition, text: input });
          }}
        >
          <PlayIcon />
          {t("Run")}
        </Button>
        <Select
          value={runId}
          onValueChange={(value) => {
            setRunId(value);
          }}
          items={definitionRuns.map((item) => ({
            value: item.externalId,
            label: format.dateTime(new Date(item.createdAt.epochMilliseconds), {
              timeStyle: "medium",
            }),
          }))}
        >
          <SelectTrigger className="w-44" aria-label={t("Show a run")}>
            <SelectValue placeholder={t("Show a run")} />
          </SelectTrigger>
          <SelectContent>
            {definitionRuns.map((item) => (
              <SelectItem key={item.id} value={item.externalId}>
                {format.dateTime(new Date(item.createdAt.epochMilliseconds), {
                  timeStyle: "medium",
                })}{" "}
                · {item.status}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        {overlay.run ? (
          <Badge variant={statusVariant(overlay.run.status)}>
            {overlay.run.status}
          </Badge>
        ) : null}
      </div>
      <div className="flex gap-3">
        <nav
          className="w-48 shrink-0 space-y-1 rounded-lg border bg-card p-2"
          aria-label={t("Palette")}
        >
          {detail.steps.map((step) => (
            <Button
              key={step.name}
              variant="ghost"
              size="sm"
              className="w-full justify-start"
              title={step.description}
              onClick={() => {
                addNode("step", step.name, step.title);
              }}
            >
              <PlusIcon />
              <span className="truncate">{step.title}</span>
            </Button>
          ))}
          {(
            [
              ["sleep", t("Wait")],
              ["approval", t("Approval")],
              ["condition", t("Condition")],
            ] as const
          ).map(([kind, label]) => (
            <Button
              key={kind}
              variant="ghost"
              size="sm"
              className="w-full justify-start"
              onClick={() => {
                addNode(kind, undefined, label);
              }}
            >
              <PlusIcon />
              {label}
            </Button>
          ))}
        </nav>
        <div
          className="h-150 min-w-0 flex-1 rounded-lg border bg-muted/30"
          data-testid="workflow-canvas"
        >
          <ReactFlow
            nodes={shown}
            edges={edges}
            nodeTypes={nodeTypes}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={onConnect}
            onNodeClick={(_, node) => {
              setSelected(node.id);
            }}
            onPaneClick={() => {
              setSelected(null);
            }}
            fitView
          >
            <Background />
            <Controls />
          </ReactFlow>
        </div>
        {selectedNode ? (
          <NodePanel
            key={selectedNode.id}
            node={selectedNode}
            steps={detail.steps}
            credentials={detail.credentials}
            canDecide={runOpen}
            onChange={(data) => {
              updateNode(selectedNode.id, data);
            }}
            onRemove={() => {
              removeNode(selectedNode.id);
            }}
            onDecide={(approved) => {
              if (runId === null) {
                return;
              }

              void decide.run({ run: runId, node: selectedNode.id, approved });
            }}
          />
        ) : null}
      </div>
    </div>
  );
}
```

# Workflow runs

> The latest workflow runs with their status, which update while a run is in progress.

Source: https://bettersupabase.com/docs/registry/ui-recipes/workflow-runs

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It calls `useWorkflowRuns` from `@/hooks/use-workflows`; the [workflows block](https://bettersupabase.com/blocks/workflows) must be installed (`npx better-supabase add workflows`). Its page on bettersupabase.com is [/recipes/workflow-runs](https://bettersupabase.com/recipes/workflow-runs).

Web, with shadcn components:

```bash
npx shadcn add badge empty skeleton table
```

```bash
npx shadcn add @better-supabase/use-workflows
```

```tsx title="apps/examples/nextjs/src/features/workflows/components/workflow-runs.tsx"
"use client";

import { WorkflowIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";

import { Badge } from "@/components/ui/badge";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { useWorkflowRuns } from "@/hooks/use-workflows";
import { Link } from "@/i18n/navigation";

import { statusVariant, workflowName } from "../workflow-labels";

/**
 * The organization's runs from the workflows block, loaded again when a run
 * changes status (`workflow-runs:<tenant>`).
 */
export function WorkflowRuns({ tenant }: { tenant: string | null }) {
  const t = useExtracted("workflows");
  const format = useFormatter();

  const { runs, status } = useWorkflowRuns({
    tenant,
    schema: "api",
    limit: 50,
  });

  if (runs === undefined) {
    return <WorkflowRunsSkeleton />;
  }

  if (runs.length === 0) {
    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <WorkflowIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No runs yet")}</EmptyTitle>
          <EmptyDescription>
            {t("Start a sample workflow to see its run here.")}
          </EmptyDescription>
        </EmptyHeader>
      </Empty>
    );
  }

  return (
    <div
      className="rounded-xl border"
      data-testid="workflow-runs"
      data-status={status}
    >
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>{t("Workflow")}</TableHead>
            <TableHead>{t("Status")}</TableHead>
            <TableHead className="text-right">{t("Started")}</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {runs.map((run) => (
            <TableRow key={run.id}>
              <TableCell>
                <Link href={`/workflows/${run.id}`} className="font-medium">
                  {workflowName(run.definition)}
                </Link>
              </TableCell>
              <TableCell>
                <Badge variant={statusVariant(run.status)}>{run.status}</Badge>
              </TableCell>
              <TableCell className="text-right text-xs text-muted-foreground tabular-nums">
                {format.dateTime(new Date(run.createdAt.epochMilliseconds), {
                  dateStyle: "medium",
                  timeStyle: "short",
                })}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

export function WorkflowRunsSkeleton() {
  return (
    <div className="space-y-2" aria-busy="true">
      {Array.from({ length: 4 }, (_, index) => (
        <Skeleton key={index} className="h-11 w-full" />
      ))}
    </div>
  );
}
```

# Workspace URL field

> An organization form with a name and a URL slug, where reserved and taken slugs come back as field errors.

Source: https://bettersupabase.com/docs/registry/ui-recipes/workspace-url-field

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [organizations](https://bettersupabase.com/blocks/organizations), [reserved-slugs](https://bettersupabase.com/blocks/reserved-slugs) blocks must be installed (`npx better-supabase add organizations reserved-slugs`). Its page on bettersupabase.com is [/recipes/workspace-url-field](https://bettersupabase.com/recipes/workspace-url-field).

Web, with shadcn components:

```bash
npx shadcn add button field input
```

```tsx title="apps/examples/nextjs/src/features/organization/components/organization-form.tsx"
"use client";

import { useActionForm } from "better-supabase/react";
import { useExtracted } from "next-intl";
import { useId } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { useErrorMessage } from "@/lib/use-error-message";

import { updateOrganization } from "../organization-actions";

export function OrganizationForm({
  name,
  slug,
  canEdit,
}: {
  name: string;
  slug: string;
  canEdit: boolean;
}) {
  const t = useExtracted("organization");
  const fieldId = useId();
  const errorMessage = useErrorMessage();

  const form = useActionForm(updateOrganization, {
    resetOnSuccess: false,
    onSuccess: () => {
      toast.success(t("Organization saved"));
    },
  });

  const error =
    form.error === undefined
      ? null
      : form.error.kind === "conflict"
        ? t("That URL is taken. Pick another one.")
        : errorMessage(form.error);

  return (
    <form {...form.formProps} className="space-y-6">
      <fieldset disabled={!canEdit}>
        <FieldGroup>
          <Field>
            <FieldLabel htmlFor={`${fieldId}-organization-name`}>
              {t("Name")}
            </FieldLabel>
            <Input
              id={`${fieldId}-organization-name`}
              name="name"
              defaultValue={name}
              required
              minLength={2}
              maxLength={80}
            />
          </Field>
          <Field data-invalid={error ? true : undefined}>
            <FieldLabel htmlFor={`${fieldId}-organization-slug`}>
              {t("URL")}
            </FieldLabel>
            <Input
              id={`${fieldId}-organization-slug`}
              name="slug"
              defaultValue={slug}
              required
              pattern="[a-z0-9][a-z0-9\-]*[a-z0-9]"
              maxLength={48}
              aria-invalid={error ? true : undefined}
            />
            <FieldDescription>
              {t("Lowercase letters, numbers and dashes.")}
            </FieldDescription>
            {error ? <FieldError>{error}</FieldError> : null}
          </Field>
        </FieldGroup>
      </fieldset>
      {canEdit ? (
        <Button type="submit" disabled={form.pending}>
          {t("Save")}
        </Button>
      ) : (
        <p className="text-sm text-muted-foreground">
          {t("Only owners and admins can change the organization.")}
        </p>
      )}
    </form>
  );
}
```

# Content mapper

> Serve the schema types to TypeScript 7.1 from the snapshot, without writing database.types.ts.

Source: https://bettersupabase.com/docs/content-mapper

TypeScript 7.1 can ask a package for the types of a file instead of reading
it from disk. With `gen.contentMapper` on, better-supabase uses that for the
schema types: `gen` writes a small `generated.ts` and a `schema.bsb` marker,
and `tsc` gets the types from better-supabase's content mapper, which renders
them from the same config and snapshot `gen` reads. The types are identical
to the files `gen` writes otherwise; the repository no longer holds
`database.types.ts` or the type half of `generated.ts`.

Content mappers need TypeScript 7.1, which is in beta (`7.1.0-beta`). Files
mode stays the default and works on every supported TypeScript version.

## Set it up [#set-it-up]

```bash
pnpm add -D typescript@7.1.0-beta
pnpm better-supabase init --content-mapper
pnpm better-supabase gen
```

[`init --content-mapper`](/docs/cli/init#content-mapper) sets
`gen.contentMapper: true`, adds the mapper to `tsconfig.json` and adds
`--runExternalCode` to the `typecheck` script. When a file holds comments or
has no `typecheck` script, it leaves the file alone and prints the change to
make instead. In an existing project, set the option yourself:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  gen: { output: "src/lib/supabase/generated.ts", contentMapper: true },
});
```

```json title="tsconfig.json"
{
  "contentMappers": [{ "package": "better-supabase", "extensions": [".bsb"] }],
  "compilerOptions": { "strict": true }
}
```

```json title="package.json"
{ "scripts": { "typecheck": "tsc --runExternalCode --noEmit" } }
```

`tsc` runs a content mapper only with `--runExternalCode`, because the mapper
is code from `node_modules`. Editors run it only in a workspace you trust.
Without either, imports of `schema.bsb` have no types.
[Doctor](/docs/cli/doctor#bs602) reports a missing `contentMappers` entry, a
TypeScript older than 7.1 and a `typecheck` script without the flag.

## What gen writes [#what-gen-writes]

| File                | Files mode                         | Mapper mode                                   |
| ------------------- | ---------------------------------- | --------------------------------------------- |
| `generated.ts`      | The runtime values and every type  | The runtime values and `export type *` below  |
| `schema.bsb`        | not written                        | One line; the mapper serves its types         |
| `database.types.ts` | The Supabase `Database` type       | not written; `Database` comes from the mapper |
| `generated.meta.js` | The runtime metadata and its types | The same                                      |

The app imports from `generated.ts` in both modes, so switching changes no
import. Code that imported `Database` from `database.types.ts` imports it
from `generated.ts` instead, which gives the enriched `Database` (typed
`jsonb` columns, generated columns read-only).

```ts title="src/lib/supabase/generated.ts"
import type { schema as typedSchema } from "./schema.bsb";
import meta from "./generated.meta.js";

export type * from "./schema.bsb";
```

Turning the option on removes `database.types.ts` on the next `gen`; turning
it off writes it again. `gen --check` checks the files in both modes.

## Caching and reloads [#caching-and-reloads]

The mapper renders the types once per project and keeps them in
`node_modules/.cache/better-supabase`, keyed by the config, the snapshot and
the better-supabase version. `gen` writes the same cache, so the first `tsc`
after a `gen` reads the types instead of rendering them.

The mapper tells TypeScript to watch the config, the snapshot,
`supabase/config.toml` and a stamp file in the cache folder, and TypeScript
asks for the types again when one changes. TypeScript can't watch a folder
pattern, so a change to `supabase/schemas` or a migration reaches the editor
through `gen`, or through [`gen --watch`](/docs/cli/gen#introspection-cache),
which rewrites the stamp only when the types change.

Each `tsc` run starts the mapper, which takes about 0.3 seconds; checking the
types costs the same as in files mode.

## Diagnostics [#diagnostics]

The mapper never stops `tsc`. When it can't render, it reports one of these
on the first character of `schema.bsb`:

| Code  | Meaning                                                                                                  |
| ----- | -------------------------------------------------------------------------------------------------------- |
| BS603 | No types: the config is missing or invalid, or there is no snapshot and nothing in the cache. Run `gen`. |
| BS604 | The config or the snapshot failed to load, so the mapper serves the last types it rendered.              |
| BS605 | A `.bsb` file other than the one `gen` writes next to `gen.output`. It gets an empty module.             |
| BS606 | An option in the `contentMappers` entry that the mapper doesn't read. It reads `config` only.            |

The mapper loads the config from the folder of `tsconfig.json`. Point it at a
config file in another folder with the `config` option, a path relative to
that `tsconfig.json`:

```json title="tsconfig.json"
{
  "contentMappers": [
    {
      "package": "better-supabase",
      "extensions": [".bsb"],
      "options": { "config": "../db/better-supabase.config.ts" }
    }
  ]
}
```

## Limits [#limits]

- TypeScript 7.1 only. Tools that read types without running `tsc` 7.1 (an
  older editor, a bundler's type stripping) see `schema.bsb` as an untyped
  module; the runtime values in `generated.ts` still load everywhere.
- The mapper is a Node process. It runs where `tsc` runs, never in the app.
- It speaks protocol 1 of TypeScript's content mapper protocol, pinned as
  `SPEC_PINS.typescriptContentMapper` ([standards](/docs/standards)).

The [content-mapper example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/content-mapper)
type-checks a small app this way.

# Data

> Read and write rows, records and the platform primitives under them.

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

- [Repository](/docs/repository)
- [Tables](/docs/repository/tables)
- [Record types](/docs/repository/record-types)
- [Record bindings](/docs/repository/record-bindings)
- [Record lists](/docs/repository/record-lists)
- [Permissions](/docs/repository/permissions)
- [List queries](/docs/platform/list)
- [Storage](/docs/platform/storage)
- [Realtime](/docs/platform/realtime)
- [Edge Functions](/docs/platform/edge-functions)
- [Lite](/docs/platform/lite)
- [Content mapper](/docs/content-mapper)

# 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/server.ts"
import { createEdge } from "better-supabase/edge";
import { betterSupabase } from "../_shared/supabase.ts";

export const bsb = createEdge(betterSupabase, { cors: true });
```

```ts title="supabase/functions/create-customer/handler.ts"
import { type ContractOf, defineFunction } from "better-supabase/edge";
import * as v from "valibot";
import { bsb } from "./server.ts";

export const createCustomer = defineFunction(bsb, {
  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
[`bsb.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 `bsb.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(bsb.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
`bsb.supabase` of `createClient`, a server's supabase-js client, or a plain
`createClient` from supabase-js.

## Document it in the spec [#document-it-in-the-spec]

`defineFunction` keeps its `input`, `output` and `summary` on the handler, so
`functionRoute` from `better-supabase/spec` turns the function into a route
for [`defineApi`](/docs/specs/customize#routes):

```ts title="lib/api.ts"
import { defineApi, functionRoute } from "better-supabase/spec";
import { createCustomer } from "../supabase/functions/create-customer/handler.ts";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM", version: "1.0.0" },
  servers: [{ url: "https://<project>.supabase.co" }],
  routes: [functionRoute("create-customer", createCustomer)],
});
```

The route is `POST /functions/v1/create-customer`: `input` is the request
body and `output` the 200 response. The third argument sets `method`,
`summary` (the function's `summary` by default) and `tags`. `basePath` goes
in front of the path like any other route, so document functions in an API
without one when its server is the Supabase project URL. A handler that
`defineFunction` didn't build throws a `TypeError`.

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 bsb.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 `page` 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 `list`. If you only want the arguments, use
`customerList.args(query)`.

## Pagination [#pagination]

A list pages by cursor: it takes `after` instead of a page number and
returns `{ items, nextCursor, hasMore }`. A cursor page skips the count and
reads the same number of rows however deep it is, and rows inserted between
requests don't shift it (see
[Pagination](/docs/repository/pagination#cursors)). End every sort with a
unique column, like `id` above, so rows that tie don't fall between pages.

```ts
const page = await customerList.run(db, query.value).orThrow();
// page: { items, nextCursor, hasMore }
customerList.toSearchParams({ ...query.value, after: page.nextCursor! });
```

`page` in the input is rejected with an issue. `size` is still capped by
`maxPageSize`, and the OpenAPI parameters and JSON Schema describe `after`.

### Page numbers [#page-numbers]

Set `pagination: 'offset'` when the UI shows page numbers or a total, or a
sort follows a relation, which a cursor can't continue after:

```ts
export const customerTable = defineListQuery(betterSupabase, "customers", {
  sorts: { name: [{ name: "asc" }, { id: "asc" }] },
  defaultSort: "name",
  pagination: "offset",
});
// page: { items, page: { number, size, total, pages, hasMore } }
```

The list then takes `page`, rejects `after`, and reads and skips every row
before the page, so late pages get slower as the table grows. 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.

## 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 });
```

## Filter trees [#filter-trees]

Facets cover "status is one of these". For nested and/or conditions, add
`filters`: an allow-list of fields that a filter tree may name, each with a
type and, optionally, the subset of its operators to allow. The tree is the
`FilterQuery` that [ReUI Filters](https://reui.io) edits, typed structurally,
so better-supabase doesn't depend on ReUI.

```ts title="lib/lists.ts"
export const customerList = defineListQuery(betterSupabase, "customers", {
  search: ["name"],
  filters: {
    name: { type: "text", label: "Name" },
    status: { type: "select" }, // options come from the enum or CHECK values
    createdAt: { type: "date", label: "Created" },
    contact: { type: "relation", column: "primaryContactId" },
    kvk: { type: "text", operators: ["is", "empty", "not_empty"] },
  },
  sorts: { newest: [{ createdAt: "desc" }, { id: "asc" }] },
  defaultSort: "newest",
});
```

| Type          | Column                             | Operators                                                                                                                    |
| ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `text`        | any                                | `contains`, `not_contains`, `starts_with`, `ends_with`, `is`, `is_not`, `empty`, `not_empty`                                 |
| `number`      | numeric                            | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`, `not_between`, `empty`, `not_empty`                                        |
| `date`        | `date`, `timestamp`, `timestamptz` | `is`, `is_not`, `is_before`, `is_after`, `is_on_or_before`, `is_on_or_after`, `between`, `not_between`, `empty`, `not_empty` |
| `select`      | any                                | `is`, `is_not`, `is_any_of`, `is_none_of`, `empty`, `not_empty`                                                              |
| `relation`    | any, usually a foreign key         | the `select` operators, on the key's value                                                                                   |
| `multiselect` | an array                           | `has_any_of`, `has_all_of`, `has_none_of`, `empty`, `not_empty`                                                              |
| `boolean`     | `bool`                             | `is`, `is_not`, `empty`, `not_empty`                                                                                         |

`parse` checks the tree and reports problems with a path such as
`['filter', 'rules', 0, 'operator']`:

- Every rule names a configured field, uses one of its operators, and has a
  value of the right shape for the operator (none, one, a list, or a range).
  Select values are checked against the enum or CHECK values.
- A tree has at most `maxFilterRules` rules (50) and `maxFilterDepth` levels
  of groups (4).
- A rule with an empty operator is unfinished, as in ReUI's
  `flattenFilterConditions`: it is dropped, and so is a group left empty.
- Negative operators (`is_not`, `not_contains`, `is_none_of` and the others)
  and `negated: true` also match rows where a nullable column is null.
- `starts_with` and `ends_with` are case-insensitive, like `contains`.

Date values are `{ date: '2026-10-13' }` or
`{ relative: { unit: 'day', offset: -7 } }`, with an optional
`time: 'HH:mm'`. Relative dates resolve when the query runs, not when it is
parsed, so a saved URL for "last 7 days" keeps meaning that. They resolve on
Temporal in the time zone you pass to `parse`, usually the user's:

```ts
const query = customerList.parse(await searchParams, {
  timeZone: "Europe/Amsterdam",
});
```

A `timestamptz` column compares instants at the zone's midnight, a
`timestamp` column local date-times, and a `date` column plain dates. The
compiled filter is ordinary repository `where` input, so it reaches PostgREST,
the [Postgres executor](/docs/auth/postgres) and the SQLite compiler the same
way. `customerList.filterWhere(query)` returns it on its own.

`customerList.filterFields(labels)` returns the `FilterField[]` that ReUI
Filters takes. Pass translated labels for fields, operators and options;
English is the default:

```tsx
"use client";
const fields = customerList.filterFields({
  fields: { name: "Naam", createdAt: "Aangemaakt" },
  options: { status: { lead: "Lead", active: "Actief" } },
});
```

In the URL, the tree is one `filter` parameter holding base64url JSON, which
`toSearchParams` writes and `parsers.filter` and `nuqs` read. `openapi`
describes it as a query parameter, and `jsonSchema` describes the tree under
`$defs`.

### Test a tree on one record [#test-a-tree-on-one-record]

`evaluateFilterQuery(query, record, fields, clock)` from
`better-supabase/list` answers whether one record matches a tree, without a
query. It uses the same operators, case rules, relative dates and null
handling as the compiled filter, so a rule that selects a row also matches
that row in memory. Pass the list's checked fields (`customerList.filters`),
or your own `FilterRecordField` list, whose `column` may be dotted to read a
nested value such as `submitter.seniority`:

```ts
import { evaluateFilterQuery } from "better-supabase/list";

const { matched, results } = evaluateFilterQuery(
  query.filter,
  customer,
  customerList.filters,
  { timeZone: "Europe/Amsterdam" },
);
```

`results` holds every rule and group that took part, by node id: `true`,
`false`, or `null` when a comparison met a missing value (SQL's unknown,
which never matches). Unfinished rules, unknown fields and empty groups
take no part, and a tree with nothing left matches. The
[rules block](/docs/blocks/automations) stores trees like this per organization
and runs them on events.

The operator table above was checked against the ReUI registry items
`filters` and `c-filters-5` (date operators) as published on 2026-10-13, in a
fixture that the tests compare with `FILTER_OPERATORS`. ReUI publishes no
version number for registry items.

## 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 `bsb.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 bsb = 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
`database.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 bsb = await stack.asUser(betterSupabase, { sub: crypto.randomUUID() });
  const notes = await bsb.notes.list();
  expect(notes.ok).toBe(true);
});

it("verifies Lite tokens on the server", async () => {
  const bsb = createServer(betterSupabase, {
    backend: "lite",
    liteDriver: "pglite",
    env: stack.env,
    fetch: stack.fetch,
  });
  const token = await signTestJwt(stack.jwtSecret, {
    sub: crypto.randomUUID(),
  });
  const ctx = await bsb.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)`.

The result also describes itself for [AsyncAPI](/docs/specs/asyncapi#broadcast-topics):
`events` and `presenceSchema` hold the payload schemas, `clientSend` says
whether clients may broadcast, `permission` is the instance permission key,
and `status` is the platform status of the topic. Private topics rely on
Realtime Authorization, which is in public beta, so their `status` is
`beta`; public topics are `ga`. `topicsFrom(module)` from
`better-supabase/spec` passes a module's topics to `defineApi`.

## 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`).
  This check and the `bsb:t:*` receive policy of `realtime-tables` read the
  claim, not the membership table, so a removed member can still join until
  their token expires. A topic with `access` checks membership through
  `tenant_ids_with()` instead; see
  [Removed members](/docs/repository/permissions#removed-members-and-the-tenant-claim).
- `{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/repository/permissions)
(`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 upgrade` 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.

### Instance permission [#instance-permission]

`permission` checks a permission on the one instance a topic is about, with
the instance id taken from a topic segment:

```ts
export const post = defineTopic(
  "organization:{organizationId}:posts:{postId}",
  { permission: "posts.read" },
);
```

Joining and sending then also need `posts.read` on the post in the last
segment. The check adds to the tenant and owner checks.

| Field      | Default                                  |
| ---------- | ---------------------------------------- |
| `key`      | Required; the string form sets only this |
| `param`    | The template's last placeholder          |
| `resource` | The part of `key` before the first `.`   |
| `scope`    | `tenant`                                 |

`param` must be a whole segment. With an
[authorization provider](/docs/extending/authorization-providers), the policy
calls its `permitted` template with `{permission}`, `{id}` (the instance
segment), `{tenant}` (the tenant segment, or `null` without one),
`{resource}` and `{scope}`; a `null` answer denies. `better-supabase upgrade`
passes the config's `authorization.functions`; anywhere else pass them to
`topic.sql({ functions })`. Without `permitted`, the policy falls back to the
[access contract](/docs/repository/permissions): the caller needs the permission in
the tenant of the topic (`tenant_ids_with`), so the check can't tell one
instance from another, and a topic without a tenant segment throws.

### Writing the policies to a file [#writing-the-policies-to-a-file]

`realtime.policies` keeps the generated policies in a schema file:
`better-supabase upgrade` imports the modules in `from`, collects every
topic they export, and writes their `sql()` to `output`, and
`upgrade --check` fails when the file no longer matches, for example in CI:

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: {
    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 `bsb:t:`). When a
module in `blocks` covers a topic you export, for example to subscribe
to it with the typed client, `upgrade` 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]

`topic.trigger(table, options)` writes a trigger that broadcasts a table's
changes to a topic. By default each message carries the row's key and the
topic values, never the row itself:

```json
{
  "operation": "UPDATE",
  "schema": "public",
  "table": "customers",
  "id": "c1",
  "values": { "organizationId": "o1" }
}
```

`id` is the primary key value, an object keyed by column for a composite key,
or `null` for a table without one. Clients refetch the row through the Data
API, so their column grants and policies decide what they see.

For column values only, the trigger calls the `realtime-tables` module's
`better_supabase.broadcast_topic()` and needs no function of its own. A
placeholder reads its snake_case column (`{organizationId}` reads
`organization_id`) unless `values` names another one; `events` limits the
operations and `event` names the broadcast event:

```ts
quotesTopic.trigger("public.quotes", { event: "quote.changed" });
```

Pass `betterSupabase` to name the table by its app key, check the columns
against the schema and look values up in parent rows:

```ts
customersTopic.trigger("customers", {
  betterSupabase,
  values: { organizationId: "organizationId" },
});
```

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:

```ts
threadTopic.trigger("deliveries", {
  betterSupabase,
  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). A `payload` object replaces the
key payload: each key is a column, a lookup, or `{ sql }`, an expression on
the row `{row}` with `tg_op` for the operation. Handle a custom payload with
its own event schema.

`topics` sends the same change to more topics, each with its own `values`:

```ts
customersTopic.trigger("customers", {
  betterSupabase,
  values: { organizationId: "organizationId" },
  topics: [{ topic: ownerTopic, values: { userId: "ownerId" } }],
});
```

Without `betterSupabase`, values and lookups use database names: `table` or
`schema.table` for `from`, and columns for `via`, `select` and `key`, whose
default is `id`:

```ts
inboxTopic.trigger("public.messages", {
  values: {
    organizationId: {
      from: "public.threads",
      via: "thread_id",
      select: "organization_id",
    },
  },
});
```

Lookups, a custom payload, per-operation events and `topics` need a function
per trigger. It 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.

The function reads each parent row once per changed row, however many
values, payload keys and topics use it, and builds a custom payload once for
all topics. A two-level lookup costs two queries per row.

`when: { changed }` sends an update only when one of the listed columns
differs between the old and the new row. Inserts and deletes always send.
On a trigger for updates alone (`events: ["update"]`) the filter becomes the
trigger's `when` clause, so Postgres skips the function entirely; with more
events the function returns early instead:

```ts
boardTopic.trigger("cards", {
  betterSupabase,
  values: { boardId: "boardId" },
  events: ["update"],
  when: { changed: ["status", "position"] },
});
```

A topic whose value comes out null, such as one whose parent is gone, is
skipped. So is a value with characters a topic doesn't allow (the check
`topic()` makes): the trigger raises a warning that names the table and
column, skips that topic, and the write goes through. The other topics
still get the change.

### Deletes and cascades [#deletes-and-cascades]

A delete reads its values and lookups from the old row, so deleting a child
row on its own reaches the parent's topic. A cascade is different: when a
parent's delete removes its children (`on delete cascade`), the parent row
is already gone when the child's trigger runs, so a lookup through it finds
nothing. The trigger then raises a warning that starts with
`better-supabase: realtime lookup found no row`, names the table and the
missing parent, and skips the topics that need it. The delete goes through.

To still reach subscribers on a cascade, keep the topic value on the row
itself (a denormalized `organization_id`, say), which needs no lookup, or add
a topic trigger on the parent so its own delete is broadcast.

Both kinds of trigger run after the write. Attached as a `before` trigger
they raise an error, since returning `null` there would cancel the write.

> **Warning**
>
>
> `payload: "row"` sends the old and new row in full (`old_record` and
> `record`). That bypasses column grants: every subscriber of the topic sees
> every column, including the ones their role can't select. Use it only for
> tables with nothing to hide, and prefer the key payload.

```ts
auditTopic.trigger("public.audit_notes", { payload: "row" });
```

`triggerSql(betterSupabase, table, options)` from 0.8 still works as an alias
for `trigger(table, { ...options, betterSupabase })` and is deprecated. Its
default payload is now the key payload too.

### Calling broadcast_topic from SQL [#calling-broadcast_topic-from-sql]

A schema file can call `better_supabase.broadcast_topic()` directly. Its
arguments are the topic template, the event (empty for the operation),
`'false'` for a public topic (empty for private), and `'row'` for the whole
row (empty or `'keys'` for the key payload). A placeholder is `{column}`, or
`{name=column}` to name the value in `values` differently from the column:

```sql title="supabase/schemas/030_quotes.sql"
create trigger quotes_broadcast after insert or update or delete on public.quotes
  for each row execute function better_supabase.broadcast_topic(
    'organization:{organizationId=organization_id}:quotes', 'quote.changed'
  );
```

It reads each placeholder from the new row (the old one for a delete). A
placeholder the table has no column for is an error. The key payload reads
only the primary key and the placeholder columns, and the catalog lookup runs
once per statement. It still sends one message per row, so a table that
takes bulk writes should broadcast through `database.realtime.tables` (the
statement-level `track_realtime`) instead.

### From the config [#from-the-config]

`database.realtime.triggers` makes `upgrade` write them to the `realtime.policies`
output after the policies. Each entry names a topic that `from` exports by its
template, and `upgrade` passes the rest to that topic's `trigger()` without
`betterSupabase`, so the config writes the same SQL as the call. An entry
takes `values`, `events`, `event`, `payload`, `topics`, `when` and `functionSchema`,
with database names in values and lookups:

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: {
    realtime: {
      policies: {
        from: ["src/realtime/topics.ts"],
        output: "supabase/schemas/905_topics.sql",
      },
      triggers: [
        {
          topic: "organization:{organizationId}:quotes",
          table: "public.quotes",
          event: "quote.changed",
        },
      ],
    },
  },
  blocks: ["realtime-tables"],
});
```

Lookups, a payload object, per-operation events and `topics` work as they do
on `trigger()`. Here the inbox's messages go to their organization's topic,
found through the parent thread, and to the thread's own topic:

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: {
    realtime: {
      policies: {
        from: ["src/realtime/topics.ts"],
        output: "supabase/schemas/905_topics.sql",
      },
      triggers: [
        {
          topic: "organization:{organizationId}:inbox",
          table: "public.messages",
          values: {
            organizationId: {
              from: "public.threads",
              via: "thread_id",
              select: "organization_id",
            },
          },
          event: { insert: "created", update: "updated", delete: "deleted" },
          payload: {
            operation: { sql: "lower(tg_op)" },
            thread_id: "thread_id",
            record_id: "id",
          },
          topics: [{ topic: "thread:{threadId}" }],
        },
      ],
    },
  },
});
```

`topics` names each extra topic by its template, which a module in `from`
also exports, and takes its own `values`.

`upgrade` refuses `database.realtime.triggers` without `realtime.policies`, or for a
template no module in `from` exports. A trigger that calls
`better_supabase.broadcast_topic()` also needs `realtime-tables` in `blocks`;
one with a function of its own does not.

### On the client [#on-the-client]

`rowKeys` reads the key payload, or returns `null` for other tables:

```ts
const change = rowKeys(betterSupabase, "customers", message);
// { operation: 'UPDATE', schema: 'public', table: 'customers', id: 'c1', values: { organizationId: 'o1' } }
```

`rowChange` reads the `payload: "row"` shape and returns app-cased rows:

```ts
const change = rowChange(betterSupabase, "customers", message);
// { operation: 'UPDATE', table: 'customers', record: Customer | null, oldRecord: Partial<Customer> | null }
```

Doctor reports trigger functions that still send whole rows without the
`payload: "row"` marker ([BS337](/docs/cli/doctor#bs337)).

## 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`.

### Fan-out [#fan-out]

`topic.fanOut(client, options)` sends one event to a topic per subscriber,
after a `SubscriberPolicy` has decided who may read it:

```ts
const result = await inbox
  .fanOut(admin, {
    subscribers: members.map((id) => ({ principal: id })),
    values: (subscriber) => ({ userId: subscriber.principal }),
    event: "created",
    payload: { id, title },
    source: {
      type: "post.created",
      tenant,
      actorId,
      payload: { id },
      subject: `post/${id}`,
    },
    server: { authorization },
  })
  .orThrow();
// { sent, skipped, failed }
```

The policy is `options.policy`, then the topic's `subscribers` option, then
the server's `authorization.delivery.subscribers`
([Runtime hooks](/docs/extending/authorization-providers#runtime-hooks)).
Without any, every subscriber gets the event, except under a server with
hooks: there an event with a `subject` goes to no one and returns a
`forbidden` error with the hint `REALTIME_SUBSCRIBER_POLICY_REQUIRED`. Pass
`policy: subjectReaders(sqlTransport(postgres.admin))` to send it to the
members who may read the subject. The policy receives `source`
and the subscribers, and can narrow the list but never widen it: a
subscriber it returns that wasn't passed in is ignored. Subscribers it leaves
out come back in `skipped`. A policy that throws sends to no one and returns
a `forbidden` error with the hint `REALTIME_SUBSCRIBER_POLICY_FAILED`. Sends
run one at a time; a failed send lands in `failed` with its error and the
rest continue.

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

### Who has a record open [#who-has-a-record-open]

With the [subjects](/docs/blocks/sql#subject-registry) module, every
registered record has a presence topic, `presence:<type>:<id>`, and the
module's policies let anyone who can read the record join it. You define no
topic and write no policy: `subjectPresence` is the topic, and
`subjectPresenceValues(subject)` turns a record into its values (`null` pauses
the join).

```tsx title="features/deals/components/deal-viewers.tsx"
"use client";

import { usePresence } from "better-supabase/react";
import {
  subjectPresence,
  subjectPresenceValues,
} from "better-supabase/realtime";

export function DealViewers({ dealId, me }: Props) {
  const { members } = usePresence(
    subjectPresence,
    subjectPresenceValues({ type: "deal", id: dealId }),
    { state: { userId: me.id, name: me.name, avatarUrl: me.avatarUrl } },
  );

  return <AvatarStack people={members.map((member) => member.state)} />;
}
```

A channel passes its `conversation` subject, a comment thread the record it
belongs to, and a [document](/docs/blocks/documents) its own subject, so one
helper covers all three. `subjectPresenceTopic(schema)` validates the
tracked states. The states come from the clients: show them as hints and
never decide access on them. A record id the subject's id type can't cast is
refused, not an error.

## 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 [`trigger`](#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({
  database: {
    buckets: {
      customerLogos: {
        path: "{organizationId}/{customerId}/logo/{version}.webp",
        policy: "tenant",
      },
    },
  },
  gen: {
    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` guard](/docs/repository/guards) 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 `memberships` 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/repository/permissions) 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",
});
```

Both come with three [image variants](#image-transformations), `sm`, `md`
and `lg`. Avatars are center-cropped squares of 48, 96 and 256 pixels;
logos fit inside squares of 64, 128 and 256 without cropping. Pass
`variants` to replace them.

`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();
```

`connect` takes a supabase-js client, its `storage`, or any other
`StorageApi`: an object whose `from(bucket)` returns the file methods of
`supabase.storage.from(bucket)` (`StorageBucketApi`, typed structurally), so
another implementation can stand in for supabase-js. `db.$storage` is
Storage as the same caller as the `Db`, so `logos.connect(ctx.db.$storage)`
also works in a [support session](/docs/auth/impersonation#storage-in-a-support-session). `apply(client)` still
needs a supabase-js client with the secret key (`StorageAdminClient`), since
it calls the bucket admin API.

### Buckets on `db` [#buckets-on-db]

Register the buckets once on the definition, and `db.$buckets.<name>` is the
bucket connected to `db.$storage`. It runs as the same caller as the `Db`,
keeps the tenant guard, and is created on first read and reused for that
`Db`. The paths stay typed by each bucket's templates.

```ts
import { defineSupabase } from "better-supabase";
import { avatars, logos } from "./buckets";

export const betterSupabase = defineSupabase(schema).buckets({
  logos,
  avatars,
});

const db = betterSupabase.connect(supabase);
const url = await db.$buckets.logos
  .signedUrl({ organizationId, customerId, version }, { ttl: "hour" })
  .orThrow();
```

A connection without storage (a bare `Executor`) throws when `$buckets` is
read, as `db.$storage` does; pass `connect(executor, context, { storage })`.
`db.$buckets` is typed on the `Db` that `betterSupabase.connect()` returns, on
`$with()` and `$withoutPlugins()` results, and on the `db`, `sql` and `admin()`
repositories of `createServer(betterSupabase)`. The framework adapters
(`next`, `hono`, `orpc`, `edge`, `mcp` and the rest) carry it too, so
`ctx.db.$buckets.logos` works in handlers, actions and routes. On a server it
uses the caller's Storage client, built on first read.

`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 memberships extension 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]

[Storage image transformations](https://supabase.com/docs/guides/storage/serving/image-transformations)
resize a stored image when it is served, so you can crop, show and download
an image at any size without storing copies. Name the sizes your app uses
as `variants` on the bucket:

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

export const photos = defineBucket({
  id: "photos",
  path: "{userId}/{name}.{ext}",
  public: true,
  variants: {
    thumb: { width: 160, height: 160, resize: "cover" },
    card: { width: 640, height: 360, resize: "cover", quality: 80 },
    full: { width: 1600, height: 1200, resize: "contain" },
  },
});
```

Each variant takes `width`, `height`, `resize` and `quality`. With both sides
set, `cover` crops the center of the image to that shape, `contain` fits the
whole image inside it, and `fill` stretches it. `defineBucket` refuses a side
outside 1 to 2500 or a quality outside 20 to 100.

`renderUrl(target, transform)` returns the URL for a variant name or an
inline transform, and the variant names are typed:

```ts
const card = await db.$buckets.photos.renderUrl(target, "card");
const custom = await db.$buckets.photos.renderUrl(target, { width: 300 });
```

For public buckets it builds `/storage/v1/render/image/public/...` without a
request; `publicUrl(target, { transform: "card" })` returns the same URL as a
`Result`. 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.

To download a resized copy instead of showing it, pass `download`, with
`true` or a file name:

```ts
await db.$buckets.photos.renderUrl(target, "full", {
  download: "photo-1600.jpg",
});
```

#### Show an image in React [#show-an-image-in-react]

`useImage(bucket, target, variant)` from `better-supabase/react` returns the
`src`, a `srcSet` at 1x and 2x, and the variant's `width` and `height` for an
`<img>`. For a public bucket it builds the URLs while rendering, without a
request. For a private bucket it signs each density and signs again before
the URLs expire; `loading` and `error` report that request.

```tsx title="src/components/avatar.tsx"
"use client";

import { useImage } from "better-supabase/react";
import { useMemo } from "react";

import { useSupabase } from "@/lib/hooks";
import { avatars } from "@/lib/storage";

export function Avatar({ path }: { path: string | null }) {
  const supabase = useSupabase();
  const storage = useMemo(() => avatars.connect(supabase), [supabase]);
  const { src, srcSet, width, height } = useImage(storage, path, "md");

  return src ? (
    <img alt="" src={src} srcSet={srcSet} width={width} height={height} />
  ) : null;
}
```

The target is a stored path or the path values. Pass `null` to render nothing
until the path is known, and `{ densities: [1, 2, 3] }` for more densities.
Server Components await `renderUrl()` instead.

`storageImageSrcSet({ url })` builds the same `srcset` for a public URL you
already have, outside React:

```ts
import { storageImageSrcSet } from "better-supabase/storage";

const srcSet = storageImageSrcSet({ url: SUPABASE_URL });
srcSet(avatarUrl, { width: 96, height: 96 });
```

#### Try a transform [#try-a-transform]

Paste the URL of an image in one of your public buckets, pick a size, and
copy the URL or the `variants` entry:

<ImageTransformPreview />

#### Limits [#limits]

Storage has no region crop: `cover` always crops around the center. Sides
stop at 2500 pixels and quality runs from 20 to 100. Transformations need a
paid plan, and a local stack needs them turned on in `supabase/config.toml`:

```toml title="supabase/config.toml"
[storage.image_transformation]
enabled = true
```

With image transformations off, 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` and `quality`
(20 to 100), and sets `resize` when you pass one. A `src` from a variant
keeps its shape: when it already carries a `width` and `height`, the height
scales with each width Next.js asks for, so a 96 by 96 `cover` crop stays
square in every `srcset` entry. When a side would pass 2500, both sides
shrink by the same factor. 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();
// server, once the browser is done
await logos.connect(supabase).claimReserved(reservation).orThrow();
```

`claimReserved(reservation)` finishes the upload. With supabase-js it
succeeds without a request, because Storage set the owner from the caller's
token. In a [support session](/docs/auth/impersonation#storage-in-a-support-session)
it makes the target the owner of the uploaded object and records the
session, so call it before you record the file wherever the code may run in
one.

### 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
          .list({ 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.

# 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 `memberships` and `softDelete` scope them
the same way as `list`.

## 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
  .list({
    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.

# 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`, `ieq`, `match`, `imatch`, `contains`, `startsWith`, `endsWith`, `search` |
| Arrays               | `has`, `hasSome`, `hasEvery`, `containedBy`                                               |
| jsonb                | `contains` (`@>`), `path` with the operators below                                        |

`ieq`, `contains`, `startsWith` and `endsWith` escape `%`, `_` and `\` in
your input, so the text matches literally. `ieq` (from 0.11) is
case-insensitive equality, such as an email or a slug typed in any case.
`contains` is case-insensitive too (both send `ilike`); `startsWith` and
`endsWith` send `like` and are case-sensitive. For a case-insensitive prefix
or suffix, use `ilike` with an escaped pattern:

```ts
where: {
  name: {
    ilike: `${escapeLike(prefix)}%`;
  }
}
```

`escapeLike` is exported from `better-supabase` and escapes the same three
characters, for input you put in a `like` or `ilike` pattern yourself. The
[`require-escape-like`](/docs/repository/lint#require-escape-like) lint rule
flags a variable passed to `like` or `ilike` without it.

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`, `ilike`, `ieq`, `match` and `imatch`:

```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.list({ 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 `update({ where })`, `delete({ where })` 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.list({ 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.list({ 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]

`update({ where })` and `delete({ where })` cannot filter through relations over PostgREST,
and return an `invalid_request` error. Use the direct Postgres executor for
those, or select the ids first.

# Query guards

> Runtime checks that catch unbounded reads, missing organizations, sensitive columns and admin keys in the browser.

Source: https://bettersupabase.com/docs/repository/guards

`guards()` 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 memberships and
trash extensions add their filters. It ships in `better-supabase/lint`, next
to the [lint rules](/docs/repository/lint) that catch the same mistakes in
the editor.

```ts
import { guards, recommendedGuards } from "better-supabase/lint";

export const betterSupabase = defineSupabase(schema).use(
  guards({ rules: recommendedGuards() }),
);
```

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                                                                                                                         |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `safeGuards()`        | Data exposure and correctness as errors: `noAdminInBrowser`, `noDeleteWithoutWhere`, `noSensitiveSelect`, `requireTenantContext` |
| `recommendedGuards()` | `safeGuards()` plus the performance rules and `storagePathColumns` as warnings (the default)                                     |
| `strictGuards()`      | Everything as an error, plus `requireMaxAffected`                                                                                |

Spread a preset to change single rules:

```ts
guards({
  rules: {
    ...recommendedGuards(),
    maxLimit: ["error", 200],
    noUnboundedList: "off",
  },
});
```

## Rules [#rules]

| Rule                      | Flags                                                                                                                                                                                                             | Option                                                   |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `noUnboundedList`         | `list` without `limit`                                                                                                                                                                                            |                                                          |
| `maxLimit`                | `limit` above the maximum                                                                                                                                                                                         | maximum, default `1000`                                  |
| `requireOrderByForCursor` | `offset`, or `limit` above 1, without `orderBy`: pages overlap or skip rows. `list` 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 `auth.organization.claim` (`'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                                                                                                                  |                                                          |
| `noDeleteWithoutWhere`    | A delete without `where`                                                                                                                                                                                          |                                                          |
| `requireMaxAffected`      | `update` or `delete` by `{ where }` 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 |                                                          |

`noUnboundedList` skips `aggregate()`, and `maxLimit` doesn't count the
extra row `page()` reads to know whether there is a next page.
`requireTenantContext` reads the same claim paths as the
[memberships](/docs/blocks/memberships) extension, so an organization in
`app_metadata` counts. `requireMaxAffected` is only in `strictGuards()`; 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.list({ 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 a `delete({ where })` that filters nothing on
its own, so `noDeleteWithoutWhere` only catches deletes built by blocks and
extensions. The `no-empty-where` [lint rule](/docs/repository/lint) 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
guards({
  rules: recommendedGuards(),
  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 guards, organization scoping, trash 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.

# 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
  .get(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.list({
  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
  .list({
    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).

`_count` and the measures belong inside `include`. Passed next to `select`,
as in `list({ _count: { notes: true } })`, they are a type error and an
`invalid_request` error at runtime, never silently ignored. To count or
aggregate the table itself, use `count()` or `aggregate()`.

# 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                                                                  |
| ------------------- | ------------------------------------------------------------------------ |
| `list(args?)`       | `Row[]`                                                                  |
| `page(args)`        | A page; see [pagination](/docs/repository/pagination)                    |
| `get(key, args?)`   | `Row`, or a `not_found` error; `Row \| null` with `optional: true`       |
| `count({ where? })` | `number` (a `HEAD` request)                                              |
| `aggregate(args)`   | Sums, averages and groups; see [aggregates](/docs/repository/aggregates) |

`get` takes the primary key, an object with any unique key (see
[unique keys](/docs/repository/unique-and-errors)), or `{ where }` 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. With a filter it asks for two rows and
returns a `multiple_rows` error (status 409) when it gets both. Pass
`optional: true` to get `null` instead of `not_found`, like supabase-js
`maybeSingle()`. For "the first row in some order", use `list` with
`limit: 1`. For "is there a row", use `count` and compare with zero.

Every read takes `select`, `where`, `include`, `orderBy`, `limit`, `offset`
and `signal`; `get` takes them as its second argument. `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
  .get(id, {
    select: ["name"],
    include: { notes: { select: ["body"], limit: 3 } },
  })
  .orThrow();
// { name: string; notes: { body: string }[] }
```

Composite keys take an object: `db.customerTags.get({ 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.list({
  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.$storage` is Storage as the same caller: the `storage` option of
  `connect(client, context, { storage })`, or the client's `storage`. Pass it
  to [`bucket.connect()`](/docs/platform/storage#client) and to blocks that
  take `storage`, so Storage runs as whoever the `Db` runs as. It throws on a
  connection with neither, such as a bare executor.
- `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. An argument with a default is optional in the generated
  types and accepts `null` and `undefined`; `$rpc` leaves an `undefined`
  argument out of the call, so the function default applies.
- `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.

# Lint rules

> Catch unbounded reads and unscoped deletes in the editor, with ESLint or oxlint.

Source: https://bettersupabase.com/docs/repository/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-empty-where": "error",
    "better-supabase/no-unbounded-list": "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-list [#no-unbounded-list]

`list()` or `list({ ... })` without `limit`. Recommended: `warn`.

### no-empty-where [#no-empty-where]

`update({ where: {} }, patch)` or `delete({ where: {} })`, a filter that
matches every row. The repository refuses this at runtime too, unless the call
passes `allowAll: true`; the rule catches it before it ships. Recommended:
`error`.

### max-limit [#max-limit]

A literal `limit` above `max` (default 1000) in `list` or `page`.
Recommended: `warn`.

### require-order-by [#require-order-by]

`list` with `offset` but no `orderBy`, which returns pages in no stable
order. Recommended: `warn`.

### require-max-affected [#require-max-affected]

`update({ where }, patch)` or `delete({ where })` 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>.list` 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-list`.

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

### require-escape-like [#require-escape-like]

A `where` value for `like` or `ilike` that comes from a variable, a property,
or a template or `+` concatenation, without `escapeLike(...)` around it. A
`%` or `_` in user input then acts as a wildcard, so `ilike: email` matches
other users' addresses. Use `ieq` for case-insensitive equality, `contains`
or `startsWith` for a substring or prefix, or wrap the value in
`escapeLike()` when you build the pattern yourself. String literals pass.
Recommended: `warn`. From 0.11.

```ts
where: {
  email: {
    ieq: email;
  }
}
where: {
  name: {
    ilike: `${escapeLike(prefix)}%`;
  }
}
```

## 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 [`guards()`](/docs/repository/guards) check what the linter can't.

# Pagination

> Page numbers or offset windows with totals, or keyset cursors for infinite lists.

Source: https://bettersupabase.com/docs/repository/pagination

`page` takes a page number, 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
  .page({ page: 2, size: 25, count: "exact", orderBy: { name: "asc" } })
  .orThrow();
// page: { number: 2, size: 25, total: 81, pages: 4, hasMore: true }
```

`withCount: true` is shorthand for `count: "exact"`. Without a count, `total` and
`pages` are `null`; with one, their type is `number`. `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
  .page({ 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
  .page({ after: null, size: 25, orderBy: { name: "asc" } })
  .orThrow();

const next = await db.customers
  .page({ 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.

Pass `withCount: true` to get `total`, the number of rows that match `where`
(the cursor is ignored). The first page reads it from the page request; each
later page sends one more request that only counts. Without `withCount`, a
cursor page has no `total`.

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 `list` 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, `page` for everything else, or
  an [aggregate](/docs/repository/aggregates) when you only need a number.

## Default order [#default-order]

`list` 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
  .page({
    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/repository/lint#unbounded-read) rule flags
`list` without `limit` on the tables `gen` marked as large in
`supabase/snapshot.json`, before they reach production.

# 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/repository/permissions

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 `blocks.permissions`, 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 add permissions
```

```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 `blocks.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({
  blocks: {
    permissions: {},
    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                                                                                                                                                                               |
| `impersonation` | `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                                                                                                                                                |

## Permissions in the app [#permissions-in-the-app]

`my_permissions(tenant, keys)` returns the keys the caller holds in a tenant.
With `keys`, it returns the ones `can()` grants, so every model answers,
including a provider and delegated tokens. Without them, it returns the keys
the model lists for the member, with wildcards as granted. Use it to hide
buttons and menu items; the SQL functions and policies still check every
write.

`createPermissions` from `better-supabase/blocks/permissions` calls it. `can(key)` and
`flags(map)` are typed by the keys you pass, such as the generated
`permissions` constant from `blocks.generated.ts`:

```ts
import { createBlockClient } from "better-supabase/blocks";
import {
  permissionsBlock,
  permissionFlags,
} from "better-supabase/blocks/permissions";

import { permissions } from "@/lib/supabase/blocks.generated";

const access = createBlockClient(db, permissionsBlock);
const allowed = await access.can(permissions.invoices.write).orThrow();
const { canEdit, canDelete } = await access
  .flags({
    edit: permissions.invoices.write,
    delete: permissions.invoices.delete,
  })
  .orThrow();

const set = await access.permissions([permissions.invoices.write]).orThrow();
permissionFlags(set, { edit: permissions.invoices.write }); // { canEdit: true }
```

`permissions(keys?)` returns a `PermissionSet`, `{ tenant, keys }`, that
`can(set, key)` and `permissionFlags(set, map)` read without another call,
for a payload query that returns rows together with what the caller may do
with them. A call without a tenant fails with the hint `TENANT_REQUIRED`.

The React hooks are a registry item: `npx shadcn add
@better-supabase/use-permissions` writes `hooks/use-permissions.ts` into the
app. `usePermission(key)` returns `undefined` while it loads, then true
or false, and `<Can>` renders its children only for a caller who holds the
key. The hooks of one render share one `my_permissions` call per tenant, so a
table whose rows each check a key costs one round trip. They read the
client's tenant unless you pass `tenant`:

```tsx title="components/task-import-action.tsx"
"use client";

import { Can } from "@/hooks/use-permissions";

import { permissions } from "@/lib/supabase/blocks.generated";

export function TaskImportAction({ projectId }: { projectId: string }) {
  return (
    <Can permission={permissions.imports.manage}>
      <TaskImportDialog projectId={projectId} />
    </Can>
  );
}
```

`createPermissionsController` is the same logic without React. Vue, Svelte
and Solid run it with `useBlockController` from `better-supabase/vue`,
`/svelte` and `/solid` (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)).
Those bindings have no `<Can>` component; render
on `can(state.permissions, key)` with `v-if`, `{#if}` or `<Show>`. Wrap the
client in `batchPermissions` so the components of one render share a call.
The [Can recipe](/docs/registry/ui-recipes/can) shows it in the Next.js example.

```vue title="ImportButton.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import { rpcTransport } from "better-supabase/blocks";
import {
  batchPermissions,
  can,
  createPermissions,
  createPermissionsController,
} from "better-supabase/blocks/permissions";

const props = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(createPermissionsController, {
  client: batchPermissions(
    createPermissions({ transport: rpcTransport(supabase), schema: "api" }),
  ),
  keys: ["imports.manage"],
  tenant: props.organizationId,
});
</script>

<template>
  <button v-if="can(state.permissions, 'imports.manage')">Import</button>
</template>
```

```svelte title="ImportButton.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import { rpcTransport } from "better-supabase/blocks";
  import {
    batchPermissions,
    can,
    createPermissions,
    createPermissionsController,
  } from "better-supabase/blocks/permissions";

  let { organizationId }: { organizationId: string } = $props();
  const { supabase } = getBetterSupabase();
  const access = useBlockController(createPermissionsController, {
    client: batchPermissions(createPermissions({ transport: rpcTransport(supabase), schema: "api" })),
    keys: ["imports.manage"],
    tenant: organizationId,
  });
</script>

{#if can($access.permissions, "imports.manage")}
  <button>Import</button>
{/if}
```

```tsx title="ImportButton.tsx"
import { Show } from "solid-js";
import { useBlockController, useSupabase } from "better-supabase/solid";
import { rpcTransport } from "better-supabase/blocks";
import {
  batchPermissions,
  can,
  createPermissions,
  createPermissionsController,
} from "better-supabase/blocks/permissions";

export function ImportButton(props: { organizationId: string }) {
  const supabase = useSupabase();
  const { state } = useBlockController(createPermissionsController, {
    client: batchPermissions(
      createPermissions({ transport: rpcTransport(supabase), schema: "api" }),
    ),
    keys: ["imports.manage"],
    tenant: props.organizationId,
  });

  return (
    <Show when={can(state().permissions, "imports.manage")}>
      <button>Import</button>
    </Show>
  );
}
```

With `mode: "custom"`, write `my_permissions(tenant, keys text[] default null)
returns text[]` next to your contract functions, and expose it through your
API schema; the Next.js example's fixture has one in
`supabase/schemas/better_supabase/functions/my_permissions.sql`.

## Models [#models]

`blocks.permissions.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({
  blocks: {
    permissions: {
      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. The block creates the tables
your schema doesn't have, and runs over the ones it does; point it at yours:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    memberships: {
      tables: { memberships: "public.organization_members" },
      columns: {
        memberships: {
          tenant: "organization_id",
          role: "role_id",
          lastUsedAt: null,
        },
      },
    },
    permissions: {
      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`.

`provider` is the default model whenever the config sets `authorization`, so
every module check that goes through `can()` or `tenant_ids_with()` answers
from the provider. Set another `model` to keep the built-in roles; doctor then
reports policies and functions that call the access contract
([BS414](/docs/cli/doctor#bs414)), because they answer from roles the provider
never sees.

```ts title="better-supabase.config.ts"
export default defineConfig({
  auth: { authorization: authorizationProvider() },
  blocks: { organizations: {}, invitations: {}, permissions: {} },
});
```

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

The tenant is one level. For an organization with environments under it,
the provider declares both as scopes and picks the tenant level with
`tenantScope`; see [nested tenancy](/docs/concepts/nested-tenancy) and
[the provider scopes](/docs/extending/authorization-providers#nested-tenancy).

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 `add` and [doctor](/docs/cli/doctor#bs411)
stop. Map an action to another key with `blocks.<module>.permissions`.

`can_assign()` calls the provider's `canAssign`, and
`blocks.permissions.functions.canAssign` replaces it. Without either, only the
service role assigns roles, unless the `memberships` 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`;
`blocks.permissions.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 `blocks.permissions.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({
  blocks: {
    permissions: {
      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 blocks view --sql
access` lists their signatures, and [doctor](/docs/cli/doctor#bs307) checks
them.

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

A memberships table of your own often stores a foreign key to a roles table
instead of the role name. `blocks.memberships.options.roleThrough` names that
table, its key column and the column with the role name:

```ts title="better-supabase.config.ts"
blocks: {
  memberships: {
    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 memberships table the block creates stores
names, so the option is only for a memberships table of your own.

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
`blocks.permissions.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 `bsb_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, `add` reads the setting from the provider's
`roleSources`: when one of them is a memberships table of your own, 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.
`blocks.memberships.options.sameTenant` lists the references that must stay
inside the row's tenant, and the tenant module puts a
`bsb_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"
blocks: {
  memberships: {
    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 ownership,
over the block's tables and yours; in custom mode the module writes no SQL.

`upgrade` can derive the entries from the foreign keys in the schema files
instead. With `sameTenant: { auto: true }`, every single-column foreign key
from a table with the tenant column to another table with it gets an entry,
unless the key already includes the tenant column. The scan reads
`references` on a column, `foreign key (...) references` table constraints
and `alter table ... add constraint ... foreign key`; a key without a column
list references the table's declared primary key. Keys over more than one
column, and references that need `match`, `through` or `where`, go in
`entries`. A listed entry wins over the derived one for the same table and
column:

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

A derived entry renders the same trigger as the same entry listed by hand.
`doctor` reports each such foreign key that has no `bsb_same_tenant_<column>`
trigger ([BS340](/docs/cli/doctor#bs340)).

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.

## Delegated tokens [#delegated-tokens]

A delegated token is one an OAuth client or agent acts with: it carries a
`client_id`, or an RFC 8693 `act` chain that is not a support or
impersonation session. The access functions narrow it in every model:
`member_can`, `member_can_while_deleting`, `platform_can` and
`tenant_ids_with` ask `better_supabase.cap_allows(permission)`, which is true
only when one of the token's scopes maps to the permission. The user's own
roles still apply, so the token gets the overlap.

Map scopes to permission keys in `authorization.delegation.scopes` (the
provider's map) or `blocks.permissions.scopes`, which is merged over it. A
scope without an entry grants the key with its own name, and `["*"]` grants
every key.

```ts title="better-supabase.config.ts"
blocks: {
  permissions: {
    scopes: {
      "posts:write": ["posts.*"],
      "mcp:admin": ["*"],
    },
  },
},
```

`cap_allows` also reads the `bsb_permission_cap` claim that
`forContext(context, { permissions })` sets, and refuses every key outside
it. A check about another user (an inviter, the holders of a permission) is
not narrowed by the caller's token, and the service role is never narrowed.
`token_scope_allows(scope)` in the `created-by` module stays available for a
policy that checks the literal scope.

A provider that maps scopes at runtime (per client, or from RFC 9396
`authorization_details`) sets the `delegation.permissions` hook on its
[authorization hooks](/docs/extending/authorization-providers#runtime-hooks).
`forContext` calls it for a delegated context and caps the work to the keys
it returns, narrowed by its own `permissions`. A throw grants nothing.

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

`blocks.permissions.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
blocks: {
  permissions: {
    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 `memberships` 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, `add` and `upgrade` read both from the
provider's `suspension`. A subject set in
`blocks.permissions.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.

Every model also renders `member_can_while_deleting(user, tenant, permission)`,
the same check as `member_can` without the disabled-tenant test. The
data-lifecycle module calls it so a member can cancel a pending deletion while
the tenant is disabled.

## 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. On a memberships
table of your own the block adds it when the table lacks it; map
`disabledAt` to the column that holds it, or to `null` to go without
suspensions:

```ts
blocks: {
  memberships: {
    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 [memberships extension](/docs/blocks/memberships) need
the tenant a request works in. `blocks.permissions.activeTenant` says where it comes
from.

| Source                   | Reads                                                                             |
| ------------------------ | --------------------------------------------------------------------------------- |
| `'claim'`                | the `auth.organization.claim` 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 memberships extension, the
`better_supabase.tenant` setting for `ctx.sql`, and the `x-bsb-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 bsb = 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
memberships extension 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 bsb.context({ tenant: organizationId });
  const customers = await db.customers.list().orThrow();
  return <CustomerList customers={customers} />;
}
```

`bsb.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 bsb.cached({ tenant: organizationId });
  return db.customers.list().orThrow();
}
```

Actions take it from their validated input with
`bsb.action({ input, tenant: (input) => input.organizationId }, fn)`.
`bsb.route()` and `bsb.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:
`bsb.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.

### Removed members and the tenant claim [#removed-members-and-the-tenant-claim]

The tenant claim lives in the access token, and a token stays valid until it
expires (`jwt_expiry` in `supabase/config.toml`: 3600 seconds by default and
in the fixture). What a removed member can still do depends on which check
reads the claim:

| Check                                                                                                                                                                      | After the membership row is deleted                                                                                                         |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `current_tenant_id()` from the `memberships` module (on its own table or yours)                                                                                            | Returns no tenant on the next call: it returns the claimed tenant only while the caller is still a member, for every `activeTenant` source. |
| `tenant_ids_with()` and `can()`                                                                                                                                            | Grant nothing on the next call: they read the membership table.                                                                             |
| A `current_tenant_id()` you write yourself in custom mode that returns the claim, like the fixture's `supabase/schemas/better_supabase/functions/current_tenant_id.sql`    | Keeps returning the tenant until the token expires.                                                                                         |
| The `bsb:t:*` receive policy of [`realtime-tables`](/docs/platform/realtime), a topic's default tenant check, and your own policies that read `auth.jwt() ->> 'tenant_id'` | Keep passing until the token expires.                                                                                                       |

A claim in `app_metadata` also survives a refresh. With the `'claim'`
source, the `memberships` module (on its own table or yours) clears `app_metadata.tenant_id` when the
membership it points at is deleted, so the next token carries no tenant. In
custom mode nothing clears it, and a removed member keeps the claim on every
new token until you clear it.

To close that window sooner:

- Lower `jwt_expiry` (for example to 600). Clients refresh more often, and a
  refresh drops the tenant once the claim's source no longer has it (an
  access token hook that reads the membership table, or the cleared
  `app_metadata`).
- End the removed user's sessions so they get no new token: call
  `supabase.auth.admin.signOut(jwt, "global")` with one of their access
  tokens, or delete their rows in `auth.sessions` from a security definer
  function. The current access token still works until it expires, but
  `bsb.proxy({ endedSession })` turns away their page loads right away (see
  [Ended sessions](/docs/auth/sessions#ended-sessions-in-your-own-proxy)).
- Prefer the module's `current_tenant_id()` or `tenant_ids_with()` over the
  raw claim in your own policies, since both read the membership table.

## 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 `memberships` 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 $$;
```

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

# 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.list({ 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.list({
      select: ["body", "createdAt"],
      orderBy: { createdAt: "desc" },
      limit: 1,
    }),
  }),
);
```

List the module in the config and add the `read-sets` SQL module:

```ts title="better-supabase.config.ts"
export default defineConfig({
  gen: { readSets: ["src/lib/read-sets.ts"] },
  blocks: ["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 }[] }
```

| 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 `memberships` 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: `list`, `get`, `count`, `aggregate` or an offset
  `page`.
- 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 bsb.cached();
  bsb.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 upgrade`), then `supabase db schema declarative sync`.

# Record bindings

> Load the comments, files and tags of one record page in parallel, each settled, hidden when the caller can't read it, and kept current over one realtime subscription.

Source: https://bettersupabase.com/docs/repository/record-bindings

A record page, such as a customer, shows parts that different blocks own: a
comment thread, its files, its tags. Each block declares its part as a
`record` binding on its manifest, named by the block's key in your config, and
`better-supabase/records` loads the bindings for one `RecordRef`. Every
binding settles on its own, so a failed or forbidden binding never takes the
page down:

| `kind`   | Means                                                      |
| -------- | ---------------------------------------------------------- |
| `ok`     | `data` holds the binding's rows                            |
| `error`  | `error` holds a plain `DbError`; show it with a Retry      |
| `hidden` | The caller can't read the binding, and no query ran for it |

| Block           | Rows                                  | Writes                | Optimistic reducer      |
| --------------- | ------------------------------------- | --------------------- | ----------------------- |
| `comments`      | the thread                            | `add`, `remove`       | `commentsOptimistic`    |
| `attachments`   | the files, newest first               | `remove`              | `attachmentsOptimistic` |
| `tags`          | the record's tags                     | `apply`, `remove`     | `tagsOptimistic`        |
| `share-links`   | the record's share links              | `create`, `revoke`    | none                    |
| `relations`     | links to and from the record          | `link`, `unlink`      | none                    |
| `versions`      | the versions, newest first            | `snapshot`, `restore` | none                    |
| `sla`           | the record's clocks                   | `start`               | none                    |
| `ledger`        | the record's accounts                 | none                  | none                    |
| `assets`        | the assets the record holds           | none                  | none                    |
| `guests`        | the guests who reach the record       | `grant`, `revoke`     | none                    |
| `passes`        | the passes issued for the record      | `issue`, `revoke`     | none                    |
| `billable-time` | the record's unbilled time            | `setRate`, `invoice`  | none                    |
| `contracts`     | the contracts with the record         | none                  | none                    |
| `dispatch`      | the work orders opened for the record | `create`, `cancel`    | none                    |
| `shipments`     | the shipments sent for the record     | `create`, `cancel`    | none                    |

The binding takes the block's key in camel case: `shareLinks` for
`share-links`, and `tickets` for a second `tasks` instance installed as
`tickets`. A write takes the record's id and the write's input, such as
`{ id, input: { kind: "relates", target } }` for `relations.link`.

## Generated record types [#generated-record-types]

With a block that has a `record` binding installed, [`gen`](/docs/cli/gen)
writes one file per record type in `records/` next to the main module. A
block binds to the record types its `on` names, or to every type in
`config.records` when it names none; `versions` binds to the types that set
`versions`. Set `gen.recordTypes: false` in the config to write none. `gen`
writes no UI: the cards come from the [UI recipes](/docs/registry/ui-recipes).

| File                | Exports                                 |
| ------------------- | --------------------------------------- |
| `records/<type>.ts` | `<type>Record`, from `defineRecordType` |
| `records/index.ts`  | every record type                       |

The files carry the `@generated` header; change the config and run `gen`
again instead of editing them. The record type is plain data, so the same
file serves the server, React and TanStack Query.

### In a Server Component [#in-a-server-component]

`bsb.record(customerRecord)` returns `load`, `timeline`, `actions` and `tags`
for the record type. `load(id, options?)` loads the bindings and the caller's
`can` flags as plain data (Temporal values become ISO strings), so the view
passes straight to a Client Component. It reads the request, so call it
outside `"use cache"`:

```ts title="src/features/customers/customer-record.ts"
import "server-only";

import { customerRecord } from "@/lib/supabase/records/customer";
import { bsb } from "@/lib/supabase/server";

export const customerPage = bsb.record(customerRecord);
```

```tsx title="src/app/customers/[id]/page.tsx"
import { customerPage } from "@/features/customers/customer-record";

export default async function CustomerPage({
  params,
}: PageProps<"/customers/[id]">) {
  const { id } = await params;
  const view = await customerPage.load(id, {
    bindings: ["comments", "versions"],
    options: { versions: { pageSize: 20 } },
  });

  return (
    <CustomerRecordProvider id={id} initial={view} topic={`customer:${id}`}>
      <CommentsCard />
    </CustomerRecordProvider>
  );
}
```

`customerPage.actions.<binding>.<write>` is a Server Action: export the ones
your UI calls from a `"use server"` file. Each builds the block's client over
the caller's `Db` and its `supabase.storage`, runs the write, and drops the
binding's [cache tags](/docs/frameworks/next#record-bindings). RLS and the
block's SQL decide what the caller may write; check `can` to hide a button.

```ts title="src/features/customers/customer-record-actions.ts"
"use server";

import { customerPage } from "./customer-record";

export const addCustomerComment = customerPage.actions.comments.add;
```

### In a Client Component [#in-a-client-component]

`createRecordProvider(customerRecord)` returns `RecordProvider` and
`useRecord`. `useRecord()` returns `{ state, actions, meta }`: the settled
bindings and `can` flags, `refresh(names?)`, and `write(binding, write,
input)`, which runs the Server Action you pass in `writes` and loads that
binding again:

```tsx
"use client";

import { createRecordProvider } from "better-supabase/react";

import { customerRecord } from "@/lib/supabase/records/customer";

export const {
  RecordProvider: CustomerRecordProvider,
  useRecord: useCustomerRecord,
} = createRecordProvider(customerRecord);

const { state, actions } = useCustomerRecord();
const comments = state.bindings.comments;

await actions.write("comments", "add", { body });
```

Without `writes` the provider runs the writes on the client, over the
supabase-js client of [`BetterSupabaseProvider`](/docs/frontend/react).

### With TanStack Query [#with-tanstack-query]

`recordQueries(customerRecord, clients)` returns query options per binding,
mutation options per write, and `can()`. Pass the block clients keyed like the
record type's blocks; `invalidateOnRecordEvents(queryClient, betterSupabase,
customerRecord)` makes a binding stale when one of its events arrives:

```ts
const customer = recordQueries(customerRecord, bsb.db.$blocks, {
  access: bsb.db.$blocks.permissions,
});
const comments = useQuery(customer.bindings.comments(id));
const add = useMutation(customer.writes.comments.add(id));
add.mutate({ body: "Shipped" });
```

### Vite and Expo [#vite-and-expo]

A Vite app runs `gen` like any other and uses the record types with
`createRecordProvider` and `recordQueries`. An Expo app usually doesn't run
`gen` itself: in a monorepo, import the record types from the package that
does, or run `gen` once with `output` inside the Expo app. `defineRecordType`
is a plain function, so a hand-written record type works the same way.

## Load a record [#load-a-record]

`loadBindings(source, subject, names, options?)` takes the manifests and the
clients built from them, keyed alike, such as `blockSet.blocks` and
`db.$blocks`. It runs the bindings in parallel and never throws:

```ts title="src/features/customers/customer-queries.ts"
import { loadBindings, bindingCan } from "better-supabase/records";

import { blockSet, type AppDb } from "@/lib/supabase";
import { permissions } from "@/lib/supabase/blocks.generated";

export async function customerBindings(db: AppDb, id: string) {
  const set = await db.permissions.permissions();

  return loadBindings(
    { blocks: blockSet.blocks, clients: db.$blocks },
    { type: "customer", id },
    ["comments", "attachments", "tags"],
    {
      can: bindingCan(set.ok ? set.data : null, permissions),
      options: { comments: { limit: 20 } },
    },
  );
}
```

`bindingCan(permissionSet, permissions)` takes the caller's
[`PermissionSet`](/docs/repository/permissions) and checks the key each binding's module
names in the `permissions` constant of `blocks.generated.ts`, so a renamed
permission still matches. With no set (`null`), every binding that needs a
key is hidden. Without `can`, every binding loads and RLS decides
what it returns. Binding names are the block keys in your config, so two
instances of one block get two bindings.

`countBinding(source, name, subjects)` returns the row count of one binding
for many records, keyed `type:id`, for a badge on each row of a list. It
fails with `unsupported` for a binding without `count`.

### With `"use cache"` [#with-use-cache]

A cached function stores what it returns, so a settled `error` would stay
cached. Call `loadBindings` outside `"use cache"`, or throw on `error` inside
it so the failure isn't stored, and tag the cached read with
[`bindingTag`](/docs/frameworks/next#record-bindings) so a write drops it.

## Header and badges [#header-and-badges]

`subjects.summary(subject, sections, { seenAt })` calls `record_summary` and
returns the record's title and path with a count and an unread flag for each
binding you name in `sections`, in one round trip. Pass `seenAt`, when the caller last opened the
record, to get unread state; without it every binding counts as read. It
counts `comments` and `attachments`; it returns `undefined` when the caller
can't read the record.

```ts
const summary = await subjects
  .summary({ type: "customer", id }, ["comments", "attachments"], {
    seenAt: lastVisit,
  })
  .orThrow();

summary?.counts.comments; // 12
summary?.unread.attachments; // true
```

`record_summary` runs as the caller, so the counts are the rows the caller
can read.

## Keep it current [#keep-it-current]

`createRecordController` loads the bindings, and the summary when you pass
the subjects client, then listens on one private topic. A message whose event
a binding lists in its `events` loads that binding again; any other message
loads every binding. Like the other
[block controllers](/docs/frontend/block-controllers), it imports no UI code.

```ts title="lib/customer-record.ts"
import { createRecordController } from "better-supabase/records";
import { topicPort } from "better-supabase/realtime";

const record = createRecordController({
  source: { blocks: blockSet.blocks, clients: bsb.db.$blocks },
  auth: bsb.auth,
  ports: { topic: topicPort(supabase) },
  subject: { type: "customer", id },
  bindings: ["comments", "attachments"],
  topic: `customer:${id}`,
  summary: subjects,
});

record.subscribe(() => {
  const { bindings, summary } = record.getSnapshot();
});
await record.actions.refresh(["comments"]);
```

## Optimistic writes [#optimistic-writes]

Each binding exports a reducer for React's `useOptimistic`, so a pending
write shows before the server answers:

```tsx
import { attachmentsOptimistic } from "better-supabase/blocks/attachments";

const [files, applyPending] = useOptimistic(rows, attachmentsOptimistic);

startTransition(async () => {
  applyPending({ type: "remove", id });
  await removeAttachment({ id, customerId });
});
```

On the server, the `bsb.record(...).actions` Server Actions run the write and
drop the cached binding.

## Write a binding [#write-a-binding]

`defineRecordBinding` describes the record binding of your own block. It is an
extension interface (`apiVersion: 1`); put it on the manifest's `record`. A
block has one binding, named by its key in the config:

```ts title="blocks/work-notes/record-binding.ts"
import { defineRecordBinding } from "better-supabase/records";

export const workNotesBinding = defineRecordBinding({
  permissions: { read: "read", write: "create" },
  load: (client: WorkNotes, subject) => client.list(subject),
  count: (client: WorkNotes, subjects) => client.countFor(subjects),
  events: ["work-note.*"],
});

export const workNotesBlock = defineBlock({
  name: "work-notes",
  sql: ["work-notes"],
  record: workNotesBinding,
  // ...
});
```

| Field         | What it is                                                                           |
| ------------- | ------------------------------------------------------------------------------------ |
| `permissions` | The module actions the binding reads and writes with, keys of `permissions.<module>` |
| `module`      | The SQL module those actions belong to; default the block's first one                |
| `load`        | The rows for one subject, as an `AsyncResult`                                        |
| `count`       | Optional: rows per subject, keyed `type:id`                                          |
| `events`      | The block events that make the binding stale; `*` ends a prefix                      |
| `optimistic`  | Optional: a reducer for `useOptimistic`                                              |

`testRecordBinding` in `better-supabase/testing` checks a binding against this
contract ([Conformance](/docs/extending/conformance)).

# Record lists and My work

> Show record binding columns on a list page, keep its state in the URL, merge a record's bindings into one timeline, and list what waits on the caller across blocks.

Source: https://bettersupabase.com/docs/repository/record-lists

[Record bindings](/docs/repository/record-bindings) load the parts of one record page.
The same bindings also fill list columns, feed a record's timeline and,
through the subjects block, a "My work" page across every installed block.
All of it lives in `better-supabase/records`, with Server Component helpers in
`better-supabase/next` and a URL state hook in `better-supabase/react`.

## A list with binding columns [#a-list-with-binding-columns]

`defineRecordList` pairs a [list query](/docs/platform/list) with a record
type and names the bindings shown as columns. Each column is filled for
the whole page in one wave, never one query per row:

| Column  | Cell                                                                     |
| ------- | ------------------------------------------------------------------------ |
| `count` | how many rows the binding has, from its `count` (one query for the page) |
| `data`  | the binding's rows, from its `column`, or one `load` per row in one wave |

```ts title="src/features/customers/customer-record-list.ts"
import "server-only";
import { recordListServer } from "better-supabase/next";
import { defineRecordList } from "better-supabase/records";

import { customerList } from "@/lib/lists";
import { customerRecord } from "@/lib/supabase/records/customer";
import { bsb } from "@/lib/supabase/server";

export const customerRecordList = defineRecordList(
  customerRecord,
  customerList,
  { columns: { comments: "count" } },
);

export const customerRecords = recordListServer(bsb, customerRecordList);
```

`load(searchParams)` parses the URL state, runs the list query and fills the
columns. It returns `{ query, page, rows, filterFields, can }`: each row holds
its `subject`, its `row` and its `cells`, and every cell settles on its own
like a binding (`ok`, `error` or `hidden`). A column the caller can't read is
`hidden` on every row and runs no query. Bad URL state fails with a
`validation` error that names the parameter:

```ts title="src/app/api/customers/records/route.ts"
import { customerRecords } from "@/features/customers/customer-record-list";

export async function GET(request: Request): Promise<Response> {
  const result = await customerRecords.load(new URL(request.url).searchParams);

  if (!result.ok) {
    return Response.json(result.error, {
      status: result.error.kind === "validation" ? 400 : 500,
    });
  }

  return Response.json(result.data);
}
```

`recordListServer` returns plain data (Temporal values become ISO strings), so
the page passes straight to a Client Component. Outside Next.js, call
`customerRecordList.load({ db, clients, permissions }, searchParams)` with the
repositories, the record type's block clients and the caller's permission set.

## Keep the list state in the URL [#keep-the-list-state-in-the-url]

`useRecordListState` keeps a list's sort, facets, filter and page in the URL
through [nuqs](https://nuqs.dev). Pass it the list definition and the
`useQueryStates` result for the list's parsers:

```tsx title="src/features/customers/use-customer-list-state.ts"
"use client";

import { useRecordListState } from "better-supabase/react";
import { createParser, useQueryStates } from "nuqs";

import { customerList } from "@/lib/lists";

const parsers = customerList.nuqs(createParser);

export function useCustomerListState() {
  return useRecordListState(customerList, useQueryStates(parsers));
}
```

It returns `query` (the typed, parsed state, or the list's defaults when the
URL holds something the list rejects), `searchParams` for the fetch, `set` to
change keys (which goes back to page 1), `setPage` and `reset`. Wrap the app
in nuqs's `NuqsAdapter` once.

## A record's timeline [#a-records-timeline]

`loadTimeline(source, subject)` loads every binding that maps its rows to
timeline entries and merges them, newest first. The comments, versions and
attachments bindings do this; a block's own binding adds a `timeline` hook
that turns a row into `{ id, kind, at, actor?, row }`. A binding that fails or
that the caller can't read adds nothing and is listed in `skipped`:

```ts title="src/features/customers/customer-record-queries.ts"
const timeline = await customerPage.timeline(customerId, { limit: 20 });
timeline.entries; // [{ binding: "comments", kind: "comment", at, actor, row }]
timeline.skipped; // { versions: { kind: "hidden" } }
```

`bsb.record(...).timeline` checks the caller's permissions first, so it never
queries a binding they can't read. Pass `bindings` to merge only some of them.

## My work [#my-work]

The subjects block installs `my_work(tenant, page_size)`: what waits on the
caller across the installed blocks, newest first. It runs as the caller, so
every row is one they can read.

| Kind       | From        | Rows                                              |
| ---------- | ----------- | ------------------------------------------------- |
| `assigned` | `tasks`     | open work items assigned to the caller            |
| `approval` | `approvals` | pending approval requests the caller can decide   |
| `reminder` | `reminders` | the caller's reminders sent in the last 7 days    |
| `mention`  | `comments`  | comments that mention the caller, last 30 days    |
| `sla`      | `sla`       | SLA clocks that warned on the caller's work items |

A kind whose block isn't installed returns nothing. `loadMyWork` groups the
items by kind and counts them for tab badges, in one round trip:

```ts title="src/features/my-work/my-work-queries.ts"
import { loadMyWork } from "better-supabase/records";

const work = await loadMyWork(db.subjects, { tenant }).orThrow();

work.groups.approval; // approvals the caller can decide
work.counts.assigned; // 3
```

Each item has a `kind`, the `subject` it is about, the `ref` of the row that
put it there, and its `title`, `url` and `at` when the record has them.

The example app's customer list, customer timeline and `/my-work` page show
all four parts together.

# Subjects

> Titles and links for any record in config.records, resolved in one batched call, with the SubjectLink and SubjectChip recipes.

Source: https://bettersupabase.com/docs/repository/record-types

Comments, relations, approvals and the other blocks point at records as a
`RecordRef`, `{ type, id }`. `better-supabase/blocks/subjects` turns those
refs into a title and a path from the [subject registry](/docs/blocks/sql#subject-registry),
so a list of rows that each name a record shows a link without a query per
row.

```bash
better-supabase add subjects
```

Give each type in `config.records` a `title` column and a `url` template:

```ts title="better-supabase.config.ts"
export default defineConfig({
  records: {
    customer: {
      table: "public.customers",
      title: "name",
      url: "/customers/{id}",
    },
  },
});
```

`resolve(refs)` calls `subject_titles` and returns the readable subjects keyed
`type:id`. It runs as the caller, so a row the caller can't read, a
soft-deleted row and an unknown type are missing from the result. It removes
duplicate refs and sends at most 500 per call, splitting larger lists:

```ts
import { createBlockClient } from "better-supabase/blocks";
import { subjectKey, subjectsBlock } from "better-supabase/blocks/subjects";

const subjects = createBlockClient(db, subjectsBlock);
const found = await subjects
  .resolve([
    { type: "customer", id: customerId },
    { type: "project", id: projectId },
  ])
  .orThrow();

found[subjectKey({ type: "customer", id: customerId })];
// { type: "customer", id: "…", title: "Road Runner Inc", url: "/customers/…" }
```

`subject_titles` fails with `SUBJECT_REFS_INVALID` when `refs` is not a JSON
array and `SUBJECT_REFS_TOO_MANY` above 500 refs.

`summary(subject, sections, { seenAt })` returns one record's title and path
with a count and unread flag per binding, for a record page's header and tab
badges ([Record bindings](/docs/repository/record-bindings#header-and-badges)).

## Paths in code [#paths-in-code]

`gen` writes a `urls` constant to `blocks.generated.ts` with a function for
every subject type that has a `url`: block entities, `config.records` and app
table subjects. Each placeholder becomes a camel-case argument:

```ts
import { urls } from "@/lib/supabase/blocks.generated";

urls.customer({ id: customer.id }); // "/customers/…"
urls.task({ projectId: task.project_id }); // "/projects/…"
```

## In the browser [#in-the-browser]

The React hooks are a registry item: `npx shadcn add
@better-supabase/use-subjects` writes `hooks/use-subjects.ts` into the app.
`useSubject(ref)` returns `subject`: `undefined` while it loads, `null` when the caller can't read the
record, then `{ type, id, title, url }`. `useSubjects(refs)` resolves a list
and returns `get(ref)`. Every `useSubject` and `useSubjects` of one render
joins one `subject_titles` call, so a table with a subject link in each row
costs one round trip. Both expose `error` and `refresh` for a retry.

```tsx title="components/subject-link.tsx"
"use client";

import { useSubject } from "@/hooks/use-subjects";

export function SubjectLink({ subject }: { subject: RecordRef }) {
  const { subject: resolved } = useSubject(subject);

  if (resolved === undefined) return <Skeleton className="h-4 w-32" />;
  if (resolved === null) return <span>A record you can't open</span>;

  return <Link href={resolved.url ?? "#"}>{resolved.title}</Link>;
}
```

`createSubjectsController` is the same logic without React. Vue, Svelte and
Solid run it with `useBlockController` from `better-supabase/vue`, `/svelte`
and `/solid` (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)).
Wrap the client in `batchSubjects` so the components
of one render share a call. The [Subject link](/docs/registry/ui-recipes/subject-link) and
[Subject chip](/docs/registry/ui-recipes/subject-chip) recipes show both in the Next.js example: the
related records panel and the approvals queue.

```vue title="SubjectTitle.vue"
<script setup lang="ts">
import { computed } from "vue";
import { useBlockController, useSupabase } from "better-supabase/vue";
import { rpcTransport } from "better-supabase/blocks";
import {
  batchSubjects,
  createSubjects,
  createSubjectsController,
  subjectKey,
} from "better-supabase/blocks/subjects";

const props = defineProps<{ type: string; id: string }>();
const supabase = useSupabase();
const ref = { type: props.type, id: props.id };
const { state } = useBlockController(createSubjectsController, {
  client: batchSubjects(
    createSubjects({ transport: rpcTransport(supabase), schema: "api" }),
  ),
  refs: [ref],
});
const subject = computed(() => state.value.subjects?.[subjectKey(ref)]);
</script>

<template>
  <a v-if="subject?.url" :href="subject.url">{{ subject.title }}</a>
</template>
```

```svelte title="SubjectTitle.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import { rpcTransport } from "better-supabase/blocks";
  import {
    batchSubjects,
    createSubjects,
    createSubjectsController,
    subjectKey,
  } from "better-supabase/blocks/subjects";

  let { type, id }: { type: string; id: string } = $props();
  const { supabase } = getBetterSupabase();
  const subjects = useBlockController(createSubjectsController, {
    client: batchSubjects(createSubjects({ transport: rpcTransport(supabase), schema: "api" })),
    refs: [{ type, id }],
  });
</script>

<span>{$subjects.subjects?.[subjectKey({ type, id })]?.title}</span>
```

```tsx title="SubjectTitle.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import { rpcTransport } from "better-supabase/blocks";
import {
  batchSubjects,
  createSubjects,
  createSubjectsController,
  subjectKey,
} from "better-supabase/blocks/subjects";

export function SubjectTitle(props: { type: string; id: string }) {
  const supabase = useSupabase();
  const ref = { type: props.type, id: props.id };
  const { state } = useBlockController(createSubjectsController, {
    client: batchSubjects(
      createSubjects({ transport: rpcTransport(supabase), schema: "api" }),
    ),
    refs: [ref],
  });

  return <span>{state().subjects?.[subjectKey(ref)]?.title}</span>;
}
```

# App tables

> Declare the tables your app owns in tables, and better-supabase upgrade writes them with the scope column, row level security, grants and a pgTAP test.

Source: https://bettersupabase.com/docs/repository/tables

Blocks cover the tables every app needs. The tables only your app has, such
as `projects` and `tasks`, go in `tables`. `better-supabase upgrade`
writes them into one schema file next to the block files, with the scope
column, row level security, grants, indexes and a pgTAP test, so they follow
the same rules as the blocks around them.

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  blocks: ["memberships", "organizations", "crm"],
  tables: {
    projects: {
      columns: {
        name: { type: "text", nullable: false },
        budget_minor: { type: "bigint", nullable: false, default: "0" },
        currency: { type: "text", nullable: false, default: "'EUR'" },
        owner_id: {
          type: "uuid",
          references: { table: "auth.users", onDelete: "set null" },
        },
        contact_id: {
          type: "uuid",
          references: { block: "crm", table: "contacts" },
        },
      },
    },
    tasks: {
      columns: {
        project_id: {
          type: "uuid",
          nullable: false,
          references: { table: "projects", onDelete: "cascade" },
        },
        title: { type: "text", nullable: false },
        done: { type: "boolean", nullable: false, default: "false" },
      },
    },
    drafts: { owner: "user", columns: { body: { type: "text" } } },
  },
});
```

[Create](https://app.bettersupabase.com/create) edits the same key on its
canvas, and `init --preset` or `apply --preset` with its URL writes it into
the config (see [init](/docs/cli/init#a-design-from-create)). Dragging a
table onto itself adds `parent_id` with `on delete set null`. A second key to
the same table gets a free name you can rename: `owner_id`, `assignee_id`
and so on for `auth.users` (the scope can claim `user_id`), and
`<table>_2_id` for other tables. Create also starts without blocks: "Draw
your own tables" adds a per-user table to an empty canvas.

## What each table gets [#what-each-table-gets]

Every table lives in `public` and gets `id`, the scope column and, unless `timestamps: false`, `created_at` and `updated_at`
with the `updated-at` trigger. These names are reserved, so a column can't
take them. Columns keep the order you write them in. The `id` is a random
`uuid` unless the table sets `id: "bigint"`, which makes it a number
`generated by default as identity`; a reference to the table then needs a
`bigint` column.

| `owner`             | Scope column                                                                     | Who reaches a row                                                     |
| ------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `inherit` (default) | follows `database.sql.owner`: the tenant column (`organization_id`) or `user_id` | per tenant, the members of the row's tenant; per user, the row's user |
| `user`              | `user_id`, referencing `auth.users`, defaulting to `auth.uid()`                  | the row's user, in any `database.sql.owner`                           |
| `none`              | none                                                                             | every signed-in user reads; only the service role writes              |

A tenant-scoped table needs the `memberships` block. The tenant column follows
`blocks.memberships.names.organization`, and the client sends its value on insert; the insert
policy rejects a tenant the user isn't a member of.

A tenant-scoped table also gets a restrictive `bsb_tenant_scope` policy that
keeps every row inside the caller's organizations. Postgres combines
permissive policies with `or`, so a permissive policy you add to the table
can widen access within those organizations, but never beyond them.

## Enriching a table you already have [#enriching-a-table-you-already-have]

An entry can name a table your own schema files already create, or one in the
saved schema snapshot. `upgrade` then enriches it instead of creating it, and
leaves your `create table`, your columns and your grants as they are. It adds,
each with `add column if not exists`, only what the table lacks: `id` (unique
instead of a primary key, since your table may have one), the scope column
(nullable), `created_at` and `updated_at` unless `timestamps: false`, and the
columns of the entry, nullable unless they have a default.

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: ["memberships"],
  tables: {
    projects: { columns: { name: { type: "text", nullable: false } } },
  },
});
```

Over a `projects` table of yours without `organization_id`, the file writes:

```sql title="supabase/schemas/900_better_supabase_app_tables.sql"
-- "public"."projects" is the app's table (supabase/schemas/projects.sql): tables.projects adds only the columns it lacks.
alter table "public"."projects" add column if not exists "organization_id" uuid;
do $$
begin
  if not exists (
    select 1 from pg_constraint
    where conrelid = '"public"."projects"'::regclass and conname = 'projects_organization_id_not_null'
  ) then
    alter table "public"."projects" add constraint projects_organization_id_not_null
      check ("organization_id" is not null) not valid;
  end if;
end;
$$;
```

The scope column takes the tenant id type, `uuid` by default. With the
`organizations` block installed, it also references the organizations table
with `on delete cascade`.

The rest renders as for a table it creates: row level security, the scope's
policies, `bsb_tenant_scope`, indexes and the pgTAP file. Its policies are
named `bsb_<table>_<command>`, so they never replace one of yours, and the
grants the scope needs are added without revoking yours first. Doctor lists
your own permissive policies and broader grants as
[BS237](/docs/cli/doctor#bs237).

The not-null check on the scope column is `not valid` on purpose. New rows
must name their owner right away. Existing rows without one match none of
the scope's policies, so only the service role reads or writes them, and an
update to one fails the check until its column is filled. Fill the column
with [`better-supabase db backfill`](/docs/cli/db#backfill), then run
`better-supabase doctor --fix`, which validates the check
([BS236](/docs/cli/doctor#bs236)). Doctor counts the rows still without an
owner as [BS238](/docs/cli/doctor#bs238). The migration pg-delta writes
keeps the check `not valid`, so it doesn't scan your rows; validating
happens on the live database only.
[Your own schemas](/docs/blocks/own-schemas) covers the same steps for blocks.

## Columns [#columns]

| Key          | Values                                                                                                     |
| ------------ | ---------------------------------------------------------------------------------------------------------- |
| `type`       | `text`, `uuid`, `integer`, `bigint`, `numeric`, `boolean`, `timestamptz`, `date`, `jsonb`, `text[]`        |
| `nullable`   | defaults to `true`                                                                                         |
| `default`    | `now()`, `gen_random_uuid()`, `auth.uid()`, `true`, `false`, a number, a `'quoted'` text, `'{}'` or `'[]'` |
| `unique`     | adds a unique constraint                                                                                   |
| `references` | another app table, `auth.users`, or a block's table; `onDelete` defaults to `no action`                    |
| `format`     | what the column holds; see the next table                                                                  |
| `options`    | 1 to 100 allowed values for a `text` (single select) or `text[]` (multi-select) column                     |
| `min`, `max` | the smallest and largest value of a number, or the shortest and longest `text`, in characters              |
| `comment`    | a description, written as `comment on column`                                                              |
| `identity`   | `true` on an `integer` or `bigint` column numbers rows: `generated by default as identity`                 |
| `access`     | `read` or `server`; see [Column access](#column-access)                                                    |
| `encrypted`  | `true` stores the value as ciphertext; see [Encrypted columns](#encrypted-columns)                         |

| `format`    | Type                 | What the database checks                      |
| ----------- | -------------------- | --------------------------------------------- |
| `email`     | `text`               | an `@` with text on both sides and a dot      |
| `url`       | `text`               | starts with `http://` or `https://`           |
| `rating`    | `integer`, `bigint`  | 1 to 5, unless `min` or `max` say otherwise   |
| `percent`   | `integer`, `numeric` | 0 to 100, unless `min` or `max` say otherwise |
| `phone`     | `text`               | nothing; a hint for forms and generators      |
| `long_text` | `text`               | nothing; a hint for a multi-line input        |
| `color`     | `text`               | nothing; a hint for a color picker            |
| `currency`  | `bigint`             | nothing; marks an amount in minor units       |

Each check is a named constraint, `<table>_<column>_check`, and the pgTAP
file checks that it exists. `FIELD_FORMATS` from `better-supabase/config`
holds the same list with each format's types, check and input, and
[custom fields](/docs/blocks/custom-fields#types) use the same names. A column takes either `options` or a `format`,
not both. Keep money as a `bigint` of minor units next to a `text` currency
column, as the blocks do: in Create, picking Currency renames the column to
`<name>_minor` and adds `<name>_currency` with a length of 3. A reference to
a block's table names the block's logical table
(`{ block: "crm", table: "contacts" }`), so it follows the block's renames,
and the block has to be in `blocks`. A name a block already uses, such as
`tags` with the `tags` block, is rejected with the block's name.

A reference between two tenant-scoped app tables, a table's reference to
itself included, also checks the tenant: the target gets a unique index on
`(organization_id, id)`, and the reference becomes a foreign key on
`(organization_id, <column>)`, so a row can't point at another tenant's
row even when the caller knows its id. `onDelete: "set null"` clears only
the reference column (`on delete set null (<column>)`, Postgres 15 and
later). The pgTAP file checks that the key covers both columns.

## Column access [#column-access]

A column's `access` decides who reads and writes it, with Postgres column
grants. Leave it out and signed-in users read and write the column within the
row policies. Set `read` and they read it, but only server code with the
service role writes it. Set `server` and they can't read or write it at all.

```ts title="better-supabase.config.ts"
tables: {
  projects: {
    columns: {
      name: { type: "text", nullable: false },
      budget: { type: "bigint", access: "read" },
      internal_notes: { type: "text", access: "server" },
    },
  },
},
```

Once a column sets `access`, `upgrade` revokes the table-wide grants from
`authenticated` and grants `select` on the readable columns, and `insert` and
`update` on the writable ones, by name. A query that names a hidden column
fails with `42501` (permission denied), even with `where false`, because
Postgres checks column grants while it plans the query. The `<table>_view`
lists the readable columns instead of `t.*`, and the typed client leaves
server columns out of a default select: `gen` marks them `selectable: false`
and makes them optional in the row type. Select one by name with the service
role client to read it.

`tables` refuses a few combinations, so the generated SQL never needs a
column the caller can't see:

- A required column with `access` needs a `default`, because signed-in users
  can't fill it on insert.
- A `server` column can't be the subject title, a search column, part of the
  subject URL or rank group, a rollup or lookup source, or in `events.include`
  or `subject.versions.columns`. A table with `versions: true` and a `server`
  column has to list `versions.columns` without it.
- A table in `expose` sets its grants there, so its columns can't set
  `access`.

The pgTAP file checks each column with `has_column_privilege` for
`authenticated`, and that reading a `server` column and updating a restricted
one fail with `42501`. In Create, the column inspector's Access field sets the
key, and the Data tab's Preview as toggle shows the sample rows as a signed-in
member or as the server. Tightening a column's access is a breaking change in
the design diff. ADR 0051 explains why access uses grants rather than a view
or policies.

## Encrypted columns [#encrypted-columns]

With the [`field-encryption`](/docs/blocks/field-encryption) block
installed, `encrypted: true` stores a column as ciphertext under the row's
data key: the tenant's for a tenant table, the user's for `owner: "user"`,
the app's for `owner: "none"`. Signed-in users write the plaintext as usual,
and a trigger checks the column's format, options and bounds and then
encrypts it before the row is stored.

```ts title="better-supabase.config.ts"
tables: {
  client_notes: {
    columns: {
      client_name: { type: "text", nullable: false },
      diagnosis: { type: "text", encrypted: true },
      birth_date: { type: "date", encrypted: true },
    },
    auditReads: true,
  },
},
```

The column is `text` in the database, and signed-in users can't select it,
the same as a `server` column. They read it through
`public.<table>_reveal(ids)`, which returns `id` and each encrypted column in
its configured type, for the rows of `ids` the caller's scope reaches (at most
500 per call). It is a security invoker wrapper around the security definer
function `better_supabase.<table>_reveal`, which PostgREST doesn't expose:

```ts
const { data } = await supabase.rpc("client_notes_reveal", { ids });
// [{ id: "…", diagnosis: "asthma", birth_date: "1990-05-01" }]
```

`gen` types the column as its configured type, not as `text`, and marks it
`selectable: false`. An encrypted column with `access: "server"` reveals
only to the service role. Anon callers get `42501`
(`APP_REVEAL_FORBIDDEN`), and more than 500 ids `22023`
(`APP_REVEAL_TOO_MANY`).

`auditReads: true` writes one audit entry per tenant for each `reveal`
call, with the type `<subject type or table>.viewed`, the category `read`,
the acting member, the row ids and the column names, so a list of 200 rows
costs one entry rather than 200. It needs the
[`audit`](/docs/blocks/audit#read-audits) block.

An encrypted column can't be unique, an identity, a reference or `text[]`,
and it can't appear where the database has to read the value: `unique` and
`indexes` keys, the subject title, URL, search and rank group, rollups,
lookups, `events.include` or `subject.versions.columns`. Turning encryption
on or off is a breaking change in the design diff. To encrypt rows that
already hold plaintext, run `update <table> set <column> = <column>` once
after you install it: the trigger seals any value that isn't ciphertext yet.
The [limits](/docs/blocks/field-encryption#limits) of the block apply.

## Relations [#relations]

A column with `references` is a many-to-one link: many rows point at one
row of the target. A table that references itself through a `parent_id`
holds a tree. A many-to-many link is a table of its own with two
references, each `onDelete: "cascade"`, and a composite `unique` on the
pair, so the same two rows are linked once:

```ts title="better-supabase.config.ts"
tables: {
  projects: { columns: { name: { type: "text", nullable: false } } },
  labels: { columns: { name: { type: "text", nullable: false } } },
  projects_labels: {
    unique: [["project_id", "label_id"]],
    columns: {
      project_id: {
        type: "uuid",
        nullable: false,
        references: { table: "projects", onDelete: "cascade" },
      },
      label_id: {
        type: "uuid",
        nullable: false,
        references: { table: "labels", onDelete: "cascade" },
      },
    },
  },
  folders: {
    columns: {
      name: { type: "text", nullable: false },
      parent_id: {
        type: "uuid",
        references: { table: "folders", onDelete: "cascade" },
      },
    },
  },
},
```

In Create, connecting two tables offers "Many to many", which adds the
junction table, and the Tree primitive adds a table with `parent_id`.
Each edge shows its cardinality and its `onDelete` action.

```ts title="better-supabase.config.ts"
tables: {
  contacts: {
    id: "bigint",
    comment: "People the team works with",
    unique: [["organization_id", "code"]],
    indexes: [["stage", "created_at"]],
    columns: {
      code: { type: "text", nullable: false, min: 2, max: 12 },
      email: { type: "text", format: "email" },
      stage: { type: "text", options: ["lead", "customer"] },
      score: { type: "integer", format: "rating" },
      number: { type: "bigint", identity: true },
    },
  },
},
```

## Table keys [#table-keys]

| Key          | Values                                                                                                   |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| `id`         | `uuid` (default) or `bigint`                                                                             |
| `unique`     | sets of columns that are unique together: `[["organization_id", "code"]]`, named `<table>_<columns>_key` |
| `indexes`    | sets of columns indexed together, in order, named `<table>_<columns>_idx`                                |
| `comment`    | a description, written as `comment on table`                                                             |
| `owner`      | `inherit`, `user` or `none`; see above                                                                   |
| `timestamps` | `false` drops `created_at`, `updated_at` and the trigger                                                 |

A set can name any column the table has, including `id`, the scope column
and the timestamps. A column a set names that the table doesn't have fails
`upgrade` with the table and the column.

## Record features [#record-features]

Give a table a `subject` and it becomes a subject type, the same as an entry
in `subjects` (see [Subject registry](/docs/blocks/sql#subject-registry)). Comments, attachments, tags,
versions, watchers, activity and search then reach its rows, because those
blocks attach to any registered subject. On [Create](https://app.bettersupabase.com/create), the Features
tab of a table turns each one on, adds its block and shows it on the table.

```ts title="better-supabase.config.ts"
tables: {
  projects: {
    columns: { name: { type: "text", nullable: false } },
    subject: {
      type: "project",
      title: "name",
      url: "/projects/{id}",
      search: {},
      rank: true,
      softDelete: true,
    },
  },
},
```

| Key           | What it does                                                                                       |
| ------------- | -------------------------------------------------------------------------------------------------- |
| `type`        | the subject type, which must not clash with an entry in `subjects`                                 |
| `title`       | the column `subject_title` returns                                                                 |
| `url`         | the path `subject_url` returns, with `{id}` replaced                                               |
| `search`      | indexes rows in the `search` block; `title` and `body` name the columns, `{}` uses the title       |
| `permissions` | the permission per action, as in `subjects`                                                        |
| `rank`        | adds a `rank` column for manual order (`text collate "C"`, ADR 0021); `{ group }` ranks per column |
| `softDelete`  | adds a `deleted_at` column, which hides the row from subject readers and search                    |
| `versions`    | keeps a version on every change; see [History](#history)                                           |

The subject's tenant column is the table's tenant column when the table is
scoped to the tenant, and none otherwise. A user column named `rank` or
`deleted_at` fails `upgrade` when the subject adds that column. Search
triggers for app tables are written into the app tables file, which runs
after the `search` block.

## Events [#events]

Set `events` and every insert, update and delete on the table writes an event
to the outbox in the same transaction, so writes through PostgREST, the SQL
editor or another service emit it too. The table needs the
[outbox](/docs/blocks/outbox) block. On [Create](https://app.bettersupabase.com/create), the Emit events
switch in a table's Features tab sets it and adds the block.

```ts title="better-supabase.config.ts"
tables: {
  tasks: {
    columns: {
      title: { type: "text", nullable: false },
      status: { type: "text" },
    },
    subject: { type: "task", title: "title" },
    events: { on: ["created", "updated"], include: ["title", "status"] },
  },
},
```

`events: true` emits all three kinds with no extra columns. The event type is
the subject type, or the table name, followed by `.created`, `.updated` or
`.deleted`. The payload is an `AppTableEventData`:

| Field            | Value                                                                 |
| ---------------- | --------------------------------------------------------------------- |
| `id`             | the row's id                                                          |
| `organizationId` | the tenant column, when the table is scoped to the tenant             |
| `subjectType`    | the subject type, when the table has a `subject`                      |
| `subjectId`      | the row's id as text, when the table has a `subject`                  |
| `changed`        | on an update, the columns whose value changed                         |
| the `include`d   | each column `include` names, with its new value (old value on delete) |

An update that changes only `updated_at` emits nothing. `gen blocks` writes
`appTableEvents` into `lib/supabase/blocks.ts`, a typed event map you pass to
`createOutbox`, and `appTableEventSchemas`, which documents them in the
AsyncAPI document through `defineApi({ events: { blocks: appTableEventSchemas } })`.
To react to the events, consume them with
[`createRuleSink`](/docs/blocks/automations#run-rules-from-events); an
automation's `workflow` action
[starts a workflow](/docs/blocks/workflow-editor#start-from-outbox-events).
The decision is ADR 0048.

## History [#history]

Set `subject.versions` and the [versions](/docs/blocks/versions) block keeps a
version of the row each time one of its columns changes, through a trigger on
the table. The table needs the versions block; `upgrade` names it when it is
missing. On [Create](https://app.bettersupabase.com/create), the Versions feature of a table sets it.

```ts title="better-supabase.config.ts"
tables: {
  projects: {
    columns: {
      name: { type: "text", nullable: false },
      budget_minor: { type: "bigint", nullable: false, default: "0" },
    },
    subject: {
      type: "project",
      title: "name",
      versions: { columns: ["name", "budget_minor"] },
    },
  },
},
```

`versions: true` keeps every column the config declares. `columns` narrows
the list, and `auto: false` leaves out the trigger, so versions are kept
only when the app calls `versions.snapshot`. `versions.restoreFields(id,
fields)` writes some fields of a version back and leaves the rest of the row
as it is. The [record history](/recipes) recipe lists the versions with the
fields each one changed and a restore per field.

## Imports and bulk edits [#imports-and-bulk-edits]

`appTableImport` from `better-supabase/blocks/imports` imports a CSV into an
app table and updates the rows that already exist, matched on one of the
table's `unique` sets. See [imports](/docs/blocks/imports#upsert-into-an-app-table).
With the [bulk](/docs/blocks/bulk) block installed, `upgrade` also writes
`app_table_set`, which the built-in `appTables.set` bulk action calls to set
columns on the selected rows of any app table that is a subject.

## Rollups and lookups [#rollups-and-lookups]

A rollup aggregates the rows of another app table that reference this one,
such as a project's open tasks. A lookup reads a column of the row a
reference points at, such as a task's project name. Both are built from
structured keys, never from SQL, so a design link can't carry a query (ADR
0044).

```ts title="better-supabase.config.ts"
tables: {
  projects: {
    columns: { name: { type: "text", nullable: false } },
    rollups: {
      open_tasks: {
        table: "tasks",
        via: "project_id",
        aggregate: "count",
        where: { column: "status", equals: "todo" },
      },
      next_due_on: {
        table: "tasks",
        via: "project_id",
        aggregate: "min",
        column: "due_on",
      },
    },
  },
  tasks: {
    columns: {
      project_id: { type: "uuid", references: { table: "projects" } },
      status: { type: "text", options: ["todo", "done"] },
      due_on: { type: "date" },
    },
    lookups: { project_name: { via: "project_id", column: "name" } },
  },
},
```

| Key                   | What it does                                                                                                              |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `rollups.*.table`     | the app table to aggregate                                                                                                |
| `rollups.*.via`       | the column of that table that references this one                                                                         |
| `rollups.*.aggregate` | `count`, `sum`, `min`, `max` or `avg`                                                                                     |
| `rollups.*.column`    | the column to aggregate: a number for `sum` and `avg`, a number, date, time or text for `min` and `max`, none for `count` |
| `rollups.*.where`     | `{ column, equals }`: only the rows whose select column (a text column with `options`) equals one option                  |
| `lookups.*.via`       | a column of this table that references another app table                                                                  |
| `lookups.*.column`    | the column of the referenced row to read, including `created_at` and `updated_at`                                         |

`upgrade` writes `public.<table>_view`: every column of the table, then one
column per rollup and lookup. The view is `security_invoker`, so it reads
the table and the linked tables with the caller's row level security, and a
rollup counts only the rows the caller can read. `authenticated` can read it
and `anon` can't. `sum` returns 0 when there are no rows, and the other
aggregates return null. `count` and `sum` over `integer` or `bigint` return
`bigint`.

`better-supabase gen` reads the view as a read-only model, so
`db.projectsView.list()` lists projects with their rollups and the
repository has no insert or update. Write through the table, then read
through the view:

```ts
const projects = await db.projectsView
  .list({ orderBy: { nextDueOn: "asc" } })
  .orThrow();
```

A name already used by a column of the table, or a table named
`<table>_view`, fails `upgrade`. Each rollup runs one subquery per row read,
using the index on the reference column, so page long lists. Adding or
removing a rollup or lookup is a safe change on the Changes tab, since the
view keeps no rows. For a formula these keys can't express, write your own
view.

## The files [#the-files]

| File                                                     | What it holds                                                                                                                                                                                         |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `supabase/schemas/900_better_supabase_app_tables.sql`    | the tables, the columns (`add column if not exists`), RLS, policies, grants, checks, unique keys, indexes, comments, triggers and the `<table>_view` of each table with rollups or lookups            |
| `supabase/tests/900_better_supabase_app_tables.test.sql` | pgTAP: each table exists, has RLS, its policies, checks, unique keys and indexes, and a stranger can't read or insert outside their scope; each view runs as the invoker and shows a stranger no rows |

`upgrade --check` fails when the files are stale, and removing the last
app table removes them. A table listed in `expose` keeps the grants
`expose` gives it.

## Screens [#screens]

better-supabase writes no pages. Build a table's screens on its generated
data hooks (`useTasks`, `useTask`, see [data hooks](/docs/frontend/data-hooks))
and start from the `record-*` [UI recipes](/docs/registry/ui-recipes#app-tables),
which build them on ReUI: a grid with saved views, a form, a board, a
calendar, a timeline and a Gantt chart.

## Example [#example]

The Next.js example declares three app tables in
`apps/examples/nextjs/better-supabase.config.ts`: `projects`, scoped to the
organization with a label from the `tags` block, `tasks` in those projects,
and `saved_views`, scoped to the user. `/projects` shows the tasks in the
`record-*` screens. `GET /api/projects` (`src/app/api/projects/route.ts`) lists the
organization's projects, and `tests/lib/app-tables.test-d.ts` checks the
generated repositories. The example writes its pgTAP test to the fixture's
`supabase/tests/database` through `database.sql.testsDir`.

## Limits [#limits]

The file adds new columns with `add column if not exists`, so under the
`migrations` workflow a column you change or drop needs a migration of its
own; pg-delta and migra diff the file and handle it.

# Unique keys and errors

> get by any unique key, typed constraint names, and narrowing database errors.

Source: https://bettersupabase.com/docs/repository/unique-and-errors

## Unique keys [#unique-keys]

`get` 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.get({ id }, { optional: true });
await db.customers.get({ organizationId, kvk }, { optional: true }); // customers_organization_id_kvk_key
// @ts-expect-error kvk alone is not unique
await db.customers.get({ kvk }, { optional: true });
```

With `optional: true` it returns the row or `null`; without it, a missing row
fails with `not_found`. `get(id)` is the primary key shortcut.

## 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`       | `get`, `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`. It carries the plain `DbError` as
`error` and as `cause`, and copies the fields you usually branch on or log
onto the exception itself:

| Field     | Value                                                            |
| --------- | ---------------------------------------------------------------- |
| `message` | the error's message                                              |
| `kind`    | the `DbError` kind (`not_found`, `conflict`, ...)                |
| `status`  | the suggested HTTP status                                        |
| `code`    | the SQLSTATE or PostgREST code (`23505`, `PGRST116`), when known |
| `details` | the Postgres `DETAIL`, when present                              |
| `hint`    | the Postgres `HINT`, when present                                |
| `table`   | the app-cased table key, when known                              |

These are own, enumerable fields, so error trackers and loggers that
serialize an error's own properties record them. The kind-specific fields,
such as `constraint` on a `conflict`, stay on `error`. The guards
(`isConflict`, `isNotFound`) and `dbErrorOf()` accept the exception
directly, and `isDbError()` is `false` for it, because it is an `Error`
rather than plain data.

```ts
try {
  await db.customers.create(input).orThrow();
} catch (exception) {
  if (exception instanceof DbException && exception.code === "23505") {
    // a unique violation; exception.error.constraint names it
  }
  throw exception;
}
```

Pass a factory to throw something your framework understands instead:

```ts
const customer = await db.customers
  .get(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.

# validation

> Validate writes with zod, valibot or any Standard Schema.

Source: https://bettersupabase.com/docs/repository/validation

When `gen.generators` includes `zod()`, `valibot()` or `standardSchema()` (no
validation library needed), the generated schema validates every write with
the generated `insert` and `update` schemas. Nothing to install:

```ts title="better-supabase.config.ts"
import { defineConfig, zod } from "better-supabase/config";

export default defineConfig({ gen: { generators: [zod()] } });
```

To pass your own schemas, per table, install `validation()` yourself; it
replaces the one the generated schema installed:

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

import { validators } from "./generated.zod.ts";

const betterSupabase = defineSupabase(schema).use(
  validation({
    schemas: {
      ...validators,
      customers: { insert: customerForm, update: customerForm.partial() },
    },
  }),
);
```

- Inserts and upserts use `insert`; updates use `update`.
- Validation runs on app-cased rows, after the other extensions, 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.

# 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                                         |
| `create(data[], args?)`           | The rows                                        |
| `update(key, patch, args?)`       | The row, or `not_found`                         |
| `update({ where }, patch, args?)` | `{ count }`, or the rows with `returning: true` |
| `upsert(data, { onConflict })`    | The row                                         |
| `upsert(data[], { onConflict })`  | The rows                                        |
| `delete(key)`                     | Nothing, or `not_found`                         |
| `delete({ where }, args?)`        | `{ count }`, or the rows with `returning: true` |

A key is the primary key value, or an object of the columns for a composite
key. `{ where }` is a filter: the write applies to every row it matches.

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.

`update` and `delete` by filter return `{ count }`. Pass `returning: true` to
get the written rows instead, narrowed with `select` and `include` as in reads.
`create` and `upsert` with an array work the other way round: they return the
rows, and `returning: false` gives `{ count }`. The result type follows the
`returning` value you pass:

| Call                                     | `returning: true` | `returning: false` | Not passed  | `boolean` variable      |
| ---------------------------------------- | ----------------- | ------------------ | ----------- | ----------------------- |
| `create(row)`, `update(key)`             | The row           | `null`             | The row     | The row or `null`       |
| `create(rows)`, `upsert(rows)`           | The rows          | `{ count }`        | The rows    | The rows or `{ count }` |
| `update({ where })`, `delete({ where })` | The rows          | `{ count }`        | `{ count }` | The rows or `{ count }` |

```ts
const sent = await db.invoices
  .update(
    { where: { status: "draft", dueAt: { lte: today } } },
    { status: "sent" },
    { returning: true, select: ["id", "number"] },
  )
  .orThrow();
```

A write by filter needs a `where` that filters rows: an empty `where`, or one
whose conditions are all `undefined`, returns `invalid_request` instead of
writing every row. Pass `allowAll: true` to `update` when you mean the whole
table; for `delete`, filter on a column that every row matches. An `update`
by key whose patch sets no columns changes nothing: it reads the current row with the same `where`, `select` and
`include` and returns it (`null` with `returning: false`), or `not_found` when
no row matches. An `update({ where })` with an empty patch returns
`{ count: 0 }`, or
the matching rows with `returning: true`. This lets you build a patch from
optional fields without a guard. When an extension such as
[updated-at](/docs/blocks/updated-at) is installed, an empty patch still
runs as a write, because the plugin may fill in a column; a plugin that
leaves it empty gets `invalid_request`.

On a table with a [trash](/docs/blocks/trash#in-the-repository) column, a
`delete({ where })` 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 an `update` or `delete` by filter may change.
When the `where` matches more, the write fails with `max_affected` (400) and
changes nothing:

```ts
const result = await db.sessions.delete(
  { 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/frontend/powersync) count the rows inside the statement or
transaction and roll the write back. The `requireMaxAffected` guard in
[`guards()`](/docs/repository/guards) and the `require-max-affected`
[lint rule](/docs/repository/lint) make the cap mandatory.

## Counting writes [#counting-writes]

`update` and `delete` by filter count the written rows exactly. Pass
`count: "planned"` or `count: "estimated"` to use the Postgres planner's
estimate over PostgREST instead; `create` and `upsert` with an array and
`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.upsert(rows, {
  onConflict: ["organizationId", "name"],
  ignoreDuplicates: true,
});
```

`upsert` with an array 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]

`create` and `upsert` with an array 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" });
}
```

# 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"
blocks: {
  streams: { api: "api" },
  "ai-chat": { api: "api" },
  "ai-files": { api: "api" },
  knowledge: { api: "api" },
  memory: { api: "api" },
  agents: { api: "api" },
  connectors: { api: "api" },
  "ai-schedules": { api: "api" },
  "ai-keys": { 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-schedules` | prompts on a schedule                                                 | [AI tasks](/docs/blocks/ai-schedules) |
| `ai-keys`      | tenant keys, batches and sandboxes                                    | [AI providers](/docs/blocks/ai-keys)  |
| `ai-cache`     | repeated model calls answered from Postgres                           | [AI cache](/docs/blocks/ai-cache)     |

Run `better-supabase upgrade`, generate a migration with
`supabase db schema declarative sync`, then `better-supabase add --adopt`.

### 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-schedules) 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"
blocks: {
  queue: {},
  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/queue)            |
| `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-editor);
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"
blocks: {
  queue: {},
  credentials: { api: "api" },
  workflows: { api: "api" },
  "workflow-sdk": {},
  "workflow-editor": { api: "api" },
},
```

| Module            | Gives the builder                                                    | Page                                             |
| ----------------- | -------------------------------------------------------------------- | ------------------------------------------------ |
| `workflows`       | the engine-neutral run registry, schedules, semaphores and admission | [Workflows](/docs/blocks/workflows)              |
| `workflow-sdk`    | the Workflow SDK's storage and queue on Postgres                     | [Workflow SDK](/docs/blocks/workflow-sdk)        |
| `workflow-editor` | definitions, versions, triggers, credentials, node runs and alerts   | [Workflow builder](/docs/blocks/workflow-editor) |
| `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/better_supabase/functions/role_permissions.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`. A schedule of a sample code workflow starts with
`workflowStarter(scheduledWorkflows, { runAs })`, where `runAs` names the
organization's owner as the publisher, so the member who created the
schedule lends it no rights; an app would usually name a
[service principal](/docs/blocks/service-accounts) there. Admission
requests start without `runAs` and run as the member who queued them. See
[Event and schedule starts run as a publisher](/docs/blocks/workflow-sdk#event-and-schedule-starts-run-as-a-publisher).

Every step of a published version runs with its publisher's rights,
capped to the permissions its nodes needed at publish, through
`versions.runAs`. A version an organization owns runs as the tenant's
service principal from the `service-accounts` block. When the publisher
leaves or loses a permission, the next step fails instead of running as the
service role. The provider's `automation.mayPublish` hook decides each
publish; [Running a step as the publisher](/docs/blocks/workflow-editor#running-a-step-as-the-publisher)
has the details, and `testAuthorizationHooks` checks that an unknown
publisher can neither publish nor run.

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

# 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`, `bsb.resource` with a list query, a custom route, and an admin route behind `bsb.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, `bsb.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 run the data hooks on `powersyncEngine`, upload with `createUploadConnector`, sign in with a magic link or OAuth, and register for push |
| `edge`           | An Edge Function with `bsb.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                                                                                                       |
| `content-mapper` | A Node script whose schema types TypeScript 7.1 gets from the [content mapper](/docs/content-mapper), type-checked with `tsc --runExternalCode`                                                          |

## Run them [#run-them]

```bash
pnpm supabase:start
pnpm build:packages
pnpm --filter @better-supabase/example-nextjs dev
```

The examples use the stack from `supabase/config.toml`. The Supabase CLI
picks its ports, and `pnpm supabase:start` writes them to
`.env.development.local`.
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 | `bsb.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, `bsb.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-editor` 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 `permissions` 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 `page` 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.

# Get started

> Install better-supabase, see an example and follow a tutorial.

Source: https://bettersupabase.com/docs/get-started

- [Installation](/docs/getting-started)
- [Optional peers](/docs/getting-started/peers)
- [Examples](/docs/examples)
- [Build a ChatGPT clone](/docs/build/chatgpt-clone)
- [Build a workflow builder](/docs/build/workflow-builder)
- [Build a unibox inbox](/docs/build/unibox-inbox)

# 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.6.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`
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({
  gen: {
    casing: "camel",
    output: "src/lib/supabase/generated.ts",
    generators: [valibot()],
  },
  blocks: { "updated-at": {}, trash: {} },
});
```

The `updated-at` and `trash` blocks keep `updated_at` current and turn deletes
into soft deletes, in Postgres and in the generated repository. 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 dev 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
  .list({
    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.118.0`           | Everything (required)                                                                                                  |
| `@supabase/postgrest-typegen`           | `>=0.6.0 <0.7`       | The CLI: `gen` 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.15`     | `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` 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`                                              |
| `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`                                                                                                 |
| `react-router`                          | `>=7.9 <9`           | `better-supabase/react-router`                                                                                         |
| `@sveltejs/kit`                         | `>=2 <4`             | `better-supabase/sveltekit`                                                                                            |
| `h3`                                    | `>=1 <3`             | `better-supabase/h3` (H3 2) and `h3/v1` (H3 1)                                                                         |
| `nuxt`                                  | `>=3 <5`             | `better-supabase/nuxt`                                                                                                 |
| `nuqs`                                  | `>=2.3 <3`           | the files the `nuqs()` generator writes (`nuqs/server`)                                                                |
| `astro`                                 | `>=7 <8`             | `better-supabase/astro`                                                                                                |
| `@solidjs/start`                        | `>=2 <3`             | `better-supabase/solid-start`                                                                                          |
| `@tanstack/react-start`                 | `>=1 <2`             | `better-supabase/tanstack-start`                                                                                       |
| `elysia`                                | `>=1 <2`             | `better-supabase/elysia`                                                                                               |
| `express`                               | `>=5 <6`             | `toExpress` in `better-supabase/node`                                                                                  |
| `fastify`                               | `>=5 <6`             | `toFastify` in `better-supabase/node`                                                                                  |
| `koa`                                   | `>=3 <4`             | `toKoa` in `better-supabase/node`                                                                                      |
| `@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/stripe`, and `better-supabase/blocks/billing`, `blocks/usage` and `commerce` unless you pass a client |
| `redis`                                 | `>=5 <7`             | `better-supabase/streams/redis` with the `url` option                                                                  |
| `yjs`                                   | `>=13.6 <14`         | `better-supabase/yjs`, unless you pass the module                                                                      |
| `jsonpath-rfc9535`                      | `>=1.3.0 <2`         | Overlays in `better-supabase/spec`, 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`                                                                                                  |
| `@ai-sdk/anthropic`                     | `>=4 <5`             | `anthropicMemory` in `better-supabase/ai-sdk/memory`                                                                   |
| `@openfeature/server-sdk`               | `>=1 <2`             | `createFlagsProvider` in `better-supabase/blocks/flags`, as an OpenFeature server provider                             |
| `@supabase-labs/middleware-openfeature` | `>=0.2.0 <0.3`       | `createFlagClient` in `better-supabase/blocks/flags`                                                                   |
| `@powersync/common`                     | `>=1 <3`             | `better-supabase/powersync`                                                                                            |
| `@vercel/connect`                       | `>=2 <3`             | `better-supabase/vercel-connect`                                                                                       |
| `@vercel/queue`                         | `>=0.8 <0.9`         | `vercelQueueProvider` in `better-supabase/blocks/queue/vercel-queue`                                                   |
| `flags`                                 | `>=4.2 <5`           | `better-supabase/flags-sdk`                                                                                            |

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.6 release,
but the CLI is tested against 0.6.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.6.0`.
`@supabase-labs/middleware-openfeature` is a 0.x Supabase Labs package, so
its range stops at the next minor release.
`flags` starts at 4.2 because `better-supabase/flags-sdk` relies on the Flags
SDK resolving an adapter factory passed by reference.

better-supabase imports none of the last six peers, and none of the
frameworks from `react-router` to `koa`: it types them structurally or loads
them on first use. They are peers so that your package manager warns you when
you upgrade one past the major releases the conformance tests cover. A
framework range that spans two majors (`@sveltejs/kit`, `h3`, `nuxt`,
`react-router`) is tested at both.

Optional peers that better-supabase loads on first use, such as `stripe` for
`{ secretKey }`, `redis` and `yjs`, load through a native `import()` that
bundlers leave alone. That works in Node, Bun and Deno, and in a bundled app
that keeps the package external. When your bundler includes the package
instead, pass the client or module yourself; for Stripe, pass
`stripeFromKey()` from `better-supabase/stripe`, which imports `stripe` by name
so the bundler and file tracing pick it up.

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"
```

# 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" },
});
```

## Caller and actor [#caller-and-actor]

Pass `caller` with the user id from the verified session. That user is the
principal of every check the runtime makes and the `user` the AI Gateway
bills; the agent row is only the actor (`{ kind: "agent", id }`) and never
becomes the subject. For a run nobody invoked, such as a schedule, pass the
user who published the agent (`agent.ownerId`).

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  tools,
  caller: { userId: session.userId, organizationId },
});
```

The runtime passes the same context to `authorizeModel`, `authorizeTool`,
`results` and the AI SDK's `runtimeContext`: `userId`, `organizationId`
(the caller's tenant, else the agent's) and `actor`. A `runtimeContext`
passed to `generate` or `stream` can't replace these keys.
`agentRunContext(agent, caller)` builds it for your own code.

### Delegated tools [#delegated-tools]

When the session itself belongs to an agent (another agent called this one
with a token from `agentClaims`), pass its actor as `actor`. If its `act`
level lists `tools`, the run gets only those tools, and a call to any other
is denied before it runs. `assertToolDelegated(actor, toolName)` throws the
same refusal, a `forbidden` `DbException` with the code
`AGENT_TOOL_NOT_DELEGATED`, for your own checks. A level without `tools`
delegates every tool, and `tools: []` delegates none. A read-only agent makes
the run's `act` read-only too, so its tools can't write either.

Only the runtime and `assertToolDelegated` read `tools`. Route guards and row
level security don't, so code that runs a tool outside the runtime must call
`assertToolDelegated` itself. Other libraries that read the same `act` claim
may deny any tool the list doesn't name, so set `tools` when you mint a token
another service checks.

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  tools,
  caller: { userId: session.userId, organizationId },
  actor: session.actor,
});
```

## Model access [#model-access]

`authorizeModel(modelId, context)` runs before every model call, with the
model's id (the string, or `modelId` of a model object). `false` or a
throw refuses the run with a `forbidden` `DbException` (code
`AI_MODEL_NOT_ALLOWED`, status 403) before the model is called, so no
tokens are spent. Every model is allowed without it, so set it when some
plans or roles may not use expensive models.

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  caller,
  authorizeModel: (modelId, { organizationId }) =>
    plans.allowsModel(organizationId, modelId),
});
```

## Tool permissions [#tool-permissions]

Each tool is the operation `ai.tool.<name>`. The runtime reads its
permission from the authorization provider's `operations` first and from
`permissions` (the tool's own key) otherwise. `false` in `permissions`
marks a tool that needs no permission. With `authorizeTool`, every call
needs the caller to hold that permission: a tool with no permission, a
`false` answer and a throw all deny the call before `toolApproval` runs.
That includes `search_knowledge`: give it a permission, or `false` when
the knowledge block's own access rules are enough.

`toolOperations` names another operation for a tool, such as
`{ void_invoice: "invoices.void" }`, so an AI tool and an MCP tool that do
the same thing check one entry in the provider's table.

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  tools,
  caller,
  operations: config.authorization?.operations,
  permissions: { search_docs: "docs.read", send_email: "email.send" },
  authorizeTool: (permission, { userId, organizationId }) =>
    can(userId, organizationId, permission),
});
```

An operation whose provider entry has `meta.risk: "high"` waits for the
user's approval, as an `ask` tool does. `agentToolOperation(name,
operations, permissions)` returns the operation, its permission and the
provider's `risk`, `effects` and `title`, for a settings page or an audit
entry.

## Search results [#search-results]

`results` drops knowledge hits the caller may not read. It gets the hits
and the run context with `toolName` and `query`, and runs before the
reranker, `onHits` and the model see them. A throw fails the tool call.

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  caller,
  knowledge: { knowledge, organizationId },
  results: (hits, { userId }) =>
    hits.filter((hit) => mayRead(userId, hit.documentId)),
});
```

`searchTool` and `recallTool` take the same `results` option when you
build them yourself.

## AI Gateway attribution [#ai-gateway-attribution]

With `caller` or `gateway`, every model call carries
[`gatewayOptions`](/docs/ai-sdk#ai-gateway): the caller's id as
`user`, then the tags `org:<tenant>`, `feature:<feature>` (default
`agent`), `agent:<id>` and your `tags`. `gateway.options` adds gateway
options such as `order`; a `user` in it is dropped, and model output or
tool input can't change these options.

```ts
const runtime = createAgentRuntime({
  agent,
  model,
  caller,
  gateway: { feature: "support", tags: ["team:billing"] },
});
```

## 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-keys), 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/queue).
Each run claims the batches whose next poll is due, so two workers never
poll the same batch:

```ts
const batches = aiBatches({
  providers: createAiKeys({ 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.

In a Next.js app, the registry item writes the server half for you:
`lib/assistant.ts` with the assistant and the tools the model may call
(`assistantTools`, built per request as the caller), and the three routes
below.

```sh
npx shadcn@latest add @better-supabase/ai-chat-next
```

## 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,
});
```

`user` is always `userId`, the caller from the verified session: a `user`
in the second argument (gateway options such as `order`) is dropped, so a
request body or a model can't change whom the spend is billed to. `tags`
in the context adds the app's tags after `org:`, `chat:` and `feature:`.
The [agent runtime](/docs/ai-sdk/agents#ai-gateway-attribution) sets both
for every model call.

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

## Thread summaries [#thread-summaries]

`summarizeWith(model, options)` is the summarizer
[`messaging.summarize`](/docs/blocks/messaging#thread-summaries) takes. It
calls `generateText` with an AI Gateway model string and tags the spend with
the tenant and the `summary` feature:

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

const summarizer = summarizeWith("anthropic/claude-sonnet-4.5", {
  providers, // the tenant's own keys as BYOK, when the app installs ai-keys
  maxOutputTokens: 400,
});

const summary = await messaging
  .summarize({ conversationId }, summarizer)
  .orThrow();
```

With `providers`, the [AI providers block](/docs/blocks/ai-keys), each
summary goes through [`tenantGatewayOptions`](/docs/ai-sdk/providers), so a
tenant with a saved key pays for its own summaries. `instructions` replaces
the default prompt and `gateway` adds AI Gateway options such as `order`.

## Jobs [#jobs]

Two [job](/docs/blocks/queue) 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/queue) 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-keys) 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.

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

Each file on a channel message becomes a pending attachment on the
message. Platform file URLs usually need the bot's token, so pass
`copyFile` to return a source the service can read (a signed URL, or a
Storage path you already wrote); without it only files with a public URL
are kept.

`importFiles({ write, read, limit })` fetches the pending files, oldest
first, writes each with `write` to the bucket and path the attachments
block chose, and confirms it so the scan can mark it clean. A file that
can't be read or written is marked failed. Run it from a cron route or a
job after `drain`:

```ts title="app/api/cron/inbox-files/route.ts"
export async function GET() {
  return Response.json(
    await handler.importFiles({
      write: async (file, data) => {
        const { error } = await admin.storage
          .from(file.bucket)
          .upload(file.path, data, { contentType: file.mimeType });

        if (error) {
          throw error;
        }
      },
    }),
  );
}
```

`read` defaults to fetching `file.source.url`; pass it for sources with
only a `path`, such as the files the upgrade moved from `inbox-files`.

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

# Identities

> Run bot jobs as the chat author's linked user, decide approval buttons through the authorization provider, and link approval prompts to the app.

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

A chat message names a platform user, not an app user. These helpers resolve the author through `principalByIdentity`, usually the [identities block](/docs/blocks/identities), and act only for a linked user. An unlinked author stays anonymous.

Each helper takes `principalByIdentity` directly or reads it from `bsb.authorization.identities`, where `bsb` is the `createServer(betterSupabase, { authorization })` instance; pass it as `server: bsb`. The option passed to the helper wins.

## Bot jobs as the linked user [#bot-jobs-as-the-linked-user]

`botJobs` resolves the author, then enqueues the job with a ticket for a linked user. The worker's `forContext(job.context)` turns the ticket back into that user's context through the provider's `tickets.subject`, so the bot reads and writes with the user's row level security.

```ts title="lib/bot.ts"
import { botJobs, chatIdentityOf } from "better-supabase/chat-sdk";

const botQueue = botJobs({
  server: bsb,
  enqueue: (payload, options) => jobs.enqueue("bot_reply", payload, options),
  ticketFor: (userId) => tickets.issue(userId),
});

bot.onNewMention(async (thread, message) => {
  await botQueue.enqueue(chatIdentityOf(message.author, thread.adapter), {
    threadId: thread.id,
    text: message.text,
  });
});
```

`ticketFor` is the app's: the authorization provider issues the opaque ticket its `tickets.subject` accepts. An author with no link, or one linked to a contact, gets no ticket and no context, so the job runs anonymous. It never falls back to the bot's service role. When the resolver or `ticketFor` throws, nothing is enqueued and the promise rejects.

Pass the `options` argument to `jobs.enqueue` as it is. Adding a `context` there would give an unlinked author's job a user.

## Approval buttons [#approval-buttons]

`chatActions` decides a button press through the provider's `chat.onAction` hook. It resolves the author, then calls `onAction(action, author)`.

```ts title="lib/bot.ts"
import { chatActionOf, chatActions } from "better-supabase/chat-sdk";

const decide = chatActions({ server: bsb });

bot.onAction("approve", async (event) => {
  const decision = await decide(chatActionOf(event));
  if (!decision.allowed) {
    await event.thread?.post("You can't approve this request here.");
    return;
  }
  await approvals.approve(event.value, { as: decision.userId });
});
```

Everything except `allowed: true` refuses, with a `reason`:

| `reason`    | When                                                                              |
| ----------- | --------------------------------------------------------------------------------- |
| `unlinked`  | The author has no link. The hook never runs.                                      |
| `unhandled` | There is no `onAction` hook, or it returned `undefined`.                          |
| `refused`   | The hook returned `allowed: false`, or a `userId` other than the linked author's. |
| `error`     | The resolver or the hook threw or rejected.                                       |

For a user author, `decision.userId` is always the linked user. For a contact, it is the user the hook named, if any.

## Approval prompts [#approval-prompts]

An approval sent by chat or email links to the app's approval page. Opening the link approves nothing: the approver signs in, and the page's server action or POST route decides. better-supabase has no GET handler that approves.

```ts
import { approvalLink, approvalPrompt } from "better-supabase/chat-sdk";

const request = { kind: "tool", subject: approvalId, requester: userId };

await thread.post(
  approvalPrompt({
    page: "https://app.example.com/approvals",
    request,
    text: "The agent wants to send invoice 1042.",
  }),
);

const emailUrl = approvalLink("https://app.example.com/approvals", request);
```

`approvalLink` adds `kind` and `subject` as query parameters. `approvalPrompt` returns `{ markdown }` with the link, labelled `Review the request` unless you pass `label`.

# 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 (thread id `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  |
| `identities` | none     | `principalByIdentity` (or `server`) and `ticketFor`: tickets for linked contacts |

## 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`.

## Answering as the linked user [#answering-as-the-linked-user]

The bot writes its replies as the service, but what it looks up for a
visitor should stay within what that visitor may see. With `identities`,
`dispatch` resolves the contact before the handlers run, the same way
[`botJobs`](/docs/chat-sdk/identities#bot-jobs-as-the-linked-user) does:
it asks `principalByIdentity` about `inboxIdentityOf(contactId)` (`kind:
"chat"`, `provider: "inbox"`), and for a contact linked to a signed-in user
it calls `ticketFor(userId)`. A handler reads the ticket with `ticketOf`
and runs its lookups through `forContext`:

```ts title="lib/bot.ts"
export const inboxBot = inboxAdapter({
  inbox,
  identities: { server: bsb, ticketFor: (userId) => tickets.issue(userId) },
});

bot.onSubscribedMessage(async (thread, message) => {
  const ticket = inboxBot.ticketOf(message);
  if (ticket === undefined) {
    await thread.post("Sign in to see your orders.");
    return;
  }
  const db = await bsb.forContext({ ticket });
  const orders = await db.orders.list().orThrow();
  await thread.post(`You have ${String(orders.length)} open orders.`);
});
```

A contact with no link, or one the resolver maps to a contact, stays a
visitor: `ticketOf` returns `undefined`, and nothing falls back to the
service role. When the resolver or `ticketFor` throws, `dispatch` rejects
before the handlers run. The ticket is only readable while the handlers
for that dispatch run.

## 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 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 |
| [Identities](/docs/chat-sdk/identities)       | `botJobs`, `chatActions` and approval prompts for linked chat users  |

## 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, sends the scheduled
   replies whose time came, 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/queue) 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.

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

# 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 add workflow-sdk 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.

# Flags SDK

> Declare flags with the Vercel Flags SDK and evaluate them from the flags block, per user, per tenant and per custom context such as a brand, with precompute and Flags Explorer.

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

`better-supabase/flags-sdk` connects the [Flags SDK](https://flags-sdk.dev)
to the [flags block](/docs/blocks/flags). You declare flags with `flag()`
from `flags/next`, and the adapter answers them from the flags table with
the same rules, overrides and rollout buckets as `flag_enabled()` in your
policies.

```bash
pnpm add flags
pnpm better-supabase add flags
```

`flags` is an optional peer (`>=4.2 <5`). The entry types the SDK
structurally and never imports it, so it adds nothing to a bundle that
doesn't use it, and a type test fails when a new `flags` release changes the
`Adapter` shape. The entry imports no Node built-ins, so it runs in
`proxy.ts`.

| Export                  | Does                                                                                     |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| `betterSupabaseAdapter` | an adapter factory over the flags block, with one definitions cache for every flag       |
| `createIdentify`        | an `identify` that returns the user, the tenant and custom contexts from verified claims |
| `getProviderData`       | Flags Explorer data for every flag that isn't archived                                   |
| `flagOptions`           | a flag's variants as `options`, which a precomputed flag needs                           |
| `flagContextOf`         | the evaluation context the adapter builds from the entities                              |

The [Next.js example](/docs/examples) wires all of them.

## Declaring flags [#declaring-flags]

`betterSupabaseAdapter` takes the options of `createFlagsProvider`: a
`transport` (with `schema` for the Data API) or fixed `definitions`, and a
`ttl` (30 seconds by default). It loads every flag with
`flag_definitions()`, which only the service role may call, so give it a
transport on the secret key. Never prefix that key with `NEXT_PUBLIC_`.

```ts title="src/flags.ts"
import "server-only";

import { createClient } from "@supabase/supabase-js";
import { rpcTransport } from "better-supabase/blocks";
import {
  betterSupabaseAdapter,
  createIdentify,
  type FlagsSdkEntities,
} from "better-supabase/flags-sdk";
import { dedupe, flag } from "flags/next";

const service = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

export const bsFlags = betterSupabaseAdapter({
  transport: rpcTransport(service),
  schema: "api",
  origin: "https://admin.acme.test/flags/{key}",
});

const identify = dedupe(
  createIdentify({
    claims: async ({ cookies }) => readVerifiedClaims(cookies),
    contexts: ({ headers }) => ({ brand: brandOf(headers.get("host")) }),
  }),
);

export const newEditor = flag<boolean, FlagsSdkEntities>({
  key: "new_editor",
  adapter: bsFlags(),
  defaultValue: false,
  identify,
});
```

Every adapter the factory makes shares one cache and one `adapterId`, so
`evaluate([...])` decides a batch from one load. A flag that is missing,
has another type or fails to load answers the declaration's
`defaultValue`, so a database outage turns features off instead of
breaking the page. Without a `defaultValue`, `decide` throws.

A variant whose value is `null` is served only when `defaultValue` is
`null` or left out. With a `null` default, any other value must match the
flag's `type`; with another default, it must have the default's type. A flag whose default variant is
missing from its variants is treated as missing, not as `null`. A row
whose `type` is not one the table allows never serves its variants; it
always answers the fallback.

`origin` tells Flags Explorer where each flag is managed: a URL in which
`{key}` is replaced by the flag key, or a function of the key.

## Identifying the caller [#identifying-the-caller]

`createIdentify` builds on [`flagContext`](/docs/blocks/flags#with-openfeature).
It returns these entities:

| Entity         | Holds                                                                         |
| -------------- | ----------------------------------------------------------------------------- |
| `user`         | `{ id }`, the user id from the claims                                         |
| `organization` | `{ id, plans, roles }`, the active tenant with its features and roles         |
| `brand`, ...   | `{ id }` for each custom context, named as in `blocks.flags.options.contexts` |

`claims` must return claims you verified locally, such as from
`getClaims()` or `bsb.context()`; `identify` runs for every flag and never
calls the Auth server. `tenant` picks the active tenant when the claims
don't carry it, and `contexts` adds custom context values from a header,
the host or a cookie. `contextClaims` from `flagContext` also works, such as
`{ brand: "brand_id" }`. Wrap the result in `dedupe` so it runs once per
request.

The entity names are the ones Vercel Flags targets (`user.id`,
`organization.id`, `brand.id`), so one `identify` serves both adapters. Move
a flag to [Vercel Flags](https://vercel.com/docs/flags) by changing its
adapter:

```ts
import { vercelAdapter } from "@flags-sdk/vercel";

export const checkoutTheme = flag<string, FlagsSdkEntities>({
  key: "checkout_theme",
  adapter: vercelAdapter(),
  defaultValue: "classic",
  identify,
});
```

The flags block is still what policies check, so keep a flag that guards
data in the flags block.

## Precompute [#precompute]

A [precomputed](https://flags-sdk.dev/docs/frameworks/next/precompute) flag
is evaluated once in `proxy.ts`, and the request is rewritten to a static
page for that combination of values. List the flag's `options` so
`generatePermutations` knows every combination; `flagOptions(definition)`
builds them from a flag's variants.

```ts title="src/proxy.ts"
import { precompute } from "flags/next";

import { pricingFlags } from "./flags";

if (path === "/pricing") {
  const code = await precompute(pricingFlags);
  return NextResponse.rewrite(new URL(`/pricing/${code}`, request.url));
}
```

```tsx title="app/pricing/[code]/page.tsx"
import { generatePermutations } from "flags/next";

import { pricingBanner, pricingFlags } from "@/flags";

export async function generateStaticParams() {
  const codes = await generatePermutations(pricingFlags);
  return codes.map((code) => ({ code }));
}

export default async function Pricing({
  params,
}: PageProps<"/pricing/[code]">) {
  const { code } = await params;
  const banner = await pricingBanner(code, pricingFlags);
  // ...
}
```

`precompute` signs the code with `FLAGS_SECRET`, a random 32-byte base64url
string. Set it in every environment (`openssl rand -base64url 32`).

Precompute only flags with a few values per visitor: a brand, a plan, a
rollout bucket. A per-user flag multiplies the permutations, and a static
page built for one user's variant can reach another user. Read those flags
dynamically.

## Dashboard pages [#dashboard-pages]

On a signed-in page, read the flag at request time, as in the
[dashboard pages guide](https://flags-sdk.dev/docs/frameworks/next/guides/dashboard-pages):

```tsx
const showEditor = await newEditor();
```

The flag only decides what the page shows. Keep the data behind it in a
policy with `flag_enabled()` or `tenant_ids_with_flag()`, so a request that
skips the page still meets the same flag:

```sql
create policy "drafts_insert" on public.drafts for insert to authenticated
  with check (better_supabase.flag_enabled('new_editor', organization_id));
```

The adapter and `flag_evaluation()` agree, with one difference: the adapter
never reads a custom context from the tenant table, so pass it through
`identify` when a rule targets it.

## Flags Explorer [#flags-explorer]

[Flags Explorer](https://vercel.com/docs/flags/flags-explorer) in
the Vercel Toolbar reads the flags from a discovery route. `getProviderData`
lists every flag in the flags block that isn't archived, with its
description, options, origin and last change; merge it with the declared
flags:

```ts title="app/.well-known/vercel/flags/route.ts"
import { getProviderData } from "better-supabase/flags-sdk";
import { mergeProviderData } from "flags";
import {
  createFlagsDiscoveryEndpoint,
  getProviderData as getDeclared,
} from "flags/next";

import { bsFlags, newEditor, pricingBanner } from "@/flags";

export const GET = createFlagsDiscoveryEndpoint(async () =>
  mergeProviderData([
    getDeclared({ newEditor, pricingBanner }),
    await getProviderData(bsFlags),
  ]),
);
```

The route answers only requests signed with `FLAGS_SECRET`. A failed load
shows as a hint in Explorer instead of an error, and so does a flag with
an unknown type. Exclude
`/.well-known/vercel/` from the proxy matcher. An override set in the
toolbar applies before the adapter's `decide`.

## Reporting exposures [#reporting-exposures]

`flag()` reports each value to Vercel Analytics. To send evaluations
elsewhere, wrap the adapter. This one sends PostHog's
`$feature_flag_called` event:

```ts
import type { FlagsSdkAdapter } from "better-supabase/flags-sdk";

function reported<V>(adapter: FlagsSdkAdapter<V>): FlagsSdkAdapter<V> {
  return {
    ...adapter,
    async decide(params) {
      const value = await adapter.decide(params);
      posthog.capture({
        distinctId: params.entities?.user?.id ?? "anonymous",
        event: "$feature_flag_called",
        properties: {
          $feature_flag: params.key,
          $feature_flag_response: value,
        },
      });
      return value;
    },
  };
}
```

For OpenTelemetry, record a `feature_flag.evaluation` event with
`feature_flag.key` and `feature_flag.result.value` on the active span in the
same place.

## Not built in [#not-built-in]

These are left to the app or to other tools for now:

- Reusable segments, such as a list of beta customers that many rules share.
  A rule lists its tenants, users, plans, roles and contexts itself.
- Self-serve previews that a tenant admin turns on. Build them as a flag
  with tenant overrides that your settings page writes.
- Scheduled changes. Run `flags.save` from a [job](/docs/blocks/queue).
- Approval before a change. A `save` hook can require one (see
  [extending blocks](/docs/extending/blocks)).
- Experiments and metrics. Use PostHog or Statsig through their own Flags
  SDK adapters.
- Brands as a table of their own. A brand is a custom context read from your
  tenant table (see [custom contexts](/docs/blocks/flags#custom-contexts)).

# Integrations

> Adapters for the AI SDK, Chat SDK, eve, the Flags SDK and the Workflow SDK.

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

- [AI SDK](/docs/ai-sdk)
- [Chat SDK](/docs/chat-sdk)
- [eve](/docs/eve)
- [Flags SDK](/docs/flags-sdk)
- [Workflow SDK](/docs/blocks/workflow-sdk)

# 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 = bsb.handler(async (_request, ctx) => {
  const customers = await ctx.db.customers
    .list({ 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

> Declare which tables the Data API roles reach, for projects that don't grant new tables automatically.

Source: https://bettersupabase.com/docs/guides/data-api-grants

Hosted Supabase projects grant every new table in `public` to `anon`,
`authenticated` and `service_role` through default privileges, so RLS is the
only gate. A project can turn those default privileges off, and locally
`[api] auto_expose_new_tables = false` in `supabase/config.toml` does the same.
Then 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
default privileges are off are the ones that break. Declaring grants also
keeps the Data API surface to the tables you list, whichever default the
project has.

## 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({
  database: {
    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"] },
    },
  },
  blocks: ["grants"],
});
```

Then write the grants into your declarative schema and diff a migration:

```bash
better-supabase 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)"],
  },
},
```

To grant every column but a few, start the list with `*` and exclude columns
with `-`. `upgrade` reads the table's `create table` in your schema files and
writes the remaining columns in declaration order, so a new column is granted
on the next sync without a config change:

```ts title="better-supabase.config.ts"
expose: {
  "public.api_keys": {
    authenticated: ["select(*, -token_hash)", "update(name)"],
  },
},
```

`upgrade` fails when no schema file declares the table, or when the
exclusions leave no column. Doctor's [BS339](/docs/cli/doctor#bs339) warns
about an excluded column the table no longer has, since a renamed secret
column is then granted under its new name. An app that renders the `grants` module itself
with `renderBlocks` from `better-supabase/sql` passes the layout's grants
through `resolveExposeColumns(grants, schemaFiles, schemas)` first; the
module refuses an unresolved `*`.

The CLI validates the config with the same rule, so `upgrade` 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({
  database: { schemas: ["public"] },
  blocks: { grants: { options: { fromPolicies: true } } },
});
```

Only tables in the generated `schemas` are derived. Run `upgrade` 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({
  gen: { 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 opt-out of automatic grants, 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) whose
`message` is `permission denied`. The table name and PostgREST's `GRANT ...`
suggestion stay in `details`, which Problem Details responses never send to
the client. The `hint` points at `expose` without naming the table:

```ts
const { error } = await db.pricingPlans.list();
// error.kind === 'forbidden'
// error.message === 'permission denied'
// error.details === 'permission denied for table pricing_plans\nGrant the required privileges ...'
// error.hint === 'The Data API role has no grant on this table ... add it 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/queue) prefers a direct connection. Over the Data
API, set `blocks.queue.api` and pass `client` with `schema` (often `api`): jobs
then call the wrappers in that schema and keep the full feature set. A
`client` without `schema` 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()`, or over the
wrappers with `client` and `schema`.

## Acting as a user without Postgres [#acting-as-a-user-without-postgres]

`bsb.forContext` and `bsb.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 `database.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/       bsb.route and bsb.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 `dev 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({
  gen: { output: "src/generated.ts" },
  database: {
    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.list({
    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 the PowerSync engine, and upload the changes through the repositories.

Source: https://bettersupabase.com/docs/guides/offline-first

An offline-first app keeps two clients over the same definition:

- `bsb`: the [React Native client](/docs/frontend/react-native) with
  [`powersyncEngine`](/docs/frontend/powersync). Screens read and write
  through `bsb.db` and the data hooks, which run on the PowerSync database,
  online or not.
- `server`: `bindClient(betterSupabase, supabase)` 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
`server.db` means RLS, block hooks 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 { bindClient } from "better-supabase/client";
import { createUploadConnector } from "better-supabase/powersync";
import { betterSupabase } from "../supabase";
import { supabase } from "../supabase/supabase.native";

const server = bindClient(betterSupabase, supabase);

export const connector = createUploadConnector({
  endpoint: process.env.EXPO_PUBLIC_POWERSYNC_URL,
  supabase,
  tables: { customers: server.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: server.db.customers,
  notes: (entry) => server.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.

`uploadRoute(repository, options)` builds the same function from a
repository, typed by the repository's insert and key types, so wrapping one
needs no cast. By default a `PUT` upserts, a `PATCH` updates and a `DELETE`
deletes. For a table whose rows are written once, such as attachments, pass
`put: "create"`: a `PUT` inserts with `create`, and a `conflict` counts as
applied, because the row is already on the server from an earlier upload of
the same change. A `PATCH` on that route fails with `unsupported` and is
discarded; a `DELETE` still deletes.

```ts
import { uploadRoute } from "better-supabase/powersync";

tables: {
  customers: server.db.customers,
  attachments: uploadRoute(server.db.attachments, { put: "create" }),
},
```

To compose a route yourself, call the route `uploadRoute` returns inside your
own function, or type a helper with `UploadRepository<Insert, Key>`; every
generated repository fits it.

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, bsb.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.

PowerSync retries on its own, so a failed retry and a failed session read
don't throw to your code. Pass `onError` to log them. When `getSession`
fails, `fetchCredentials` returns `null` and calls `onError` with a
`DbError` of kind `unauthorized` (or `network` when the Auth server is
unreachable) and `{ phase: "credentials" }`. An upload error that keeps the
batch queued arrives with `{ phase: "upload" }`. A signed-out session is not
an error:

```ts
createUploadConnector({
  endpoint: process.env.EXPO_PUBLIC_POWERSYNC_URL,
  supabase,
  tables: { customers: server.db.customers },
  onError: (error, { phase }) => log.warn(`powersync ${phase}`, error),
});
```

## 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. Pass
the connector to the engine, `powersyncEngine(powersync, { connector })`, and
`useConflicts()` from `better-supabase/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 `powersyncEngine`, `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 bsb = createNext(betterSupabase, {
  readUrl: process.env.REPLICA_URL,
  replicas: { pinMs: 5000 },
});
```

Nothing else changes: `ctx.db` routes each call.

| Call                                                                    | Goes to         |
| ----------------------------------------------------------------------- | --------------- |
| `list`, `get`, `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 `bsb.fetchHandler` writes, the
response sets an `HttpOnly` cookie `bsb-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]

`bsb.context(request)` reads the cookie for every adapter, so Hono, oRPC
and edge functions honor a pin set by Next.js. Only `bsb.action` and
`bsb.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 dev keys`](/docs/cli/dev#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.list({
        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 bsb = 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(bsb.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   | `bsb.forContext(job.context)`          |
| A webhook or a script | a user you look up from the event           | `bsb.actingAs(userId, { tenant_id })`  |
| Work no user owns     | the service role, bypassing RLS             | `bsb.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`. For the work no user
owns, [cron jobs and webhooks](/docs/auth/server#cron-jobs-and-webhooks)
lists which `bsb.admin()` handle covers rows, Storage and Auth admin.

## 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,
`bsb.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 bsb.forContext(job.context).orThrow();
  const invoice = await db.invoices.get(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.

`bsb.admin(job.context)` and `admin.$with(job.context)` stamp `createdBy` and
apply the memberships extension'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 bsb = 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/queue#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 = bsb.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
`bsb.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).

# Production

> Run better-supabase in production and test it against the local stack.

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

- [Testing](/docs/testing)
- [Monorepo](/docs/guides/monorepo)
- [Read replicas](/docs/guides/read-replicas)
- [Offline-first](/docs/guides/offline-first)
- [Limitations](/docs/guides/limitations)

# 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 dev 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 dev 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 dev env`](/docs/cli/dev#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.

supabase-js warns when its trace propagation option is on and the runtime
has no OpenTelemetry tracing loaded. Test users switch it off; pass
`{ tracePropagation: true }` to keep it.

`signLocalJwt(claims)` signs a token the same way without building
repositories, for requests you send yourself.

## Command tests [#command-tests]

`alice.context` is the server `RequestContext` for the user: the actor and
the claims `asUser` signed. Together with `alice.supabase` and `alice.db` it
builds the request a command or handler receives, so a test calls the
function directly and RLS still applies:

```ts title="archive-customer.test.ts"
import { asUser } from "better-supabase/testing";

import { archiveCustomer } from "./archive-customer";

const alice = await asUser(betterSupabase, { sub: aliceId, tenant_id: acme });

await archiveCustomer(
  { supabase: alice.supabase, db: alice.db, context: alice.context },
  { id: customerId },
);
```

The token is `alice.token`. Send it as `Authorization: Bearer` when the
command sits behind a route and you call the route over HTTP instead.

## Isolation between tests [#isolation-between-tests]

Tests that run at the same time must not share rows. These helpers cover the
ways to do that:

| Helper                              | Isolates with                                   | Covers                     |
| ----------------------------------- | ----------------------------------------------- | -------------------------- |
| `scopedSeed(betterSupabase, build)` | A fresh tenant and rows per test, deleted after | `db` over PostgREST, `sql` |
| `withRollback(user, fn)`            | One transaction that always rolls back          | `sql` only                 |
| `withClaims(postgres, claims, fn)`  | One transaction that always rolls back          | raw SQL as a user          |

`scopedSeed` creates a new tenant id, inserts the fixtures that `build`
returns through the service role, and returns the inserted rows.
`cleanup()` (or `await using`) deletes them in reverse order, the tenant row
last. It needs `stack.secretKey` or `$SUPABASE_SECRET_KEY`. Use `id()` for
rows that other fixtures reference:

```ts title="customers.test.ts"
import { test } from "node:test";
import { asUser, scopedSeed } from "better-supabase/testing";

test("lists the tenant's customers", async () => {
  await using scope = await scopedSeed(
    betterSupabase,
    ({ tenant }) => ({
      customers: { first: { organizationId: tenant, name: "First" } },
    }),
    {
      tenant: {
        table: "organizations",
        row: (id) => ({ id, name: id, slug: id }),
      },
    },
  );

  const alice = await asUser(betterSupabase, {
    sub: aliceId,
    tenant_id: scope.tenant,
  });

  expect(await alice.db.customers.count().orThrow()).toBe(1);
});
```

`node:test` runs the tests of one file one after another and the files in
parallel, and each call gets its own tenant, so the files never see each
other's rows. Give the tenant row a unique `slug` or name, as above.

`withRollback(alice, fn)` runs `fn` as the user inside one transaction over
direct Postgres, passes it the same repositories as `alice.sql`, and rolls
back when `fn` returns or throws. It returns what `fn` returned. It needs
`asUser` to have been given `postgres`, and throws a clear error otherwise.
Rollback covers only that `sql` path: `alice.db` talks to PostgREST over
HTTP and commits on its own, so a write through `db` stays.

```ts
const alice = await asUser(betterSupabase, claims, { postgres });

const count = await withRollback(alice, async (db) => {
  await db.customers.create({ organizationId: acme, name: "Temp" }).orThrow();

  return db.customers.count().orThrow();
});
```

`withClaims(postgres, claims, fn)` does the same for raw SQL, without
`asUser`. It runs `fn` with a `SqlClient` inside one transaction that sets
`role` (from `claims.role`, else `authenticated`) and `request.jwt.claims`,
so `auth.uid()`, `auth.jwt()` and RLS answer as they would for that user, and
it always rolls back:

```ts
import { createPostgres } from "better-supabase/postgres";
import { withClaims } from "better-supabase/testing";

const postgres = createPostgres({
  connectionString: process.env.SUPABASE_DB_URL,
});

await withClaims(postgres, { sub: aliceId, tenant_id: acme }, async (sql) => {
  const [row] = await sql.queryRaw<{ id: string }>("select auth.uid() as id");
  expect(row?.id).toBe(aliceId);
});
```

## Jest and jest-expo [#jest-and-jest-expo]

better-supabase ships ES modules only, on purpose, so Jest has to transform
it. Add it to the allow-list of `transformIgnorePatterns` (Jest skips
`node_modules` by default), next to the packages your preset already lists:

```js title="jest.config.js"
module.exports = {
  preset: "jest-expo",
  transformIgnorePatterns: [
    "node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|better-supabase))",
  ],
};
```

Under the `jsdom` environment, older Jest releases have no `TextEncoder` or
`TextDecoder` global. better-supabase creates them when it first decodes a
session, not on import, so importing the client works either way. If a test
decodes a session in such an environment, polyfill both before it runs, for
example in `setupFiles` with the ones from `node:util`. The same applies to
[Expo](/docs/frameworks/expo#jest-and-jest-expo).

## Fixtures [#fixtures]

Share rows between `supabase db reset` and your tests with a
[typed seed](/docs/cli/db#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,
});
```

A [tenant seed](/docs/cli/db#seeds-per-tenant) fills one tenant from its
id, so a test can give each organization it creates the same rows:

```ts
import { seed as tenantSeed } from "../supabase/seed.tenant.ts";

const { id } = await organizations.create({ name: "Test org" }).orThrow();
await tenantSeed.insert(postgres.admin, { id });
```

`supabaseClaimFixtures` holds access-token claims where someone acts for the
user, with the `created-by`, `impersonator` and `delegation` each one reads as:
`supportSession`, `supportSessionReadOnly`, `impersonation`, `oauthClient`,
`agentChain`, `agentRun` and `agentRunReadOnly`. `supabaseInvalidClaimFixtures`
holds the claims better-supabase refuses (the 0.5.0 support token and
malformed `act` levels), each expecting an invalid session with code
`ACTOR_INVALID`. They follow the claim contract every hook writes, so a test
can sign one and check a guard or a support banner. A library that reads the
same claims can call `actorOf` and `delegationOf` from `better-supabase/server`
on each fixture for a parity test:

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

## Factories [#factories]

`factories()` from `better-supabase/config` writes `<output>.factories.ts`,
with one factory per table that returns a valid insert row. Defaults follow
enums, CHECK bounds and `StoragePath` buckets, a foreign key takes the parent
row or its id, and a column without a safe default is a required input.
Block entities also get a factory under their final singular noun, such as
`factories.task`.

```ts title="better-supabase.config.ts"
import { defineConfig, factories } from "better-supabase/config";

export default defineConfig({ gen: { generators: [factories()] } });
```

```ts title="tests/tasks.test.ts"
import { factories } from "@/lib/supabase/generated.factories";

const acme = await alice.db.customers.create(factories.customers()).orThrow();
const task = factories.tasks({ customerId: acme, title: "Call back" });
const many = factories.tasks.many(20, { customerId: acme });
await factories.tasks.create(alice.db.tasks, { customerId: acme });
```

Each call numbers its row, and text and uuid defaults include that number, so
rows from one factory differ. `many(count, input)` builds a list, and `create(repository, input)` passes
the row to a repository's `create`. `tables` limits the file to some tables,
and `testingImport` changes where it imports `defineFactory` and
`factoryTime` from (`better-supabase/testing` by default). Both are exported
for factories you write by hand.

## 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 bsb = 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.

### Hermetic server tests [#hermetic-server-tests]

A unit test that builds its own server should not depend on the variables
your shell exports or the stack that happens to be running. Pass the
variables as an object, and the keys inline:

```ts title="server.test.ts"
import { createServer } from "better-supabase/server";
import { createTestSigner } from "better-supabase/testing";

const signer = await createTestSigner();

const bsb = createServer(betterSupabase, {
  env: {
    SUPABASE_URL: "http://127.0.0.1:54321",
    SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test",
  },
  auth: { jwks: signer.jwks },
});

const ctx = await bsb.context(
  new Request("https://app.test/", {
    headers: { authorization: `Bearer ${await signer.sign({ sub: aliceId })}` },
  }),
);
```

An `env` object is the only source the server reads: `SUPABASE_JWKS` or a
stale `SUPABASE_URL` in `process.env` is ignored. With `auth: { jwks: false }`
no key is looked up at all, so every ES256 token resolves as invalid.
[createServer](/docs/auth/server#env-and-keys) lists the full order.

## 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 bsb = 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 and extensions, so the [memberships extension](/docs/blocks/memberships)
  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
[`bsb.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 `bsb.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 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.every_table_has_rls(schema)`                     | Fails and lists every table in the schema without row level security (`tests.rls_enabled` is the same check) |
| `tests.policies_use(schema, pattern)`                   | Fails and lists every policy whose `using` and `with check` expressions don't match the regular expression   |
| `tests.no_anon_policies(schema)`                        | Fails and lists every policy that applies to `anon`, by name or through `public`                             |
| `tests.every_table_audited(schema, exempt)`             | Fails and lists every table without an audit trigger; `exempt` holds `like` patterns for table names         |
| `tests.partitions_have_rls(schema)`                     | Fails and lists every partition of an RLS table that has RLS off, the default partition included             |

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 `upgrade` after
upgrading to rewrite a module file from an earlier version.

# External access tokens

> Accept RFC 9068 JWT access tokens from your own or a platform's authorization server, verified against its JWKS, so the request runs as the user under RLS.

Source: https://bettersupabase.com/docs/auth/access-tokens

`accessTokenResolver` accepts JWT access tokens that an authorization server
other than Supabase Auth issued, such as a platform's OAuth server or one you
built with the [oauth-server block](/docs/blocks/oauth-server). It verifies
the token against the issuer's JWK Set and resolves it to a `user` caller with
`source: "oauth"`. The token's `sub`, `client_id` and `scope` become the
request's claims, so RLS and the delegated-token narrowing see the same user
and the same scopes.

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

export const bsb = createHono(betterSupabase, {
  postgres,
  auth: {
    resolvers: [
      accessTokenResolver({
        jwks: new URL(env.OAUTH_JWKS_URL),
        issuer: env.OAUTH_ISSUER,
        audience: "https://api.example.com",
      }),
    ],
  },
});
```

The claims reach Postgres unchanged, so `auth.uid()` returns the token's `sub`,
a policy such as `owner = (select auth.uid())` filters rows for that user, and
`request.jwt.claims` keeps `client_id` and `scope` for policies that check
them. The pgTAP test `oauth_token_claims.test.sql` in this repository covers
that path.

Resolvers run before the Supabase Bearer and cookie checks. The resolver
only claims an `Authorization: Bearer` token whose header has `typ: "at+jwt"`
(RFC 9068 section 2.1), so a Supabase session token or an API key falls
through to the usual resolution.

## What it checks [#what-it-checks]

A claimed token that fails a check answers 401 and never falls through to
another resolver.

| Check                                                                                  | Error code                            |
| -------------------------------------------------------------------------------------- | ------------------------------------- |
| `iss` equals `issuer`                                                                  | `ACCESS_TOKEN_ISSUER`                 |
| `aud` holds `audience` (a string or one of a list)                                     | `ACCESS_TOKEN_AUDIENCE`               |
| `exp` is in the future and `iat` is not, with `clockSkew` seconds of slack (30)        | `ACCESS_TOKEN_EXPIRED`                |
| the signature verifies with a key from the JWKS, and `sub`, `client_id`, `jti` are set | `ACCESS_TOKEN_INVALID`                |
| a `role` claim, when present, equals `role` (`authenticated`)                          | `ACCESS_TOKEN_ROLE`                   |
| there is no `cnf` claim, since sender-constrained tokens need a proof check            | `ACCESS_TOKEN_BOUND`                  |
| the token is not revoked                                                               | `ACCESS_TOKEN_REVOKED`                |
| the revocation check answered                                                          | `ACCESS_TOKEN_REVOCATION_UNAVAILABLE` |

The accepted algorithms are `RS256`, `PS256`, `ES256` and `EdDSA`; set
`algorithms` to narrow them. RSA keys need at least 2048 bits, and an RSA key
whose `alg` names another algorithm is never used.

## The JWK Set [#the-jwk-set]

`jwks` takes a JWK Set, a `URL`, or a function that returns one. A URL is
fetched once and cached for `cacheSeconds` (300). A token signed with a `kid`
the cached set lacks refetches it, at most once per `refetchSeconds` (30), so
the issuer can rotate keys without a restart. When a refetch fails, the
resolver keeps verifying with the keys it has.

## Revocation [#revocation]

JWT access tokens are valid until `exp` unless something says otherwise.
Pass `revocation` to check every request against a revocation list:

```ts
import {
  createOAuthServer,
  sqlTransport,
} from "better-supabase/blocks/oauth-server";

const oauth = createOAuthServer({ transport: sqlTransport(postgres.admin) });

accessTokenResolver({
  jwks: new URL(env.OAUTH_JWKS_URL),
  issuer: env.OAUTH_ISSUER,
  audience: "https://api.example.com",
  revocation: oauth,
});
```

`revocation.isRevoked(jti, grantId)` gets the token's `jti` and its
`grant_id` claim (`grantClaim` names another). The oauth-server block's
client fits as it is. A check that fails with a network error answers 503,
not a pass.

## Claims and the database [#claims-and-the-database]

The resolved claims are the token's claims with `role` set to the configured
role. `claims(payload)` adds claims of your own, such as a tenant id; it can't
override the reserved ones (`sub`, `role`, `client_id`, `scope`, `iss`, `aud`,
`exp`, `iat`, `jti`).

`ctx.sql` and `ctx.db` over direct Postgres set these claims as
`request.jwt.claims`, so policies read `auth.uid()` and the token's `scope`
narrows the user's permissions (see
[Delegated tokens](/docs/repository/permissions#delegated-tokens)). `ctx.supabase` forwards the token to
PostgREST, which accepts it only when the issuer is registered as a
[third-party auth provider](https://supabase.com/docs/guides/auth/third-party/overview)
of the project. Without that, use `ctx.sql` or `ctx.db` for these requests.

The `oauth` source never refreshes and never writes cookies; a client gets a
new access token from its own authorization server.

# 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 { bsb } from "@/lib/supabase/server";

export const deleteMyAccount = bsb.action(
  { aal: "aal2" },
  async (_input, { auth }) => {
    if (auth.kind !== "user") {
      return err(dbError("unauthorized", "Sign in to delete your account"));
    }
    return bsb.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 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`. `upgrade` reads
the `create table` and `drop table` statements in your declarative schema
files (your migrations only when the project has none) and writes
one `bsb_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({
  blocks: {
    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 { bsb } from "@/lib/supabase/server";

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

export async function signOutEverywhere(userId: string) {
  return bsb.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 `bsb.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. `bsb` 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 bsb.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 { createAudit, rpcTransport } from "better-supabase/blocks/audit";
import { createServer } from "better-supabase/server";

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

export const bsb = 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.** `bsb.deleteAccount` also calls
   `bsb.invalidateSession(userId)`, so `bsb.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 `bsb.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. Call `deleteAccount` again: when Auth
reports the user already gone, it removes the listed objects and returns
`ok` without announcing the delete a second time.

## 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"` |
| `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 `bsb.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`, `POSTGRES_URL`, `POSTGRES_URL_NON_POOLING`                               |
| `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)       |

The Vercel Supabase integration and Vercel Postgres set `POSTGRES_URL` (the
transaction pooler) and `POSTGRES_URL_NON_POOLING`. `dbUrl` prefers the pooled
one, which suits serverless functions and `createPostgres()`. Set
`SUPABASE_DB_URL` to the non-pooling URL when a connection needs a session,
such as `LISTEN` or session-level prepared statements.

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

`createServer` loads the env this way unless you pass one with its `env`
option, which also takes the variables as an object and then reads nothing
else. The order it looks up token keys in is on the
[createServer page](/docs/auth/server#env-and-keys).

## 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 `impersonation` SQL module, then pass a store to `createServer`:

```bash
npx better-supabase add impersonation
```

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

export const bsb = createServer(betterSupabase, {
  env,
  postgres,
  support: supportSessions({
    store: sqlSupportStore(postgres),
    authorize: ({ admin }) => admin.claims.app_metadata?.role === "support",
    policy: { ttl: 30 * 60, requireReason: true },
  }),
});
```

Support sessions deny by default. A start needs an `authorize` decision (or
the [authorization hooks](/docs/extending/authorization-providers#runtime-hooks))
and a second admin's approval of the request; see
[Deciding who may start one](#deciding-who-may-start-one) and
[Approval by a second admin](#approval-by-a-second-admin).

`supportSessions` lives in its own function so apps without support mode
don't bundle it. The store reads its time values with the `temporal` you pass
to `defineSupabase`; `sqlSupportStore(postgres, { temporal })` takes its own
instead. `createNext` takes the same option, and `better-supabase/next`
exports `supportSessions` too.

The module adds the `support_sessions` and `support_requests` tables and the
functions `start_support_session`, `end_support_session`,
`active_support_session`, `list_support_sessions`, `expire_support_sessions`,
`request_support_session`, `approve_support_request`, `support_request` and
`approved_support_request`. Starting a session checks
`is_platform('support.start')` in the database with the admin's own claims, so
only admins your [access model](/docs/repository/permissions) 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 { bsb } from "@/lib/supabase/server";

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

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

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

### Your own request context [#your-own-request-context]

An app that owns its request context (its own `AsyncLocalStorage`, a header
set by a gateway, or a context object built per request) can keep the
session id there instead of in the `bsb-support` cookie. Pass `sessionId` to
read it from the request, or resolve the session yourself:

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

const support = supportSessions({
  store: sqlSupportStore(postgres.admin),
  sessionId: (request) => request.headers.get("x-support-session") ?? undefined,
});

// Anywhere the app has the admin's auth and its stored session id:
const active = await server.support.resolve(appContext.supportSessionId, auth);
const ctx = server.contextFor(auth, active ? { support: active } : {});
const db = server.dbFor(auth, {}, active ? { support: active } : {});
```

`support.resolve(sessionId, auth)` runs the same checks as the cookie path: the
session must belong to the admin in `auth`, still be running, and the
target's claims must pass the claims schema. It returns `undefined`
otherwise, so the admin keeps their own view. `dbFor(auth, context, { support })`
returns the same repositories as `contextFor(auth, { support }).db`, running
as the target over Postgres.

### 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 |
| `approval`      | `"distinct"` | A start needs another admin's approval; `"none"` turns it off                             |

A read-only session runs each transaction with `begin read only`, so any write
fails with Postgres error `25006`, which a repository or block call returns as
`forbidden` (code `25006`) with a hint that the connection is read-only. 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.
Blocks built on [`dbTransport(ctx.db)`](/docs/blocks/create-blocks#transports)
follow, and `ctx.storage` reads and writes Storage as the target (see below).

### Storage in a support session [#storage-in-a-support-session]

Storage policies read `auth.uid()` from the request's token, and there is no
token for the target, so `ctx.supabase.storage` can't serve them. In a
support session, `ctx.storage` reads Storage as the target instead. It has
the file API of `supabase.storage` (a `StorageApi`), so a
[connected bucket](/docs/platform/storage#client) and the blocks that take
`storage` work with it unchanged. `ctx.db.$storage` and `ctx.sql.$storage`
are the same object, and outside a support session `ctx.db.$storage` is the
caller's `supabase.storage`, so code that connects buckets through the `Db`
works in both (`documents` is a `defineBucket` definition):

```ts title="app/files/[...path]/route.ts"
const ctx = await bsb.context(request);
const url = await documents
  .connect(ctx.db.$storage)
  .signedUrl(path, { ttl: 60 })
  .orThrow();
return Response.redirect(url);
```

Each read runs as the target over Postgres, so the policies on
`storage.objects` decide what they see:

| Methods                                                    | How they run                                                                                               |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `list`, `listV2`                                           | `storage.search` and `storage.search_v2` as the target, the queries Storage runs, so RLS filters each page |
| `exists`, `info`                                           | A `storage.objects` read as the target; no Storage request                                                 |
| `download`, `createSignedUrl`, `createSignedUrls`          | A visibility check as the target, then the service client serves the object                                |
| `getPublicUrl`                                             | Built by the service client without a check, as it is built without a request                              |
| `upload`, `update`, `remove`, `copy`, `move`, `purgeCache` | Writes in a writable session, after a dry run as the target (see below); refused in a read-only one        |
| `createSignedUploadUrl`, `uploadToSignedUrl`               | Signed in a writable session after the same dry run as `upload`; refused in a read-only one                |
| `claimSignedUpload(bucket, path)`                          | Makes the target the owner of a signed upload and records the session (see below)                          |

A path the target can't see answers like a missing object (`not_found`,
code `NoSuchKey`), and `exists` returns `false`. Paths are normalised the
way storage-js normalises them before the check, so the checked object and
the served one are the same.

In a writable session, each write first runs the row change Storage would
make for the target's own request, as the target and inside a savepoint that
is rolled back: an insert for `upload`, an insert with `on conflict do update`
for `update` and `upload` with `upsert: true`, an update of the name and
bucket for `move`, a read of the source and an insert of the copy for `copy`,
and a delete for `remove`. The new row's `owner_id` is the target, so
policies that compare it with `auth.uid()` decide as they would for the
target. A refused dry run resolves to Storage's `403` error with the policy's
message (`forbidden`, code `AccessDenied`), and nothing is written. `remove`
deletes only the paths the dry run could delete and skips the rest, as
Storage does. `purgeCache` checks that the target can see the object.

Then the service client performs the write, so the bucket's size limit,
allowed MIME types and duplicate check apply as usual. The admin Postgres
connection makes the target the owner of the stored object, and its
`user_metadata` records `supportSessionId` and `supportAdminId` next to the
caller's `metadata`. When that update fails, the written object is removed
(a move is moved back) and the error is returned.

A signed upload takes one more step. `createSignedUploadUrl` runs the same
dry run as `upload` (an insert, or an upsert with `upsert: true`) and then
the service client signs the URL. The browser's upload reaches Storage
without the session, so the stored object has no owner and no session stamp
until the server claims it. Call `claimReserved(reservation)` on the
connected bucket, or `claimSignedUpload(bucket, path)` on the `StorageApi`,
after the upload and before you record the file:

```ts
// server, in the support session
const files = bucket.connect(ctx.storage);
const reservation = await files.reserve(path).orThrow();
// browser: uploadToSignedUrl(reservation.path, reservation.token, file)
// server, when the browser reports the upload as done
await files.claimReserved(reservation).orThrow();
```

The claim checks again, as the target, that the target may insert an object
at the path, then the admin connection makes the target the owner of the
object and adds `supportSessionId` and `supportAdminId` to its
`user_metadata`. It only claims an object without an owner that was stored
or replaced after the session started (`updated_at` at or after
`session.startedAt`), and in a versioned bucket only the current version. An
older object is `forbidden` (code `AccessDenied`), so the claim can't hand
the target an export, an import or another file the service role stored
before the session. A claim of an object the target already owns succeeds
without stamping it again, one another user owns is `forbidden` (code
`AccessDenied`), and a missing one is `not_found`. A refused or failed claim
removes nothing: the claim can't tell the browser's upload from another
object without an owner on that path, so removing it would let a session
delete files the target may not delete. The object stays without an owner until a
[`sweep`](/docs/platform/storage#orphan-sweep) removes it. `uploadToSignedUrl`
on the server claims what it stores and removes the object when the claim
fails or is refused. Outside a support session `claimReserved` succeeds
without a request, since Storage already set the owner from the caller's
token, so the same code runs in both.

Some edges come from how the claim decides. It rehearses an insert of a row
that already exists: Postgres checks the insert policy before the unique
index, so a duplicate key means the target may store the object there.

- The rehearsed row leaves `metadata` (`mimetype`, `size`) and `version`
  empty, so a policy on those columns can decide differently from the real
  upload.
- A policy that refuses when an object with that name already exists
  refuses the claim with `42501`, even for an upload the target was allowed
  to make.
- An upsert reservation is gated by the update policy when the URL is
  signed. The claim can't check that policy again.
- Another service-role process can store or update an object without an
  owner on exactly that path during the session, which moves its
  `updated_at` past the session's start. The claim can't tell that object
  from the browser's upload.

A signed upload URL is valid for about two hours. When the claim never
comes, the object stays without an owner and without the session stamp.
Remove such objects with [`sweep`](/docs/platform/storage#orphan-sweep) on the bucket.

In a read-only session every write, the signed upload URL and the claim
resolve to a `403` error with code `SUPPORT_READ_ONLY`. A connected bucket
maps it to `forbidden`. The blocks work in a writable session too.
[`attachments.put`](/docs/blocks/attachments) uploads from the server. The
blocks that hand out a signed upload URL claim the object in their own
finish step, before it records the upload: `attachments.confirm`,
[`drive.commit`](/docs/blocks/drive) and
[`files.confirm`](/docs/blocks/ai-files) of the AI files block. A refused
claim keeps the record pending and returns the block's error: `forbidden`
with the hint `ATTACHMENT_FORBIDDEN`, `DRIVE_FORBIDDEN` or
`AI_FILE_FORBIDDEN`, or `not_found` with `ATTACHMENT_NOT_UPLOADED` or
`AI_FILE_NOT_UPLOADED` when the object is missing. Outside a support session
these steps make no extra request.

A public bucket is served to anyone by URL, but its objects are still rows in
`storage.objects`. When the bucket has no `select` policy that lets the
target see them, `download`, the signed URLs, `exists` and the listings
answer as if the objects were missing, while `getPublicUrl` still returns a
working URL. Add a `select` policy for the bucket when support sessions
should read it.

`ctx.storage` needs `SUPABASE_SECRET_KEY`. Outside a support session it is
`undefined`; use `ctx.supabase.storage` there.
`supportStorage({ admin, sql, adminSql, session, readOnly })` from
`better-supabase/server` builds the same `StorageApi` from a service-role
`supabase.storage`, a `SqlClient` that runs as the target (writes need its
`transaction`), the admin `SqlClient` (`postgres.admin`) and the session's
`id`, `adminId` and `targetUserId`. Without `adminSql` and `session`, writes,
signed upload URLs and claims in a writable session resolve to a `403` error
with code `SUPPORT_STORAGE_WRITES`.

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,
the reason, `readOnly`, `memberships` and `metadata`. It returns a decision:

| Decision                 | Effect                                                                         |
| ------------------------ | ------------------------------------------------------------------------------ |
| `false`, a throw         | Refused with `SUPPORT_FORBIDDEN`                                               |
| `true`                   | Allowed once another admin approved a request (see below)                      |
| `{ approvedBy: "<id>" }` | Allowed, and `<id>` is recorded as the approver; the admin's own id is refused |

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

Without `authorize`, the `support.authorize` hook of the
[authorization hooks](/docs/extending/authorization-providers#runtime-hooks)
decides. Without either, every start is refused. `authorize: "store"` hands
the decision to the store alone: the SQL store then checks
`is_platform('support.start')` and nothing else, so use it only when that
permission is the whole rule. A start refused by the decision never reaches
the store.

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#impersonation) for the module's names,
hooks and options.

### Approval by a second admin [#approval-by-a-second-admin]

With `policy.approval: "distinct"`, the default, one admin asks for a session
and a different admin approves it before the start:

```ts title="src/admin/support.ts"
const asked = await server.support
  .request(auth, { targetUserId, reason: "Ticket 4821: missing invoices" })
  .orThrow();

// Later, signed in as a second admin:
await server.support.approve(reviewerAuth, asked.id).orThrow();

// The first admin starts the session; it uses the approved request.
const { session, cookie } = await server.support
  .start(auth, { targetUserId, reason: asked.reason })
  .orThrow();
```

A start without an approved request fails with `SUPPORT_APPROVAL_REQUIRED`.
The requester can never approve their own request (`SUPPORT_SELF_APPROVAL`),
and the SQL module refuses it again in a check constraint, so a client that
calls the database directly can't skip the second admin. A request stays open
for the module's `approvalTtl` (1 hour) and starts one session. A read-only
request never starts a writable session.

`approverOf` in the authorization hooks decides who may approve, with
`{ kind: "support-session", subject: requestId, requester }`. It is never
asked about the requester as approver: better-supabase refuses them first. Without it, the SQL store
checks the approver in the database: the provider's `isApprover` template,
or `is_platform('support.start')` without a provider. Rename that key with
`blocks["impersonation"].permissions.approve`.

| Code                           | When                                                       |
| ------------------------------ | ---------------------------------------------------------- |
| `SUPPORT_APPROVAL_REQUIRED`    | A start finds no approved, unused request                  |
| `SUPPORT_SELF_APPROVAL`        | The requester approves, or `approvedBy` names the admin    |
| `SUPPORT_REQUEST_MISSING`      | `approve` names a request that doesn't exist               |
| `SUPPORT_REQUEST_DECIDED`      | Another admin already approved the request                 |
| `SUPPORT_REQUEST_CLOSED`       | The request expired or already started a session           |
| `SUPPORT_APPROVAL_UNSUPPORTED` | `request` or `approve` on a store without approval methods |

Set `policy.approval: "none"` and the module's `options.approval: "none"`
together to start without a second admin; the SQL store checks its own
option. `createNext` has no request or approve helper yet: call them on a
`createServer` instance, or call `request_support_session` and
`approve_support_request` as the signed-in admin. A custom store implements
`request`, `getRequest`, `approve` and `approvedRequest` to support approval.

With the [approvals](/docs/blocks/approvals#tool-calls-and-support-sessions)
module installed, a request for a tenant also files an approval request
(`subjectType` `support_request`, action `start`), so it shows up in the
approvals inbox. Approving it there approves the support request, rejecting
it closes the support request (`SUPPORT_REQUEST_CLOSED`), and
`approve_support_request` closes the approval request. The approvers stay
the ones above.

### 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["impersonation"].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]

`betterSupabase.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) with `support_session_id`,
`impersonated_by` and `impersonation_reason` set, and every row the admin
changes in a writable session records the same columns, so you can list
everything one session did, from its start to its end. A session that
another staff member revokes keeps the session id in the entry's metadata.

`bsb.support.list(auth, { active: true })` lists running sessions for an
admin page, and `bsb.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.

### Ending expired sessions [#ending-expired-sessions]

A session stops working the moment it expires, since every request checks
`expires_at`. Its row stays open until something ends it, so run
`bsb.support.expire()` on a schedule to close expired sessions and record
them: the SQL store calls `expire_support_sessions()` over `postgres.admin`,
which ends each one through `end_support_session(id, 'expired')`, so the
audit log gets a `support.ended` entry, and `betterSupabase.on` receives `support.ended`
with `endedBy: "expired"` for each. `expireRoute` serves it to a cron caller
behind a bearer secret and answers `{ "expired": 2 }`:

```ts title="app/api/cron/support/route.ts"
import "server-only";

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

export const GET = bsb.support.expireRoute({ secret: process.env.CRON_SECRET });
```

`expire_support_sessions()` is executable by the service role only. A store
of your own implements the optional `expire()` method for this; without it,
`bsb.support.expire()` fails with `SUPPORT_EXPIRE_UNSUPPORTED`.

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["impersonation"].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` (and
optionally `expire`) 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 = bsb.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 `bsb.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                           |
| `{ kind: "agent", sub, tools, read_only }`, from `agentClaims`                    | `agent`, with `tools`, `readOnly`, `chain` | not set                | the `scope` claim, when set     |
| 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               |

An `oauth-client` actor is limited to the scopes the user delegated, and so
is an `agent` on a token that carries a `scope`: the `scopes` guard option
checks a support or impersonated session, and an agent on the user's own
session, as the user's own, and a read-only support session still blocks
writes. An agent writes with the user's row level security unless its
level sets `read_only: true`. An impersonation token is always read-only,
whatever its `read_only` says; to allow its writes, set the provider's
`authorization.functions.actorMayWrite` template.

`agentClaims(claims, { agent, tools, readOnly })` from
`better-supabase/server` puts the agent on top of the user's claims and
moves any earlier `act` below it. Leave `tools` out to delegate every tool;
`tools: []` delegates none, and a runtime given the agent as `actor` refuses
the others (see [Delegated tools](/docs/ai-sdk/agents#delegated-tools)).
`readOnly: true` writes `read_only: true`.
An agent run's context carries the level as `run.act`, so a tool passes
`agentClaims(claims, run.act)` to `asUser` or `forContext` and the audit
log records the agent as the actor.

`tools` is enforced by the agent runtime only: `createAgentRuntime({ actor })`
drops and refuses the tools the list doesn't name. The route guards, the
`scopes` option and row level security don't read it, so a tool that runs
outside the runtime is limited by the user's rights and the token's `scope`,
not by `tools`. Other libraries that read the same claim may deny every tool
the list doesn't name, so pass `tools` explicitly when you mint a token for
them.

An invalid `act` claim makes the session
`{ kind: 'invalid', reason: 'actor' }` (code `ACTOR_INVALID`), never the user
without an actor. `resolveAuth` answers 401, and `toSession` returns the same
verdict for a hand-built state. The claim is invalid when it is not a chain
of objects each with a `sub`, has a `kind` other than `support`,
`impersonation` or `agent`, has a support level without `session_id`, an
agent level with a `session_id`, an empty tool name or `tools` that is not a
list, or a support or agent level whose `read_only` is not a boolean.

A support token from 0.5.0, with `session_id` but no `kind`, is invalid too,
in TypeScript and in `actor_may_write()`. better-supabase refuses it instead
of reading it as a read-only support session, so an old token can't keep
working with a guess at its rights. Start a new support session to mint a
token with `kind: "support"`.

`session.delegation.scopes` leaves out the OpenID Connect identity scopes
(`openid`, `email`, `profile`, `phone`, `address` and `offline_access`,
exported as `IDENTITY_SCOPES`), which grant no permission. A token whose
`scope` names only identity scopes still has a delegation with
`scopes: []`, so an agent on it passes no `scopes` check instead of getting
the user's full rights.

`better_supabase.actor_may_write()` decides by the outermost `kind`: a
support session writes only with `read_only: false`, an agent unless
`read_only` is `true`, and impersonation or any other `kind` never.
`read_only` is compared as a JSON boolean, so a string fails closed.

`actorOf(claims)`, `delegationOf(claims)` and `impersonatorOf(claims)` from
`better-supabase/server` read the same claims anywhere else, without a
request. `actorOf` returns `{ ok: true, actor }` or
`{ ok: false, reason: "invalid-chain" }`.

`supabaseClaimFixtures` from `better-supabase/testing` holds a token of each
kind, with what `toSession` reads from it. `supabaseInvalidClaimFixtures`
holds the tokens it refuses, each expecting
`{ kind: "invalid", reason: "actor", code: "ACTOR_INVALID" }`: the 0.5.0
support token and the malformed levels listed above. Both are plain data, so
another library can sign and check the same cases:

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

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

const legacy = supabaseInvalidClaimFixtures.legacySupportSession;
// legacy.expect: { kind: "invalid", reason: "actor", code: "ACTOR_INVALID" }
```

`better-supabase/testing/act-cases.json` (also `actCases` from
`better-supabase/testing`) lists `act` chains with the verdict for each:
`valid`, the actor `kind`, `readOnly`, `tools` and what `actor_may_write()`
returns as `mayWrite`. It is versioned (`version: 1`), so another library
that parses the same claims can run the same cases:

```ts title="tests/act.test.ts"
import cases from "better-supabase/testing/act-cases.json" with { type: "json" };

for (const { name, claims, valid } of cases.cases) {
  test(name, () => expect(myParser(claims).ok).toBe(valid));
}
```

# 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
}
```

Route guards answer 401 for `invalid` before your handler runs. A handler
that skips the guard and queries through `ctx.db` gets the unauthorized
`DbError` as a failed `Result`, so a bad token never reads as anon. `ctx.supabase`
stays a raw client and is not covered by this.

## 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/*", bsb.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 dev 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;
- a network failure, timeout, 5xx or 429 sets `resolution.outage` to
  `{ cause, status?, kept, durationMs }`, which the proxy and `withSession`
  hand to `onAuthOutage`
  ([details](/docs/frameworks/next#auth-outages)).

With `refresh: true`, chunks the readable session doesn't use are also
expired on responses that keep the session, not only on a refresh
(`pruneChunks: false` turns that off).

```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 memberships extension, `current_tenant_id()` and the storage and realtime policies
read the active tenant from `tenant_id` (or `app_metadata.tenant_id`).
`auth.organization.claim` 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 bsb.session()` and the `useSession` from
  `createHooks<typeof bsb>()` 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 `auth.organization.claim` 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 bsb.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`,
  `bsb.session()` and the `useSession` from `createHooks<typeof bsb>()`.
- 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
[created-by](/docs/blocks/created-by) and [memberships](/docs/blocks/memberships)
extensions 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 bsb.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 = bsb.route(
  async (request, { db }) => db.invoices.create(await request.json()),
  { aal: "aal2" },
);
```

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

```json title="403 response"
{
  "type": "about:blank",
  "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 bsb.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.

## Enrolment and step-up controllers [#enrolment-and-step-up-controllers]

`better-supabase/blocks/mfa` has two headless controllers over
`supabase.auth.mfa`. The React hooks are a registry item: install them with
`npx shadcn add @better-supabase/use-mfa`, which writes `hooks/use-mfa.ts`
into the app. Vue, Svelte and Solid run the controllers with
`useBlockController` from `better-supabase/vue`, `/svelte` and `/solid`
(see [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)).

- `useMfa()` (`createMfaController`) is for a security settings page. It
  lists the factors and reads the assurance level. `enroll()` keeps the QR
  code and the secret in `enrollment`, `verify(code)` confirms the factor
  with its first code, `cancel()` removes an unconfirmed one, and
  `unenroll(id)` removes a factor.
- `useStepUp()` (`createStepUpController`) raises the session to `aal2`.
  Show it when a call fails with `needsStepUp(error)`, which matches the
  `INSUFFICIENT_AAL` refusal above. `verify(code)` challenges the first
  verified factor and reads the level again. Retry the call once
  `satisfied` is true.

```tsx
"use client";

import type { DbError } from "better-supabase";

import { needsStepUp, useStepUp } from "@/hooks/use-mfa";
import { useState } from "react";

export function StepUp(props: {
  error: DbError | undefined;
  retry: () => void;
}) {
  const { error, retry } = props;
  const { satisfied, verifying, verify } = useStepUp();
  const [code, setCode] = useState("");
  if (!needsStepUp(error) || satisfied) return null;
  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault();
        if ((await verify(code)).ok) retry();
      }}
    >
      <input value={code} onChange={(event) => setCode(event.target.value)} />
      <button disabled={verifying}>Verify</button>
    </form>
  );
}
```

In Vue, Svelte and Solid, pass `mfa` to the controller yourself:

```vue title="TwoFactor.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import { createMfaController } from "better-supabase/blocks/mfa";

const { state, actions } = useBlockController(createMfaController, {
  mfa: useSupabase().auth.mfa,
});
</script>

<template>
  <img v-if="state.enrollment" :src="state.enrollment.qrCode" alt="QR code" />
  <button v-else @click="actions.enroll()">Set up two-factor</button>
</template>
```

```svelte title="StepUp.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import { createStepUpController } from "better-supabase/blocks/mfa";

  const { supabase } = getBetterSupabase();
  const stepUp = useBlockController(createStepUpController, {
    mfa: supabase.auth.mfa,
  });
</script>

{#if !$stepUp.satisfied}<p>Enter a code from your authenticator.</p>{/if}
```

```tsx title="Factors.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import { createMfaController } from "better-supabase/blocks/mfa";

export function Factors() {
  const { state } = useBlockController(createMfaController, {
    mfa: useSupabase().auth.mfa,
  });
  return <p>{state().factors?.length ?? 0} factors</p>;
}
```

Without a framework, build the controller and subscribe to it:

```ts
import { createStepUpController } from "better-supabase/blocks/mfa";

const stepUp = createStepUpController({
  mfa: supabase.auth.mfa,
  auth: bsb.auth,
});
const stop = stepUp.subscribe(() => render(stepUp.getSnapshot()));
```

## 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 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(bsb)` 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 bsb = createServer(betterSupabase);

export default {
  fetch: pipeline(
    [
      withBetterSupabase(bsb, { allow: ["user"] }),
      withPostgresClient(), // ctx.postgres, raw queries as the caller
    ],
    async (req, ctx) => {
      const customers = await ctx.db.customers
        .list({ 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                                                                                              |
| ------------ | ---------------------------------------------------------------------------------------------------------- |
| `bsb`        | 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 `bsb-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 { createQueue } from "better-supabase/blocks/queue";
import { withBetterSupabase, withBlock } from "better-supabase/server";

const entries = [
  withBetterSupabase(bsb, { allow: ["user"] }),
  withPostgresAdminClient(),
  withBlock("jobs", (ctx: { postgresAdmin: PostgresApi }) =>
    createQueue({ sql: 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]

`bsb.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 { bsb } from "@/lib/supabase/server";
import { routing } from "@/i18n/routing";

const intl = createMiddleware(routing);

export const proxy = (request: NextRequest) =>
  bsb.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: bsb-proxy;dur=1.4, bsb-verify;dur=0.6
```

`bsb-verify` is the session check (including a refresh when one ran),
`bsb-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 last `x-forwarded-for` hop, the
one your own edge appended. Hops to its left come from the client, which can
send any value, so reading them would let a client spend another IP's refresh
limit. The last hop is right on Vercel, which overwrites the header, and
behind one proxy. Behind several 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]

`useOAuthConsent` (`createOAuthConsentController` in
`better-supabase/blocks/sessions`) reads the request for
`authorization_id`. The React hook is a registry item: install it with
`npx shadcn add @better-supabase/use-sessions`, which writes
`hooks/use-sessions.ts` into the app. Vue, Svelte and Solid run the
controller with `useBlockController` from `better-supabase/vue`, `/svelte`
and `/solid` (see
[Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid)).
Auth answers with the client, the user and the requested scopes, or with a
`redirect_url` when the user approved this client before; the controller
then leaves at once:

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

import { useOAuthConsent } from "@/hooks/use-sessions";

const { request, deciding, error, approve, deny } = useOAuthConsent({
  authorizationId, // from the URL
  redirect: (url) => window.location.replace(url),
});
// request.client.name, request.user.email, request.scopes, request.redirectHost
```

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". The controller
shares the read per `authorization_id`.

`approve()` and `deny()` call Auth with `skipBrowserRedirect`, then go to
the `redirect_url` it returns. `deciding` stays set while the browser
leaves, so keep the buttons disabled on it. A failed decision keeps the
request and sets `error`.

The browser only ever goes to a `redirect_url` that Auth returned, which
points at a redirect URI the client registered. Show `redirectHost` 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.

In Vue, Svelte and Solid, pass `oauth` to the controller yourself:

```vue title="Consent.vue"
<script setup lang="ts">
import { useBlockController, useSupabase } from "better-supabase/vue";
import { createOAuthConsentController } from "better-supabase/blocks/sessions";

const props = defineProps<{ authorizationId: string }>();
const { state, actions } = useBlockController(createOAuthConsentController, {
  oauth: useSupabase().auth.oauth,
  authorizationId: props.authorizationId,
});
</script>

<template>
  <p v-if="state.request">Connect {{ state.request.client.name }}?</p>
  <button :disabled="state.deciding !== undefined" @click="actions.approve()">
    Approve
  </button>
</template>
```

```svelte title="Grants.svelte"
<script lang="ts">
  import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
  import { createOAuthGrantsController } from "better-supabase/blocks/sessions";

  const { supabase } = getBetterSupabase();
  const grants = useBlockController(createOAuthGrantsController, {
    oauth: supabase.auth.oauth,
  });
</script>

<p>{$grants.grants?.length ?? 0} connected agents</p>
```

```tsx title="Grants.tsx"
import { useBlockController, useSupabase } from "better-supabase/solid";
import { createOAuthGrantsController } from "better-supabase/blocks/sessions";

export function Grants() {
  const { state } = useBlockController(createOAuthGrantsController, {
    oauth: useSupabase().auth.oauth,
  });
  return <p>{state().grants?.length ?? 0} connected agents</p>;
}
```

Without a framework, `createOAuthConsentController` and
`createOAuthGrantsController` take the same options plus `auth`:

```ts
import { createOAuthGrantsController } from "better-supabase/blocks/sessions";

const grants = createOAuthGrantsController({
  oauth: supabase.auth.oauth,
  auth: bsb.auth,
});
```

## Connected agents [#connected-agents]

`useOAuthGrants()` (`createOAuthGrantsController`) lists every client the
user approved, newest first, with its scopes and `grantedAt`, and
`revoke(clientId)` disconnects one. On the server, or without the hook,
`listGrants` and `revokeGrant` do the same:

```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, DATABASE_URL, POSTGRES_URL

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

Without a `connectionString`, `createPostgres()` reads the first of
`SUPABASE_DB_URL`, `DATABASE_URL`, `POSTGRES_URL` and
`POSTGRES_URL_NON_POOLING` that is set, the [`dbUrl`](/docs/auth/env) order.
Each query runs in its own transaction with transaction-local settings, so
the transaction pooler that `POSTGRES_URL` points to works.

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/repository/permissions#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` | `15000`                               | `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`.

A session or transaction can set its own limit with the `statementTimeout`
session option, for a job that runs one long query on a shared pool:

```ts
await postgres.transaction(
  (tx) => tx.queryRaw("refresh materialized view stats"),
  { statementTimeout: 120_000 },
);
const reports = postgres.executorFor(claims, { statementTimeout: 20_000 });
```

A transaction that waits longer than `idleInTransactionTimeout` between
statements, such as one stuck on an `await` to another service, is ended so
it can't hold row locks. `0` turns that off. The setting applies only inside
a transaction: an `admin` query with no statement timeout to set runs without
`begin` and `commit`, in one round trip. Set `statementTimeout.admin` and it
runs in a transaction like the others.

## Pool errors [#pool-errors]

A connection that fails while it sits idle in the pool, such as one the
server closed, makes `pg` emit `error`, which crashes Node when nothing
listens. `createPostgres` always listens. Pass `events` and the error reaches
your `error` handlers as a `DbError`; otherwise it goes to `logger`, or the
console. A handler or logger that throws is ignored, and the pool replaces
the connection on the next `connect`.

```ts
const postgres = createPostgres({ events: betterSupabase.events });

betterSupabase.on("error", ({ error }) => report(error));
```

## Counts [#counts]

The Postgres executor counts like PostgREST, so a `page` or `count` with
a count mode returns the same total over either path:

| `count`     | What it runs                                                                              |
| ----------- | ----------------------------------------------------------------------------------------- |
| `exact`     | `count(*)` over every matching row                                                        |
| `planned`   | `explain (format json)` of the query; the planner's row estimate                          |
| `estimated` | an exact count that stops after 1000 rows, and the planner's estimate when there are more |

Counts are `bigint` and decode to a `number`; a count past
`Number.MAX_SAFE_INTEGER` fails instead of rounding. Pass
`estimatedCountThreshold` to move the cutoff, to match a different
`db-max-rows`:

```ts
const db = betterSupabase.connect(
  postgresExecutor(client, { estimatedCountThreshold: 5000 }),
  { claims },
);
```

## 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.create(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. The executor keeps
that lookup until a call fails with an error that says the function changed
(SQLSTATE 42883, 42P13 or 42804). Arguments for `OUT` and `TABLE` parameters
are dropped, as PostgREST drops them. 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": "about:blank",
  "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
```

Errors that mean no more than their HTTP status use `"type": "about:blank"`
with the status phrase as the title, as RFC 9457 section 4.2.1 asks. These
are `invalid_request` (Bad Request), `unauthorized`, `forbidden`,
`not_found` (Not Found), `conflict`, `rate_limited` (Too Many Requests) and
`unexpected` (Internal Server Error), each at its default status. Every
other kind, and a generic kind sent with another status, gets
`https://bettersupabase.com/problems/<kind>` with dashes, such as
`https://bettersupabase.com/problems/quota-exceeded`. Branch on `kind` or
`code` rather than on `type`.

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`. A `forbidden` error
that names the keys the caller lacks carries them as `permissions`, as a
workflow publish refused with `hint: "WORKFLOW_PUBLISHER_FORBIDDEN"` does.
A SQL function sets it by raising `42501` with
`detail = '{"permissions": [...]}'`.

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": "about:blank",
  "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 bsb = createServer(betterSupabase, {
  postgres: createPostgres(), // optional: enables ctx.sql and actingAs()
});
```

## Per request [#per-request]

```ts
const ctx = await bsb.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 bsb-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 `bsb-primary-until` after a write when
read replicas are configured. The Hono, edge and oRPC (`bsb.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(bsb, 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 bsb.resolve(request);
const ctx = bsb.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`.

## Env and keys [#env-and-keys]

`createServer` reads its settings from `env`, the first one given wins:

1. The `env` option: a loaded env from [`loadEnv`](/docs/auth/env), or the
   variables themselves (`{ SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY }`).
   Variables passed this way are the only ones read; a missing one is an
   error, never filled in from `process.env`.
2. The variables `getEnv` from `@supabase/middleware` reads: Workers
   bindings, then `process.env`, then `Deno.env`.

Tokens verify against the first key source that is set:

1. `auth.jwks`. Inline keys, a `URL`, or `false` (or `null`): no keys and no
   lookup, so only HS256 tokens with `auth.jwtSecret` verify.
2. `env.jwks`, from `SUPABASE_JWKS`.
3. `env.jwksUrl`, from `SUPABASE_JWKS_URL`, else
   `<url>/auth/v1/.well-known/jwks.json`.

`SUPABASE_PUBLIC_URL` plays no part here; only the
[MCP HTTP transport](/docs/frameworks/mcp) reads it.

A test server that must not see your shell's variables passes both, as in
[hermetic server tests](/docs/testing#hermetic-server-tests).

## Explicit identities [#explicit-identities]

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

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

`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/queue#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 bsb.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 bsb = 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(bsb, token)` verifies a bearer token
once and returns the `AuthState`; `bsb.contextFor(auth)` turns any
`AuthState` into the same context `bsb.context(request)` returns, with `db`,
`supabase` and the caller's claims:

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

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

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 created-by and
memberships extensions 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 bsb = 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 bsb = 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 bsb = 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 [memberships extension](/docs/blocks/memberships),
the `better_supabase.tenant` setting for `ctx.sql` and the `x-bsb-tenant`
header (`TENANT_HEADER`) for `ctx.db`, which `current_tenant_id()` reads with
`blocks.permissions.activeTenant: 'resolver'`. See
[The active tenant](/docs/repository/permissions#the-active-tenant).

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

const ctx = await bsb.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
`bsb.context({ tenant })` or `bsb.cached({ tenant })` (see
[The active tenant](/docs/repository/permissions#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 bsb = 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 `bsb.admin()`
for privileged repository work, or `bsb.actingAs(userId)` to run over Postgres
as a particular user.

### Cron jobs and webhooks [#cron-jobs-and-webhooks]

Code that runs without a caller, such as a cron job or a webhook handler, gets
everything it needs from `bsb.admin()`. You don't need a second client from
`@supabase/server/core`:

| Work                              | Handle                                                      |
| --------------------------------- | ----------------------------------------------------------- |
| Rows and database functions       | `bsb.admin().<model>` repositories and `bsb.admin().$rpc`   |
| A bucket registered with `bucket` | `bsb.admin().$buckets.<name>`                               |
| Any other bucket                  | `bsb.admin().$storage.from(bucket)`, e.g. `.remove([path])` |
| Auth users (create, invite, ban)  | `bsb.admin().$authAdmin`, from 0.11                         |
| Anything else supabase-js does    | `bsb.admin().$client`, the secret-key client                |

```ts title="app/api/cron/cleanup/route.ts"
export async function GET() {
  const admin = bsb.admin();
  const { data } = await admin.$authAdmin.listUsers({ perPage: 100 });
  const stale = data.users.filter((user) => user.banned_until);

  await admin.$storage
    .from("exports")
    .remove(stale.map((user) => `${user.id}.csv`));

  return new Response(null, { status: 204 });
}
```

All of them use the secret key and bypass RLS. Pass `{ as: userId }` to
`bsb.admin()` to record that user as the actor of the writes.

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. When the chunks don't parse together, such
as a leftover `.2` from a longer session next to a shorter rewrite,
`readSession` uses the longest prefix of chunks that parses, and only reads
no session when none does.

`pruneSessionChunks(cookies, name)` returns the `Max-Age=0` writes for
chunks the readable session doesn't use. `resolveAuth` with `refresh` sends
them on every response that keeps the session, so stale chunks are gone
before the next refresh; set `pruneChunks: false` (in `auth` on the server)
to only prune when the session is rewritten.

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 bsb = 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.
`bsb.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. `bsb.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)).

# 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.get(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.get(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.list(), Result, toAppError);
}

// And back: DbErrors pass through, PostgREST errors are classified,
// 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. The default mapper, `toDbError`, classifies a
PostgREST or Postgres error object (`code`, `message`, `details`, `hint`),
or a thrown `PostgrestError`, the way `mapDbError` does, so a unique
violation becomes a `conflict` and `PGRST116` becomes `not_found`. 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.get(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.list({
  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.list` that included notes, and nothing else. Keys stay
`['bsb', 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.list({ include: { notes: true } });
bsb.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).

`bsb.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.list({
  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({ gen: { 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.list({ 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({
  gen: { 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.
When a result column has a codec, such as an `int8` read as a `bigint` or a
`numeric` read as a string, `$rpc` selects the result columns and casts
those to `text`, as repository reads do. A JSON number would already have
lost an `int8` above 2^53 or a `numeric`'s digits before the codec saw it.
`{ raw: true }` skips the decoding and the cast, and returns database names,
typed as `unknown`.

A scalar `int8` or `numeric` result, or one inside a `json` or `jsonb`
result, has no columns to cast: return it as `text` from the function when
it can be that large. `doctor` reports scalar ones as BS223. The blocks'
own functions return every `int8` as text, and their clients read it back
as a `bigint` or a `Money`.

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

# Commerce

> Money in bigint minor units, and versioned payment and tax providers with Stripe adapters.

Source: https://bettersupabase.com/docs/concepts/commerce

`better-supabase/commerce` is the base the commerce blocks build on.
It holds money as a `bigint` count of the currency's minor units, so a price
never passes through a floating-point number. It also defines two versioned
extension interfaces, `PaymentProvider` and `TaxProvider`, with adapters for
Stripe PaymentIntents and Stripe Tax and a `fixedRates` tax provider. It has
no tables and no SQL module.

```ts
import {
  fixedRates,
  money,
  parseMoney,
  stripePayments,
} from "better-supabase/commerce";
```

## Money [#money]

A `Money` is `{ amount: bigint, currency: string }`. `amount` counts minor
units: cents for `EUR`, yen for `JPY`, fils (a thousandth) for `KWD`. The
number of minor units per currency comes from ISO 4217 List One, pinned in
`SPEC_PINS.iso4217`, so `money(1999n, "EUR")` is 19.99 euro. A currency that
isn't in the list, or that has no minor unit (such as gold, `XAU`), throws a
`RangeError`.

```ts
const price = parseMoney("19.99", "EUR"); // { amount: 1999n, currency: "EUR" }
const total = multiplyMoney(price, 3); // 59.97 EUR
formatMoney(total, "nl-NL"); // "€ 59,97"
```

| Function                                        | Returns                                                                              |
| ----------------------------------------------- | ------------------------------------------------------------------------------------ |
| `money(amount, currency)`                       | A frozen `Money`; a `number` amount must be a safe integer                           |
| `parseMoney(decimal, currency)`                 | The amount of a decimal string, refusing more decimals than the currency has         |
| `toDecimal(value)`, `formatMoney(value, ...)`   | A decimal string, or a localized string from `Intl.NumberFormat`                     |
| `moneyToJson(value)`, `moneyFromJson(json)`     | `{ amount: "1999", currency: "EUR" }` for JSON bodies and `jsonb` columns, and back  |
| `addMoney`, `subtractMoney`, `sumMoney`         | Sums; a second currency throws `CurrencyMismatchError`                               |
| `multiplyMoney(value, quantity)`, `negateMoney` | The value times a whole quantity, or negated                                         |
| `compareMoney(a, b)`                            | `-1`, `0` or `1`                                                                     |
| `applyRate(value, rate, { included })`          | The value times a decimal rate such as `"0.21"`, rounded half to even                |
| `allocateMoney(value, weights)`                 | Parts that add up to the value exactly, the leftover units to the largest remainders |
| `minorUnits(currency)`, `isCurrency(code)`      | The currency's decimals, or whether it has any                                       |

`applyRate` with `included: true` takes a price that already contains the
tax and returns the tax inside it: `value * rate / (1 + rate)`. Use
`allocateMoney` to spread an order discount over its lines without losing or
gaining a cent.

## Payments [#payments]

A `PaymentProvider` takes payments for orders. It has `apiVersion: 1`, a
`name`, and:

| Method                                      | Does                                                                    |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| `createPayment({ amount, reference, ... })` | Creates a payment; `capture: "manual"` holds the funds until `capture`  |
| `capture(id, { amount })`                   | Captures an authorized payment, all of it unless `amount` is less       |
| `refund(id, { amount, reason })`            | Refunds a captured payment, all of what is left unless `amount` is less |
| `parseWebhook(request)`                     | Verifies a delivery and returns its events as `PaymentEvent`s           |
| `cancel(id)`, `get(id)`                     | Optional: release an uncaptured payment, or read one                    |

Every method returns a `Result`, and none of them throw. An amount the
provider can't charge or a refund above what was captured is
`invalid_input`, an unknown payment is `not_found`, a webhook with a bad
signature is `unauthorized`, and a declined card is `raised` with the hint
`PAYMENT_DECLINED`. Pass an `idempotencyKey` so a retried call returns the
first call's result instead of charging twice.

```ts title="lib/payments.ts"
export const payments = stripePayments({
  stripe: { secretKey: env.STRIPE_SECRET_KEY },
  webhookSecret: env.STRIPE_WEBHOOK_SECRET,
});

const payment = await payments
  .createPayment(
    { amount: order.total, reference: order.id },
    { idempotencyKey: `order:${order.id}` },
  )
  .orThrow();
// Hand payment.clientSecret to the browser to confirm with Stripe.js.
```

`stripePayments` creates a PaymentIntent with automatic payment methods
(change it with `paymentIntent`) and keeps the `reference` in the intent's
metadata, so webhook events carry it back. Before a capture or refund with
an amount, it checks the amount's currency against the intent's. `stripe`
is an optional peer loaded on the first call; pass a client instead of
`{ secretKey }` to share one, or a function to pick one per request.

`parseWebhook` turns Stripe's `payment_intent.*` and `refund.*` events into
`PaymentEvent`s and returns none for other types. A redelivered event
repeats its `id`, so record it in the [webhook inbox](/docs/blocks/webhooks-in)
or the `idempotency` module and handle it once:

```ts title="app/api/stripe/payments/route.ts"
export async function POST(request: Request) {
  const events = await payments.parseWebhook(request).orThrow();
  for (const event of events)
    if (event.type === "payment.succeeded") await markPaid(event.reference);
  return new Response(null, { status: 204 });
}
```

### Stripe amounts [#stripe-amounts]

Stripe counts a few currencies differently from ISO 4217. `toStripeAmount`
and `fromStripeAmount` convert: `ISK` and `UGX` are sent with two decimals,
`MGA` with none, and `BHD`, `JOD`, `KWD`, `OMR` and `TND` must be a multiple
of 10 minor units. Both return a `Result`. An amount Stripe can't take
exactly, or a Stripe amount with a fraction of a minor unit (an `ISK` amount
that isn't a multiple of 100), is `invalid_input` with the hint
`STRIPE_AMOUNT_PRECISION`, never rounded.

## Tax [#tax]

A `TaxProvider` computes and records sales tax. It has `apiVersion: 1`, a
`name`, and three methods:

| Method                                       | Does                                                                     |
| -------------------------------------------- | ------------------------------------------------------------------------ |
| `quote(cart)`                                | The tax per cart line, in cart order, then shipping, and the total       |
| `commit({ quote, reference })`               | Records the sale under the order number and returns the `TaxTransaction` |
| `reverse({ transaction, reference, lines })` | Reverses all of a sale, or the refunded part of each line                |

A cart has a currency, lines with an `amount` after discounts and an
optional `taxCategory`, optional `shipping`, the shipping `address` and an
optional `customer`. A line with `inclusive: true` already contains its tax.
A customer with `exempt: true` (a reverse-charged B2B sale) pays none. A cart
that mixes currencies is `invalid_input`, and an address without a rate is
`not_found`.

`fixedRates` keeps rates in your code, per country (`NL`) or country and
state (`US-CA`, which wins over `US`), and per tax category. It stores
nothing: keep the transaction `commit` returns with the order.

```ts
const tax = fixedRates({
  rates: {
    NL: { standard: "0.21", reduced: "0.09" },
    DE: { standard: "0.19", reduced: "0.07" },
  },
});

const quote = await tax
  .quote({
    currency: "EUR",
    lines: [{ id: "line_1", amount: parseMoney("100.00", "EUR") }],
    address: { country: "NL" },
  })
  .orThrow();
// quote.totalTax is 21.00 EUR
```

`stripeTax` runs the same contract on Stripe Tax: `quote` creates a tax
calculation, `commit` a tax transaction from it, and `reverse` a full or
partial reversal. Map your categories to Stripe tax codes with `taxCodes`.

```ts
const tax = stripeTax({
  stripe: { secretKey: env.STRIPE_SECRET_KEY },
  taxCodes: { ebook: "txcd_10302000" },
});
```

## Test your own provider [#test-your-own-provider]

`testPaymentProvider` and `testTaxProvider` from `better-supabase/testing`
are the conformance kits. Run them against a test-mode account, or against
your own adapter. The [conformance page](/docs/extending/conformance) lists
what they check.

```ts
import { testPaymentProvider } from "better-supabase/testing";

const report = await testPaymentProvider(payments, {
  authorize: (payment) => confirmWithTestCard(payment),
});
```

# 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({
  gen: { 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 extension, 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.

## Glossary [#glossary]

Each concept has one name. The renames land in phases listed on the
[roadmap](/docs/roadmap#planned-block-instances-and-one-data-contract);
until a phase ships, the code still exports the name in the Replaces
column, and each phase ships a codemod for its renames.
[ADR 0093](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0093-block-instances-and-one-data-contract.md)
records the decision.

| Term          | Meaning                                                                                | Replaces                           |
| ------------- | -------------------------------------------------------------------------------------- | ---------------------------------- |
| block         | the unit you install: tables, functions, a client and generated code                   | module, SQL module, kit            |
| core block    | a block installed once per project (memberships, permissions, outbox, queue, audit)    | foundation block                   |
| instance      | one named install of a block, with its own tables                                      | nothing                            |
| record type   | a kind of row other blocks attach to (`RecordRef`, config `records`)                   | subject, `SubjectRef`              |
| names         | the nouns an instance uses (`blocks.<name>.names`)                                     | entities                           |
| tables        | the app's own tables (config `tables`)                                                 | `tables`                           |
| organization  | the account that owns data, in every name you see                                      | tenant (stays in internal code)    |
| preset        | a named selection of blocks                                                            | nothing                            |
| design        | the JSON document Create writes, the one format for presets                            | preset codes                       |
| registry item | an installable file: a page, widget, route or recipe                                   | screens, glue                      |
| provider      | a versioned interface your app implements (`ShippingProvider`)                         | carrier, backend                   |
| adapter       | a framework integration (config `adapters`)                                            | integration, bridge                |
| plugin        | the versioned repository extension interface for third parties                         | the built-in plugins               |
| controller    | headless state for a widget or a list                                                  | nothing                            |
| hook          | a framework wrapper around a controller or a query                                     | nothing                            |
| engine        | what runs the hooks: TanStack Query, the controllers, PowerSync, TanStack DB, SQLite   | separate client data layers        |
| client        | the one better-supabase client, named `bsb`; instances and tables are properties on it | `bs`, transport                    |
| callbacks     | the `before_*` and `after_*` functions of a block (`blocks.<name>.callbacks`)          | block hooks                        |
| owner         | who owns a row: `organization`, `user` or `none` (`blocks.<name>.ownership`)           | scope, mode                        |
| event         | an outbox CloudEvent; in-process `on` handlers are listeners                           | nothing                            |
| automation    | a trigger and actions an organization can edit                                         | rules, flows                       |
| workflow      | code that runs durably                                                                 | nothing                            |
| schedule      | a timer on the queue                                                                   | reminders, SLA and sequence timers |

The data verbs are the same on every table and every instance: `list`,
`page`, `get`, `count` and `aggregate` read; `create`, `update`, `upsert`
and `delete` write; `attach` and `detach` link a row to a record. "Feature"
only means a feature flag.

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

`bsb` 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 `bsb`, so a call reads
the same in every framework:

```ts
const { db } = await bsb.context(); // Next.js Server Component
app.use("/api/*", bsb.middleware()); // Hono
Deno.serve(bsb.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`    | `bsb`            | the server; Next.js adds `import "server-only"` |
| `lib/supabase/client.ts`    | `bsb`            | the browser                                     |
| `lib/supabase/generated.ts` | `schema`         | both, written by `better-supabase gen`          |

Hono and oRPC starters create `bsb` 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 `bsb` 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]

From 0.15 the runtime instance is `bsb`, not `bs`, matching the CLI's short
name; [`better-supabase codemod 0.15`](/docs/cli/codemod) renames it.

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.

## Block subjects and tenants (0.14) [#block-subjects-and-tenants-014]

From 0.14, a block method names the record it works on with one
`SubjectRef`, `{ type, id }`, and reads the organization from the block's
`tenant` option. Rows name the record `subject`. The full list of calls is in
the [0.13 to 0.14 guide](/docs/migration/0.13-to-0.14).

| Old                                                         | New                                         |
| ----------------------------------------------------------- | ------------------------------------------- |
| `organizationId, subjectType, subjectId` method arguments   | `subject` (`{ type, id }`), first           |
| `organizationId` method argument                            | the block's `tenant` option                 |
| `row.subjectType`, `row.subjectId`                          | `row.subject.type`, `row.subject.id`        |
| `subjectIds` in batch methods, results keyed by id          | `subjects`, results keyed `type:id`         |
| `useComments({ organizationId, subjectType, subjectId })`   | `useComments({ subject })`                  |
| `useReactions({ organizationId, subjectType, subjectIds })` | `useReactions({ subjects })`                |
| `useTagInput({ organizationId, subjectType, subjectId })`   | `useTagInput({ subject, kind })`            |
| a `lib/blocks.ts` wrapper per block                         | `createBlockClient(ctx.db, block, options)` |

# Nested tenancy

> Model an organization with environments, projects or workspaces under it, and decide which level the modules treat as the tenant.

Source: https://bettersupabase.com/docs/concepts/nested-tenancy

Many apps have two levels: an organization that owns billing and members, and
environments, projects or workspaces under it that own the data. better-supabase
works per tenant, one id per row. This page says how to pick the level that is
the tenant, and what each choice means for subjects, the memberships extension and
`access`.

## Pick the tenant level [#pick-the-tenant-level]

The tenant is the level whose id sits in the `organization_id` column of every
module table, and in the request's tenant claim.

| Choice                    | Module tables hold | Use it when                                                                |
| ------------------------- | ------------------ | -------------------------------------------------------------------------- |
| Organization is tenant    | organization ids   | Members share everything in the organization; the lower level is your data |
| Environment is the tenant | environment ids    | Access, usage, audit and jobs differ per environment                       |

With the first choice nothing is special: leave the lower level to your own
tables and policies. The rest of this page is about the second.

## The environment as tenant [#the-environment-as-tenant]

Declare both levels as scopes of an
[authorization provider](/docs/extending/authorization-providers#nested-tenancy)
with `parent`, and set `tenantScope` to the lower one. Built-in roles and
permission tables have one level only, so nested tenancy needs a provider that
answers `idsWith` for the environment scope.

The consequences are the same everywhere:

- `can(scope, id, permission)` in the [access](/docs/repository/permissions) module asks
  the provider for the environments where the caller holds the key. A role on
  the organization reaches its environments when the provider says so.
- The [memberships extension](/docs/blocks/memberships) scopes queries to one id, so put
  the environment id in the claim or pass `{ tenant }` to
  `betterSupabase.connect` once the caller's membership is checked.
- Columns named `organization_id` in module tables hold environment ids. Keep
  the name: renaming it per module would break the generated clients.
- Anything the organization owns outright, such as the subscription in
  [billing](/docs/blocks/billing), is stored per tenant, so with environments
  as tenants each environment has its own customer and subscription. Keep
  organization-level billing in your own table keyed by the organization.

## Subjects at either level [#subjects-at-either-level]

`config.records` registers record types such as `task` or `deployment` once.
Each subject names its own `tenant` column, so a subject table can carry the
environment id while another carries the organization id. Subject checks
(`can_subject`) use the tenant of the subject's table, so a comment on a task
asks the environment, and a comment on an organization-level record asks the
organization.

## What to keep in mind [#what-to-keep-in-mind]

- One tenant level per module install. Two levels in one module's tables would
  make every policy ambiguous.
- Usage meters can be organization-level or environment-level: the tenant is
  whatever the `usage` module is installed on, and a computed meter's query
  decides which ids it returns. See
  [distinct and computed meters](/docs/blocks/usage#distinct-and-computed-meters).
- The audit log records the tenant of the row. With environments as tenants,
  `audit()` takes `tenant_path` or `tenant_function` for tables that reach
  their tenant through another table.
- Check the tenant against the caller's memberships before you trust it from a
  URL or body.

# 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.get(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.get(id).orThrow();

// Or chain:
const name = await db.customers
  .get(id)
  .map((row) => row.name)
  .unwrapOr("Unknown");
```

A `DbException` has the `DbError` as `error` and `cause`, and its own
`kind`, `status`, `code`, `details`, `hint` and `table` fields; see
[Throwing your own errors](/docs/repository/unique-and-errors#throwing-your-own-errors).

`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    | `get`, `update`, `delete` with no row, `PGRST116`, `P0002` (`no_data_found`)                                                                                                                                                                                                                                                                   |
| `unauthorized`    | 401    | Missing or expired JWT                                                                                                                                                                                                                                                                                                                         |
| `forbidden`       | 403    | `42501`, RLS `WITH CHECK` failures; `25006`, a write in a read-only transaction (with a hint); guards with `aal` set `required` ([MFA](/docs/auth/mfa-sso)); an [authorizer](/docs/extending/authorizers) refusal sets `permission`, and `approval` with `code: "APPROVAL_REQUIRED"`; a refusal that names the missing keys sets `permissions` |
| `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*`, and `@supabase/server` pool errors (`POSTGRES_POOL_BUSY`, `POSTGRES_CONNECT_PAUSED`); `retryAfter` is the seconds to wait, when known                                                                                                                                                                                   |
| `aborted`         | 499    | The `AbortSignal` fired                                                                                                                                                                                                                                                                                                                        |
| `invalid_request` | 400    | A query the backend cannot express                                                                                                                                                                                                                                                                                                             |
| `validation`      | 422    | Standard Schema issues from the validation extension, 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/frontend/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.get(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.

### Map errors by kind and SQLSTATE code [#map-errors-by-kind-and-sqlstate-code]

`matchDbError` builds the factory from one handler per error kind. Handlers
under `codes` match a SQLSTATE or PostgREST code and run before the handler
for the kind. A handler that returns `undefined`, and an error with no
handler, throw a `DbException`.

```ts
import { matchDbError } from "better-supabase";

const toAppError = matchDbError({
  conflict: (error) => new ConflictError(error.constraint),
  forbidden: (error) => new ForbiddenError(error.permission),
  not_found: () => new NotFoundError(),
  codes: {
    "22000": () => new ForbiddenError(),
    "23514": (error) => new InvalidError(error.message),
  },
});

await db.posts.update(id, patch).orThrow(toAppError);
```

Each kind handler receives the error narrowed to its kind, so `error.constraint`
is typed for `conflict`. The result works with `betterSupabase.mapError(toAppError)`
too.

## 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 { createAudit } from "better-supabase/blocks/audit";

const audit = createAudit({ transport, temporal: Temporal });
```

That covers the `create*` functions, `createQueue({ sql, queues, temporal })`,
`createRateLimit({ sql, temporal })`, `exportAuditLog`, `purgeAuditLog`,
`scimHandler`, and the options of `createSettings` and `createOnboarding`.
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                                                                                 |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `gen.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`                                           |
| `createQueue({ sql, 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.list({ where: { createdAt: { gte: since } } });
```

Without the codec, `timestamptz` columns stay ISO strings, as PostgREST
returns them.

To keep strings without Temporal but get one canonical form, set
`codecs: { timestamptz: 'utc' }`. The select casts the column to `text`, and
the repository rewrites each value in UTC as ISO 8601 with a `Z`, keeping
every fraction digit Postgres sent: `2026-10-21 07:10:00.123456+02` reads as
`2026-10-21T05:10:00.123456Z`. The type stays `string`, `infinity` and
`-infinity` pass through, and `timestamp` columns are left as they are.

## 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,
]);
```

# 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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

`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 bsb = createAstro(betterSupabase, { signIn: "/sign-in" });
```

```ts title="src/middleware.ts"
import { bsb } from "./lib/server";

export const onRequest = bsb.onRequest;
```

The middleware verifies the caller, refreshes the session cookie on
navigations and form posts, and puts `db`, `bsb`, `auth`, `memberships` 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]

`bsb.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 { bsb } from "../lib/server";

const refused = await bsb.guard(Astro, { roles: ["admin"] });
if (refused) return refused;
const members = await bsb.locals(Astro).db.members.list().orThrow();
---
<ul>{members.map((member) => <li>{member.name}</li>)}</ul>
```

`bsb.require(context, options)` returns the caller and its repositories, or
throws the refusal `Response`.

## Actions [#actions]

`bsb.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 { bsb } from "../lib/server";
import { NoteInput } from "../lib/schemas";

export const server = {
  addNote: defineAction({
    accept: "form",
    handler: bsb.action(
      { input: NoteInput, requireOrganization: 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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

```ts title="supabase/functions/api/server.ts"
import { createEdge } from "better-supabase/edge";
import { betterSupabase } from "../_shared/supabase.ts";

export const bsb = createEdge(betterSupabase, { cors: true });
```

```ts title="supabase/functions/api/index.ts"
import { bsb } from "./server.ts";

Deno.serve(
  bsb.handler(async (request, { db }) => {
    const { name } = await request.json();
    return db.customers.create({ name, organizationId: "..." });
  }),
);
```

`bsb.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(
  bsb.handler((_request, { auth }) => ({
    role: auth.kind === "user" ? auth.claims.user_role : null,
  })),
);
```

## REST resources [#rest-resources]

`bsb.resources` serves the same REST routes as [Hono resources](/docs/frameworks/hono#resources)
without a router:

```ts
Deno.serve(
  bsb.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 bsb = createEdge(betterSupabase, { cors: true, authorizer });

Deno.serve(
  bsb.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]

`bsb.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 `bsb.require`:

```ts
Deno.serve(
  bsb.routes(
    {
      "GET /customers/:id": (_request, { db, params }) =>
        db.customers.get(params.id),
      "POST /customers/:id/archive": {
        roles: ["admin"],
        roleClaim: "user_role",
        requireOrganization: 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 bsb = 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 bsb = createServer(betterSupabase);

export default {
  fetch: toEdge(
    [withCors({ origin: "https://app.example.com" }), withBetterSupabase(bsb)],
    async (_request, ctx) => Response.json(await ctx.db.notes.list().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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

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 bsb = createServer(betterSupabase);
const bridge = toElysia([withBetterSupabase(bsb, { allow: ["user"] })]);

const app = new Elysia()
  .derive(({ request }) => bridge.context(request))
  .get("/notes", ({ db }) => db.notes.list().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 `bsb-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`, `requireOrganization`, `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.list().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 bsb = 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]

`bsb.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 { bsb } from "../../lib/supabase/server";

export const loader = bsb.loader(
  ({ db }) => db.customers.list({ 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.

`bsb.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]

`bsb.loader` returns an `expo-server` `LoaderFunction`, so it works anywhere a
loader does. When a route already uses `createServerLoader`, call
`bsb.request` inside it instead:

```tsx title="src/app/customers/[id].tsx"
import { createServerLoader } from "expo-server";
import { bsb } from "../../lib/supabase/server";

export const loader = createServerLoader(async (request, params) => {
  const { db } = await bsb.request(request, { allow: ["user"] });
  const customer = await db.customers
    .get(String(params.id), { select: ["id", "name"] })
    .orThrow();
  return { customer, country: request.headers.get("cf-ipcountry") };
});
```

`bsb.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 `bsb.loader` when you want that mapping.

To add your own step around every loader, wrap `bsb.loader` in a function that
returns a `LoaderFunction`:

```ts title="src/lib/supabase/loader.ts"
import type { LoaderFunction } from "expo-server";
import { bsb } from "./server";

export function timedLoader<T>(
  name: string,
  fn: Parameters<typeof bsb.loader<T>>[0],
): LoaderFunction<T> {
  const loader = bsb.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]

`bsb.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 { bsb } from "../../lib/supabase/server";

export const GET = bsb.handler((_request, { db }) =>
  db.customers.list({ 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 { bsb } from "../../lib/supabase/server";

export const GET = toExpo([withBetterSupabase(bsb)], async (_request, ctx) =>
  Response.json(await ctx.db.notes.list().orThrow()),
);
```

## Middleware [#middleware]

`bsb.middleware()` in `+middleware.ts` refreshes an expired cookie session
before loaders run and writes the new cookies with `setResponseHeaders`. From
0.11 it also hands that session to the loaders and `bsb.request` calls of the
same request, so they read the refreshed session without calling Auth again,
even with `refresh: false`. A loader that runs without middleware refreshes on
its own, so the middleware is optional for routes that only need a loader.
Either way a request makes at most one Auth call.

```ts title="src/app/+middleware.ts"
import { bsb } from "../lib/supabase/server";

export default bsb.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.

## Client entries [#client-entries]

Screens import the client from one of two entries. `better-supabase/client`
is the web client, for Expo web and any browser code.
`better-supabase/client/native` is the one for iOS and Android: it takes
[`secureStorage`](/docs/frontend/react-native) and works without the DOM.
Both load without a global `TextEncoder` or `TextDecoder`; better-supabase
creates them when it first signs or decodes a token.

## Jest and jest-expo [#jest-and-jest-expo]

better-supabase is ESM-only on purpose, so `jest-expo` has to transform it.
Add `better-supabase` to the allow-list in `transformIgnorePatterns`:

```js title="jest.config.js"
module.exports = {
  preset: "jest-expo",
  transformIgnorePatterns: [
    "node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|better-supabase))",
  ],
};
```

A `jsdom` test environment can lack `TextEncoder` and `TextDecoder`. Importing
better-supabase does not need them, but a test that decodes a session does:
polyfill both in `setupFiles` with the ones from `node:util`. The
[testing page](/docs/testing#jest-and-jest-expo) has the same section.

## 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/frontend/powersync). The
[Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
runs one list definition on both.

## Session snapshot [#session-snapshot]

In an API route, `bsb.snapshot(request)` returns the verified user, shared by
every call with the same token, and `bsb.contextFromSession(snapshot)` builds
a context from it with no second verification:

```ts title="app/api/notes+api.ts"
export async function GET(request: Request) {
  const { db } = await bsb.contextFromSession(await bsb.snapshot(request));
  return Response.json(await db.notes.list().orThrow());
}
```

A snapshot holds the access token: never return it in a response. A copy
of it (a spread, `structuredClone`, a serialized loader result) gets the
`invalid` context, so pass plain values such as the user id onward instead.
Prefetches never refresh the session, so parallel prefetches never race on
the refresh token.

# 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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

`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 bsb = createServer(betterSupabase);

const app = new H3()
  .use(toH3([withBetterSupabase(bsb, { allow: ["user"] })]))
  .get("/notes", (event) => event.context.db.notes.list().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
`bsb-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`,
`requireOrganization`, `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.list().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 { bsb } from "../utils/supabase";

export default defineMiddleware(
  toH3([withBetterSupabase(bsb, { refresh: true, allow: ["user", "anon"] })]),
);
```

The bridge makes the request body readable twice, so an entry and the handler
both see it.

# Hono and Next.js together

> One server for a Next.js app and a Hono API, with shared cookies, CORS and Problem Details.

Source: https://bettersupabase.com/docs/frameworks/hono-and-next

An app with a Next.js front end and a Hono API in the same repository can
share one [server](/docs/auth/server). Build it once, then hand it to the
other adapter with `server`:

```ts title="src/lib/bsb.ts"
import { createHono } from "better-supabase/hono";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./supabase";

export const bsNext = createNext(betterSupabase);

export const api = createHono(betterSupabase, {
  server: bsNext,
  cors: { origin: ["https://app.example.com"] },
});
```

`createHono` then does not call `createServer` again, so both sides use the
same Supabase clients, credentials providers and event sinks. The server
options you pass next to `server` are ignored.

```ts title="src/api.ts"
import { api } from "./lib/bsb";

export const app = api
  .app()
  .use("/api/*", api.middleware())
  .get("/api/me", (c) => c.json({ id: c.var.auth.user?.id }));
```

## Cookies [#cookies]

The Next.js proxy refreshes the session and writes the cookies. The Hono
middleware reads the same cookies and never refreshes, so a Hono API on the
same site sees the session the proxy refreshed. An API on another origin needs
`cors` and a bearer token instead of cookies.

## CORS [#cors]

`cors` on `createHono` answers preflights and adds the headers to every route
of `app()`. `true` uses the headers supabase-js sends and `*` as the origin;
`{ origin, headers, methods, maxAge }` narrows them. The same option exists on
[`createEdge`](/docs/frameworks/edge).

## Problem Details [#problem-details]

`api.app()` installs `api.onError`, which turns a thrown `DbException` or an
unexpected error into an RFC 9457 response. Pass `exposeErrors` to include
messages outside development.

# 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 bsb = createHono(betterSupabase);

const app = bsb
  .app()
  .use("/api/*", bsb.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 `bsb.admin()` and `bsb.actingAs()` work too. `bsb.app()` is
`new Hono<typeof bsb.Env>()` with [`bsb.onError`](#handlers) installed.
`typeof bsb.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 bsb.Env;
const admin = new Hono<Env>();
const tenantOf = (c: Context<Env>) =>
  c.var.auth.kind === "user" ? c.var.auth.claims.tenant_id : undefined;
```

`bsb.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]

`bsb.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.bsb`  | The full server context, including `supabase` and `sql` |

```ts
app.use("/public/*", bsb.middleware({ allow: ["user", "anon"] }));
app.use("/app/*", bsb.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]

`bsb.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 bsb = createServer(betterSupabase);

const app = new Hono()
  .use(
    toHono([
      withBetterSupabase(bsb, { allow: ["user"] }),
      withPostgresClient(),
    ]),
  )
  .get("/api/notes", async (c) =>
    c.json(await c.var.db.notes.list().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(bsb)])`,
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.bsb` and the handler context are typed by the schema's
output, and so is `profile` with `.userMetadata(schema)`. `bsb.app()` and
`typeof bsb.Env` carry both types; with a hand-written `HonoEnv`, pass the
claims as its fourth parameter:

```ts
const app = bsb
  .app()
  .use("/api/*", bsb.middleware())
  .get("/api/role", (c) =>
    c.json({
      role: c.var.auth.kind === "user" ? c.var.auth.claims.user_role : null,
    }),
  );
```

## Handlers [#handlers]

`bsb.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",
  bsb.handler((c, { db }) => db.customers.get(c.req.param("id"))),
);
app.post(
  "/api/customers",
  bsb.handler(async (c, { db }) => db.customers.create(await c.req.json()), {
    status: 201,
  }),
);
```

`bsb.onError` handles errors thrown outside `bsb.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 bsb = createHono(betterSupabase, {
  waitUntil: (promise) => EdgeRuntime.waitUntil(promise),
});
```

## Rate limits [#rate-limits]

`bsb.rateLimit()` counts one hit per request with
[`createRateLimit`](/docs/blocks/sql#limits-in-route-handlers) and adds the
`RateLimit` response field. With a `rule`, it also adds `RateLimit-Policy`.
A refused request gets a `problem+json` 429 with `Retry-After`, and `key`
returning `undefined` skips the limit:

```ts
import { createRateLimit } from "better-supabase/blocks/queue";

const limiter = createRateLimit({ sql: postgres.admin });

app.use(
  "/api/*",
  bsb.rateLimit({
    limiter,
    scope: "api",
    rule: { max: 100, period: "1 minute" },
    key: (c) => c.req.header("x-api-key"),
  }),
);
```

Without `rule`, the scope's rule from `set_rate_limit` applies. `policy`
names the policy in the headers (default: the scope). A limiter error
answers with its status unless `failOpen` is set.

## Resources [#resources]

`bsb.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",
  bsb.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 bsb = createHono(betterSupabase, { authorizer });

app.route(
  "/api/customers",
  bsb.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 bsb = createHono(betterSupabase);
const token = await signLocalJwt({ sub: userId, tenant_id: organizationId });
await app.request("/api/customers", {
  headers: { authorization: `Bearer ${token}` },
});
```

## Session snapshot [#session-snapshot]

`bsb.middleware()` sets `c.var.snapshot` to the verified user, or `null`.
`bsb.contextFromSession(c.var.snapshot)` builds a context from it with no
second verification and no header read, for work that runs outside the
handler's own context:

```ts
app.get("/notes", async (c) => {
  const { db } = await bsb.contextFromSession(c.var.snapshot);
  return c.json(await db.notes.list().orThrow());
});
```

A snapshot holds the access token: never return it in a response. A copy of
it gets the `invalid` context, so pass plain values such as the user id
onward instead.

# 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()`             |

## Adapter tiers [#adapter-tiers]

Every adapter ships in each release and keeps working. They differ in how
much testing and documentation they get
([ADR 0094](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0094-supported-and-community-adapters.md)).

| Tier      | Adapters                                                                      | What it gets                                                                                                       |
| --------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Supported | Next.js, Hono, Expo Router, SvelteKit, TanStack Start, React Router           | The full test matrix (unit and conformance tests, type tests, an example app), every guide, and triaged issues     |
| Community | Astro, Edge Functions, Elysia, H3, Nuxt and Nitro 2, NestJS, Node, SolidStart | A smoke test that serves one request, a docs page with a community note, and fixes that may lag the supported tier |

New guides and features land on the supported adapters first. An adapter
moves between the tiers when its usage changes: a community adapter that
many apps run moves up, and a supported adapter that few apps run moves down
in a minor release, with a changeset.

# 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 bsb = 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(bsb.fetch);
```

`bsb.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 `bsb.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 bsb = 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` |

The list tool takes `after` and returns `nextCursor`, which suits agents that
read a table one page at a time. With `pagination: 'offset'` on the resource
(or its list query) it takes `page` instead. 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]

`bsb.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 bsb = 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 bsb = 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. The first `tools/list` logs one
  warning that names the hidden tools.

Pass the provider's operations table as `operations` and an entry
`mcp.tool.<name>` replaces that tool's `permission`, or gates a tool that
declares none. A tool's `operation` (such as `invoices.void`) names the
entry to look up in place of `mcp.tool.<name>`.

`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 bsb = 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 `bsb` 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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

`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 { bsb } from "./supabase";

@Module({ controllers: [NotesController] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(toNestMiddleware([withBetterSupabase(bsb, { 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`, `requireOrganization`, `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.list({ select: ["id", "title"] }).orThrow();
  }

  @Get("all")
  @UseGuards(guard({ roles: ["admin"] }))
  all(@Ctx("db") db: Db) {
    return db.notes.list().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]

`bsb.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 { bsb } from "@/lib/supabase/server";

export async function getSession(): Promise<AuthSession> {
  "use cache: private";
  const session = await bsb.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/rbac/functions/custom_access_token_hook.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 `memberships` 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 memberships extension 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 [memberships extension](/docs/blocks/memberships) and
`current_tenant_id()` read (`auth.organization.claim` 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) =>
  bsb.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 `bsb.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 bsb.cached();
  return db.customers.list({ select: ["id", "name", "status"] }).orThrow();
}
```

`bsb.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 `bsb:session:<user id>`, so `bsb.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
  `bsb.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
  (`bsb:<table>@<tenant>` and `bsb:<table>@*` under an active tenant, else
  `bsb:<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:
  `bsb.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
  `bsb.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 `bsb.cached()` set
the tags and the lifetime:

```ts title="src/lib/access.ts"
import { bsb } from "@/lib/supabase";
import { permissionsFor } from "@/lib/permissions";

export async function loadSnapshot(organizationId: string) {
  "use cache: private";
  const session = await bsb.session();
  const snapshot = permissionsFor(session, organizationId);
  await bsb.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
bsb.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, `bsb.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). The [island pattern](/docs/frameworks/next-islands)
covers the session memo that shares the verification across scopes, even
while the first one is still in flight.

Data that is the same for every user belongs in a plain `'use cache'` with
`bsb.cacheTag()` and `bsb.admin()` (see [cache tags](/docs/frameworks/next#cache-tags)).
Don't pass a user id into a shared `'use cache'` function that uses
`bsb.admin()`: that bypasses RLS.

Mutations through `bsb.action()` call `updateTag`, which also clears the
client router cache, so the user sees their own write. Mutations elsewhere
(`bsb.route()`, webhooks) expire the tags with `revalidateTag(tag, { expire: 0 })`.

## 7. Signing in and out [#7-signing-in-and-out]

The fastest password sign-in runs on the server. `bsb.signIn(credentials)`
makes one Auth call, writes the session cookies on the action's response,
stores the verified session for the next render and returns `redirectTo`.
That is `credentials.next` when it is a same-origin path (so `?next=` can't
become an open redirect), else `fallback` (`/`):

```ts title="src/features/auth/auth-actions.ts"
"use server";
import type { ActionResult, SignedIn } from "better-supabase/next";
import { bsb } from "@/lib/supabase/server";

export async function signIn(input: {
  email: string;
  password: string;
  next: string | null;
}): Promise<ActionResult<SignedIn>> {
  return bsb.signIn(input, { fallback: "/" });
}
```

The form then navigates once, with no redirect hop and no
`router.refresh()`: the action's response already carried the new cookies.
`mfa` is true when the user has a verified second factor, so send them to
the challenge first:

```ts
const result = await signIn({ email, password, next });
if (result.ok) {
  const { redirectTo, mfa } = result.data;
  router.replace(
    mfa ? `/mfa?next=${encodeURIComponent(redirectTo)}` : redirectTo,
  );
}
```

`safeRedirectPath(next, fallback)` from `better-supabase/server` is the same
check for your own flows.

For other sign-in methods, 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
`bsb.session()` and every `bsb.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 dev 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 | `bsb.admin()` inside `'use cache'`                         |
| 2. App Shell     | Once per browser session, prefetched | The session and data derived from it     | `bsb.cached()`, `bsb.session()`, `bsb.liveCount(spec, db)` |
| 3. Link prefetch | When a link enters the viewport      | The data behind that link                | `bsb.cacheTags()`, read sets through `db.$many`            |
| 4. Navigation    | On click, not prefetched             | Per-request and uncached data            | `bsb.context()`, `bsb.route()`                             |

- **Stage 1** can't read the request. `bsb.admin()` bypasses RLS, so only
  use it for data every visitor may see, and tag the entry with
  `bsb.cacheTags()` so mutations revalidate it.
- **Stage 2** runs once and is reused until `sessionStale` expires or
  `bsb.invalidateSession()` drops it. Keep it small: the menu, the
  session, a few counts. Pass the `db` from `bsb.cached()` to
  `bsb.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 `bsb.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 bsb = createNext(betterSupabase, {
  debug: { budget: { calls: 8, waves: 2 } },
});
```

```ts title="src/app/api/bsb-stats/route.ts"
export const GET = bsb.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-bsb-request-id`, forwarded to
  Server Components and sent back on the document and `_rsc` responses).
  `bsb.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.
- `bsb.route()` responses carry `x-bsb-db-calls: calls;waves;ms` directly.
  Pages stream, so their headers leave before the render finishes; their
  totals are served by `bsb.debugRoute()` at the URL in the `x-bsb-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 bsb = 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'` (`bsb.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.

# Island pattern

> Many private Suspense islands on one page that share one token verification per request.

Source: https://bettersupabase.com/docs/frameworks/next-islands

A page under [Cache Components](/docs/frameworks/next-cache-components) is a
static shell plus islands: each island is a `<Suspense>` boundary around an
async Server Component with its own `'use cache: private'` reader. Every
private cache scope runs on its own, so React `cache()` can't share the
session between them. better-supabase shares it through a session memo on
the server instance instead:

- the first scope of a request verifies the token against the JWKS, and every
  other scope reuses that result, even while the first is still in flight;
- no scope calls the Auth server, reads the clock before `cookies()`, or
  refreshes the session (only the [proxy](/docs/frameworks/next#proxy) does);
- a verified token stays in the memo until it expires, keyed by a SHA-256 hash
  of the token, never by a user id or a header, so one process can serve many
  users safely.

The [Next.js example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs)
has a page with ten islands (`/islands`), and its e2e test checks that the
ten verify the token at most once.

## 1. Keep the page synchronous [#1-keep-the-page-synchronous]

The page renders the static chrome and one `<Suspense>` per island. It reads
nothing from the request, so it prerenders into the shell:

```tsx title="src/app/[locale]/(app)/islands/page.tsx"
export const instant = true;

export default function IslandsPage() {
  return (
    <>
      <PageHeader title="Islands" />
      <Suspense fallback={<CountIslandSkeleton />}>
        <CountIsland table="customers" />
      </Suspense>
      <Suspense fallback={<CountIslandSkeleton />}>
        <CountIsland table="notes" />
      </Suspense>
    </>
  );
}
```

## 2. Read the session inside each island [#2-read-the-session-inside-each-island]

Each island checks the caller itself. A layout never gates an island: a
layout that reads the session takes the whole subtree out of the shell, and
the island would still have to check again for a direct request.

```ts title="src/features/islands/islands-queries.ts"
import "server-only";
import { tenantOf } from "better-supabase/next";
import { bsb } from "@/lib/supabase/server";

export async function getIslandSession() {
  "use cache: private";
  const { session } = await bsb.cached();

  if (session.kind !== "user") return null;

  return {
    organizationId: tenantOf(session) ?? null,
    authzVersion: session.authzVersion,
  };
}
```

`bsb.cached()` reads the snapshot from the memo, sets `cacheLife` so the
entry never outlives the token, and tags it with `sessionTag(userId)` and
`sessionIdTag(sessionId)`.

## 3. Pass plain values into cached readers [#3-pass-plain-values-into-cached-readers]

A cached function keys its entry on its arguments, and arguments cross a
serialization boundary. Pass the ids the reader needs, never the session:

```ts title="src/features/islands/islands-queries.ts"
export async function getTableCount(
  table: "customers" | "notes",
  organizationId: string,
): Promise<number> {
  "use cache: private";
  const { db } = await bsb.cached({ tables: [table] });

  return db[table].count({ where: { organizationId } }).orThrow();
}
```

When you work with the snapshot directly, read it in the same scope that
uses it. `bsb.getRequestSession()` returns the verified `SessionSnapshot`
(`userId`, `sessionId`, `claims`, `expiresAt`, `issuedAt` and
`authzVersion`), and `bsb.contextFromSession(snapshot)` builds the database
and storage clients from it without reading a cookie or calling the network:

```ts
async function Island() {
  "use cache: private";
  const snapshot = await bsb.getRequestSession();
  const { db } = await bsb.contextFromSession(snapshot);
  // ...
}
```

`contextFromSession` only accepts a snapshot that `getRequestSession` made
in this process. A copy (from `structuredClone`, a spread, or a cached
function's arguments) gets the `invalid` context, so a forged object can't
stand in for a verified token. An expired snapshot gets `anon` with reason
`expired`.

To key your own cache on the session, use `sessionCacheKey(snapshot)`:
`userId:sessionId:authzVersion`. It never contains the token, and it stays
the same when the token rotates within one session.

## 4. Use `io()` only for request-time dates [#4-use-io-only-for-request-time-dates]

The memo reads the clock only after `cookies()` resolved, so a private
island never trips the "sync IO before a dynamic API" check. If an island
itself needs the current time, await `io()` from `next/server` first, or
format times that come from the data (such as `expiresAt`) instead.

## 5. Drop a user's entries [#5-drop-a-users-entries]

`bsb.invalidateSession(userId)` expires every `bsb.cached()` entry of the
user, evicts the user from the session memo and calls
`versions.evict(userId)` when the server has an
[authorization-version cache](/docs/extending/authorization-providers#version-cache).
Call it after a role change, so the next render verifies again.

## Sizing the memo [#sizing-the-memo]

`createNext(betterSupabase, { sessions: { max, ttlMs } })` sets the memo's
size (1000 tokens by default) and the longest time an entry lives; an entry
never outlives the token's `exp`. `sessions: false` verifies in every
scope. `bsb.sessions.stats()` returns `hits`, `misses` and `size`, which is
what the example's e2e test reads.

# 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 bsb = createNext(betterSupabase);
```

`createNext(betterSupabase)` is [`createServer`](/docs/auth/server) plus the Next.js
pieces below, so `bsb.admin()` and `bsb.actingAs()` work too. It needs Next.js
16.3 or later.

## Proxy [#proxy]

```ts title="src/proxy.ts"
import { bsb } from "@/lib/supabase/server";

export const proxy = (request) => bsb.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) =>
  bsb.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: `bsb-proxy` (the whole
proxy), `bsb-auth` (resolving the session), and `bsb-parse`, `bsb-verify`,
`bsb-refresh` and `bsb-ended` for the phases that ran. A page load with a
valid token shows no `bsb-refresh` or `bsb-ended`: it made no Auth call.

A token is refreshed only when it expires within `refreshSkewSeconds`
(`auth.leeway`, else 60). Outside that window the proxy parses the cookie
and verifies the token locally, with no fetch. After a refresh it stores the
new session in the [session memo](/docs/frameworks/next-islands), so the
render that follows doesn't verify it again. When two requests race to
rotate one refresh token, Auth answers the loser with
`refresh_token_already_used`. The proxy treats that as `raced`: it keeps the
cookies the winner set and keeps the user signed in while the old token is
still valid, instead of signing them out.

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) =>
  bsb.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 `bsb.proxy` can run the same check with
`sessionStatus` and `clearSessionCookies`
([details](/docs/auth/sessions#ended-sessions-in-your-own-proxy)).

### Auth outages [#auth-outages]

When a refresh fails because Auth is unreachable, times out or answers 5xx
or 429, the proxy calls `onAuthOutage(event, request)`. The event carries
the `cause` (`"network"`, `"timeout"` or `"status"`), the `status` for a
status outage, `durationMs`, and `kept`: true when the access token was
still valid and the caller stays signed in. When `kept` is false, a
`Response` you return replaces the proxy's response, so a signed-in user
sees a retry page instead of being sent to sign-in:

```ts
export const proxy = (request: NextRequest) =>
  bsb.proxy(request, {
    protect,
    onAuthOutage: (event) =>
      event.kept
        ? undefined
        : new Response("Sign-in is temporarily unavailable.", {
            status: 503,
            headers: { "retry-after": "10" },
          }),
  });
```

A rejected refresh token is not an outage: the session is cleared and
`protect` runs as usual. The `refresh` event also carries `reason`,
`status` and `code` for a failed refresh. `withSession` takes the same
`onAuthOutage` option outside Next.js.

## Server Components [#server-components]

```tsx
export default async function Customers() {
  const { db } = await bsb.context();
  const customers = await db.customers
    .list({ select: ["id", "name"] })
    .orThrow();
  return <CustomerList customers={customers} />;
}
```

`bsb.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.

`bsb.context({ tenant })` scopes the context to a tenant from the route
params, in place of the `memberships` resolver, which has no request URL here.
The tenant gets the same checks as the resolver's
([the active tenant](/docs/repository/permissions#the-active-tenant)), and each
tenant gets its own memoized context. `bsb.cached({ tenant })` does the same
in a private cache; pass the tenant in as an argument of your function.

`bsb.require(options)` is `bsb.context()` for a page only some callers may
see. It takes the same `allow`, `aal` and `scopes` as `bsb.route()`, plus
`requireOrganization` 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 bsb.require({
    requireOrganization: true,
    roles: ["admin"],
  });
  const events = await db.auditEvents
    .list({ where: { organizationId: tenant } })
    .orThrow();
  return <AuditLog events={events} />;
}
```

`bsb.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.

`bsb.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 bsb.session();
if (session.kind === "user") session.claims.user_role;
```

`bsb.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 `bsb.action()`.

Use `bsb.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, and the
[island pattern](/docs/frameworks/next-islands) for pages with many private
Suspense islands. Every scope of one request shares a single verification
through `bsb.getRequestSession()`.

## Route handlers [#route-handlers]

```ts title="src/app/api/customers/[id]/route.ts"
export const GET = bsb.route<{ id: string }>((request, { db, params }) =>
  db.customers.get(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]

`bsb.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 { bsb } from "@/lib/supabase/server";

export const { GET, POST, PATCH, PUT, DELETE } = bsb.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 `bsb.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 = bsb.route<{ id: string }>(
  (request, { db, params }) =>
    db.customers.get(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 `bsb.action`, the edge,
Hono and oRPC guards too.

## Server actions [#server-actions]

```ts title="src/app/customers/actions.ts"
"use server";

export const createCustomer = bsb.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.

`requireOrganization` 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 = bsb.action(
  {
    input: Invite,
    requireOrganization: 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.

- `requireOrganization: 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 `bsb.route()`; pass `resource: (input, ctx) =>`
  to load the row it acts on instead (see
  [Load the resource](/docs/extending/authorizers#load-the-resource)). 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 `bsb.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.
- `bsb.route(handler, { requireOrganization, 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, `bsb.startSupport({ targetUserId, reason })`
and `bsb.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. `bsb.cached()` reads add a `bsb:support:<id>` tag
(`supportTag(id)`), so ending the session drops the target's cached pages.

### Block actions [#block-actions]

`bsb.blockAction(client, method, options)` is an action that calls one block
method as the caller. It runs the same guard, validation, `permission` and
`authorize` checks as `bsb.action`, and its result is typed from the method's
data. Without `args` the validated input is the method's only argument;
`args(input, ctx)` returns the arguments when the method takes anything
else, and TypeScript requires it then.

```ts title="src/app/tasks/actions.ts"
"use server";

import * as v from "valibot";

import { permissions } from "@/lib/supabase/blocks.generated";

export const createTask = bsb.blockAction(({ db }) => db.tasks, "create", {
  input: v.object({ title: v.pipe(v.string(), v.minLength(1)) }),
  requireOrganization: true,
  permission: permissions["tasks"].write,
  args: (input, { tenant }) => [tenant, input],
});
```

The client is built per call from the request's context, so the block runs
as the signed-in user. A refused caller, a failed validation and a database
error all come back as `{ ok: false, error }`, like any other action.

## Cache tags [#cache-tags]

Every mutation invalidates `bsb:<table>`, its tenant's `bsb:<table>@<tenant>`
and `bsb:<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";
  bsb.cacheTag("plans");
  return bsb
    .admin()
    .plans.list({ 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 `bsb.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.list({
    where: { organizationId: tenant },
  });
  bsb.cacheTags(spec, { tenant });
  return bsb.admin().$run(spec).orThrow();
}
```

The read is tagged `bsb:customers@<tenant>` and `bsb:customers@*`. A
mutation invalidates `bsb:customers`, the tag of its tenant (from the
request context, such as the memberships extension's), or `bsb:customers@*` when it
has none, and the tags of the rows it changed. Reads without a `tenant` keep
`bsb:<table>`, which every mutation of the table invalidates, and so does a
read across tenants (`{ tenant: "*" }`), which carries `bsb:<table>` as well
as `bsb:<table>@*`. `bsb.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 `bsb.action()` or
`bsb.route()` still sets the replica pin cookie and keeps the event sends
running.

## Record bindings [#record-bindings]

A record page's [bindings](/docs/repository/record-bindings) have their own cache tags,
so a write to one binding keeps the others cached.
`bsb.record(customerRecord)` takes a
[generated record type](/docs/repository/record-bindings#generated-record-types) and
returns `load(id, options?)` and `timeline(id, options?)` for Server
Components, `actions`, one Server Action per binding write, each of which
drops its binding's tags, and `tags(id, tenant?)`, the record tag and each
binding tag, for your own cached reads.

| Function                                        | Tag                                                                   |
| ----------------------------------------------- | --------------------------------------------------------------------- |
| `recordTag(subject, { tenant? })`               | `bsb:record@<tenant>:<type>:<id>`, the header and badges              |
| `bindingTag(subject, binding, { tenant? })`     | the record tag plus `#<binding>`                                      |
| `invalidateBinding(subject, binding, options?)` | drops both: `updateTag` in a server action, `revalidateTag` elsewhere |

```ts title="src/features/customers/customer-queries.ts"
import { recordTag, bindingTag } from "better-supabase/next";
import { cacheTag } from "next/cache";

async function customerFiles(tenant: string, id: string) {
  "use cache";
  const subject = { type: "customer", id };
  cacheTag(
    bindingTag(subject, "attachments", { tenant }),
    recordTag(subject, { tenant }),
  );
  const result = await loadFiles(tenant, subject);
  if (!result.ok) throw result.error;
  return result.data;
}
```

Export the actions your UI calls from a `"use server"` file. Each takes
`{ id, input }`, checks the caller's permission for the write, and calls
`invalidateBinding` with the request's tenant after a successful write:

```ts title="src/features/customers/customer-record-actions.ts"
"use server";

import { customerPage } from "./customer-record";

export const removeCustomerAttachments =
  customerPage.actions.attachments.remove;
```

# 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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

`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 bsb = createBetterServer(betterSupabase);

createServer(
  toNodeHandler(
    [withBetterSupabase(bsb, { allow: ["user"] })],
    (request, { db }) => db.notes.list({ 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 { bsb } from "./lib/server";

const app = express();
app.use(
  toExpress([
    withBetterSupabase(bsb, { refresh: true, allow: ["user", "anon"] }),
  ]),
);

app.get("/notes", async (req, res) => {
  res.json(await res.locals.db.notes.list().orThrow());
});
app.get("/admin/members", guard({ roles: ["admin"] }), async (req, res) => {
  res.json(await res.locals.db.members.list().orThrow());
});

app.use(problemErrorHandler());
```

Guards take the same options as every adapter: `allow`, `aal`, `scopes`,
`roles` and `roleClaim`, `requireOrganization`, `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 `requireOrganization` 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.list().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 { bsb } from "./lib/server";

const app = Fastify();
app.decorateRequest("locals", null);
app.addHook(
  "onRequest",
  toFastify([withBetterSupabase(bsb, { allow: ["user"] })]),
);

app.get("/notes", (request) => request.locals.db.notes.list().orThrow());
app.get(
  "/admin/members",
  { preHandler: fastifyGuard({ roles: ["admin"] }) },
  (request) => request.locals.db.members.list().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 { bsb } from "./lib/server";

const app = new Koa();
app.use(toKoa([withBetterSupabase(bsb, { allow: ["user"] })]));
app.use(koaGuard({ roles: ["admin"] }));
app.use(async (ctx) => {
  ctx.body = await ctx.state.db.members.list().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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

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 bsb = createServer(betterSupabase);
export default [
  withBetterSupabase(bsb, { 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`, `bsb`, `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.list().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 bsb = createOrpc(betterSupabase);

const base = os.$context<OrpcRequestContext>();
const authed = base.use(bsb.middleware());
const open = base.use(bsb.middleware({ allow: ["user", "anon"] }));

export const router = {
  customers: {
    list: authed.handler(({ context }) =>
      bsb.unwrap(context.db.customers.list({ select: ["id", "name"] })),
    ),
    rename: authed
      .input(
        v.object({ id: v.string(), name: v.pipe(v.string(), v.minLength(2)) }),
      )
      .handler(({ input, context }) =>
        bsb.unwrap(context.db.customers.update(input.id, { name: input.name })),
      ),
  },
  health: open.handler(() => ({ ok: true })),
};
```

Serve the router with `bsb.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 `bsb-primary-until` after a write when
[read replicas](/docs/guides/read-replicas) are configured.

```ts
import { RPCHandler } from "@orpc/server/fetch";

export default {
  fetch: bsb.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.

## Serve the API [#serve-the-api]

`bsb.router(api, options)` turns a [`defineApi`](/docs/specs/resources) model
into oRPC procedures, so the document and the server come from one
definition. Without `resources` and `blocks`, the API publishes every table
and every block instance `betterSupabase.blocks(set)` registered:

```ts title="src/api.ts"
import { openapi } from "@orpc/openapi";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { os } from "@orpc/server";
import { createOrpc, type OrpcRequestContext } from "better-supabase/orpc";
import { defineApi } from "better-supabase/spec";

import { betterSupabase } from "./lib/supabase";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  basePath: "/api",
});

const bsb = createOrpc(betterSupabase);

const { procedures } = bsb.router(api, {
  guard: { requireOrganization: true },
  meta: openapi,
});

export const handle = bsb.fetchHandler(
  new OpenAPIHandler(os.$context<OrpcRequestContext>().router(procedures)),
);
```

Each table gets its CRUD routes plus the contract verbs `page`, `count`,
`aggregate` and `upsert` as `POST /<table>/<verb>`. Each block instance gets
`POST /<instance>/<method>` for every method of its client, and
`POST /<instance>/<member>/<method>` for its sub-clients. A method that
takes more than one argument takes them as a JSON array. The client runs as
the caller, so RLS applies.

| Option      | What it does                                                                        |
| ----------- | ----------------------------------------------------------------------------------- |
| `guard`     | The `middleware()` options every procedure runs behind, `permission` included       |
| `meta`      | Route metadata per procedure, such as `openapi` from `@orpc/openapi`                |
| `resources` | `defineResource` handlers by table key, to reuse hooks or actions you defined there |

`routes` lists the method, path and operation id of every procedure, and they
match the operations of `api.openapi()`. A block write takes the permission
in `blocks.<instance>.permissions` of `defineApi`, decided by the
`authorizer`. A failed call becomes an `ORPCError` with the `DbError` as
Problem Details, as `bsb.unwrap` does.

## 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
`bsb.middleware()`:

```ts
import { RPCHandler } from "@orpc/server/fetch";
import { toOrpc } from "better-supabase/orpc";
import { createServer, withBetterSupabase } from "better-supabase/server";

const bsb = createServer(betterSupabase);
// The router's procedures read context.db.
const base = os.$context<{
  request: Request;
  db: ReturnType<typeof bsb.admin>;
}>();

export default {
  fetch: toOrpc([withBetterSupabase(bsb)], new RPCHandler(router), {
    prefix: "/rpc",
  }),
};
```

A refused caller gets the guard's 401 Problem Details before oRPC runs. Use
`bsb.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(bsb.middleware());

const router = os.router({
  customers: {
    get: os.customers.get.handler(({ context, input }) =>
      bsb.unwrap(
        context.db.customers.get(input.id, { select: ["id", "name"] }),
      ),
    ),
  },
});

const handle = bsb.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]

`bsb.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.bsb`: the full server context.

With [`.claims(schema)`](/docs/auth#typed-claims) on the definition,
`context.auth` and `context.bsb` are typed by the schema's output:

```ts
export const role = authed.handler(({ context }) =>
  context.auth.kind === "user" ? context.auth.claims.user_role : null,
);
```

## Rate limits [#rate-limits]

`bsb.rateLimit()` counts one hit per call with
[`createRateLimit`](/docs/blocks/sql#limits-in-route-handlers). `key` gets
the context and the procedure path; returning `undefined` skips the limit.
Its type parameter is the context `key` reads, `OrpcRequestContext` by
default. `bsb.fetchHandler`
adds the `RateLimit` field to the response, plus `RateLimit-Policy` with a
`rule`. A refused call throws `TOO_MANY_REQUESTS`, and the response carries
`Retry-After`:

```ts
import { createRateLimit } from "better-supabase/blocks/queue";

const limiter = createRateLimit({ sql: postgres.admin });

const limited = os.$context<OrpcRequestContext>().use(
  bsb.rateLimit({
    limiter,
    scope: "rpc",
    rule: { max: 60, period: "1 minute" },
    key: ({ context, path }) => {
      const key = context.request.headers.get("x-api-key");
      return key === null ? undefined : `${key}:${path.join(".")}`;
    },
  }),
);
```

The headers reach the client only through `bsb.fetchHandler`.

## Permissions [#permissions]

`createOrpc` takes the server's `authorizer` option. `bsb.middleware()` and
`bsb.authed()` take the guard options `allow`, `aal` and `scopes`, and the
checks `requireOrganization`, `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 bsb = createOrpc(betterSupabase, { authorizer });

export const remove = bsb
  .authed({ requireOrganization: true, permission: "customers.delete" })
  .input(v.object({ id: v.string() }))
  .handler(({ input, context }) =>
    bsb.unwrap(context.db.customers.delete(input.id)),
  );
```

To decide on the row the procedure acts on, put `bsb.middleware()` after
`.input()` and pass `resource`. It gets the validated input and the context
(`db`, `session`, `tenant`), and its return value is the resource the
authorizer sees ([Load the resource](/docs/extending/authorizers#load-the-resource)):

```ts title="src/router.ts"
export const rename = bsb
  .authed()
  .input(v.object({ id: v.string(), name: v.string() }))
  .use(
    bsb.middleware({
      permission: "customers.update",
      resource: async (input, { db }) => {
        const { id } = v.parse(v.object({ id: v.string() }), input);
        const row = await db.customers
          .get(id, { select: ["id", "ownerId"] })
          .orThrow();

        return { type: "customer", id, properties: { ownerId: row.ownerId } };
      },
    }),
  )
  .handler(({ input, context }) =>
    bsb.unwrap(context.db.customers.update(input.id, { name: input.name })),
  );
```

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

### Permissions in a contract-first router [#permissions-in-a-contract-first-router]

`bsb.authed()` builds a procedure from scratch, so it doesn't apply to an
implementer from `implement(contract)`. Guard the router with
`bsb.middleware()`, then add `.use(bsb.middleware({ ... }))` to each procedure
that needs more, with the same guard options:

```ts title="src/server.ts"
const os = implement(contract)
  .$context<OrpcRequestContext>()
  .use(bsb.middleware());

const router = os.router({
  customers: {
    get: os.customers.get.handler(({ context, input }) =>
      bsb.unwrap(context.db.customers.get(input.id)),
    ),
    remove: os.customers.remove
      .use(
        bsb.middleware({
          requireOrganization: true,
          permission: "customers.delete",
        }),
      )
      .handler(async ({ context, input }) => {
        await bsb.unwrap(context.db.customers.delete(input.id));
        return { deleted: true };
      }),
  },
});
```

The nested middleware reuses the request's resolved caller, so the second
guard verifies no token again. In both styles the `authorizer` passed to
`createOrpc` decides every `permission`. An authorization package plugs in
there, as the authorizer, instead of adding a guard of its own in front of
the procedures.

## Errors [#errors]

`bsb.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 bsb = createServer(betterSupabase, options);
  return extendServer<BetterAcme<M, F, E>>(bsb, {
    route(fn, routeOptions = {}) {
      return (c) =>
        handle(bsb, 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 bsb = createServer(betterSupabase);

export const supabase =
  createContext<BetterSupabaseContributions<Models, Functions, unknown>>();

export const supabaseMiddleware = toReactRouter(
  [withBetterSupabase(bsb, { 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.list().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.

## Session snapshot [#session-snapshot]

The helpers from `createReactRouter(betterSupabase)` hand every loader
`locals.snapshot`, the verified user, so a loader can build a context with
`bsb.contextFromSession(locals.snapshot)` and no second verification:

```ts title="app/routes/notes.tsx"
export const loader = bsb.loader({}, async (_args, locals) => {
  const { db } = await bsb.contextFromSession(locals.snapshot);
  return db.notes.list().orThrow();
});
```

A snapshot holds the access token: never return it in a response. A copy
of it (a spread, `structuredClone`, a serialized loader result) gets the
`invalid` context, so pass plain values such as the user id onward instead.
Prefetches never refresh the session, so parallel prefetches never race on
the refresh token.

# 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

> **Community adapter**
>
>
> This is a community adapter. It ships with every release and has a smoke test,
> but the full test matrix and new guides target the supported adapters. See
> [adapter tiers](/docs/frameworks#adapter-tiers).

`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 bsb = createSolidStart(betterSupabase, {
  getRequestEvent,
  signIn: "/sign-in",
});
```

```ts title="src/middleware.ts"
import { createMiddleware } from "@solidjs/start/middleware";
import { bsb } from "./lib/server";

export default createMiddleware(bsb.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`, `bsb`, `auth`, `memberships` and `session` on
`event.locals`.

## Queries [#queries]

`bsb.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 { bsb } from "./server";

export const getNotes = query(async () => {
  "use server";
  const { db } = await bsb.require();
  return db.notes.list({ select: ["id", "title"] }).orThrow();
}, "notes");
```

`bsb.locals()` returns the request's locals without a check.

## Actions [#actions]

`bsb.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 { bsb } from "./server";
import { NoteInput } from "./schemas";

const createNote = bsb.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 bsb = createServer(betterSupabase);

export const handle = toSvelteKit([
  withBetterSupabase(bsb, { 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.list({ 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.

## Session snapshot [#session-snapshot]

The helpers from `createSvelteKit(betterSupabase)` share one verified session
per token across loads. `bsb.require(event)` returns `snapshot` next to the
context, and `bsb.contextFromSession(snapshot)` builds another context from
it with no second verification and no cookie read:

```ts title="src/routes/notes/+page.server.ts"
export const load = async (event) => {
  const { snapshot } = await bsb.require(event);
  const { db } = await bsb.contextFromSession(snapshot);
  return { notes: await db.notes.list().orThrow() };
};
```

A snapshot holds the access token: never return it in a response. A copy
of it (a spread, `structuredClone`, a serialized loader result) gets the
`invalid` context, so pass plain values such as the user id onward instead.
Prefetches never refresh the session, so parallel prefetches never race on
the refresh token.

# 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 bsb = createServer(betterSupabase);

export const supabase = createMiddleware().server(
  toTanStackStart([
    withBetterSupabase(bsb, { 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.list().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.

## Session snapshot [#session-snapshot]

The helpers from `createTanStackStart(betterSupabase)` return `snapshot` from
`bsb.require(context)`. `bsb.contextFromSession(snapshot)` builds another
context from it with no second verification, so two loaders of one request
verify the token once:

```ts
const { snapshot } = await bsb.require(context);
const { db } = await bsb.contextFromSession(snapshot);
```

A snapshot holds the access token: never return it in a response. A copy
of it (a spread, `structuredClone`, a serialized loader result) gets the
`invalid` context, so pass plain values such as the user id onward instead.
Prefetches never refresh the session, so parallel prefetches never race on
the refresh token.

# 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 add realtime-tables
   ```

With the [memberships extension](/docs/blocks/memberships), tables broadcast on a
per-tenant topic (`bsb: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 `database.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 `database.realtime.users`.
It uses the topic `bsb: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.list({
  include: { notes: true },
  limit: 50,
});

function Customers() {
  const { data } = useQuery(bsb.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 (`auth.organization.claim` 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 bsb.liveCount(unread); // { spec, count }
return <UnreadSummary seed={seed} />;

// Client Component
const { count } = useLiveCount(seed);
```

`bsb.liveCount` uses `bsb.context()`; inside `'use cache: private'`,
pass the `db` from `bsb.cached()` as the second argument. For a tenant from
the route, pass the `db` from `bsb.context({ tenant })` or
`bsb.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 `database.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 `database.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).

# Local data

> The same data hooks on TanStack Query, TanStack DB, a device database or a live query.

Source: https://bettersupabase.com/docs/frontend/local-data

- [TanStack Query](/docs/frontend/query)
- [TanStack DB](/docs/frontend/tanstack-db)
- [Live queries](/docs/frontend/live-queries)
- [PowerSync](/docs/frontend/powersync)
- [SQLite](/docs/frontend/sqlite)

# PowerSync

> The data hooks and the repositories on a PowerSync SQLite database on the device.

Source: https://bettersupabase.com/docs/frontend/powersync

```ts title="src/lib/supabase/client.native.ts"
import { PowerSyncDatabase } from "@powersync/react-native";
import { createNativeClient } from "better-supabase/client/native";
import { powersyncEngine } from "better-supabase/powersync";
import { connector } from "../powersync/connector";
import { betterSupabase } from "./index";
import { schema } from "../powersync/schema";

export const powersync = new PowerSyncDatabase({
  schema,
  database: { dbFilename: "app.db" },
});

export const bsb = createNativeClient(betterSupabase, supabase, {
  engine: powersyncEngine(powersync, { connector, local: ["customers"] }),
});
```

`powersyncEngine(db)` runs `bsb.db` and the generated
[data hooks](/docs/frontend/data-hooks) on the PowerSync database: each
repository call compiles to SQLite and runs on the device, online or not.
Rows come back the way PostgREST returns them: in the definition's casing,
with booleans, JSON and Temporal values decoded the same way. `local` lists
the tables PowerSync syncs; `bsb.db` reads and writes every other table
through PostgREST. Leave it out when every table syncs.

```tsx title="src/components/customer-list.native.tsx"
import { useCustomers } from "@/lib/supabase/hooks";

export function CustomerList() {
  const customers = useCustomers({
    where: { status: "active" },
    orderBy: { name: "asc" },
  });
  // ...
}
```

`@powersync/common` is not a dependency; the engine only needs `getAll`,
`execute` and `writeTransaction`, `onChange` for live reads and
`registerListener` with `currentStatus` for the sync state, so
`@powersync/react-native`, `@powersync/web` and a test double all work. To
run the repositories without the client, use
`betterSupabase.connect(powersyncExecutor(powersync))`.

## What runs on SQLite [#what-runs-on-sqlite]

Filters, search, sorting, offset and cursor pages, counts, aggregates,
includes (nested ones too, with their own `where`, `orderBy`, `limit` and
`required`), related counts and related aggregates, and inserts, updates,
upserts and deletes all run. A read with includes stays one SQL statement:
each include is a correlated subquery that returns JSON, and the executor
decodes its rows the same way as the top-level ones. Some things have no
SQLite form:

| Feature                                       | On SQLite                                 |
| --------------------------------------------- | ----------------------------------------- |
| 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, or
a single read from `betterSupabase.spec`, 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,
});

const detail = betterSupabase.spec.customers.list({
  include: {
    notes: { orderBy: { createdAt: "desc" }, limit: 5 },
    _count: { notes: true },
  },
});
expect(await checkSqlite(betterSupabase, detail)).toMatchObject({ ok: true });
```

`checkSqlite` compiles every query the list runs (the page, its count and the
facet counts), or the one read a spec describes, 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]

What SQLite can't run goes to the server: a read that compiles to
`unsupported` (full-text search, a function source) runs on the client's
PostgREST executor, and so does `db.$rpc`. Everything else stays on SQLite.
Writes never fall back, so PowerSync's upload queue stays the only write path
for a synced table, and an unsupported write still returns `unsupported`.
When the device is offline, the read returns the server's error, usually kind
`network`. Pass `fallback: false` to keep every call on the device, or
another executor as `fallback`.

A hook that reads from the server is read again when one of its local tables
changes, and every hook is read again when PowerSync connects after it was
offline, so a fallback read catches up once the device is back.

## 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, aggregates, includes and related counts all compile
to SQLite, as long as the included tables sync too. The reads that don't are
usually 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 into a local table the hooks read.
- Search a synced text column with `search: ["name", "kvk"]` (`ilike`)
  instead of `{ fts: ... }`.
- Keep it on the server through the fallback, and accept that it needs a
  connection. It reads again when PowerSync connects.

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([]);
});
```

The errors that test reports are the reads that go to the server; each has
the feature in `details`.

## Live results [#live-results]

The hooks read once and then follow their table: PowerSync reports every
local write and every synced row, at most once every 30 ms (`throttleMs`),
and each hook that reads a changed table reads again. A hook named after
something other than a table, such as a list definition wrapped in
`defineDataHooks("customer-pages", ...)`, names its tables in
`tables: { "customer-pages": ["customers"] }`. Writes through a hook apply
to the cached lists first, run on the device and roll back on an error.

### Sync state [#sync-state]

`useSyncStatus()` and `useConflicts()` from `better-supabase/react` read the
provider's engine:

```tsx
import { useConflicts, useSyncStatus } from "better-supabase/react";

function SyncBanner() {
  const sync = useSyncStatus();
  const { changes, dismiss } = useConflicts();
  // ...
}
```

`useSyncStatus` returns `connected`, `connecting`, `hasSynced`, `uploading`,
`downloading` and the last sync `error`, re-renders only when one of them
changes, and returns `undefined` when the engine syncs nothing.
`useConflicts` reads the changes the engine's `connector` could not upload;
see [Offline-first](/docs/guides/offline-first#conflicts).

A read with a count (`page` 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 `powersyncEngine` (or `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.
`createUploadConnector` replays them through repositories on PostgREST (a
client without the engine), and
`uploadRoute(server.db.attachments, { put: "create" })` inserts the rows of a
write-once table, counting a conflict as already applied. `onError` reports a
failed session read and retried uploads. See
[Offline-first](/docs/guides/offline-first) for the routes, outcomes and
conflicts.

## Uploaded columns [#uploaded-columns]

A generated column is in the row you sync down, but Postgres rejects an
insert or update that sets it. `WritableColumnOf` lists the columns of a table
that accept writes, so a column list for the upload fails to compile when it
names a generated one:

```ts title="src/lib/powersync/connector.ts"
import type {
  GeneratedColumnOf,
  WritableColumnOf,
} from "../supabase/generated";

// With `casing: "snake"` the names are the database names PowerSync uses.
const uploadable = [
  "organization_id",
  "user_id",
  "role",
] as const satisfies readonly WritableColumnOf<"memberships">[];

// Dropped from the upload payload before it reaches PostgREST.
type Skipped = GeneratedColumnOf<"memberships">; // "tier_role"
```

Adding `"tier_role"` to `uploadable` is a type error.

# 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 = bsb.queries; // or createQueries(betterSupabase, () => db)

useQuery(
  q.customers.list({ select: ["id", "name"], where: { status: "active" } }),
);
useSuspenseQuery(q.customers.get(id, { include: { notes: true } }));
useQuery(q.customers.page({ 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.get(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 `['bsb', 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.list` 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 `['bsb', ...]` 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, bsb.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 `get` 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.list({ 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.list({
  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 })`.

## Block queries [#block-queries]

`blockQueries(clients)` gives the block clients the same option factories,
under their own method names. A read method (`get*`, `list*`, `find*`,
`search*`, `tree` and the other `BLOCK_READ_PREFIXES`) becomes query options
keyed `["bsb", instance, method, ...args]`, the key space table queries use,
so `invalidateTables(queryClient, ["tasks"])` makes them stale too. Every
other method becomes mutation options that make the instance's queries stale
when they succeed.

```ts title="lib/block-queries.ts"
import { blockQueries, invalidateOnBlockEvents } from "better-supabase/query";

import { blockSet } from "@/lib/supabase";
import { bsb } from "@/lib/supabase/client";

export const bq = blockQueries(bsb.db.$blocks);

invalidateOnBlockEvents(queryClient, betterSupabase, blockSet.blocks);
```

```ts
useQuery(bq.tasks.list(workspaceId, { open: true }));
const rename = useMutation(bq.tasks.update());
rename.mutate([{ taskId, field: "title", value: "Shipped" }]);
```

A mutation whose method takes more than one argument takes them as a tuple.
Renamed blocks keep their renamed method names, and a failed call throws a
`DbException`, as the table queries do. `invalidateOnBlockEvents` makes the
queries of a block stale when one of its events arrives, such as a
`task.created` another tab caused: the read methods the block lists for the
event in its `invalidates`, or all of its read methods. It returns the
function that stops listening.

## Record types [#record-types]

`recordQueries(customerRecord, clients, options?)` gives one record page's
[bindings](/docs/repository/record-bindings) the same factories: `bindings.<name>(id)`
query options keyed `["bsb-record", type, id, binding]`,
`writes.<binding>.<write>(id)` mutation options that make that binding stale,
and `can()` from the `permissions` client you pass as `options.access`. Pass it
a [generated record type](/docs/repository/record-bindings#generated-record-types).
`invalidateOnRecordEvents(queryClient, betterSupabase, customerRecord)` makes a
binding stale when one of its events arrives.

## 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 bsb.context();
const q = createQueries(betterSupabase, db);
await queryClient.prefetchQuery(q.customers.list({ 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).

# SQLite

> The data hooks and the repositories on a local Expo SQLite or Supabase Lite database, without PowerSync.

Source: https://bettersupabase.com/docs/frontend/sqlite

```ts title="src/lib/supabase/client.native.ts"
import { addDatabaseChangeListener, openDatabaseAsync } from "expo-sqlite";
import { createNativeClient } from "better-supabase/client/native";
import { expoSqliteDatabase, sqliteEngine } from "better-supabase/sqlite";
import { betterSupabase } from "./index";

const sqlite = await openDatabaseAsync("app.db", {
  enableChangeListener: true,
});

export const bsb = createNativeClient(betterSupabase, supabase, {
  engine: sqliteEngine(
    expoSqliteDatabase(sqlite, { addDatabaseChangeListener }),
    { local: ["notes"] },
  ),
});
```

`sqliteEngine(database)` runs `bsb.db` and the [data hooks](/docs/frontend/data-hooks)
on a SQLite database the app owns, with the same SQLite compiler as
[PowerSync](/docs/frontend/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. `local` lists the tables the database holds; `bsb.db` reads and writes
every other table on the server. Leave it out to run every table locally.

```tsx title="app/drafts.tsx"
import { useNotes } from "@/lib/supabase/hooks";

export function Drafts() {
  const drafts = useNotes({
    where: { status: "draft" },
    orderBy: { updatedAt: "desc" },
  });
  // ...
}
```

The hooks read the database once and again after each change to the table
they read. With Expo SQLite, open the database with `enableChangeListener:
true` and pass `addDatabaseChangeListener`; changes from another open
database file are ignored, and events arrive in batches of at most one every
30 ms (`throttleMs` changes that). A database without change events, such as
Supabase Lite, reads again after each write `bsb.db` makes. Writes through
the hooks apply to the cached lists first and roll back on an error.

## Databases [#databases]

| Database                                                   | Adapter                                                 |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| Expo SQLite (`openDatabaseAsync`)                          | `expoSqliteDatabase(db, { addDatabaseChangeListener })` |
| Supabase Lite (a connection with `exec`)                   | `liteDatabase(connection)`                              |
| A database with `getAll`, `execute` and `writeTransaction` | pass it as it is                                        |

Neither `expo-sqlite` nor `@supabase/lite` is a dependency: the adapters read
`getAllAsync`, `runAsync` and `withTransactionAsync`, or `exec`, from the
database they get. Expo 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. Lite writes run one at a time
between `begin` and `commit`. 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`: full-text search and function
sources. By default those reads and `db.$rpc` go to the client's PostgREST
executor; pass `fallback: false` to keep every call local, or another
executor as `fallback`. Includes and related counts run locally.
`checkSqlite` from `better-supabase/sqlite` checks a list definition or a
query spec in a test.

# TanStack DB

> TanStack DB collections that load through a query option and write through the repositories, next to the data hooks.

Source: https://bettersupabase.com/docs/frontend/tanstack-db

`better-supabase/tanstack-db` builds TanStack DB collections that load
through one of your [query options](/docs/frontend/query) and write through
the repositories, and `tanstackDbEngine` keeps the
[data hooks](/docs/frontend/data-hooks) in step with them. TanStack DB is
not a dependency: the options are typed by shape.

## 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.list({ 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).

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

## With the data hooks [#with-the-data-hooks]

`tanstackDbEngine` runs the generated hooks next to the collections: a change
to a collection (an optimistic write, a refetch) reads every hook on that
table again, and a hook's write refetches the table's collection, so live
queries and hooks show the same rows.

```ts title="src/lib/client.ts"
import { createClient } from "better-supabase/client";
import { queryEngine } from "better-supabase/query";
import { realtimeChanges } from "better-supabase/realtime";
import { tanstackDbEngine } from "better-supabase/tanstack-db";
import { customers } from "./collections";

export const bsb = createClient(betterSupabase, {
  engine: tanstackDbEngine({
    collections: () => ({ customers }),
    cache: queryEngine(queryClient, {
      live: realtimeChanges(betterSupabase, supabase),
    }),
  }),
});
```

`collections` is a function here because the collections read `bsb.db`; the
engine calls it when a hook first follows a read. `cache` decides where the
hooks keep their reads, in memory by default.

## Live queries [#live-queries]

Join and filter collections with TanStack DB's `useLiveQuery`. To refresh
them when another client changes a table, pass
`live: realtimeChanges(betterSupabase, supabase)` from
`better-supabase/realtime` to the engine's `cache`, as above, or invalidate
the tables from your own subscription with
`invalidateTables(queryClient, ["customers"])`.

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.

# Block controllers

> The framework-agnostic state behind every block hook, and the ports that give it storage, shortcuts, files, push and deep links on the web and React Native.

Source: https://bettersupabase.com/docs/frontend/block-controllers

Every block hook, such as `useNotifications` or `useCommandSearch`, is a
thin wrapper over a controller: a plain TypeScript object that loads the
block's data, follows its realtime topic and runs its actions. Controllers
import no React, DOM or React Native code, so the same hook runs in React DOM
and React Native, and a Vue, Svelte or Solid UI can use the controller
directly.

## The contract [#the-contract]

A controller has four members, the external-store contract that React's
`useSyncExternalStore` and the other frameworks' stores expect:

| Member          | What it does                                                                                                |
| --------------- | ----------------------------------------------------------------------------------------------------------- |
| `subscribe(fn)` | Adds a listener. The first one starts the work (loads, realtime, shortcuts); the last one to leave stops it |
| `getSnapshot()` | The current state, the same object until it changes                                                         |
| `actions`       | The methods, such as `refresh`, `dismiss` or `toggle`                                                       |
| `dispose()`     | Stops the work and drops every listener                                                                     |

```ts title="lib/unread.ts"
import { createNotificationsController } from "better-supabase/blocks/notifications";
import { topicPort } from "better-supabase/realtime";

const unread = createNotificationsController({
  auth: bsb.auth,
  ports: { topic: topicPort(bsb.supabase) },
  topic: `notifications:${userId}`,
  load: () => listNotifications(),
});

const stop = unread.subscribe(() => {
  badge.textContent = String(unread.getSnapshot().count);
});
```

A Vue binding is a `shallowRef` that `subscribe` updates; a Svelte one is a
`readable` store whose start function returns the unsubscribe.

## Controllers by block [#controllers-by-block]

Each block exports its controllers from its main entry,
`better-supabase/blocks/<name>`. The React hooks are registry items that you
copy into the app, so you can change them: `npx shadcn add
@better-supabase/use-<name>` writes `hooks/use-<name>.ts`, which builds the
controllers from the provider's client. Vue, Svelte and Solid run a
controller with `useBlockController` (see
[Vue, Svelte and Solid](#vue-svelte-and-solid)).

| Block             | Controllers                                                                                                    | Hooks                                                      |
| ----------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `ai-chat`         | `createAiChatsController`, `createAiChatTreeController`, `createAiModelsController`, `createAiShareController` | `useAiChats`, `useAiChatTree`, `useAiModels`, `useAiShare` |
| `announcements`   | `createAnnouncementsController`                                                                                | `useAnnouncements`                                         |
| `api-keys`        | `createApiKeysController`                                                                                      | `useApiKeys`                                               |
| `audit`           | `createAuditLogController`                                                                                     | `useAuditLog`                                              |
| `billing`         | `createPlanPickerController`                                                                                   | `usePlanPicker`                                            |
| `bookings`        | `createAvailableSlotsController`                                                                               | `useAvailableSlots`                                        |
| `calendar`        | `createCalendarController`                                                                                     | `useCalendar`                                              |
| `comments`        | `createCommentsController`                                                                                     | `useComments`                                              |
| `entitlements`    | `createFeatureGateController`                                                                                  | `useFeatureGate`                                           |
| `forms`           | `createQuestionnaireController`                                                                                | `useQuestionnaire`                                         |
| `inbox`           | `createInboxController`, `createConversationController`, `createInboxWidgetController`                         | `useInbox`, `useConversation`, `useInboxWidget`            |
| `mfa`             | `createMfaController`, `createStepUpController`                                                                | `useMfa`, `useStepUp`                                      |
| `notifications`   | `createNotificationsController`                                                                                | `useNotifications`                                         |
| `onboarding`      | `createOnboardingController`                                                                                   | `useOnboarding`                                            |
| `organizations`   | `createOrganizationsController`, `createMembersController`, `createInvitationsController`                      | `useOrganizations`, `useMembers`, `useInvitations`         |
| `reactions`       | `createReactionsController`                                                                                    | `useReactions`                                             |
| `rules`           | `createRuleBuilderController`                                                                                  | `useRuleBuilder`                                           |
| `saved-views`     | `createSavedViewsController`, `matchSavedViews`                                                                | `useSavedViews`                                            |
| `search`          | `createCommandSearchController`                                                                                | `useCommandSearch`                                         |
| `sessions`        | `createOAuthConsentController`, `createOAuthGrantsController`                                                  | `useOAuthConsent`, `useOAuthGrants`                        |
| `settings`        | `createSettingsController`, `settingFields`                                                                    | `useSettings`                                              |
| `tags`            | `createTagInputController`                                                                                     | `useTagInput`                                              |
| `undo`            | `createUndoController`                                                                                         | `useUndoStack`                                             |
| `usage`           | `createUsageMeterController`                                                                                   | `useUsageMeter`                                            |
| `workflows`       | `createWorkflowRunsController`, `createWorkflowRunController`                                                  | `useWorkflowRuns`, `useWorkflowRun`                        |
| `workflow-editor` | `createWorkflowEditorController`, `createWorkflowCanvasRunController`                                          | `useWorkflowEditor`, `useWorkflowCanvasRun`                |

Controllers with a moving input take it as an action instead of starting
over: `setRange` on the calendar and bookings controllers and `setSubjects`
on the reactions controller keep the shown data until the new data arrives.

The comments, reactions and tag input controllers work on records: they take
the record as `subject` (`subjects` for reactions), a `RecordRef` such as
`{ type: "task", id: taskId }`, and a block client built with its `tenant`.
The hooks build that client from the provider's client, with the schema from
its `blocks` option and the tenant from its `tenant` option or the session's
tenant claim (see [Block hooks](/docs/frontend/client#block-hooks)).

## Writing a React hook [#writing-a-react-hook]

The registry hooks use four exports of `better-supabase/react`, and you can
use them for a hook of your own. `useBlockEnv()` returns the provider's
client, session and ports, `useBlockTenant(env)` the tenant it acts in (and
renders again when it changes), and `useController(create, deps)` makes the
controller, makes it again when `deps` change and returns its state and
actions as `view`. `envBlockOptions(env, block)` from `better-supabase/blocks`
gives a block client the transport and schema `gen` wrote for that block.

```ts title="hooks/use-comments.ts"
"use client";

import { envBlockOptions, type RecordRef } from "better-supabase/blocks";
import {
  createComments,
  createCommentsController,
} from "better-supabase/blocks/comments";
import {
  useBlockEnv,
  useBlockTenant,
  useController,
} from "better-supabase/react";

export function useComments(subject: RecordRef) {
  const env = useBlockEnv();
  const tenant = useBlockTenant(env);

  return useController(
    () =>
      createCommentsController({
        client: createComments({
          ...envBlockOptions(env, "comments"),
          ...(tenant === undefined ? {} : { tenant }),
        }),
        auth: env.auth,
        ports: env.ports,
        subject: tenant === undefined ? null : subject,
        topic: "messages",
      }),
    [env, tenant, subject.type, subject.id],
  ).view;
}
```

## Vue, Svelte and Solid [#vue-svelte-and-solid]

`useBlockController(create, options)` from `better-supabase/vue`,
`better-supabase/svelte` and `better-supabase/solid` makes a controller once
and binds it to the framework. `auth` and `ports` come from the app's
better-supabase plugin, context or provider unless `options` has them. The
work starts on mount and stops when the component goes away.

Pass `false` as a third argument for a controller that takes neither `auth`
nor `ports`, so it runs without a provider: `createAiModelsController`,
`createAiShareController`, `createAttachmentUploadController`,
`createAvailableSlotsController`, `createCalendarController`,
`createQuestionnaireController`, `createSavedViewsController` and
`createCommandSearchController`. Without it, the call reads the provider and
fails when there is none.

| Entry                    | Returns                                                                  |
| ------------------------ | ------------------------------------------------------------------------ |
| `better-supabase/vue`    | `{ state, actions }`, with `state` a shallow ref of the snapshot         |
| `better-supabase/svelte` | a readable store of the snapshot (read with `$`), with `.actions`        |
| `better-supabase/solid`  | `{ state, actions }`, with `state()` reading the snapshot through `from` |

```vue title="Component.vue"
<script setup lang="ts">
import {
  createAnnouncements,
  createAnnouncementsController,
  rpcTransport,
} from "better-supabase/blocks/announcements";
import { useBlockController, useSupabase } from "better-supabase/vue";

const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state } = useBlockController(createAnnouncementsController, {
  client: createAnnouncements({ transport: rpcTransport(supabase) }),
  organizationId,
});
</script>

<template>
  <ul>
    <li v-for="item in state.items ?? []" :key="item.id">{{ item.title }}</li>
  </ul>
</template>
```

## Any block method [#any-block-method]

Blocks without a controller of their own, and renamed blocks, use three
generic hooks. They take any block client and a method name, and type the
arguments and data from the method. Each framework entry has them:
`better-supabase/react`, `better-supabase/vue`, `better-supabase/svelte` and
`better-supabase/solid`.

| Hook                                              | What it does                                                                   |
| ------------------------------------------------- | ------------------------------------------------------------------------------ |
| `useBlockQuery(client, method, args, options)`    | Calls a read method on mount and when `args` change; `refetch` calls it again  |
| `useBlockMutation(client, method)`                | `mutate(...args)` calls a write method and tracks `status`, `data` and `error` |
| `useBlockEvent(source, events, pattern, handler)` | Calls `handler` for each block event matching `pattern`, such as `"task.*"`    |

```tsx title="components/task-list.tsx"
import {
  useBlockEvent,
  useBlockMutation,
  useBlockQuery,
} from "better-supabase/react";

import { blockEvents } from "@/lib/supabase/blocks.generated";

export function TaskList({ tasks, workspaceId }: Props) {
  const list = useBlockQuery(tasks, "list", [workspaceId, { open: true }], {
    refetchOn: { source: bsb, types: ["task.created", "task.updated"] },
  });
  const rename = useBlockMutation(tasks, "update");

  useBlockEvent(bsb, blockEvents, "task.*", (event) => {
    console.log(event.type, event.data.taskId);
  });

  if (list.status === "loading") return <TaskSkeleton />;
  if (list.status === "error") return <TaskError onRetry={list.refetch} />;
  // ...
}
```

`refetchOn` takes the client's `betterSupabase` (or anything with `events`)
and the event types that make the read stale. `useBlockEvent` types each
event's data from `blockEvents` in `blocks.generated.ts`.

## Ports [#ports]

A controller reaches the platform only through ports, small interfaces the
client carries in `bsb.ports`. `createClient` from `better-supabase/client`
fills the web ones; a React Native app passes `nativePorts()` from
`better-supabase/client/native` to `createNativeClient`.

| Port       | Type             | Web default                        | React Native default               | Used by                   |
| ---------- | ---------------- | ---------------------------------- | ---------------------------------- | ------------------------- |
| `topic`    | `TopicPort`      | `topicPort(supabase)`              | `topicPort(supabase)`              | Every realtime controller |
| `storage`  | `StoragePort`    | `webStorage()` over `localStorage` | `nativeStorage(AsyncStorage)`      | `useInboxWidget`          |
| `keyboard` | `KeyboardPort`   | `webKeyboard()` over `keydown`     | none                               | `useCommandSearch`        |
| `files`    | `FilePickerPort` | `webFilePicker()`                  | `nativeFilePicker(DocumentPicker)` | Apps, for now             |
| `upload`   | `UploadPort`     | `storageUpload(supabase)`          | `storageUpload(supabase)`          | Apps, for now             |
| `push`     | `PushTokenPort`  | `webPushToken({ vapidPublicKey })` | `expoPushToken(Notifications)`     | Apps, for now             |
| `links`    | `DeepLinkPort`   | `webDeepLinks()`                   | `nativeDeepLinks(Linking)`         | Apps, for now             |

The topic port defaults to `topicPort(bsb.supabase)` from
`better-supabase/realtime` when `bsb.ports` has none. A port the platform lacks stays unset, and the controller works
without it: `useCommandSearch` on a phone has no Cmd+K.

```ts title="src/lib/supabase/client.native.ts"
import { createNativeClient, nativePorts } from "better-supabase/client/native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";

export const bsb = createNativeClient(betterSupabase, supabase, {
  ports: nativePorts({
    supabase,
    storage: AsyncStorage,
    linking: Linking,
    notifications: Notifications,
    projectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID,
  }),
});
```

On the web, `createClient(betterSupabase, { env, ports })` merges your
ports over the defaults, for example a push port with your VAPID key:

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

export const bsb = createClient(betterSupabase, {
  env,
  ports: { push: webPushToken({ vapidPublicKey: env.vapidPublicKey }) },
});
```

## Testing a controller [#testing-a-controller]

Pass fakes for the client and the ports, subscribe once, and read the
snapshot. No renderer is needed:

```ts title="tests/unread.test.ts"
import { createNotificationsController } from "better-supabase/blocks/notifications";

const joined: string[] = [];
const controller = createNotificationsController({
  auth: { current: () => signedIn, subscribe: () => () => undefined },
  ports: {
    topic: (topic, watch) => {
      joined.push(topic);
      watch.onStatus("subscribed");
      return { leave: () => undefined, send: () => undefined };
    },
  },
  topic: "notifications:u1",
  load: async () => [{ id: "n1", readAt: null }],
});
const stop = controller.subscribe(() => undefined);
await Promise.resolve();
expect(controller.getSnapshot().count).toBe(1);
stop();
```

`defineController` and `whileSignedIn` from `better-supabase/client` build
controllers of your own the same way.

# 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 bsb = createClient(betterSupabase, {
  env: {
    url: import.meta.env.VITE_SUPABASE_URL,
    publishableKey: import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
  },
});

const customers = await bsb.db.customers
  .list({ select: ["id", "name"] })
  .orThrow();
```

- `bsb.db` carries the current session's actor and claims, so extensions
  like [created-by](/docs/blocks/created-by) and [memberships](/docs/blocks/memberships)
  behave as they do on the server. It is rebuilt when the user changes.
- `bsb.queries` holds [TanStack Query options](/docs/frontend/query).
- `bsb.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.
- `bsb.supabase` is the supabase-js client, for auth flows, storage and
  anything else.

## Block hooks [#block-hooks]

The block hooks, such as `useComments`, `useReactions` and `useTagInput`,
read each block's schema, function names and realtime topic from the
`blocks` option, and act in the tenant from the `memberships` option:

```ts title="src/lib/supabase/client.ts"
import { blockConfig } from "./blocks.generated";

export const bsb = createClient(betterSupabase, {
  env,
  blocks: blockConfig,
  organization: () => currentOrganizationId(),
});
```

Without `organization`, `db` and the hooks use the session's organization
claim (`config.auth.organization.claim`). Pass a function for an organization
the user picks in the UI; it runs on every call. While there is none, the
hooks wait instead of calling the database.

## 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 `bsb.db`, `bsb.queries` and `bsb.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.

## Ports [#ports]

`bsb.ports` holds the platform services the block hooks use: `localStorage`,
the Cmd+K listener, a file picker, deep links and uploads to Supabase
Storage. `ports` in the options adds or replaces them, such as a push port
with `webPushToken({ vapidPublicKey })`. See
[block controllers](/docs/frontend/block-controllers).

## Edge Functions [#edge-functions]

`functions<Contracts>(bsb.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 }>(bsb.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.

# Data hooks

> Generated list and record hooks per table for React, Vue, Svelte and Solid, with optimistic writes, on a memory cache, TanStack Query, TanStack DB or a database on the device.

Source: https://bettersupabase.com/docs/frontend/data-hooks

With `gen.hooks` set, `better-supabase gen` writes `hooks.ts` next to the
main module: a list hook and a record hook for every table, typed by your
definition. It defaults to `"react"` when the config names the `next`,
`react` or `expo` adapter; set `"vue"`, `"svelte"` or `"solid"` for another
framework, or `false` to write none.

```ts title="better-supabase.config.ts"
export default defineConfig({
  gen: { output: "src/lib/supabase/generated.ts", hooks: "react" },
});
```

```ts title="src/lib/supabase/hooks.ts"
export const { useList: useCustomers, useOne: useCustomer } = defineDataHooks(
  "customers",
  (db: AppDb) => db.customers,
);
```

A table whose singular and plural names match gets `use<Name>List` and
`use<Name>`.

## Reading [#reading]

Each hook reads `bsb.db` from the provider, so it acts as the signed-in user
and in the active organization. `useCustomers(args)` takes the same
arguments as `db.customers.list(args)`, and `useCustomer(id)` the same as
`db.customers.get(id)`:

```tsx title="src/features/customers/customer-list.tsx"
"use client";

import { useCustomers } from "@/lib/supabase/hooks";

export function CustomerList() {
  const customers = useCustomers({ orderBy: { name: "asc" }, limit: 50 });

  if (customers.isPending) {
    return <CustomerListSkeleton />;
  }

  if (customers.error) {
    return <InlineError onRetry={customers.refetch} />;
  }

  return <CustomerTable rows={customers.data ?? []} />;
}
```

| Field       | What it holds                                         |
| ----------- | ----------------------------------------------------- |
| `data`      | The rows or the row, `undefined` until the first read |
| `error`     | The read's `DbError`                                  |
| `isPending` | True until the first answer arrives                   |
| `status`    | `idle`, `loading`, `success` or `error`               |

Two components that read the same table with the same arguments share one
read. A change of user, organization or arguments reads again.

`where` also takes a filter tree, the shape
[list queries](/docs/platform/list) and saved views produce. The
hook checks it against the table's filterable columns and returns kind
`invalid_request` for an unknown column or operator:

```tsx
const customers = useCustomers({
  where: {
    id: "root",
    type: "group",
    combinator: "or",
    rules: [
      {
        id: "a",
        type: "rule",
        path: ["status"],
        operator: "eq",
        value: "active",
      },
      {
        id: "b",
        type: "rule",
        path: ["name"],
        operator: "ilike",
        value: "%acme%",
      },
    ],
  },
});
```

## Writing [#writing]

`create(input)`, `update(id, patch)` and `delete(id)` call the repository
and update every cached list and record of that table at once. An error
rolls the change back. Each one resolves to the row and rejects with a
`DbException`, so a form can await it:

```tsx
const { create } = useCustomers();

await create({ name: "Acme" });
```

## TanStack Query [#tanstack-query]

By default the hooks keep their reads in memory. To keep them in TanStack
Query, so devtools, `staleTime` and `invalidateQueries` see them, pass
`queryEngine` to `createClient`. The reads live under
`["bsb", <table>, <organization>, <verb>, <args>]`:

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

export const bsb = createClient(betterSupabase, {
  engine: queryEngine(queryClient),
});
```

```ts
queryClient.invalidateQueries({ queryKey: ["bsb", "customers"] });
```

`@tanstack/query-core` is an optional peer: install it only when you use the
engine.

## Engines [#engines]

The engine decides where the hooks keep their reads and where `bsb.db` runs.
Every engine runs the same generated hooks, so moving a screen offline is a
change to `createClient`, not to the components.

| Engine                        | Entry                         | Reads kept in                                        | `bsb.db` runs on       |
| ----------------------------- | ----------------------------- | ---------------------------------------------------- | ---------------------- |
| `memoryEngine()`, the default | `better-supabase/client`      | memory                                               | PostgREST              |
| `queryEngine(queryClient)`    | `better-supabase/query`       | TanStack Query                                       | PostgREST              |
| `tanstackDbEngine(options)`   | `better-supabase/tanstack-db` | TanStack Query, refreshed by TanStack DB collections | PostgREST              |
| `powersyncEngine(db)`         | `better-supabase/powersync`   | TanStack Query or memory                             | the PowerSync database |
| `sqliteEngine(database)`      | `better-supabase/sqlite`      | TanStack Query or memory                             | Expo SQLite or Lite    |

The device engines take `local`, the tables the device database holds, and
send the rest to PostgREST. See [PowerSync](/docs/frontend/powersync),
[SQLite](/docs/frontend/sqlite) and [TanStack DB](/docs/frontend/tanstack-db).

### Live reads [#live-reads]

Pass `live` to `memoryEngine` or `queryEngine` to read a hook again when its
table changes in the database. `realtimeChanges(betterSupabase, supabase)`
from `better-supabase/realtime` follows Supabase Realtime for the tables in
`realtime.tables`, or the ones you pass as `tables`:

```ts
import { queryEngine } from "better-supabase/query";
import { realtimeChanges } from "better-supabase/realtime";

export const bsb = createClient(betterSupabase, {
  engine: queryEngine(queryClient, {
    live: realtimeChanges(betterSupabase, supabase, {
      tables: ["customers", "notes"],
    }),
  }),
});
```

A hook named after something other than a table names its tables in the
engine's `tables`, such as `tables: { "customer-pages": ["customers"] }`. The
device engines follow their own database's changes and need no `live`.

## Your own hooks [#your-own-hooks]

`defineDataHooks(name, select)` from `better-supabase/react`, `/vue`,
`/svelte` or `/solid` makes the same pair for any source with `list`, `get`,
`create`, `update` or `delete`, such as a filtered view of a table. `name`
is the cache key, so two hooks with one name share reads and writes.

# 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/client.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 bsb = 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. `bsb.db`, `bsb.queries`, `bsb.auth` and `bsb.supabase`
work as they do in the browser, and the [React hooks](/docs/frontend/react)
take the same `bsb`.

`better-supabase init expo` writes this file, a server file for
[Expo Router](/docs/frameworks/expo) and `+middleware.ts`.

## Block hooks on a device [#block-hooks-on-a-device]

The block hooks, such as `useNotifications` from the
`@better-supabase/use-notifications` registry item (`npx shadcn add
@better-supabase/use-notifications` writes `hooks/use-notifications.ts`),
run in React Native as they do in React DOM: their state lives in a [controller](/docs/frontend/block-controllers)
that reaches the platform only through ports. Pass the Expo modules you
installed to `nativePorts`:

```ts title="src/lib/supabase/client.native.ts"
import { createNativeClient, nativePorts } from "better-supabase/client/native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";

export const bsb = createNativeClient(betterSupabase, supabase, {
  ports: nativePorts({
    supabase,
    linking: Linking,
    notifications: Notifications,
    projectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID,
  }),
});
```

The Expo example shows the unread count from `useNotifications` above its
customer list (`src/lib/notifications.ts`), the same hook the Next.js
example's header badge calls.

## 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/client.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/client.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/frontend/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 { bsb } from "@/lib/supabase/client";

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <BetterSupabaseProvider client={bsb} 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 { bsb } from "./supabase/client";

export const { useDb, useQueries, useAuth, useSupabase } =
  createHooks<typeof bsb>();
```

```tsx
function CustomerList() {
  const q = useQueries();
  const { data } = useQuery(q.customers.list({ 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                                                       |
| `useImage(bucket, target, variant)`        | `src`, `srcSet`, `width` and `height` for an `<img>` at a [bucket variant](/docs/platform/storage#image-transformations) |
| `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 [`bsb.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
  });
}
```

## Record pages [#record-pages]

`createRecordProvider(customerRecord)` returns a `RecordProvider` and a
`useRecord` hook for one
[generated record type](/docs/repository/record-bindings#generated-record-types). The
provider takes the record's `id`, the `initial` view the server loaded, the
`writes` (Server Actions) and a realtime `topic`; `useRecord()` returns
`{ state, actions, meta }`.

## 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 `bsb.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
  `bsb.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
  `bsb.context({ tenant: organizationId })`, or in a
  `'use cache: private'` function that takes `organizationId` as an argument
  and calls `bsb.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: `bsb.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 = bsb.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 { bsb } from "./lib/supabase/client";

const queryClient = new QueryClient();

export default function App(props: { children: JSX.Element }) {
  return (
    <QueryClientProvider client={queryClient}>
      <BetterSupabaseProvider client={bsb} 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 { bsb } from "./supabase/client";

export const { useDb, useQueries, useAuth, useSession } =
  createBindings<typeof bsb>();
```

## 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 { bsb } from "$lib/supabase/client";

  let { data, children } = $props();
  const queryClient = new QueryClient();

  setBetterSupabase(bsb, { 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.list({ 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 bsb>()` returns the bound client typed for your
schema, with `db`, `queries` and `supabase`. Outside a component (a module
or a test), `createBetterSvelte(bsb)` returns the same object without a
context. For the server hook, see [SvelteKit](/docs/frameworks/sveltekit).

# 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 { bsb } from "./lib/supabase/client";

const queryClient = new QueryClient();

createApp(App)
  .use(VueQueryPlugin, { queryClient })
  .use(betterSupabase(bsb, { 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 { bsb } from "./supabase/client";

export const { useDb, useQueries, useAuth, useSession } =
  createBindings<typeof bsb>();
```

```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.list({ 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.list({ 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.

# 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.list({ 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.11 to 0.12

> What to check when upgrading to better-supabase 0.12 and 0.13, starting with the audit() signature, the acting_member() grant and the module file headers.

Source: https://bettersupabase.com/docs/migration/0.11-to-0.12

Most of 0.12 needs no change in your code. Check the items below, then run
`better-supabase sql upgrade`, `better-supabase sql sync` and
`better-supabase doctor`. Items marked 0.13 arrive in the release after
0.12.

## `audit()` takes 11 arguments [#audit-takes-11-arguments]

`better_supabase.audit()` gained two optional arguments in 0.11,
`tenant_path` and `tenant_function`, so it went from 9 arguments to 11. An app
that upgrades from 0.10 meets the change on this upgrade. Postgres can't add
arguments to a function in place, so the schema diff that `sql sync` feeds
drops the 9-argument function and creates the 11-argument one:

```sql title="supabase/migrations/..._better_supabase.sql"
drop function if exists better_supabase.audit(
  regclass, text[], boolean, text[], text, text, text, text, text
);
create or replace function better_supabase.audit(
  target regclass,
  ignore text[] default '{}',
  replace_trigger boolean default false,
  redact text[] default '{}',
  category text default null,
  event_prefix text default null,
  target_type text default null,
  tenant_column text default null,
  label_column text default null,
  tenant_path text default null,
  tenant_function regprocedure default null
)
...
```

Calls with named arguments keep working, and the triggers on audited tables
stay in place. Two things in your own migrations break once the old function
is gone:

- A grant or a `comment on function` that names the old argument list. Name
  the new one: `better_supabase.audit(regclass, text[], boolean, text[], text,
text, text, text, text, text, regprocedure)`.
- A call with positional arguments. Call `audit()` with named arguments, which
  survive the next added argument too:

```sql
select better_supabase.audit(
  'public.projects',
  redact => array['api_key'],
  tenant_column => 'workspace_id'
);
```

From 0.13, doctor reports a migration that hasn't run and drops a
`better_supabase` function without creating it again with the same number of
arguments as [BS235](/docs/cli/doctor#bs235). Read these notes when it fires,
and update your calls and grants in the same migration.

## `acting_member()` runs only for service_role (0.13) [#acting_member-runs-only-for-service_role-013]

`better_supabase.acting_member()` reads the member id the server sets for
`admin({}, { as })`. From 0.13 only `service_role` may execute it; `anon` and
`authenticated` get `permission denied`. The audit, actor and support
functions and triggers that record an actor run as their owner, so they keep
working for every caller. `set_actor()` became `security definer` for this.

If your own SQL calls `acting_member()`, run it as `service_role` or from a
`security definer` function. See
[writes the secret key makes for a member](/docs/blocks/sql#writes-the-secret-key-makes-for-a-member).

## Module file headers (0.13) [#module-file-headers-013]

The first line of a module file used to carry the package version, as in
`-- better-supabase module: audit (0.12.0)`, so every release rewrote every
file in `supabase/schemas/better_supabase` and the diff hid the real change.
From 0.13 the header names only the module, and `sql sync` and `sql upgrade`
leave a file alone when nothing but that header differs. The version a file
was written at stays in its `@bs-module name@N` marker. A file that still
carries a version in its header counts as current, so it keeps that header
until the module's SQL changes.

## Create moved to the cloud app [#create-moved-to-the-cloud-app]

`createUrl()` and the comment `init` writes above a preset link to
`https://app.bettersupabase.com/create`. Old `bettersupabase.com/create`
links redirect there with their query string, and `presetOf` and `designOf`
read both, so a saved link keeps working.

## Environment variables [#environment-variables]

`better-supabase env` writes `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY`
under their plain names for server code, the same two with the browser
prefix, and the new `SUPABASE_JWKS_URL`. `--out` still works; `--write <file>`
is the new name. With `auth.jwks: null`, server auth now verifies against no
keys instead of falling through to the JWKS URL. Use `undefined` to keep the
URL.

# 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`               | `BlocksConfig`, `BlockConfig`, `BlockMode`, `AccessBlockConfig`                                                       |
| `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, `blocks`. 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({
  blocks: {
    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 `betterSupabase.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"),
  }),
  blocks: { access: { model: "provider" } },
});
```

| 0.5                                                                | 0.6                                                                                                      |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| `permdock: { manifest, catalog }` in the config                    | `authorization: authorizationProvider({ manifest, catalog })`                                            |
| `blocks.access.model: "permdock"`                                  | `blocks.access.model: "provider"`                                                                        |
| `blocks.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
  `blocks` 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 `blocks.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.
- `blocks.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.

# From 0.8 to 0.9

> What to change when upgrading to better-supabase 0.9, which routes permission checks through the authorization provider, caps delegated tokens, runs workflows as their publisher, approves support sessions by a second admin and sends CloudEvents webhooks.

Source: https://bettersupabase.com/docs/migration/0.8-to-0.9

0.9 closes places where work ran with more rights than the caller had: jobs
queued by an OAuth client, published workflows, support sessions and fan-out
to subscribers. Most changes are behaviour, not names, so
`better-supabase codemod 0.9` moves the one renamed config key, renames the
config types, rewrites the CLI calls in your scripts, and lists each call to
review:

```bash
pnpm better-supabase codemod 0.9 --dry-run
```

Then run `better-supabase sql sync` and create a migration. Every installed
module file changes, because the read-only check each one carries now knows
the agent actor. Beyond that, `access`, `tenant`, `subjects`, `support-sessions`,
`webhooks-out`, `workflow-builder`, `notifications`, `actor`, `approvals`,
`ai-chat`, `share-links`, `versions`, `promotions`, `time-tracking`,
`state-machine`, `comments`, `workflows`, `workflow-sdk-world` and
`saved-views` changed, and `identities`, `service-principals` and `templates`
are new.

## Config key [#config-key]

Blocks moved out of `sql` to a top-level `blocks` key. A config that still
sets `sql.modules` fails to load with `config_invalid` and
``sql.modules: moved to the top-level `blocks` key in 0.9``. The codemod
moves the key in `better-supabase.config.ts` (or `.mts`, `.js`, `.mjs`,
`.json`), with its comments, and drops `sql` when nothing else is left in it.
`blocks` goes right after `sql`, or in sorted position when the top-level
keys are already sorted. The codemod runs even though the config fails to
load:

```ts title="better-supabase.config.ts"
// 0.8
sql: { dir: "supabase/schemas", modules: { tenant: true, access: { model: "roles" } } },

// 0.9
sql: { dir: "supabase/schemas" },
blocks: { tenant: true, access: { model: "roles" } },
```

It also renames the config types: `ModulesConfig` to `BlocksConfig`,
`ModuleConfig` to `BlockConfig`, `AccessModuleConfig` to `AccessBlockConfig`
and `ModuleMode` to `BlockMode`. A config that sets both `sql.modules` and
`blocks`, keeps `sql.modules` in an environment block, or builds `sql` in
another module is listed for review: move the entries into `blocks` by hand.
Code that reads `config.sql.modules` from a loaded config reads
`config.blocks` instead.

## CLI commands and flags [#cli-commands-and-flags]

`sql`, `blocks`, `preset`, `skills`, `spec`, `scaffold` and `codemod` are
command groups now: each subcommand has its own help, and the spelling of
`sql add tenant` or `blocks list` stays the same. A flag has one meaning
across the CLI (`--out` is a file, `--out-dir` a directory, `--only` narrows
what runs, `--force` only overwrites), so these calls changed:

| 0.8                              | 0.9                                |
| -------------------------------- | ---------------------------------- |
| `openapi emit`                   | `spec emit openapi`                |
| `spec check`                     | `spec emit --check`                |
| `init --with <name>`             | `init --integration <name>`        |
| `add --framework <name>`         | `add --integration <name>`         |
| `adopt --from <tables>`          | `adopt --tables <tables>`          |
| `gen --tasks <list>`             | `gen --only <list>`                |
| `gen --out <dir>`                | `gen --out-dir <dir>`              |
| `introspect --format <kind>`     | `introspect --kind <kind>`         |
| `doctor --strict`                | `doctor --fail-on warning`         |
| `env --print`                    | `env --out -`                      |
| `env --from <file>`              | `env --status-file <file>`         |
| `skills install --from <source>` | `skills install --source <source>` |
| `skills install --agent <names>` | `skills install --for <names>`     |
| `sql add --force` (hook overlap) | `sql add --allow-hook-overlap`     |

The old forms exit with code 2 and a `command_moved` error (a renamed flag
gets `flag_renamed`) whose `suggestion` is the new call, until 1.0. The codemod rewrites them in
`package.json` scripts and `.github/workflows/*.yml`, and lists the ones in
other shell scripts, Makefiles and YAML files for review. The package also
installs a short `bsb` bin, so `pnpm bsb doctor` runs the same CLI.

## Access model [#access-model]

With `authorization` in the config, `blocks.access.model` now defaults to
`"provider"`. `can()`, `tenant_ids_with()` and every module check answer from
the provider's templates. To keep the built-in roles, set the model:

```ts title="better-supabase.config.ts"
blocks: { access: { model: "roles" } },
```

Doctor BS414 then reports each policy and function that calls the access
contract, because those answer from roles the provider never sees. See
[Access](/docs/blocks/access#provider).

`authorization.functions.canApprove` is deprecated. Set `isApprover`, which
every module that waits for an approval reads:

| 0.8                        | 0.9                                                                  |
| -------------------------- | -------------------------------------------------------------------- |
| `canApprove` with `{tool}` | `isApprover` with `{kind}` `'ai-tool'` and `{subject}` the tool name |

0.9 still reads `canApprove` when `isApprover` is unset; doctor BS415 warns
while a provider sets it.

Under the provider model, `member_organization_ids()`,
`has_organization_role()` and `organization_member_role()` take membership
from the provider's `memberIds` and `memberIdsFor` templates, so a user the
provider no longer lists stops counting as a member even while a
memberships row remains. Their role arguments still compare role names the
provider may not use. Doctor [BS334](/docs/cli/doctor#bs334) lists your own
policies and functions that call them; replace each with
`better_supabase.tenant_ids_with('<permission>')` or `member_can()`, which
the delegation cap below also narrows.

### Delegated tokens are narrowed everywhere [#delegated-tokens-are-narrowed-everywhere]

A token that carries a `client_id`, or an RFC 8693 `act` chain that is not a
support or impersonation session, is now narrowed
inside `member_can`, `member_can_while_deleting`, `platform_can` and
`tenant_ids_with`, in every access model: a permission passes only when one
of the token's scopes maps to it. In 0.8 only the policies that called
`token_scope_allows` narrowed it. Map each scope your OAuth clients and MCP
servers grant to the permission keys it covers, or those clients lose
access to every key whose name differs from a scope:

```ts title="better-supabase.config.ts"
blocks: {
  access: {
    scopes: { "posts:write": ["posts.*"], "mcp:admin": ["*"] },
  },
},
```

`authorization.delegation.scopes` is the provider's form of the same map, and
a provider that maps scopes at runtime sets the `delegation.permissions`
hook. The `token_scope_allows` checks you added to write policies can stay,
but the cap already covers them. `forContext(context, { permissions })` caps
the work to a list of keys the same way. See
[Delegated tokens](/docs/blocks/access#delegated-tokens).

### One operations table [#one-operations-table]

Every action a module checks is now an operation named `<module>.<action>`
(such as `audit.view`), and `better_supabase.operation_permission(op)`
answers its permission key in SQL, after your renames in
`blocks.<module>.permissions` and with the provider's `operations` over
them. An MCP tool's `operation`, an agent's `toolOperations` and a
workflow-builder step's `operation` name an entry in place of the prefixed
`mcp.tool.<name>`, `ai.tool.<name>` or `workflow.run`. The permission keys
themselves don't change, so this needs no edit unless you want a tool to
share an operation with the module it wraps. See
[Operations](/docs/extending/authorization-providers).

## Jobs and workflows [#jobs-and-workflows]

A job or workflow queued by an OAuth or MCP client now carries the client's
`client_id`, `scope`, `authorization_details` and `act` chain, and
`forContext` runs it with those claims. A job that used to run with the
user's full rights now runs with the client's scope, which the delegation
cap applies to every permission check. An invalid `act` chain is refused with
`forbidden` instead of being dropped. `claimsFor` can no longer set the four
delegation claims. `delay` must be a whole number of seconds. See
[Jobs](/docs/blocks/jobs).

Published workflow-builder versions record who published them and the
permissions their nodes need. A publisher must hold every one of those
permissions, or the publish returns `WORKFLOW_PUBLISHER_FORBIDDEN` with the
missing keys in `permissions`, and each step runs with the publisher's current claims through
`versions.runAs`. A demoted or removed publisher fails the step instead of
falling back to the service role. Versions published before 0.9 have no
publisher and fail with `WORKFLOW_PUBLISHER_REVOKED` until someone publishes
them again. See
[Workflow builder](/docs/blocks/workflow-builder#publisher-rights).

A version an organization owns (published by the service, with no member as
publisher) runs as the organization's default
[service principal](/docs/blocks/service-principals), capped to the
permissions the version stored. Create one with `default: true` before you
publish such versions, or set an `automation.runAs` hook; without either,
their steps fail.

Code workflows started by an event or a schedule need `runAs`, the publisher
the run starts as and the permission keys its steps may use. A schedule
start without it throws. The run's context is the publisher's, capped to
those keys, and the event's actor or the schedule's creator is only recorded
as `bs.trigger`. Admission requests keep running as their actor:

```ts
await workflows.schedules
  .tick({
    start: workflowStarter(
      { weeklyDigest },
      { runAs: { publisher: automationUserId, permissions: ["digest.send"] } },
    ),
  })
  .orThrow();
```

`startOnEvent` takes the same `runAs`, which can also be a function of the
event or the start call for a publisher per tenant. See
[Event and schedule starts run as a publisher](/docs/blocks/workflow-sdk#event-and-schedule-starts-run-as-a-publisher).

## Support sessions [#support-sessions]

`supportSessions` refuses every start unless `authorize` (or the server's
`authorization.support.authorize`) allows it. `authorize` returns `true`,
`false` or `{ approvedBy }`. A start also needs a request approved by a
different admin unless `policy.approval` is `"none"`:

```ts
supportSessions({
  store,
  authorize: ({ admin }) => admin.claims["role"] === "support",
  policy: { approval: "distinct" },
});
```

`authorize: "store"` keeps the 0.8 behaviour, where the store's SQL policy
alone decides. To start sessions without a second admin, set both
`policy.approval: "none"` and the module's
`blocks.support-sessions.options.approval: "none"`. See [Support sessions](/docs/auth/impersonation).

In a writable support session, `ctx.storage` now writes: `upload`, `update`,
`remove`, `copy`, `move` and `purgeCache` run as the target after a dry run
against the policies on `storage.objects`, where 0.8 refused them with
`SUPPORT_STORAGE_WRITES`. Signed uploads are still refused with that code.
`attachments.put` no longer creates a signed upload URL before it uploads. A
direct `supportStorage` call needs `adminSql` and `session` for writes. See
[Storage in a support session](/docs/auth/impersonation#storage-in-a-support-session).

## Webhooks and problems [#webhooks-and-problems]

Outgoing webhooks send CloudEvents 1.0 (`application/cloudevents+json`) in
place of `{ type, timestamp, data }`, signed per Standard Webhooks. A
`transform` returns only the event's `data`. Receivers read `data`, `type`
and `id` from the CloudEvent. `publish_webhook_event` takes `subject` and
`read_permission`, and delivers only to endpoints whose owner may read the
event. See [Outgoing webhooks](/docs/blocks/webhooks-out).

`sql upgrade` keeps endpoints created before 0.9 on the 0.8 body: they get
`format = 'standard'`, and new endpoints get `cloudevents`
(`blocks.webhooks-out.options.defaultFormat`). Move each endpoint to
`cloudevents` once its receiver reads CloudEvents:

```sql
update better_supabase.webhook_endpoints set format = 'cloudevents' where id = $1;
```

An adopted endpoints table keeps its own column. When it doesn't map
`format`, or a row holds `null`, deliveries use `defaultFormat`. `sql upgrade`
can't add a column to a table it doesn't own, so an adopted table with no
`format` column moves every endpoint to `cloudevents` at once unless you set
`defaultFormat: "standard"`. From 0.11, `sql sync` refuses that config
without an explicit `defaultFormat`, and doctor reports it as
[BS344](/docs/cli/doctor#bs344).
`codemod 0.9` writes `defaultFormat: "standard"` into an adopted block that
maps no `format` column and sets none, so it keeps the 0.8 body, and lists
the line for review.

Generic problems use `type: "about:blank"` and the HTTP status phrase as
`title`:

| 0.8 `title`         | 0.9 `title`             |
| ------------------- | ----------------------- |
| `Invalid request`   | `Bad Request`           |
| `Not found`         | `Not Found`             |
| `Too many requests` | `Too Many Requests`     |
| `Unexpected error`  | `Internal Server Error` |

Match problems on `kind` or `code`, which did not change. See
[Problems](/docs/auth/problems).

## Delivery fails closed [#delivery-fails-closed]

Under an authorization provider without `delivery.subscribers` or
`delivery.audience`, webhooks, notifications and reminders now deliver an
event about a [registered subject](/docs/blocks/sql#subject-registry) only to
the users and service principals who may read it
(`can_subject_for(user, type, id, 'read')`). In 0.8 every subscriber got it.
A share link never counts. Events without a subject, or about a type the
registry doesn't list, keep the 0.8 checks.

Realtime `fanOut` goes further: under a server with authorization hooks and
no subscriber policy, an event with a `subject` goes to no one and returns
`forbidden` with the hint `REALTIME_SUBSCRIBER_POLICY_REQUIRED`. Pass the
subject's readers as the policy:

```ts
import { subjectReaders } from "better-supabase/realtime";

await inbox
  .fanOut(admin, {
    ...options,
    policy: subjectReaders(sqlTransport(postgres.admin)),
  })
  .orThrow();
```

See [Realtime](/docs/platform/realtime) and
[Outgoing webhooks](/docs/blocks/webhooks-out).

## Agent actors [#agent-actors]

An RFC 8693 `act` level may now be `{ kind: "agent", sub, tools, read_only }`,
which `agentClaims(claims, { agent, tools, readOnly })` from
`better-supabase/server` builds. In 0.8 an unknown `kind` made the chain
invalid. An agent writes with the user's row level security unless its
level sets `read_only: true`, and a token that carries a `scope` limits it
like an OAuth client. Pass `agentClaims(claims, run.act)` to `asUser` or
`forContext` in agent tools so the audit log names the agent. A level with
`kind: "agent"` whose `tools` is not a list of names, or whose `read_only`
isn't a boolean, is refused. A level without `tools` delegates every tool and
`tools: []` none; `createAgentRuntime({ actor })` refuses a tool the actor
wasn't delegated with `AGENT_TOOL_NOT_DELEGATED`. See
[Impersonation](/docs/auth/impersonation).

`actor_may_write()` now decides by `kind` first. An impersonation token is
read-only even with `read_only: false`, which 0.8 let write; use the
provider's `authorization.functions.actorMayWrite` template to allow those
writes. An unknown `kind`, or a `read_only` that is a string, can't write.

## Realtime triggers [#realtime-triggers]

Topic triggers no longer send the whole row. The default message is
`{ operation, schema, table, id, values }`: the primary key and the topic
values. A 0.8 trigger sent every column to every subscriber of the topic,
including columns their grants hide.

- `triggerSql(betterSupabase, table, options)` is deprecated. Call
  `trigger(table, { ...options, betterSupabase })`, which takes the same
  options plus `topics` for fan-out. The alias stays for 0.9.
- Subscribers that read the row with `rowChange` switch to `rowKeys` and
  refetch the row through the Data API, so policies and column grants apply.
- A table whose subscribers need the whole row passes `payload: "row"`, or
  `payload: "row"` in its `realtime.triggers` entry. Read
  [Row changes](/docs/platform/realtime#row-changes) first: it bypasses column
  grants.
- A topic value with characters topics don't allow now raises a warning and
  skips that send instead of failing the write.
- `broadcast_topic()` refuses to run as a `before` trigger.
- The trigger function's name now includes the schema
  (`bs_broadcast_<topic>_<schema>_<table>`), so tables with the same name in
  two schemas no longer replace each other's function. The trigger keeps its
  name. Drop the old `bs_broadcast_<topic>_<table>` functions in the
  migration; grants on them don't carry over.

Regenerate the triggers (`sql sync` for `realtime.triggers`, or rerun the
code that writes them) and create a migration. Doctor reports trigger
functions that still send whole rows without the opt-in
([BS337](/docs/cli/doctor#bs337)).

## Pagination [#pagination]

`defineListQuery` and the default list of `defineResource` and `defineApi`
now page by cursor. A cursor page reads the same number of rows however deep
it is, and rows inserted between requests don't shift it. It has no page
number and no total:

| 0.8 (offset, the old default)              | 0.9 (cursor, the new default)                               |
| ------------------------------------------ | ----------------------------------------------------------- |
| `?page=3&size=50`                          | `?after=<nextCursor>&size=50`                               |
| `page.page.number`, `.total`, `.pages`     | `page.hasMore`, `page.nextCursor`                           |
| `ListPage<R, F, C>` is an `OffsetPage`     | `ListPage<R, F, C>` is a `CursorPage`                       |
| `list.args(query)` returns `page`, `count` | `list.args(query)` returns `after`                          |
| OpenAPI and MCP list `page`                | OpenAPI and MCP list `after`; Arazzo adds a paging workflow |

A request that still sends `page` fails with "This list pages with `after`".
To keep page numbers, set `pagination: "offset"`:

```ts title="lib/lists.ts"
export const customers = defineListQuery(betterSupabase, "customers", {
  sorts: { name: [{ name: "asc" }, { id: "asc" }] },
  defaultSort: "name",
  pagination: "offset",
});
```

The codemod adds `pagination: "offset"` to `defineListQuery` and
`defineResource` calls that set no pagination, in files that read page
numbers (`.page`, `pageCount`, `totalPages`, a `page` key or URL param). It
lists the other calls, and every `defineApi` `resources` map, for review:
add the key where a UI in another file shows page numbers or a total. A list
that sorts by a relation (`{ organization: { name: "asc" } }`) needs
`pagination: "offset"` too, since a cursor can only follow the table's own
columns. Give each cursor list a unique column last in every sort, as the
example does, so rows with the same name don't skip a page.

The default list of a table or view without a primary key stays on offset,
because it has no unique column to continue after.

The World's `world.analytics` lists return keyset
cursors instead of offsets. Treat a cursor as opaque and pass back the one
the previous page returned; an offset number from 0.8 now fails with "not an
analytics cursor".

## Blocks [#blocks]

`create_share_link` takes two more arguments: `version_id`, which pins the
link to a version of its subject and must belong to it, and `expires_at`,
which must be in the future. Calls with four arguments still work, but
grants or revokes your SQL wrote on the four-argument signature
`create_share_link(uuid, text, text, text)` no longer exist after the sync:
write them for `(uuid, text, text, text, uuid, timestamptz)`.
`revoke_share_link` now accepts only the link's creator or a member with
`share_links.create`, `list_share_links` returns only links the caller may
read, and `record_view` refuses non-members.
An adopted links table needs a `version_id uuid` column, or map
`columns.links.versionId` to `null` and links are never pinned.
`shareLinks.resolve(token)` returns `versionId`. See
[Share links](/docs/blocks/share-links).

`promotions` calls `before_promotion_activate(promotion uuid)` when you
define it, after every write that leaves a promotion active, including an
edit to one that already is. A function of that name that you wrote for
another purpose now runs on each such save; rename it. See
[Promotions](/docs/blocks/promotions#hooks).

With `config.subjects` set and no `subjects` option on `comments`, comments
now accept only the registered subject types and require that the caller can
read the subject row. In 0.8 a comment could name any type. Register the
types you comment on, or give `comments` its own `subjects`. See
[Comments](/docs/blocks/comments).

Tagging a record needs `tags.apply` (or `tags.manage`). In 0.8 any member
could add or remove a tag on any record of their organization. The
`role_permissions` defaults give `tags.apply` to members; apps with their own
roles grant it to the roles that tag. See [Tags](/docs/blocks/tags).

`calendar_attendees.role` accepts only `chair`, `required`, `optional` and
`non-participant`. The check is added `not valid` and validated in the same
migration; rows with another role leave it unvalidated with a warning, so fix
them and run `validate constraint attendees_role_check`.

`list_comments` takes an `after_id` after `after`, so rows with the same
`created_at` no longer repeat or skip between pages. Pass the last comment's
id as `cursorId` with `cursor`. Grants on the six-argument signature no longer
exist after the sync. See [Comments](/docs/blocks/comments).

## Money in the sales blocks [#money-in-the-sales-blocks]

Every amount with a currency in the deals, quotes, invoices, pipelines and
employees blocks is a `Money` value from `better-supabase/commerce` instead of
a bare `bigint`. Build one with `money(amount, currency)` and read `.amount`
for the `bigint`. Target goals and progress stay `bigint`.

| 0.8                                                                  | 0.9                                                                           |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `deals.create(org, { amount: 4_800_000n, currency: "EUR" })`         | `deals.create(org, { amount: money(4_800_000n, "EUR") })`                     |
| `{ description, unitAmount: 9_500n }`                                | `{ description, unitAmount: money(9_500n, "EUR") }`                           |
| `invoices.recordPayment(id, { amount: 137_940n })`                   | `invoices.recordPayment(id, { amount: money(137_940n, "EUR") })`              |
| `deals.forecast(org, { currency: "EUR", target: 60_000_000n })`      | `deals.forecast(org, { currency: "EUR", target: money(60_000_000n, "EUR") })` |
| `employees.addContract(id, { salary: 5_400_000n, currency: "EUR" })` | `employees.addContract(id, { salary: money(5_400_000n, "EUR") })`             |

An amount in another currency than its record returns an `invalid_request`
error with hint `CURRENCY_MISMATCH` before any database call, and an unknown
ISO 4217 code returns `CURRENCY_INVALID`. The SQL enforces the same rule, and
`record_invoice_payment` takes a `currency` after the amount: grants your SQL
wrote on the five-argument signature no longer exist after the sync.

The block functions return every `int8` as text, so amounts past 2^53 survive
the round trip, and the clients reject a missing or malformed amount instead
of reading `0n`. Code that calls these functions through `$rpc` or `queryRaw`
gets strings where it got numbers. `db.$rpc` also casts the `int8` and
`numeric` columns of a typed table function to text and decodes them with the
column's codec. Doctor BS223 lists exposed functions that return a bare
`bigint` or `numeric`, which PostgREST sends as a JSON number. See
[ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md).

### Time tracking rates [#time-tracking-rates]

A time entry's `rate` is `Money` too, and the timesheet totals return
`amounts`, one `Money` per currency, instead of `amount`:

| 0.8                                              | 0.9                                                                |
| ------------------------------------------------ | ------------------------------------------------------------------ |
| `time.start(org, { billable: true, rate: 120 })` | `time.start(org, { billable: true, rate: money(12_000n, "EUR") })` |
| `entry.rate` is `120`                            | `entry.rate` is `money(12_000n, "EUR")`                            |
| `sheet.totals.amount` is `480`                   | `sheet.totals.amounts` is `[money(48_000n, "EUR")]`                |

The `rate` column becomes `bigint` in minor units, next to a new
`rate_currency`. `start_timer` and `log_time` take `rate bigint` and
`rate_currency text`, and the `numeric` signatures are dropped, so grants
your SQL wrote on them no longer exist. `time_entry.*` events carry `rate` as
text and `rateCurrency`. The upgrade multiplies existing rates by 100, which
is right for a currency with two decimals, and leaves `rate_currency` empty.
Set it in the migration:

```sql
update better_supabase.time_entries
set rate_currency = 'EUR'
where rate is not null and rate_currency is null;
alter table better_supabase.time_entries
  validate constraint time_entries_rate_pair_check;
```

An adopted entries table needs a `rate_currency text` column. See
[Time tracking](/docs/blocks/time-tracking#timers-and-entries).

## AI SDK and notifications [#ai-sdk-and-notifications]

`gatewayOptions` ignores a `user` in its second argument; the AI Gateway
`user` only comes from `context.userId`. With `authorizeTool` set on
`createAgentRuntime`, a tool without a declared permission is denied. See
[Agents](/docs/ai-sdk/agents).

`deliver()` in the notifications block counts suppressed and audience-denied
deliveries in `skipped`. A hand-written realtime `Topic` needs a `fanOut`
method. See [Notifications](/docs/blocks/notifications).

## Records, workflows and state [#records-workflows-and-state]

These changes come with record-bound workflows. New SQL arguments all have
defaults, so existing calls keep working, but `sql sync` drops the old
signatures. Rewrite any grants, revokes or `comment on function` statements
your own SQL wrote on them for the new argument lists.

### State machine transitions run as the caller [#state-machine-transitions-run-as-the-caller]

`better_supabase.transition()` is no longer a security definer function. The
subject table's row level security decides who may move a record, and a
caller who can't update the row gets `STATE_SUBJECT_NOT_FOUND` (a `not_found`
error from `createStateMachine().transition()`) instead of `true`. In 0.8 any
signed-in user could change the status of any record of a machine. Give
members an update policy on each machine's table, or call `transition()`
with the service role where a server action moves records on their behalf. A
`bulk` row the caller can't update now fails instead of moving. See
[Who may transition](/docs/blocks/state-machine#who-may-transition).

### Comments activity per record [#comments-activity-per-record]

With the `subjects` module installed, the `activity_entries` read policy and
`list_activity` check `can_subject(subject_type, subject_id, 'read')`, so
members no longer see entries on records they can't read. Entries without a
subject, or on an unregistered type, keep the tenant check. `ActivityEntry`
has a required `source` field (`"event"` or `"audit"`): code that builds
entries by hand, such as test fixtures or a custom feed, sets it. See
[Visibility per record](/docs/blocks/activity#visibility-per-record).

### Notification delivery tracking [#notification-delivery-tracking]

`complete_notification_delivery` takes a seventh argument, `template_key`,
and the six-argument signature is dropped. `resendWebhook` records every
Resend status through `record_delivery_event`, which ships in notifications
module version 6: run `sql sync` and apply the migration before you deploy
the new handler, or it answers 500. See
[Delivery tracking](/docs/blocks/notifications#delivery-tracking).

### Workflow runs on records [#workflow-runs-on-records]

`record_workflow_run`, `workflow_runs_list` and `request_workflow_start` gain
`subject_type` and `subject_id`, and the first two gain `mode`.
`record_workflow_run` also takes `principal_kind`, `principal_id` and
`trigger_node`. `workflow_runs_list` defaults to `mode => 'live'`, so test
runs are left out unless you ask for them.

Runs that `startOnEvent` and `workflowStarter` start with `runAs` now record
the user who triggered them as `bs.actor` and `actor_id`. In 0.8 that was the
publisher; the publisher is now in `principal_kind` and `principal_id`, and
`runAs` takes `principalKind`. Reports or policies that read `actor_id` as
"who the run acted as" read `principal_id` instead. `startFor` no longer
accepts `bs.principal_kind` or `bs.principal` from caller attributes. See
[Runs on a record](/docs/blocks/workflows#runs-on-a-record).

### Workflow builder [#workflow-builder]

`save_workflow_definition` takes `subject_types`, `scope` and `restricted`,
`workflow_definitions_list` takes `subject_type`, `workflow_start_target` and
`workflow_run_principal` take a `tenant`, and `save_workflow_draft` takes
`input_schema`. `versions.runAs(version, tenant)` needs the tenant when the
version belongs to a system definition, because the run's organization isn't
on the definition. `validateGraph` no longer reports a second trigger node as
an error: a graph may have several, and a trigger picks one with
`config.node`. An editor that relied on the error to block a second trigger
checks the count itself. See
[Runs on a record](/docs/blocks/workflow-builder#runs-on-a-record) and
[System workflows](/docs/blocks/workflow-builder#system-workflows-forks-and-opt-outs).

### Saved views [#saved-views]

The `save` callback passed to `useSavedViews` also receives `state`, the
view state `savedViews.save()` stores. A callback that forwards its whole
argument to an API that rejects unknown keys picks the fields it needs, or
passes `state` on. See
[Sync with the URL](/docs/blocks/saved-views#sync-with-the-url).

# From 0.9 to 0.10

> What to check when upgrading from better-supabase 0.9 to 0.10 and 0.11, starting with the webhook format of adopted endpoints, the attachment permission defaults and the regenerated realtime triggers.

Source: https://bettersupabase.com/docs/migration/0.9-to-0.10

Most of 0.10 needs no change in your code. Check the items below, then run
`better-supabase sql upgrade`, `better-supabase sql sync` and
`better-supabase doctor`. Items marked 0.11 arrive in the release after
0.10.

## Webhook format of adopted endpoints [#webhook-format-of-adopted-endpoints]

`defaultFormat` has defaulted to `cloudevents` since 0.9. A managed
endpoints table kept its old rows on the 0.8 body (`format = 'standard'`),
but an adopted table without a `format` column has no per-row value, so
every endpoint switched to CloudEvents with no change to the config.

From 0.11, `sql sync` refuses an adopted `webhooks-out` whose endpoints
table maps no `format` column unless `options.defaultFormat` is set, and
doctor reports the same config as [BS344](/docs/cli/doctor#bs344). Set it to
the body your receivers read today:

```ts title="better-supabase.config.ts"
"webhooks-out": {
  mode: "adopt",
  options: { defaultFormat: "standard" },
},
```

Use `"cloudevents"` if your receivers already read CloudEvents, or add a
`format` column and map it in `columns.endpoints.format` to choose per
endpoint. See [existing tables](/docs/blocks/webhooks-out#existing-tables).

`better-supabase codemod 0.11` writes `defaultFormat: "cloudevents"` into
such a block, which keeps what it sends today, and lists the line for
review; change it to `"standard"` if your receivers still read the 0.8 body.

## Attachment permissions per subject [#attachment-permissions-per-subject]

Attachments on a subject use the block-wide `upload` and `read` permissions
unless the subject names its own. From 0.11 the subject registry
(`config.subjects.<type>.permissions`) carries `upload` and `manage` to the
attachments block, and `create` and `moderate` to comments. Its `read` stays
the key for reading the subject itself, so a files `read` key still goes in
`blocks.attachments.options.subjects.<type>.permissions`. Doctor reports a subject
whose table has stricter row level security than the block-wide keys as
[BS345](/docs/cli/doctor#bs345). See
[attachments](/docs/blocks/attachments#permissions-per-subject).

## Realtime topic triggers (0.11) [#realtime-topic-triggers-011]

`sql sync` rewrites the functions behind `topic.trigger()` and
`realtime.triggers`. They read each parent row once per changed row, and the
messages they send don't change. Two additions:

- `when: { changed: [...] }` sends an update only when a listed column
  changed.
- A delete whose lookup finds no parent row, as in an `on delete cascade`,
  raises a warning instead of skipping the topic silently.

See [deletes and cascades](/docs/platform/realtime#deletes-and-cascades).

## Case-insensitive equality (0.11) [#case-insensitive-equality-011]

`where: { email: { ieq: input } }` matches a value regardless of case and
escapes `%`, `_` and `\` in it, so user input can't widen the match. Replace
`ilike` filters that pass user input without `escapeLike`; the new
`better-supabase/require-escape-like` lint rule finds them. See
[filtering](/docs/repository/filtering).

## Expo session refresh (0.11) [#expo-session-refresh-011]

`bs.middleware` hands the session it refreshed to the loaders of the same
request, so a loader with `refresh: false` sees the new session on the first
render, and each request makes at most one Auth call. Loaders without
middleware still refresh on their own. See [Expo](/docs/frameworks/expo).

## Auth admin from cron jobs (0.11) [#auth-admin-from-cron-jobs-011]

`bs.admin().$authAdmin` is the typed Auth admin API of the secret-key
client. Use it instead of creating a second client for cron jobs and
webhook handlers. See
[cron jobs and webhooks](/docs/auth/server#cron-jobs-and-webhooks).

# Older versions

> Upgrade guides for versions before 0.13.

Source: https://bettersupabase.com/docs/migration/older

- [From 0.11 to 0.12](/docs/migration/0.11-to-0.12)
- [From 0.9 to 0.10](/docs/migration/0.9-to-0.10)
- [From 0.8 to 0.9](/docs/migration/0.8-to-0.9)
- [From 0.5 to 0.6](/docs/migration/0.5-to-0.6)
- [From 0.4 to 0.5](/docs/migration/0.4-to-0.5)
- [From 0.3 to 0.4](/docs/migration/0.3-to-0.4)
- [From 0.1 to 0.2](/docs/migration/0.1-to-0.2)

# From 0.13 to 0.14

> What to change when upgrading to better-supabase 0.14, where block methods take a subject ref and read the tenant from the block.

Source: https://bettersupabase.com/docs/migration/0.13-to-0.14

0.14 changes the signature of every block method that works on a record, and
moves comments onto the messaging core with the activity feed in its own
block. Update the calls below, then run `better-supabase sql upgrade` and
`better-supabase sql sync` and generate a migration.

## Block methods take a subject and read the tenant from the block [#block-methods-take-a-subject-and-read-the-tenant-from-the-block]

The methods of comments, attachments, tags, reactions, relations, versions,
sla, share links, custom fields, reminders, time tracking, approvals and the
state machine take the record they work on as a `SubjectRef`, `{ type, id }`,
as their first argument. They no longer take an `organizationId`: the
blocks read it from their new `tenant` option, an organization id or a
function that returns the current one. A call without a tenant fails with
`invalid_request` and the hint `TENANT_REQUIRED`. The state machine and the
sla clock methods take no tenant, since the record decides it.

Rows carry `subject` in place of `subjectType` and `subjectId`. Batch methods
take a list of subjects and return a record keyed `type:id`, which
`subjectKey(subject)` from `better-supabase/blocks` builds. Event payloads,
the SQL functions and their columns keep `subjectType` and `subjectId`.

| Before                                                                                                | After                                                                                                                                    |
| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `comments.create({ organizationId, subjectType, subjectId, body })`                                   | `comments.create(subject, { body })`                                                                                                     |
| `comments.list(organizationId, subjectType, subjectId, options?)`                                     | `comments.list(subject, options?)`                                                                                                       |
| `comments.counts(organizationId, subjectType, subjectIds)`, keyed by id                               | `comments.counts(subjects)`, keyed `type:id`                                                                                             |
| `comments.copy(organizationId, from, to)`                                                             | `comments.copy(from, to)`                                                                                                                |
| `comments.history(organizationId, subject?, options?)`                                                | `createActivity({ transport, tenant }).list(subject?, options?)` (see [comments on the messaging core](#comments-on-the-messaging-core)) |
| `attachments.upload({ organizationId, subjectType, subjectId, ... })`                                 | `attachments.upload(subject \| null, attachment)`                                                                                        |
| `attachments.put({ organizationId, subjectType, subjectId, ... }, file)`                              | `attachments.put(subject \| null, attachment, file)`                                                                                     |
| `attachments.list(organizationId, subjectType?, subjectId?)`                                          | `attachments.list(subject?)`                                                                                                             |
| `versions.snapshot({ organizationId, subjectType, subjectId, ... })`                                  | `versions.snapshot(subject, input?)`                                                                                                     |
| `versions.list(organizationId, subjectType, subjectId, options?)`                                     | `versions.list(subject, options?)`                                                                                                       |
| `shareLinks.create(organizationId, subjectType, subjectId, options?)`                                 | `shareLinks.create(subject, options?)`                                                                                                   |
| `shareLinks.recordView(organizationId, subjectType, subjectId, viewerKind, viewer?)`                  | `shareLinks.recordView(subject, viewerKind, viewer?)`                                                                                    |
| `shareLinks.list(subjectType, subjectId)`, `setState(subjectType, subjectId, starred)`                | `shareLinks.list(subject)`, `setState(subject, starred)`                                                                                 |
| `tags.create(organizationId, kind, name, ...)`, `tags.list(organizationId, kind?)`                    | `tags.create(kind, name, ...)`, `tags.list(kind?)`                                                                                       |
| `tags.tag(organizationId, subjectType, subjectId, tagId)`, and `untag`                                | `tags.tag(subject, tagId)`, and `untag`                                                                                                  |
| `tags.forSubject(organizationId, subjectType, subjectId)`                                             | `tags.forSubject(subject)`                                                                                                               |
| `reactions.react(organizationId, subjectType, subjectId, emoji, options?)`                            | `reactions.react(subject, emoji, options?)`                                                                                              |
| `reactions.summary(organizationId, subjectType, subjectIds)`, keyed by id                             | `reactions.summary(subjects)`, keyed `type:id`                                                                                           |
| `reactions.customEmoji(organizationId)`, `saveCustomEmoji(organizationId, input)`                     | `reactions.customEmoji()`, `saveCustomEmoji(input)`                                                                                      |
| `customFields.listDefinitions(organizationId, entity)`, `saveDefinition(organizationId, entity, ...)` | `customFields.listDefinitions(entity)`, `saveDefinition(entity, ...)`                                                                    |
| `customFields.readValues(organizationId, subjectType, subjectId)`                                     | `customFields.readValues(subject)`                                                                                                       |
| `customFields.setValue(organizationId, subjectType, subjectId, definitionId, value)`                  | `customFields.setValue(subject, definitionId, value)`                                                                                    |
| `relations.link({ organizationId, source, target, kind, ... })`, and `unlink`                         | `relations.link(source, target, { kind, data?, fields? })`, and `unlink(source, target, { kind })`                                       |
| `relations.of(organizationId, subject, kind?)`, `edges(organizationId, kind)`                         | `relations.of(subject, kind?)`, `edges(kind)`                                                                                            |
| `relations.ancestors(organizationId, kind, subject)`, and `descendants`                               | `relations.ancestors(subject, kind)`, and `descendants`                                                                                  |
| `relations.rollup({ organizationId, subject, ... })`                                                  | `relations.rollup(subject, { kind, of, agg, column? })`                                                                                  |
| `relations.convertedInto(organizationId, origin, intoType)`, `lineage(organizationId, subject)`       | `relations.convertedInto(origin, intoType)`, `lineage(subject)`                                                                          |
| `sla.savePolicy(organizationId, policy)`                                                              | `sla.savePolicy(policy)`                                                                                                                 |
| `approvals.request({ organizationId, subjectType, subjectId, action, ... })`                          | `approvals.request(subject, { action, reason?, payload? })`                                                                              |
| `approvals.list(organizationId, options?)`                                                            | `approvals.list(options?)`, with an optional `subject` filter                                                                            |
| `approvals.isApproved(subjectType, subjectId, action)`                                                | `approvals.isApproved(subject, action)`                                                                                                  |
| `stateMachine.transition(subjectType, subjectId, to, reason?)`                                        | `stateMachine.transition(subject, to, reason?)`, and the same for `pause`, `resume`, `setFlag`, `state`, `history` and `stages`          |
| `reminders.save(organizationId, subjectType, subjectId, offsetMinutes?, fields?)`                     | `reminders.save(subject, offsetMinutes?, fields?)`                                                                                       |
| `reminders.list(organizationId, subjectType, subjectId)`                                              | `reminders.list(subject)`                                                                                                                |
| `timeTracking.start(organizationId, fields?)`, `stop(organizationId)`, `running(organizationId)`      | `timeTracking.start(fields?)`, `stop()`, `running()`                                                                                     |
| `timeTracking.log`, `list`, `timesheet`, `submit` and `timesheets` with `organizationId` first        | the same calls without it; entries take `subject` instead of `subjectType` and `subjectId`                                               |
| `row.subjectType`, `row.subjectId`                                                                    | `row.subject.type`, `row.subject.id`                                                                                                     |
| `useComments({ organizationId, subjectType, subjectId, schema?, topic?, limit? })`                    | `useComments({ subject, topic?, limit? })`                                                                                               |
| `useReactions({ organizationId, subjectType, subjectIds, schema?, topic? })`                          | `useReactions({ subjects, topic? })`                                                                                                     |
| `useTagInput({ organizationId, subjectType, subjectId, kind, schema? })`                              | `useTagInput({ subject, kind })`                                                                                                         |

To upgrade:

1. Pass `tenant` to the blocks you create yourself
   (`createComments({ transport, tenant: () => ctx.tenant })`), or build them
   with `createBlockClient` or `blockSet.client`, which pass the request's
   tenant (see [one block per request](/docs/blocks/create-blocks#one-block-per-request)).
2. Replace each `organizationId, subjectType, subjectId` argument list with
   one `{ type, id }` object, and read `row.subject` instead of
   `row.subjectType` and `row.subjectId`. Read batch results by
   `subjectKey(subject)`.
3. Pass `blocks: blockConfig` to the browser `createClient`, and `tenant`
   when the tenant isn't the session's tenant claim, then drop `schema` and
   `organizationId` from the hooks (see [block hooks](/docs/frontend/client#block-hooks)).
4. Run `better-supabase sql sync`, then generate a migration, for the
   comments and attachments author functions.

## One block client per request instead of a wrapper per block [#one-block-client-per-request-instead-of-a-wrapper-per-block]

A `lib/blocks.ts` that wrapped each block to pass the organization can go.
`createBlockClient(ctx.db, commentsBlock, { config: blockConfig })` and
`blockSet.client(ctx.db, "comments")` build one block for a request: they
call through `ctx.db` and act in `ctx.db.$context.tenant`.

## Comments and attachments return their author [#comments-and-attachments-return-their-author]

`comments.list` and `attachments.list` return `author` on each row, with the
name and avatar URL from the profiles module, through the new security
definer functions `comment_authors(uuid[])` and `attachment_authors(uuid[])`.
They return only authors of rows the caller may read. Without the profiles
module, `author.name` and `author.avatarUrl` are `undefined`.

## Billing stores a Stripe account next to the customer [#billing-stores-a-stripe-account-next-to-the-customer]

The `billing` module is at version 3. `billing_customers` gains a nullable
`stripe_customer_account_id` column (`^acct_`), and `stripe_customer_id` is
nullable, with a check that a row has one of the two. Existing rows keep
their customer, and nothing changes for apps that don't set
`customerAccounts` in `createBilling`. Run `better-supabase sql upgrade`
(or `sql sync`), then generate a migration; the module file adds the column
and drops the `not null` in place.

Code that reads `billing_customers.stripe_customer_id` directly should
expect `null` for a tenant billed as an account before its first checkout.
`StripeEventOutcome` and `BillingStatus` have a new `accountId` field, so a
test that compares either with `toEqual` needs it. See
[billing tenants as Stripe accounts](/docs/blocks/billing#billing-tenants-as-stripe-accounts).

## Stock, leave balances and invoice payments move onto the ledger [#stock-leave-balances-and-invoice-payments-move-onto-the-ledger]

Inventory, leave and invoices now require the new [ledger](/docs/blocks/ledger)
module, and `better-supabase add` installs it with them. Their client methods
and SQL functions keep their names and results. The tables underneath change:

| Before                         | After                                                                                    |
| ------------------------------ | ---------------------------------------------------------------------------------------- |
| `stock_movements`              | `ledger_transactions` of kind `stock.<reason>` on a `stock:<variant>:<location>` account |
| `leave_ledger`                 | `ledger_transactions` of kind `leave.<entry>` on a `leave:<type>:<user>:<year>` account  |
| `invoice_payments`             | `ledger_transactions` of kind `invoice.payment` or `invoice.refund` on `invoice:<id>`    |
| `apply_stock_movement` trigger | the ledger's balance trigger and `ledger_balances`                                       |

Run `better-supabase sql upgrade`. Each module's upgrade copies the old rows
into ledger transactions with the same ids, so movement, entry and payment ids
you stored stay valid, then drops the old table. A schema diff from `sql sync` alone
drops the tables without copying them, so upgrade before you deploy.

Code that read those tables directly reads the ledger instead: `ledger_entries`
joined to `ledger_accounts` by code, or `createLedger(...).entries(accountId)`.
An adopted `stock_movements`, `leave_ledger` or `invoice_payments` table in
`blocks.<name>.tables` is no longer a key; remove it from the config.

## Reactions carry votes [#reactions-carry-votes]

[Reactions](/docs/blocks/reactions#votes) store up and down votes next to
emoji. Each row gains `kind` (`emoji` or `vote`) and `value` (`1` for emoji,
`1` or `-1` for a vote), the unique key gains `kind`, and a new
`reaction_scores` table keeps each subject's vote score. Run
`better-supabase sql upgrade`: the reactions upgrade adds the columns with
`kind = 'emoji'` and `value = 1` for the rows you have, so existing reactions
keep their meaning.

`reaction.added` and `reaction.removed` now also fire for votes, with
`kind: "vote"` and the `value`. A consumer that should only see emoji
reactions filters on `kind === "emoji"`. `summary()`, `react()` and the
realtime counts stay emoji-only.

With `blocks.reactions.mode: "custom"`, add `kind text not null default
'emoji'` and `value smallint not null default 1` to your own table, and
include `kind` in its unique key on subject, actor and emoji.

## Review moderation moves to the moderation block [#review-moderation-moves-to-the-moderation-block]

[Reviews](/docs/blocks/reviews#moderation) now moderate through the
[moderation](/docs/blocks/moderation) block, so the reviews block requires it.
Add `moderation: {}` to `blocks` in `better-supabase.config.ts` and grant
`moderation.read`, `moderation.manage` and `moderation.ban` to the roles that
moderated reviews.

Run `better-supabase sql upgrade`, then `better-supabase sql data`. The
upgrade creates a moderation item for each review with its status (published
becomes `visible`, rejected becomes `hidden`, pending stays `pending`), moves
`moderated_by` and `moderated_at` onto the item, logs each past decision, and
drops both columns from `reviews`. Read the last decision with
`moderation.item({ type: "review", id })` (`decidedBy`, `decidedAt`).

`publish`, `reject` and the review's `status` work as before. With
`blocks.reviews.mode: "custom"`, drop `moderated_by` and `moderated_at` from
your table, and remove `moderatedBy` and `moderatedAt` from
`blocks.reviews.columns` if you renamed them.

Installing moderation shifts the later better-supabase module files in
`supabase/schemas/_better_supabase` by one number. Run `better-supabase sql
sync`, delete the files with the old numbers, and confirm with
`better-supabase sql sync --check`.

## Onboarding runs on enrollments [#onboarding-runs-on-enrollments]

[Onboarding](/docs/blocks/onboarding) checklists now run on the
[enrollments](/docs/blocks/enrollments) block, so the onboarding block
requires it. Add `enrollments: {}` to `blocks` in `better-supabase.config.ts`.
Grant `enrollments.read` to roles that should see onboarding next to other
enrollments; onboarding itself still checks `onboarding.read` and
`onboarding.complete`.

Run `better-supabase sql upgrade`. The upgrade creates an enrollment in the
program `onboarding:<checklist>` for each checklist and subject (the
organization for an organization checklist, the user for a user checklist),
copies each completed step with who completed it and when, marks
enrollments with every step done as `completed`, and drops
`onboarding_progress`.

`createOnboarding`, `useOnboarding` and the `onboarding_progress`,
`complete_onboarding_step` and `reset_onboarding_step` functions work as
before. Queries that read the `onboarding_progress` table read
`enrollment_steps` joined to `enrollments` instead, where `program` is
`'onboarding:' || checklist`. A seed that inserted into `onboarding_progress`
calls `complete_onboarding_step` as the user instead. With `blocks.onboarding.mode: "custom"`, the
upgrade leaves your table alone.

name and avatar URL from the profiles module. Attachments read it through the
new security definer function `attachment_authors(uuid[])`, and comments
through the messaging functions, since a comment is now a message. Both
return only authors of rows the caller may read. Without the profiles
module, `author` is `undefined`.

## Comments on the messaging core [#comments-on-the-messaging-core]

A record's comments are now its conversation on the
[messaging](/docs/blocks/messaging) core (conversation kind `subject`), and
each comment is a message. Resolve, pins, edit history (`edits`), anchors,
attachments and read markers come from messaging, and `assign` turns a
comment into a work item. The activity feed moved out of comments into its
own [activity](/docs/blocks/activity) block. See
[Comments](/docs/blocks/comments) for the new API.

The comments block now requires `tenant`, `access`, `subjects` and
`messaging`, and runs in the `tenant` scope only: a config with
`scope: "user"` or `scope: "none"` refuses it. Register each record type you
comment on in `config.subjects`; its read rules decide who reads the
comments.

| Before                                                                                            | After                                                                                                                          |
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `comments.read`, `comments.create`, `comments.moderate` in a role                                 | `messages.read`, `messages.send`, `messages.moderate`; the default `admin` and `member` roles no longer list `comments.*` keys |
| `ActivityHistoryOptions`                                                                          | `ActivityListOptions`                                                                                                          |
| `activitySink`, `activityListQuery` and the activity types from `better-supabase/blocks/comments` | the same names from `better-supabase/blocks/activity`, with `activity: {}` in `blocks`                                         |
| `blocks.comments.permissions.activity`                                                            | `blocks.activity.permissions.read`                                                                                             |
| `comment.created`                                                                                 | `message.sent`, with `conversationKind: "subject"`, `recordType` and `recordId` in its data                                    |
| `comment.deleted`                                                                                 | `message.deleted`                                                                                                              |
| `comment.mentioned` (event and notification)                                                      | the `message.mentioned` notification from messaging, and `mentionIds` on `message.sent`                                        |
| watcher notifications mapped from `comment.created`                                               | `comment.posted`, which the comments module sends to the record's followers                                                    |
| `commentsEvents`, `CommentsEvents` from `better-supabase/blocks/comments`                         | `messagingEvents`, `MessagingEvents` from `better-supabase/blocks/messaging`                                                   |
| `blocks.comments.options.subjects`                                                                | `config.subjects`                                                                                                              |
| `blocks.comments.options.maxBodyLength`                                                           | `blocks.messaging.options.maxBodyLength` (default 40000)                                                                       |
| `blocks.comments.options.extraColumns`                                                            | `blocks.messaging.options.extraColumns`                                                                                        |
| `blocks.comments.options.documentSchema`                                                          | removed; check the document before you call `create` or `edit`                                                                 |
| `blocks.comments.options.notify`                                                                  | `blocks.comments.options.notify` for `comment.posted`, and `blocks.messaging.options.notify` for mentions                      |
| `blocks.comments.options.follow`, `mentionGroups`                                                 | unchanged                                                                                                                      |
| `blocks.comments.entities` (the `comment` entity)                                                 | the `message` entity, renamed with `blocks.messaging.entities`                                                                 |
| `useComments` `topic`: a private topic of your own                                                | the messaging topic prefix, `blocks.messaging.options.topic` by default; `topic: null` turns Realtime off                      |
| `comment.mentions`, `NewComment.mentions`, `CommentEdit.mentions`                                 | `mentionIds`                                                                                                                   |
| `comment.createdAt`                                                                               | `comment.sentAt` (when the comment was posted)                                                                                 |
| `commentsBlock.schemas`                                                                           | removed; `commentInputSchema` and `commentChangeSchema` are still exported                                                     |
| reactions on `{ type: "comment", id }`                                                            | reactions on `{ type: "message", id }`                                                                                         |
| `edit_comment`, `delete_comment`, `comment_subject_readable`, `comment_authors`                   | `edit_message`, `delete_message`, `can_subject(type, id, 'read')`                                                              |

To upgrade:

1. Add `messaging: {}` and, if you read the feed or run `activitySink`,
   `activity: {}` to `blocks`. Move `maxBodyLength` and `extraColumns` from
   `blocks.comments.options` to `blocks.messaging.options`, and the record
   types from `blocks.comments.options.subjects` to `config.subjects`.
2. Replace `comments.*` keys with `messages.*` keys in the custom roles your
   app stores, and in any `can(session, "comments.create")` check.
3. Import `activitySink`, `activityListQuery` and the activity types from
   `better-supabase/blocks/activity`, and replace `comments.history(...)`
   with `createActivity(...).list(...)` or
   `createBlockClient(ctx.db, activityBlock, { config: blockConfig }).list(...)`.
4. Listen to `message.*` in place of `comment.*`, and drop a
   `comment.created` entry from `watcherHandler` types, since followers
   already get `comment.posted`.
5. Read `mentionIds` and `sentAt` in place of `mentions` and `createdAt`,
   and react to comments as `message` subjects.
6. Run `better-supabase sql upgrade`, then `better-supabase sql sync`, and
   generate a migration.

A few rules moved with the data. A record type's `scope` (a scope your
authorization provider declares) now sits on `config.subjects.<type>.scope`,
and a grant on the record still opens its comments. A record type's read key
moved to `config.subjects.<type>.permissions.read`; with it, a mention
notifies only members who hold that key, and without it a mention checks the
tenant's `messages.read` key and the record's own row policies don't apply to
the person mentioned. Comments no longer delete with their record unless
`config.subjects.<type>.cascade` is set. `create_comment` runs as the caller,
so a `mentionGroups` table needs `select` granted to `authenticated`.

### The data migration [#the-data-migration]

The comments module goes from version 7 to 8. Its upgrade step, which
`better-supabase sql upgrade` writes before the schema files, moves the data
before the old table goes:

- Each record's comments become one `subject` conversation, created by the
  author of its first comment, with `last_message_at` set to its newest
  comment.
- Each comment becomes a message with the same id, author, body, document,
  mentions, `sent_at` (the old `created_at`), `edited_at` and `deleted_at`.
  Replies keep their parent, and every reply joins its root's thread, with
  the root's reply count, last reply time and first three repliers filled in.
  Earlier versions of an edited comment weren't stored before, so `edits`
  starts empty.
- Extra columns the comments and messages share by name are copied, so add
  your `extraColumns` to `blocks.messaging.options` before you run it.
- With the reactions and attachments modules, reactions and files on a
  `comment` subject move to the `message` subject.
- The `comments` table and the functions `create_comment` (old
  signatures), `edit_comment`, `delete_comment`,
  `comment_subject_readable`, `comment_authors` and the comment triggers are
  dropped. `activity_entries` and its rows stay, owned by the activity module.

Review the generated migration before you apply it: the step runs once, and
the old table is gone afterwards.

## The inbox on the messaging core [#the-inbox-on-the-messaging-core]

Inbox conversations are now core conversations of kind `inbox` on the
[messaging](/docs/blocks/messaging) core, and their messages and internal
notes are core messages; notes have `kind: "note"` and only staff read them.
The inbox keeps its own tables for inboxes, members, contacts, deliveries,
inbound events and templates, plus `inbox_conversations` and
`inbox_message_details` for the fields messaging doesn't have. The inbox
block now requires `messaging` and `attachments`, and reactions on inbox
messages need the `reactions` block. See [Inbox](/docs/blocks/inbox) for
the new API.

| Before                                                                     | After                                                                                                          |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `inbox_messages`                                                           | `messages` (kind `message` or `note`) with `inbox_message_details`                                             |
| `conversation_participants`, `conversation_reads`                          | `conversation_members`, with the read marker in `last_read_at`                                                 |
| `inbox_mentions`                                                           | the message's mentions in messaging                                                                            |
| the `inbox-files` bucket and the jsonb `attachments` array                 | attachment rows of the attachments block, scanned before anyone reads them                                     |
| `set_inbox_typing(conversation, typing, actor)`                            | `set_typing(conversation, typing, actor)` from messaging                                                       |
| `react_to_message`, reactions on `{ type: "inbox_message", id }`           | `react_to_inbox_message`, reactions on `{ type: "message", id }`                                               |
| `messages.react` returns `{ [emoji]: actors }`                             | an array of `{ emoji, count, reacted, actors }`                                                                |
| `InboxMessage.reactions` as `{ [emoji]: actors }`                          | the same array as `messages.react`                                                                             |
| `InboxMessage.mentions`                                                    | `mentionIds`                                                                                                   |
| `InboxMessage.authorId`, `editedAt`, `deletedAt` as `null`                 | `undefined`; a contact's messages have no `authorId`, read `conversation.contactId`                            |
| `InboxMessage.attachments` as `MessageAttachment[]`                        | the core `MessageFile[]` (`id`, `name`, `mimeType`, `size`, `status`)                                          |
| `MessageAttachment`, `MessageInput.attachments` with paths or URLs         | `MessageInput.attachments` with ids from `messages.createUpload`; channel files in `files` (`InboxFileSource`) |
| `PurgedContact.attachments` as paths                                       | `{ bucket, path }` records                                                                                     |
| the topic `inbox:<conversationId>`                                         | the messaging topic `messages:<conversationId>`; `inbox:org:<organizationId>` stays                            |
| `useConversation`, `useInboxWidget` and their controllers reload on a ping | they take `get(messageId)` and fetch only the pinged message                                                   |
| `copyFile` copies into `inbox-files`                                       | `copyFile` returns an `InboxFileSource`; `importFiles` fetches, writes and confirms each file                  |

To upgrade:

1. Add `messaging: {}` and `attachments: {}` to `blocks`, and `reactions: {}`
   if your inbox uses reactions.
2. Pass `get` to `useConversation` and `useInboxWidget`, such as a server
   action that returns `inbox.messages.get(messageId)`.
3. Read `mentionIds` and the reaction array, compare `deletedAt` with
   `undefined`, and take a contact's id from the conversation.
4. Upload files with `messages.createUpload`, then `confirmUpload`, and
   pass the ids in `attachments`. Run the chat-sdk `importFiles` (or a loop
   over `files.pending`) from a cron route for channel files.
5. Run `better-supabase sql upgrade`, then `better-supabase sql sync`, and
   generate a migration.

### The inbox data migration [#the-inbox-data-migration]

The inbox module goes from version 3 to 4. Its upgrade step, which
`better-supabase sql upgrade` writes before the schema files, moves the data
before the old tables go:

- Each conversation becomes a core conversation of kind `inbox` with the
  same id, and its inbox fields stay in `inbox_conversations`.
- Each message and note becomes a core message with the same id, body,
  mentions and times, and its direction, author type, format, external id,
  reply and metadata move to `inbox_message_details`.
- Participants and read markers become conversation members with
  `last_read_at`.
- Each file in a message's `attachments` array becomes a pending
  attachment whose source is its old path in `inbox-files` (or its URL);
  `importFiles` with a `read` that downloads from `inbox-files` copies them.
  The step stops when messages have files and the attachments table is
  missing.
- With the reactions block, the reactions kept on each message move to the
  `message` subject, and rows on `inbox_message` are renamed. Without it the
  step warns and drops them.
- The old tables and `set_inbox_typing`, `react_to_message` and the other
  replaced functions are dropped. The `inbox-files` bucket stays until you
  have imported its files.

Review the generated migration before you apply it: the step runs once.

## Signatures get their own block [#signatures-get-their-own-block]

The signatures client, its manifest and its events moved out of
`better-supabase/blocks/forms` into `better-supabase/blocks/signatures`. The
SQL module, its tables, `blocks.signatures` in the config and the block name
stay the same, so no migration is needed. Change the imports:

| Before (`better-supabase/blocks/forms`) | After (`better-supabase/blocks/signatures`) |
| --------------------------------------- | ------------------------------------------- |
| `createSignatures` and its types        | `createSignatures` and its types            |
| `signaturesBlock`                       | `signaturesBlock`                           |
| `signaturesEvents`, `SignaturesEvents`  | `signaturesEvents`, `SignaturesEvents`      |

`better-supabase/blocks/forms` keeps the forms client, the questionnaire
controller and `formsBlock`.

# From 0.14 to 0.15

> What to change when upgrading to better-supabase 0.15, where the config is grouped, the runtime instance is bsb, the SQL engine says block and the CLI has one command per job.

Source: https://bettersupabase.com/docs/migration/0.14-to-0.15

0.15 groups the config file, renames the runtime instance to `bsb`, moves
the SQL engine's names from module to block and gives the CLI one command per
job. `better-supabase codemod 0.15` makes the changes below in your config,
source files, `package.json` scripts and GitHub workflows. Run it, review the
diff, then run `better-supabase upgrade` and generate a migration.

```sh
npx better-supabase codemod 0.15
```

## The config is grouped [#the-config-is-grouped]

`better-supabase.config.ts` groups its keys into `database`, `auth`, `gen`,
`api` and `tooling`. `preset`, `blocks`, `plugins`, `records`, `tables` and
`adapters` stay at the top level. A config that still uses a 0.14 key fails
to load, and the message names the key's new place.

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: { schemas: ["public"], sql: { owner: "tenant", api: "api" } },
  auth: { organization: { claim: "tenant_id" } },
  gen: { casing: "camel", output: "src/lib/supabase/generated.ts" },
  tooling: { doctor: { strict: true } },
  records: { customer: { table: "customers", title: "name" } },
  tables: { notes: { owner: "user", columns: { body: { type: "text" } } } },
  adapters: ["next"],
  blocks: { tenant: { names: { organization: "workspace" } } },
});
```

| Group      | Keys                                                                                                                                                                                                                        |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `database` | `source`, `schemas`, `sql`, `seed`, `expose`, `buckets`, `realtime`, `topics`, `vectorSearch`                                                                                                                               |
| `auth`     | `organization`, `claims`, `authorization`, `entitlements`                                                                                                                                                                   |
| `gen`      | `output`, `casing`, `postgrestVersion`, `generateFeatures`, `tables` (the generator overrides), `json`, `codecs`, `relations`, `sensitive`, `storagePaths`, `functions`, `generators`, `readSets`, `tasks`, `watchInterval` |
| `api`      | `openapi`, `specs`                                                                                                                                                                                                          |
| `tooling`  | `doctor`, `env`, `keys`, `skills`, `scaffold`                                                                                                                                                                               |

Some keys take new names on the way:

| 0.14                           | 0.15                       |
| ------------------------------ | -------------------------- |
| `subjects`                     | `records`                  |
| `appTables`                    | `tables`                   |
| `appTables.<name>.scope`       | `tables.<name>.owner`      |
| `integrations`                 | `adapters`                 |
| `tables` (generator overrides) | `gen.tables`               |
| `sql.scope`                    | `database.sql.owner`       |
| `claims.tenant`                | `auth.organization.claim`  |
| `plugins.tenant.column`        | `auth.organization.column` |
| `blocks.<name>.mode`           | `blocks.<name>.ownership`  |
| `blocks.<name>.scope`          | `blocks.<name>.owner`      |
| `blocks.<name>.entities`       | `blocks.<name>.names`      |
| `blocks.<name>.hooks`          | `blocks.<name>.callbacks`  |

Environment blocks (`$env`, `$ci` and the others) take the same groups. A
`better-supabase.config.json` is regrouped the same way. Create designs keep
`appTables` and `scope` in this release.

`resolveConfig` returns the same resolved config as in 0.14, so code that
reads `config.claims.tenant` or `config.appTables` from a resolved config
keeps working. `flattenConfig` and `groupConfig` from `better-supabase/config`
turn one shape into the other.

## The runtime instance is bsb [#the-runtime-instance-is-bsb]

The instance `createBetterSupabase` returns is called `bsb` in the docs, the
templates and the request contexts: `ctx.bsb`, `c.var.bsb`, `locals.bsb` and
`context.bsb`. The codemod renames `bs` where a file creates, imports or
destructures it. Database objects better-supabase installs use the `bsb_`
prefix; `upgrade` renames the old `bs_` ones.

## The SQL engine says block [#the-sql-engine-says-block]

`SqlModule` is `BlockSql`, `ModuleDefinition` is `BlockSqlDefinition`, and
the other `Module*` types from `better-supabase/sql` are `BlockSql*`.
`SubjectRef` is `RecordRef`, `entityForm` is `recordForm`, `ShipmentCarrier`
is `ShippingProvider` and `QueueBackend` is `QueueProvider`. Installed
versions live in `better_supabase.blocks`; `upgrade` copies the rows of
`better_supabase.modules` before the schema diff drops it.

## The CLI has one command per job [#the-cli-has-one-command-per-job]

The CLI keeps one command for each job: `init`, `add`, `remove`, `upgrade`,
`gen`, `doctor`, `db`, `dev`, `blocks`, `spec`, `mcp` and the helpers. The
commands below moved. Running an old one exits 2 and names the new one, and
the codemod rewrites them in `package.json` scripts and GitHub workflows.

| 0.14                          | 0.15                                          |
| ----------------------------- | --------------------------------------------- |
| `apply --preset <p>`          | `add --preset <p>`                            |
| `adopt <block>`               | `add --adopt <block>`                         |
| `sql add <block>`             | `add <block>`                                 |
| `sql sync`, `sql upgrade`     | `upgrade`                                     |
| `sql data`                    | `add --adopt`                                 |
| `sql list`                    | `blocks list --installed`                     |
| `sql print <block>`           | `blocks view <block> --sql`                   |
| `preset decode <code>`        | `blocks list --preset <name or URL>`          |
| `preset info`                 | `config --preset`                             |
| `introspect`                  | `gen --save-snapshot`                         |
| `seed`                        | `db seed`                                     |
| `env`, `run`, `keys`, `stack` | `dev env`, `dev run`, `dev keys`, `dev stack` |

`gen --out <file>` now names the file `gen --save-snapshot` writes;
the generated code still goes to `--out-dir`. `add --force` overwrites
files that exist, so a block whose hooks overlap the authorization provider's
needs `--allow-hook-overlap`. `remove` is new: it
takes blocks out of the config and `lib/blocks.ts` and deletes their module
files, and [`db diff`](/docs/cli/db#diff) writes the migration that drops
their tables. See the [CLI reference](/docs/cli) for every command.

## Designs replace preset codes [#designs-replace-preset-codes]

Create URLs and the CLI carry the design JSON instead of a preset code. The
config's `preset` key takes a preset name or an object with the conventions
that differ from the defaults and the blocks:

```ts title="better-supabase.config.ts"
// 0.14
preset: "1pWEF3d578B1iGt16",

// 0.15
preset: { casing: "camel", scope: "user", blocks: ["profiles", "tags"] },
```

A Create URL is `/create?preset=<name>` for an unchanged named preset and
`/create?design=<code>` otherwise, with the preset inside the design. A design
file is the design JSON with its `version` and `preset` keys, without the
0.14 `design` wrapper. `init --preset`, `add --preset` and `blocks list
--preset` take a name, a URL, a design code or a file, and stop on a 0.14 code
with a message that names the codemod. The codemod turns a code in the
config's `preset` into the name or object it holds, and rewrites
`*.design.json` files.

`encodePreset`, `decodePreset`, `isPresetCode`, `defaultPresetCode`,
`withPresetField`, `PRESET_BLOCKS`, `PRESET_FIELDS`, `PRESET_VERSION` and the
`DecodedPreset` type are gone from `better-supabase/preset`. Use
`presetSpec`, `presetFromSpec` and `presetOfSpec` for the config value,
`createUrl` for a link and `presetAndDesignOf` to read one.
`designConfigSource`, `designInstallCommand` and `designApplyCommand` merge
into `presetConfigSource`, `presetInstallCommand` and `presetApplyCommand`,
which take an optional design and are now async.

## Blocks take plain names [#blocks-take-plain-names]

26 blocks have new names. Each one changes in the config, the CLI, its
subpath, its exports, its operation names and its CloudEvent source:

```ts title="better-supabase.config.ts"
// 0.14
blocks: { tenant: {}, "work-items": {}, rules: {} },

// 0.15
blocks: { memberships: {}, tasks: {}, automations: {} },
```

| 0.14                 | 0.15               |
| -------------------- | ------------------ |
| `work-items`         | `tasks`            |
| `parties`            | `contacts`         |
| `engagements`        | `activities`       |
| `targets`            | `goals`            |
| `number-sequences`   | `numbering`        |
| `actor`              | `created-by`       |
| `ensure-rls`         | `rls-guard`        |
| `shared-signals`     | `security-events`  |
| `service-principals` | `service-accounts` |
| `support-sessions`   | `impersonation`    |
| `controls`           | `compliance`       |
| `ai-tasks`           | `ai-schedules`     |
| `ai-providers`       | `ai-keys`          |
| `workflow-builder`   | `workflow-editor`  |
| `jobs`               | `queue`            |
| `access`             | `permissions`      |
| `rules`              | `automations`      |
| `tenant`             | `memberships`      |
| `activity`           | `timeline`         |
| `workflow-sdk-world` | `workflow-sdk`     |

The codemod renames the `blocks` keys and lists, the `preset.blocks` list,
the operation names in `auth.authorization.operations`, the imports from
`better-supabase/blocks/<name>` and the exports named after a block. It
lists the event sources `better-supabase/<name>` it finds, so you can update
your subscribers. A 0.14 name in the config stops `add` and `gen` with a
message that names the codemod.

Table names, permission keys, event types, hint codes and queue names keep
their 0.14 values. Run `better-supabase upgrade`, then
[`db diff`](/docs/cli/db#diff): the migration moves the installed blocks to
their new names, and renames two tables whose names clashed with a renamed
block. Hiring's `jobs` table becomes `job_openings`, and catalog's
`collections` table becomes `product_collections`.

## More blocks register record types [#more-blocks-register-record-types]

The quotes, invoices, orders, bookings and forms blocks register their primary
noun as a record type, so `records.quote`, `records.invoice`, `records.order`,
`records.booking` and `records.form` now fail with `config_invalid`. Remove the
entry when it points at the block's own table. When it points at a table of
your own, keep it and turn the block's record type off:

```ts title="better-supabase.config.ts"
blocks: {
  bookings: { names: { booking: { subject: false } } },
},
```

Then run `better-supabase upgrade`. Every block that owns tables now declares
its nouns, so `blocks.<name>.names` renames them; see
[SQL blocks](/docs/blocks/sql).

## Plugins become block extensions [#plugins-become-block-extensions]

The built-in plugins live with the blocks that own their columns, and the
`plugins` config key goes. The codemod moves the config and the imports:

| 0.14                                                      | 0.15                                                                                            |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `plugins.timestamps`                                      | the `updated-at` block (`createdAt` and `updatedAt` options)                                    |
| `plugins.actor`                                           | the `created-by` block (`createdBy`, `updatedBy` and `impersonatedBy`)                          |
| `plugins.softDelete.column`                               | the `trash` block's `deletedAt` option                                                          |
| `plugins.tenant.column`                                   | `auth.organization.column`                                                                      |
| `timestamps()` from `better-supabase/plugins/timestamps`  | `updatedAtExtension()` from `better-supabase/blocks/updated-at`                                 |
| `actor()` from `better-supabase/plugins/actor`            | `createdByExtension()` from `better-supabase/blocks/created-by`                                 |
| `softDelete()` from `better-supabase/plugins/soft-delete` | `trashExtension()` from `better-supabase/blocks/trash`                                          |
| `tenant()` from `better-supabase/plugins/tenant`          | `membershipsExtension()` from `better-supabase/blocks/memberships`                              |
| `rules()`, `safe()`, `recommended()`, `strict()`          | `guards()`, `safeGuards()`, `recommendedGuards()`, `strictGuards()` from `better-supabase/lint` |
| `validation()` from `better-supabase/plugins/validation`  | `validation()` from `better-supabase`                                                           |

`gen` now installs the extensions of the installed blocks in the generated
schema, and `validation()` with the generated validators when a `zod()`,
`valibot()` or `standardSchema()` generator is configured. Remove the
`.use()` calls the codemod moved unless they pass options; a `.use()` of an
extension with options replaces the one the schema installed.

One default changed: a query on an organization table without an
organization is left to RLS (`onMissing: "skip"`). The codemod keeps the
0.14 behavior by writing `auth.organization.onMissing: "error"` for a config
that had `plugins.tenant`; set it yourself to fail closed.

## One data contract for tables and blocks [#one-data-contract-for-tables-and-blocks]

Repositories and block clients share the data verbs: `list`, `page`, `get`,
`count` and `aggregate` read, `create`, `update`, `upsert` and `delete`
write, and `attach` and `detach` link a row to a record. The codemod
rewrites the repository calls:

| 0.14                                           | 0.15                                                 |
| ---------------------------------------------- | ---------------------------------------------------- |
| `findMany(args)`                               | `list(args)`                                         |
| `paginate(args)`                               | `page(args)`                                         |
| `findById(id)`                                 | `get(id)`, or `get(id, { optional: true })` for null |
| `findUnique(key)`, `findOnly(args)`            | `get(key)`, or `get({ where })` for a filter         |
| `findFirst(args)`                              | `list({ ...args, limit: 1 })`, then the first row    |
| `exists(args)`                                 | `count(args)`, then compare with zero                |
| `createMany(rows)`, `upsertMany(rows)`         | `create(rows)`, `upsert(rows)` with an array         |
| `updateMany(where, patch)`                     | `update({ where }, patch)`                           |
| `deleteMany(where)`                            | `delete({ where })`                                  |
| `guards({ noUnboundedFindMany })`              | `guards({ noUnboundedList })`                        |
| `guards({ noDeleteManyWithoutWhere })`         | `guards({ noDeleteWithoutWhere })`                   |
| `better-supabase/no-unbounded-find-many`       | `better-supabase/no-unbounded-list`                  |
| `better-supabase/no-delete-many-without-where` | `better-supabase/no-empty-where`                     |

`get` takes the primary key, an object with one complete unique key, or
`{ where }`. With `{ where }` it fails with `multiple_rows` when more than
one row matches, so it replaces `findOnly`. Read sets and query specs have
no `.map`, so where the codemod wrote `findFirst` as a list with
`.map((rows) => rows[0] ?? null)`, use `get({ where }, { optional: true })`
or destructure the first row; the codemod lists those calls.

The lint rule `no-empty-where` reports an `update` or `delete` whose filter is
`{ where: {} }`, and `no-unbounded-list` reports a `list` without `limit`.

Block clients move to the same verbs. These calls are listed for review,
because the names are too common to rewrite safely:

| 0.14                                                   | 0.15                                                      |
| ------------------------------------------------------ | --------------------------------------------------------- |
| `tags.tag(record, tagId)`, `tags.untag(record, tagId)` | `tags.attach(record, tagId)`, `detach`                    |
| `tags.create(kind, name, color, description, fields)`  | `tags.create({ kind, name, color, description, fields })` |
| `tags.update(id, name, fields)`                        | `tags.update(id, { name, fields })`                       |
| `tags.list(kind)`                                      | `tags.list({ kind })`                                     |
| `forSubject(record)` on tags, channels and scores      | `list({ on: record })`                                    |
| `save(...)` on a block client                          | `upsert(...)` with the same arguments                     |
| `set(...)` on ai-cache and on ai-files `providerFiles` | `upsert(...)`                                             |

The rule builder and saved-view controllers keep `save`, the settings client
keeps `setUser` and `setOrganization`, and domain verbs such as `approve`,
`send` and `publish` stay.

## Blocks install as instances [#blocks-install-as-instances]

Each key in `blocks` is now an instance. A config keyed by block name keeps
working unchanged. To install a block twice, add a key with `block` set to
the block's name; when a block depends on a block you installed twice, name
the instance it uses in `uses`. See [Instances](/docs/blocks#instances).
`resolveConfig` reports a core block, such as `memberships`, that is renamed
or installed twice.

## Every instance renders its own SQL [#every-instance-renders-its-own-sql]

`better_supabase.blocks` has a new `block` column. The schema file creates it
for new installs, and the data file of a second instance adds it with
`alter table ... add column if not exists`, so an existing database needs no
manual step. Rows written before have `block` set to null, which means the
row's `name` is the block. Block markers keep their format; a second instance
adds its block after the mode (`-- @bsb-block tickets@1 managed tasks`).

## Adoption is per table and detected [#adoption-is-per-table-and-detected]

`ownership: "adopt"` is removed. A managed block now leaves alone every table
your schema already has: the CLI reads your declarative schema files, or the
schema snapshot or live database for an app on migrations, and for each block
table it finds at the default name or at the name `blocks.<name>.tables`
maps, the block skips its `create table`. It adds only the columns it needs
that your table lacks, with `add column if not exists` (nullable unless the
column has a default), and writes its functions, policies, triggers and
indexes over your table. `tables` and `columns` work as before, and an
optional column mapped to `null` still turns its feature off.

A config that still sets `ownership: "adopt"` fails to load with a message
that names the key. Run `better-supabase codemod 0.15`, which deletes it and
keeps the block's `tables` and `columns`, then `better-supabase upgrade`:

```ts title="better-supabase.config.ts"
blocks: {
  audit: {
    tables: { log: "public.activity_log" },
    columns: { log: { tenant: "workspace_id" } },
  },
},
```

`add --adopt <block> --tables ...` still proposes `tables` and `columns`, but
writes no `ownership`, and a required column your table lacks is a warning,
because the block's migration adds it. The migration-only options
(`tokenStorage: "plain"`, `secretStorage: "column"`, `statuses` and the rest)
are accepted when the block runs over a table your app already has, and
doctor still reports them as [BS314](/docs/cli/doctor#bs314). `custom`
ownership is unchanged. See
[Over your own tables](/docs/blocks/sql#over-your-own-tables) and
[Your own schemas](/docs/blocks/own-schemas).

## Blocks contribute to approvals, pipelines and the other generic blocks [#blocks-contribute-to-approvals-pipelines-and-the-other-generic-blocks]

`approvals`, `bulk`, `pipelines`, `share-links` and `subjects` no
longer list the blocks that use them. Each consumer declares its part in
`contributes`, so the rendered SQL keeps the same branches in a new order.
Run `better-supabase upgrade`, then `better-supabase db diff` to write the
migration; the functions change only in the order of their `case` branches.
A second instance contributes only when its `uses` names the generic
instance. See [Contributions](/docs/blocks#contributions).

## Blocks attach to record types through on [#blocks-attach-to-record-types-through-on]

The blocks that took `options.subjects` (`attachments`, `tags`, `versions`,
`trash`, `sla`, `pipelines` and the others) read `on` on the block entry
instead. `better-supabase upgrade` moves the value; `field-history` lists also
become `{ fields }`:

```ts title="better-supabase.config.ts"
// Before
"field-history": { options: { subjects: { deal: ["amount"] } } },
tags: { options: { subjects: ["deal"] } },
// After
"field-history": { on: { deal: { fields: ["amount"] } } },
tags: { on: ["deal"] },
```

A config that still sets `options.subjects` fails to render with the key to
move. An unset `on` still means every type in `config.records`, and doctor
(BS346) reports a type in `on` that `config.records` doesn't register. The
rendered SQL changes only in its comments. See
[Record types](/docs/blocks#record-types).

## Block clients are keyed by instance [#block-clients-are-keyed-by-instance]

`blocks.generated.ts` has an entry for each instance, and a second instance
gets its own renamed client (`ticketsBlock` for `tickets: { block: "tasks" }`)
that calls the instance's functions. Pass it to `defineBlocks` or
`createBlocks` under the instance key; `createBlocks` reads the entry under
that key before the one under the block's name, and refuses an entry that
installs another block. `createBlockClient` takes `instance` for the same
lookup.

Two blocks in one set may now provide the same port (`notifier`, `files`,
`attachments`, `workflows`, `mailer`). A block that consumes it takes the
provider its `blocks.<instance>.uses` names, else the one
`createBlocks({ ports: { notifier: "inbox" } })` names, and fails with both
keys when neither picks one.

Block queries share the table key space: `blockQueries` keys a read
`["bsb", instance, method, ...args]` instead of `["bs-block", ...]`, and its
meta is `{ bsTables: [instance] }` instead of `bsBlock`. One matcher,
`invalidateTables`, makes table and instance queries stale, and it takes the
methods to narrow to. The 0.15 codemod rewrites `"bs-block"` literals and
lists `bsBlock` reads for review.

## Block instances live on db [#block-instances-live-on-db]

`.blocks(blockSet)` on the definition puts every instance on `db`, so a
hand-written `blocks(supabase, tenant)` helper is no longer needed.
`db.comments` runs through the same executor as the tables and acts in the
active organization; `db.$with({ tenant })` switches it. An instance whose
name a table already uses stays reachable at `db.$blocks.<instance>`, and
`db.$api(fn, args)` calls a function in the API schema:

```ts
// Before
const comments = blocks(supabase, tenant).comments;
await blocks(supabase).call("archive_customer", { id });
// After
const comments = db.comments;
await db.$api("archive_customer", { id });
```

`blocks.generated.ts` now exports `blocks`, the block for each instance
under its key, so `defineBlocks(blockConfig, blocks)` lists them all. With
`gen.hooks` set, `gen` also writes `hooks.ts` with a list and a record hook
per table ([Data hooks](/docs/frontend/data-hooks)).

The `requireTenant` option of `bsb.action`, `bsb.blockAction` and the oRPC
guard is `requireOrganization`, and so is the helper. The 0.15 codemod
renames both, and doctor reports BS418 for a `requireTenant` that is left.

## Record sections become record bindings [#record-sections-become-record-bindings]

A block has one `record` binding instead of a `sections` map, and the binding
takes the block's key in your config, so a second instance gets its own
binding. `gen` writes one framework-neutral file per record type,
`records/<type>.ts` with `<type>Record`, instead of the `features/<subject>/`
folder, and no longer writes `navigation.generated.ts` or
`commands.generated.ts`. `gen.generateFeatures` is now `gen.recordTypes`.

```ts title="src/features/customers/customer-record.ts"
// Before
import { customerRecord } from "@/lib/supabase/features/customer/customer-server";
export const removeAttachment = bsb.sectionAction(/* ... */);
// After
import { customerRecord } from "@/lib/supabase/records/customer";
export const customerPage = bsb.record(customerRecord);
export const removeCustomerAttachments =
  customerPage.actions.attachments.remove;
```

`recordServer` and `bsb.sectionAction` are gone: `bsb.record(type)` returns
`load`, `timeline`, `actions` and `tags`. `load` takes `bindings` instead of
`sections`, its view and the controller snapshot hold `bindings`, and a
timeline entry names its `binding`. Build the client provider with
`createRecordProvider(customerRecord)`, and pass the record type to
`recordQueries`, whose factories live under `bindings`.

`better-supabase codemod 0.15` renames `generateFeatures`, the section
exports (`defineSection` to `defineRecordBinding`, `commentsSection` to
`commentsBinding`, `loadSections` to `loadBindings`, `sectionTag` to
`bindingTag`, `testSection` to `testRecordBinding` and the rest), and lists
the feature imports, `recordServer` and `sectionAction` calls for review. A
config that still sets `generateFeatures` fails with the key to use. See
[Record bindings](/docs/repository/record-bindings).

## Copied tables become instances [#copied-tables-become-instances]

Four blocks dropped a table that copied another block:

| Block       | Before                                   | After                                                                              |
| ----------- | ---------------------------------------- | ---------------------------------------------------------------------------------- |
| `leases`    | `maintenance_requests` and its functions | a `tasks` instance with `names: { work_item: "maintenance_request" }`              |
| `passes`    | `pass_credit_entries`                    | ledger accounts per pass; `credits(passId)` reads them                             |
| `inventory` | its own `inventory_locations`, always    | `tables: { locations: null }` keeps stock at the `assets` locations                |
| `donations` | receipt numbers counted by the block     | a `donation_receipt` sequence it contributes to `numbering`, which it now requires |

`better-supabase upgrade` moves the data: pass credits post onto the ledger,
each organization's receipt sequence starts after its highest receipt, and
the old maintenance table is renamed to `maintenance_requests_legacy`. Copy
the maintenance rows you keep into the tasks instance before you generate
the next schema migration, which drops the legacy table.
`better-supabase codemod 0.15` lists the calls to `fileMaintenance`,
`updateMaintenance`, `listMaintenance` and `maintenanceRequestInputSchema`
for review. See [Leases](/docs/blocks/leases#maintenance-requests).

## Presets write instances [#presets-write-instances]

A preset object takes `instances`: `blocks` entries with `block`, `names`,
`options`, `tables`, `on` and `uses`. The `helpdesk` and `itsm` presets now
name the tasks block's work items `ticket` and its projects `issue`, so their
tables, functions and generated hooks say tickets; `property` adds a
`maintenance` instance of tasks. To keep the 0.14 names on a project that
uses one of these presets, set the names back in your own entry, which wins
over the preset's:

```ts title="better-supabase.config.ts"
export default defineConfig({
  preset: "helpdesk",
  blocks: { tasks: { names: { work_item: "work_item", project: "project" } } },
});
```

A design with a preset object that has `instances` is format 8
([Designs](/docs/presets/designs)). See
[Instances in a preset](/docs/presets#instances-in-a-preset).

## Block hooks are registry items [#block-hooks-are-registry-items]

The `better-supabase/blocks/<block>/react`, `/vue`, `/svelte` and `/solid`
subpaths and `better-supabase/chat-sdk/react` are gone. Install a block's
React hooks into the app, then import them from there:

```sh
npx shadcn add @better-supabase/use-notifications
```

```ts
// before
import { useNotifications } from "better-supabase/blocks/notifications/react";
// after
import { useNotifications } from "@/hooks/use-notifications";
```

The hooks keep their names, options and return values, and the file is
yours to change. Vue, Svelte and Solid call `useBlockController` with the
block's controller and the options the old hook took:

```ts
// before
import { useOnboarding } from "better-supabase/blocks/onboarding/vue";
const { state } = useOnboarding({ client, checklist });
// after
import { createOnboardingController } from "better-supabase/blocks/onboarding";
import { useBlockController } from "better-supabase/vue";
const { state } = useBlockController(createOnboardingController, {
  client,
  checklist,
});
```

See [Block controllers](/docs/frontend/block-controllers#vue-svelte-and-solid).

## `scaffold screens` is removed [#scaffold-screens-is-removed]

better-supabase no longer writes pages. The files an earlier
`scaffold screens` wrote are yours and keep working. For new screens, use the
table's generated data hooks (`useTasks`, `useTask`) and start from the
`record-*` [UI recipes](/docs/registry/ui-recipes#app-tables). Running
`bsb scaffold screens` now exits with a message that points there.

## One API layer [#one-api-layer]

`defineApi` now publishes every table and every block instance unless you
say otherwise. If you passed `resources`, only those tables stay published,
so nothing changes there; without it, every table appears. Each table also
gets the contract verbs `page`, `count`, `aggregate` and `upsert` as
`POST /<table>/<verb>`, and `defineResource` and `bsb.resource` serve them.
Pass `verbs: false` to a resource to keep the 0.14 routes, `resources: false`
or `blocks: false` to leave a whole group out, and an action path that
collides with a verb (such as `POST /count`) needs another path.

`bsb.blockRouter` is removed. Serve the same model the document describes
with `bsb.router(api)`:

```ts
// before
const tasks = bsb.blockRouter(({ db }) => db.tasks, {
  name: "tasks",
  methods: ["list", "create"],
  meta: (route) => openapi({ method: route.method, path: route.path }),
});

// after
const api = defineApi(betterSupabase, {
  info,
  resources: false,
  blocks: { tasks: { verbs: ["list", "create"] } },
});
const { procedures } = bsb.router(api, { meta: openapi });
```

A method that takes more than one argument still takes a JSON array. See
[Serve the API](/docs/frameworks/orpc#serve-the-api).

## `scaffold api` is removed [#scaffold-api-is-removed]

`bsb scaffold api`, the `scaffold` group, the `tooling.scaffold` config key
and the `scaffold` step of `gen` are gone, because `bsb.router(api)` serves
the API without a generated module. Delete `lib/api.generated.ts` and the
route files `scaffold api` wrote, serve the router instead, and delete the
`scaffold` key from `better-supabase.config.ts`; a config that still sets it
fails to load with that message.

## Spec renderers live in `better-supabase/spec` [#spec-renderers-live-in-better-supabasespec]

The `better-supabase/openapi`, `better-supabase/asyncapi`,
`better-supabase/arazzo` and `better-supabase/overlay` subpaths are removed.
Import the same names from `better-supabase/spec`:

```ts
// before
import { createOpenApi } from "better-supabase/openapi";
import { createAsyncApi } from "better-supabase/asyncapi";

// after
import { createAsyncApi, createOpenApi } from "better-supabase/spec";
```

`better-supabase codemod 0.15` rewrites these imports.

## Adapter tiers [#adapter-tiers]

Next.js, Hono, Expo Router, SvelteKit, TanStack Start and React Router are
supported adapters, with the full test matrix and an example app. Astro,
Edge Functions, Elysia, H3, Nuxt, NestJS, Node and SolidStart are community
adapters with a smoke test. Nothing is removed and no import changes; see
[adapter tiers](/docs/frameworks#adapter-tiers) before you pick one for a new
app.

## Local-first runs on engines [#local-first-runs-on-engines]

PowerSync, SQLite and TanStack DB are engines behind the generated data
hooks, so a native screen reads `useCustomers(...)` like a web screen. Pass
the engine to the client:

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

export const bsb = createNativeClient(betterSupabase, supabase, {
  engine: powersyncEngine(powersync, { connector, local: ["customers"] }),
});
```

`bsb.db` then runs on the device database for the tables in `local` and on
PostgREST for the rest. The upload connector replays through a client
without the engine, `bindClient(betterSupabase, supabase)`.

| 0.14                                                              | 0.15                                                                                 |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `watch(powersync, query, { tables: sqliteTables(...) })`          | a generated hook on `powersyncEngine`                                                |
| `useWatch` from `better-supabase/powersync/react`                 | a generated hook, or `defineDataHooks` for a list definition                         |
| `useSyncStatus(db)`, `useConflicts(connector)`                    | `useSyncStatus()`, `useConflicts()` from `better-supabase/react`                     |
| `betterSupabase.connect(powersyncExecutor(powersync))` as `local` | `bsb.db` with `powersyncEngine`                                                      |
| `better-supabase/expo-sqlite`, `expoSqliteExecutor`               | `sqliteEngine(expoSqliteDatabase(db))` from `better-supabase/sqlite`                 |
| `supabaseCollection` from `better-supabase/tanstack-db/supabase`  | `collectionOptions` and `tanstackDbEngine` from `better-supabase/tanstack-db`        |
| `realtime: true` on a collection                                  | `live: realtimeChanges(betterSupabase, supabase)` on `memoryEngine` or `queryEngine` |

`@supabase-labs/tanstack-db` is no longer a peer. `where` on every hook and
repository also takes a filter tree from a list query or a saved view. See
[Data hooks](/docs/frontend/data-hooks#engines),
[PowerSync](/docs/frontend/powersync) and [SQLite](/docs/frontend/sqlite).

## Installed blocks work together [#installed-blocks-work-together]

Run `bsb upgrade` after updating. The new migrations change behavior where
two blocks are installed together:

- With `usage`, `ai-chat` records run tokens on `ai.input_tokens` and
  `ai.output_tokens`. If the app already meters tokens with
  `meterTelemetry` or `usageEntries`, set `meterUsage: false` on ai-chat so
  they are not counted twice.
- With `invoices`, `orders` creates and pays an invoice for every paid
  order. Orders paid before the upgrade keep no invoice.
- With `employees`, time entries and leave requests gain an `employee_id`
  column, filled for existing rows by the upgrade.
- The attachments data file now sets each of its buckets' `public` flag on
  every apply. A bucket you made public by hand in the dashboard turns
  private again unless you list it in `publicBuckets`, which needs
  `requireScan: false`.

## The crm preset adds sales engagement [#the-crm-preset-adds-sales-engagement]

`preset: "crm"` now also installs mail-sync, sequences, segments,
assignment, enrichment, forecasts and lead-scoring, with what they require
(automations, enrollments, scores and subjects). The next `bsb upgrade`
writes their module files, and the migration adds tables and functions only. To keep the
0.14 selection, write it out as a preset object:

```ts title="better-supabase.config.ts"
export default defineConfig({
  preset: {
    blocks: [
      "organizations",
      "invitations",
      "teams",
      "profiles",
      "billing",
      "entitlements",
      "notifications",
      "audit",
      "api-keys",
      "settings",
      "onboarding",
      "outbox",
      "contacts",
      "pipelines",
      "deals",
      "quotes",
      "goals",
      "activities",
      "tags",
      "comments",
      "timeline",
      "custom-fields",
      "saved-views",
      "search",
    ],
  },
});
```

The new `cms`, `erp` and `pim` presets start a content site, a small ERP
and a product catalog ([Presets](/docs/presets#named-presets)).

## Flows become default automations [#flows-become-default-automations]

`better-supabase/flows` is removed, with `createFlows`, `defineFlow` and the
built-in flows. The blocks that fed another now ship
[default automations](/docs/blocks/automations#default-automations), rows
every organization copies and may turn off, edit or reset:

| 0.14 flow                | 0.15 default automation               | Action to register              |
| ------------------------ | ------------------------------------- | ------------------------------- |
| `conversationToWorkItem` | Open a task for each new conversation | `taskFromConversationAction`    |
| `applicationToEmployee`  | Preboard each hired candidate         | `employeeFromApplicationAction` |
| `quoteToOrder`           | Order each accepted quote (off)       | `orderFromQuoteAction`          |
| `bookingToEngagement`    | none: the bookings trigger does it    | none                            |

Run `bsb upgrade` so the automations module creates `rule_defaults`, then
replace `createFlows` in the outbox consumer with one action registry:

```ts
const actions = createActionRegistry([
  notifyAction(notifications),
  taskFromConversationAction({ clients: (tenant) => blocksFor(tenant) }),
]);
const sink = createRuleSink({
  rules: rulesAdmin,
  dispatcher: createRuleDispatcher({ actions }),
});
await outbox.consume("rules", sink);
```

`createRuleDispatcher` takes `actions` instead of `handlers`: wrap a custom
handler in `defineAction({ kind, title, run })`. `RuleActionHandler` and
`RuleActionCall` are now `ActionHandler` and `ActionCall`, and the hints
`RULE_ACTION_UNKNOWN` and `RULE_ACTION_INVALID` are now `ACTION_UNKNOWN` and
`ACTION_CONFIG_INVALID`. A `load` that returns `undefined` no longer skips
the event; it falls back to `eventTarget(event)`, so return early in the
consumer for events you don't want rules to see. A block you uninstall
leaves its default automations behind; turn them off.

## Workflows start from automations [#workflows-start-from-automations]

Workflow-editor triggers of kind `event` are gone, with `triggers.onEvent`,
`builder.sink()`, `triggerMatches` and `triggerWhenProblem`. `bsb upgrade`
deletes the event triggers. Recreate each one as an automation whose trigger
is the event type, whose conditions replace `config.when`, and whose action is
`{ kind: "workflow", config: { definition } }`; register
`workflowAction(builder)` in the action registry.

The timers of the SLA, sequences, AI schedules, workflows and content blocks
run from the queue: schedule them with
`jobs.ensureSchedules(blockSchedules(blocks), { prefix: BLOCK_SCHEDULE_PREFIX })`
and drain the `block_ticks` queue with `blockTickHandler`
([Block timers](/docs/blocks/queue#block-timers)). A cron route that already
calls `sla.check()` or `sequences.runDue()` keeps working. The content
block's `publishSchedule` option is removed: its pg_cron job gives way to the
`content.publish` timer, so add `"content.publish": () => content.publishDue()`
to the tick handler.

## Presets turn on their default automations [#presets-turn-on-their-default-automations]

The presets whose blocks feed each other now install the automations block:
`helpdesk` opens a ticket for each new conversation, `people` preboards each
hired candidate, `erp` orders each accepted quote, and `automation` starts
its workflows from automations. The presets with block timers or file
ingestion (`crm`, `helpdesk`, `cms`, `itsm`, `claims`, `ai`, `ai-chat` and
`ai-agents`) now install the queue. Run `bsb upgrade` after the upgrade, and
register the actions the wiring checklist lists.

The automations block takes `enable` and `disable`, lists of default
automation keys that change whether a new organization's copy starts on:

```ts title="better-supabase.config.ts"
export default defineConfig({
  preset: "erp",
  blocks: { automations: { options: { disable: ["quotes.quote_to_order"] } } },
});
```

## Blocks declare their wiring [#blocks-declare-their-wiring]

Block manifests now declare their env keys, providers, routes and workers,
and `add` and `init` write the wiring: missing keys at the end of
`.env.example`, commented provider stubs in `providers.ts` next to
`blocks.generated.ts`, and a numbered checklist for the routes, the crons,
the block timers and the actions to register.
`add` also renders installed blocks again when a new block changes their SQL.

Two new doctor checks fail on an app that was already missing wiring:

- BS504: a required env key, such as `CRON_SECRET` for the outbox or
  `STRIPE_WEBHOOK_SECRET` for billing, that no env file lists. Add the key to
  `.env.example` and set it.
- BS505: enrichment or mail-sync installed with no provider passed. Pass it
  where you build the blocks:

```ts title="src/lib/supabase/providers.ts"
import { peopleDataLabsEnrichment } from "better-supabase/blocks/enrichment";
import { gmailMail, outlookMail } from "better-supabase/blocks/mail-sync";

import { blocks } from "./blocks.generated.ts";

export const providers = {
  enrichment: blocks.enrichment.with({
    provider: peopleDataLabsEnrichment({
      apiKey: process.env["PDL_API_KEY"] ?? "",
    }),
  }),
  "mail-sync": blocks["mail-sync"].with({
    providers: [gmailMail(), outlookMail()],
  }),
} satisfies Partial<typeof blocks>;
```

Then spread it: `defineBlocks(blockConfig, { ...blocks, ...providers })`.
Run `bsb add <block>` for an installed block to have the stubs and keys
written for you.

## Doctor finds what is left [#doctor-finds-what-is-left]

`better-supabase doctor` reports BS418 for app code that still imports a
renamed block subpath or plugin subpath, an export named after a renamed
block, a spec subpath, a `blockRouter` call, or a record feature, leases
maintenance, pass credit or local-first API that 0.15 removed. A config key that moved fails to load with its new place, so after
the codemod a clean doctor run means nothing from 0.14 is left.

## Upgrade order [#upgrade-order]

Upgrade in this order, so each step starts from code that loads:

1. Install better-supabase 0.15.
2. Run `npx better-supabase codemod 0.15` and review the diff, with its list
   of calls to check by hand.
3. Run `npx better-supabase upgrade`, then generate and apply the migration
   (`supabase db schema declarative sync`, or `better-supabase db diff`).
4. Copy the rows you keep from `maintenance_requests_legacy` into the tasks
   instance before the next schema migration drops it.
5. Run `npx better-supabase gen`, which writes the record types and the
   instance-keyed `blocks.generated.ts`.
6. Run `npx better-supabase doctor` and fix each BS418 finding, then your
   typecheck and tests.

The same steps work as a prompt for a coding agent: give it this page, the
codemod's review list and the doctor output, and ask it to work through the
steps in order and stop when doctor and the typecheck are clean.

# 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 move to 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.

### Keep migrations only [#keep-migrations-only]

An app that writes every migration by hand can skip declarative schemas.
Set the workflow in the config, or run `better-supabase init --workflow migrations`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  database: { sql: { workflow: "migrations" } },
  blocks: ["memberships", "audit"],
});
```

`add` and `upgrade` then write the modules into
`supabase/migrations/<stamp>_better_supabase_modules.sql`, and you apply it
with `supabase migration up`. Save a schema snapshot with
`better-supabase gen --save-snapshot` too, so the CLI sees the tables your
migrations create. An app still on declarative schemas 1.0 sets
`workflow: "migra"` to keep `supabase db diff`. See
[Workflows](/docs/blocks/sql#workflows).

## Blocks run over the tables you have [#blocks-run-over-the-tables-you-have]

You don't tell a block which tables are yours. The CLI reads your own schema
files (and migrations it didn't write) and the saved schema snapshot, and
decides per table: a block table you already have, at its default name or at
the name `blocks.<name>.tables` maps, keeps your definition, and the block
creates only the tables you don't have. So a block covers both the features
you already built (organizations, invitations, an audit log, an events
table) and the ones you don't have, and adding a table to your schema later
moves it over without a config change.

Over your table, the block skips its `create table`, adds only the columns
it needs that your table lacks with `add column if not exists`, casts its
writes to your column types where they may differ (an enum `status`, for
example), and never recreates your table or revokes your grants. Its
policies and triggers there are named `bsb_<table>_<command>` and
`bsb_<name>`, so they never replace one of yours.
[Your own schemas](/docs/blocks/own-schemas) explains the detection, the
names, the doctor findings and how block files are protected from edits.

`blocks.<name>.ownership` has two values. `managed` (the default) is the
behavior above. `custom` writes no SQL: you keep your own functions and
write the block's contract yourself (the functions your policies, other
blocks and the TypeScript APIs call). Use it for a function you can't
replace yet; doctor checks its signature against the contract (BS307), and
`blocks view --sql <block>` lists the signatures. The
[Block SQL page](/docs/blocks/sql#existing-tables-managed-and-custom) shows
which blocks support `custom`.

An `ownership: "adopt"` from before 0.15 is rejected when the config loads.
Run `better-supabase codemod 0.15` to remove it (it keeps `tables` and
`columns`), then `better-supabase upgrade`.

## 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({
  blocks: {
    permissions: {},
    "webhooks-out": {},
    memberships: {
      tables: { memberships: "public.organization_users" },
      columns: {
        memberships: {
          tenant: "organization_id",
          role: "role_id",
          lastUsedAt: null,
        },
      },
    },
    organizations: {
      tables: { organizations: "public.organizations" },
      columns: { organizations: { createdBy: null, deletedAt: null } },
      permissions: { update: "organization.settings.manage" },
      callbacks: {
        schema: "public",
        functions: { after_organization_create: "seed_organization" },
      },
      options: { attributes: ["website", "logo_path", "default_currency"] },
    },
    outbox: {
      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, `callbacks` 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 `add` and `upgrade` with the valid names, so a typo
fails before it reaches the database.
[`better-supabase add --adopt <block> --tables <schema.table>`](/docs/cli/presets#adopting-tables)
proposes the `tables` and `columns` of an entry from the tables in your
database.

## Give your own tables a scope [#give-your-own-tables-a-scope]

A table only your app has, such as `projects`, can get the same scope column,
row level security and policies as the block tables through a
`tables.<name>` entry. When your schema already creates the table, the entry
enriches it: it adds only the missing `id`, scope column (`organization_id`
or `user_id`), `created_at` and `updated_at`, the scope's policies and, per
tenant, the restrictive `bsb_tenant_scope` policy. The scope column gets a
`not valid` not-null check, so new rows must name an owner while existing
rows stay until you fill them with
[`better-supabase db backfill`](/docs/cli/db#backfill) and validate the
check with `better-supabase doctor --fix`. See
[App tables](/docs/repository/tables#enriching-a-table-you-already-have).

## Choose an access model [#choose-an-access-model]

Policies and block functions ask one question, `better_supabase.can(scope, id,
permission)`, and [`blocks.permissions.model`](/docs/repository/permissions#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 `blocks.permissions.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 outbox over your events table writes to it. 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 upgrade` after each package
  update. It writes forward steps into a migration before the schema diff runs,
  and `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]

`bsb.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 { bsb } from "@/lib/supabase/server";

const intl = createMiddleware(routing);

export const proxy = (request: NextRequest) =>
  bsb.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 (or set
   `database.sql.workflow: "migrations"` to keep migrations only), add
   `better-supabase.config.ts` with `access` in the model your app uses, and
   run `better-supabase doctor` to see what it finds.
2. Add `memberships` over your memberships table, mapped with `tables` and
   `columns`, and swap one policy helper for `tenant_ids_with`.
3. Add the blocks for the features you already have (organizations,
   invitations, audit, the events table), one per pull request. Each runs
   over your table; run `better-supabase upgrade`, read the comments it
   writes over your tables, and run your tests after each.
4. Add the blocks you don't have yet; they create their own tables.
5. Add `tables.<name>` entries for your own tables, run
   `better-supabase db backfill` for each scope column doctor reports
   (BS238), then `better-supabase doctor --fix`.
6. Move the session handling to an adapter and `bsb.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.14 to 0.15](/docs/migration/0.14-to-0.15)                     | The config is grouped into `database`, `auth`, `gen`, `api` and `tooling`, the runtime instance is `bsb`, the SQL engine says block           |
| [From 0.13 to 0.14](/docs/migration/0.13-to-0.14)                     | Block methods take a `SubjectRef` and read the tenant from the block, `createBlockClient`, comments on the messaging core, the activity block |
| [From 0.11 to 0.12](/docs/migration/0.11-to-0.12)                     | The 11-argument `audit()`, the `acting_member()` grant, module file headers, Create links, env variables                                      |
| [From 0.9 to 0.10](/docs/migration/0.9-to-0.10)                       | The webhook format of adopted endpoints, attachment permissions per subject, realtime triggers, `ieq`, Expo refresh, `$authAdmin`             |
| [From 0.8 to 0.9](/docs/migration/0.8-to-0.9)                         | Provider-backed access by default, the delegation cap, publisher runs, support approvals, CloudEvents webhooks                                |
| [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.list({ select: ['id', 'name'] }))`    |
| `.select('*, notes(*)')`                                           | `include: { notes: true }`                                  |
| `.select('..., notes(count)')`                                     | `include: { _count: { notes: true } }`                      |
| `.single()` / `.maybeSingle()`                                     | `get(id)` / `get({ slug }, { optional: true })`             |
| `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.page({ 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.get(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.6.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.list({ select: ['id', 'name'] })`                      |
| `.select('*')`                                                          | `list()` (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 [`page`](/docs/repository/pagination)    |
| `.eq('id', id).single()`                                                | `get(id)` (a `not_found` error when missing)                         |
| `.eq('slug', slug).maybeSingle()`                                       | `get({ slug }, { optional: true })` (`null` when missing)            |
| `.eq('status', 'active').maybeSingle()`                                 | `get({ where: { status: 'active' } }, { optional: true })`           |
| `.limit(1).maybeSingle()`                                               | `list({ where, limit: 1 })`                                          |
| `.select('*', { count: 'exact', head: true })`                          | `count({ where })`                                                   |
| `.select('*', { count: 'exact' }).range(20, 29)`                        | `page({ offset: 20, limit: 10, count: 'exact' })`                    |
| `.insert(row).select().single()`                                        | `create(row)`                                                        |
| `.insert(rows).select()`                                                | `create(rows)`                                                       |
| `.update(patch).eq('id', id).select().single()`                         | `update(id, patch)`                                                  |
| `.update(patch).eq('status', 'lead')`                                   | `update({ where: { status: 'lead' } }, patch)`                       |
| `.update(patch).eq('id', id).eq('organization', organization).select()` | `update(id, patch, { where: { organization } })`                     |
| `.update(patch).in('id', ids).select()`                                 | `update({ where: { id: { in: ids } } }, 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.

## Replacing a supabase-js access layer [#replacing-a-supabase-js-access-layer]

An app that wraps supabase-js in its own access layer usually casts CHECK
columns and embedded rows by hand. After `better-supabase gen`, the typed
client from `betterSupabase.$client` (and any `SupabaseClient<Database>` built
with the generated `Database`) already carries both:

```ts
const { data } = await client
  .from("customers")
  .select("id, status, customer_tags(tag_id)")
  .throwOnError();
// data[number]: { id: string; status: 'lead' | 'active' | 'archived'; customer_tags: { tag_id: string }[] }
```

The `status` union comes from the column's CHECK constraint, so the
`as 'lead' | 'active'` casts can go. The embedded `customer_tags` shape comes
from the foreign keys in `database.types.ts`.

Repositories express the same embed as `include`:

| supabase-js select                     | Repository                                                                   |
| -------------------------------------- | ---------------------------------------------------------------------------- |
| `.select('id, customer_tags(tag_id)')` | `list({ select: ['id'], include: { customerTags: { select: ['tagId'] } } })` |

Move a call over when you want camelCase rows, a `Result` instead of
`{ data, error }`, or plugins such as tenant scoping. Leave the others on
`$client`, which always uses database names.

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

# 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

`arazzoFormat` from `better-supabase/spec` 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,
  defineApi,
  defineWorkflow,
  paginationWorkflows,
  type ResourceOperationIds,
} 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: {} },
  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/spec";
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/spec";

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 (the default; not `pagination: "offset"`), 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

`asyncapiFormat` from `better-supabase/spec` 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, 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/spec";
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
`database.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
`database.realtime.tables` is an `asyncapi-table-unknown` error; `false` leaves the
tables out.

Each table gets the channel `table<Key>` with the address
`bsb:t:<schema>.<table>`:

| Table setting in `realtime` | Address                           | Parameter                               |
| --------------------------- | --------------------------------- | --------------------------------------- |
| none                        | `bsb:t:public.tags`               | none                                    |
| `tenant`                    | `bsb:t:public.customers:{tenant}` | `tenant`, the row's tenant column value |
| `user`                      | `bsb: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. Pass the
`defineTopic()` results from `better-supabase/realtime` as they are, so the
document and the Realtime policies come from the same definition.
`topicsFrom(module)` collects every topic a module exports, keyed by export
name, and skips its other exports:

```ts title="src/api/asyncapi.ts"
import { defineApi, topicsFrom } from "better-supabase/spec";
import { betterSupabase } from "../lib/supabase";
import * as topics from "../realtime/topics";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM events", version: "1.0.0" },
  realtimeUrl: "wss://example.supabase.co/realtime/v1/websocket",
  events: { topics: topicsFrom(topics) },
});
```

`topicsFrom` also takes several modules, or arrays of topics (keyed by their
`name`, or by their template). A plain object with the fields below works
too:

| 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                                        |
| `presenceSchema`         | The presence payload schema; `defineTopic` sets it from its `presence` option           |
| `send`, `clientSend`     | `true` when 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             |
| `status`                 | `beta` or `ga`, written as `x-better-supabase-realtime-status`; defaults from `private` |
| `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.

### Realtime status [#realtime-status]

Every topic and table channel carries `x-better-supabase-realtime-status`,
the platform status of what it relies on. Private channels need Realtime
Authorization for broadcast and presence, which is in public beta, so they
are `beta`. Public broadcast and presence, and Postgres changes, are `ga`.
Table change signals are private broadcasts, so table channels are `beta`.
`defineTopic` reports the same value as `topic.status`.

The [`spec` command](/docs/cli/spec) reads the topics in
`realtime.policies.from`, the modules `upgrade` writes policies from, and
warns with `asyncapi-topic-undocumented` about each topic the AsyncAPI
document leaves out.

## 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. `BlockEventSchemas<M>` types the keys and the schemas
by a block's event map, such as `BlockEventSchemas<MessagingEvents>`: it only
takes that block's 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]

Without `resources`, the API publishes every table with its defaults. A map
publishes only the tables it names, and `false` publishes none. Each key is a
table's app key, mapped 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,
      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 |
| `verbs`                   | see below                            | The contract verbs, or `false` for none                       |
| `list`                    | `after` and `size`                   | A `defineListQuery`, whose filters become query parameters    |
| `pagination`              | `cursor`, `offset` without a key     | `cursor` (`after`, `size`) or `offset` (`page`, `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`.

The contract verbs are `POST /<table>/<verb>` with the repository method's
arguments as the JSON body: `page`, `count` and `aggregate` for every
resource, and `upsert` for a table with a primary key that serves `create`
and `update`. Their operation ids are `pageCustomers`, `countCustomers` and
so on. A write hook (`beforeCreate`, `beforeUpdate`) on `defineResource`
drops `upsert`, because an upsert would skip it. An action can't take a
verb's path while that verb is served.

## Block instances [#block-instances]

Without `blocks`, the API publishes every instance
`betterSupabase.blocks(set)` registered. Each method of the instance's
client becomes `POST /<instance>/<method>`, and each method of a sub-client
`POST /<instance>/<member>/<method>`, with the operation id
`<instance>.<method>`. A method that takes more than one argument takes them
as a JSON array. Helpers that are no calls, such as route handlers and
sinks, stay out.

```ts
defineApi(betterSupabase, {
  info: { title: "CRM API", version: "1.0.0" },
  blocks: {
    tasks: {
      verbs: ["list", "get", "create", "complete"],
      tag: "Tasks",
      permissions: { complete: "tasks.complete" },
    },
  },
});
```

A map publishes only the instances it names, and `false` publishes none.
`verbs` limits the methods, `path` and `tag` replace `/<instance>` and the
instance key, and `permissions` names the permission for each method. With
`requirePermissions`, every method except `list`, `page`, `get`, `count` and
`aggregate` needs one.

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 `bsb.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.

`functionRoute(name, fn, options?)` builds the route of a `defineFunction`
Edge Function, at `/functions/v1/<name>` with its input as the body and its
output as the 200 response:

```ts
import { functionRoute } from "better-supabase/spec";

defineApi(betterSupabase, {
  info,
  routes: [
    functionRoute("create-customer", createCustomer, { tags: ["customers"] }),
  ],
});
```

See [Edge Functions](/docs/platform/edge-functions#document-it-in-the-spec)
for the options and how `basePath` applies.

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

# Data API

> Document the Supabase Data API (PostgREST) next to your own routes, with database column names, PostgREST filters and the RPCs from your schema.

Source: https://bettersupabase.com/docs/specs/data-api

`dataApi` on [`defineApi`](/docs/specs) adds the Supabase Data API to the
same OpenAPI document as your resources and routes: the `/rest/v1/<table>`
endpoints PostgREST serves for your tables and views, and
`POST /rest/v1/rpc/<name>` for your functions. Use it when clients call the
Data API directly, for example a mobile app with `supabase-js`, and you want
one document for everything they call.

```ts title="src/api/openapi.ts"
import { defineApi } from "better-supabase/spec";
import { betterSupabase } from "../lib/supabase";

export const api = defineApi(betterSupabase, {
  info: { title: "CRM", version: "1.0.0" },
  servers: [{ url: "https://crm.example.com/api" }],
  supabaseUrl: "https://example.supabase.co",
  resources: { customers: true },
  dataApi: {
    tables: ["customers", "notes"],
    functions: ["customers_by_status"],
    operations: { notes: ["select"] },
  },
});

const { document } = api.openapi();
```

| Field        | What it does                                                                                         |
| ------------ | ---------------------------------------------------------------------------------------------------- |
| `tables`     | Tables and views by app key, or `true` for all of them                                               |
| `functions`  | Functions by key, or `true` for all of them                                                          |
| `operations` | The operations per table: `select`, `insert`, `update` and `delete`. A table listed here is included |
| `tag`        | The tag of every Data API operation; defaults to `Data API`                                          |

A table gets all four operations; a view gets the ones its columns allow.

## Database names [#database-names]

The Data API reads and writes database column names, so its schemas do too,
whatever `casing` your client uses. Each table gets its own components,
`DataApiCustomersRow`, `DataApiCustomersInsert` and `DataApiCustomersUpdate`,
next to the app-cased `CustomersRow` your resources use. A table outside
`public` gets the schema in its name (`DataApiBillingInvoicesRow`) and a
required `Accept-Profile` or `Content-Profile` header.

## Tables [#tables]

| Operation | Request                   | What it documents                                                                               |
| --------- | ------------------------- | ----------------------------------------------------------------------------------------------- |
| `select`  | `GET /rest/v1/<table>`    | `select`, `order`, `limit`, `offset`, a filter per column, and the `Range` and `Prefer` headers |
| `insert`  | `POST /rest/v1/<table>`   | One row or an array of rows, `columns`, `on_conflict` and `Prefer`                              |
| `update`  | `PATCH /rest/v1/<table>`  | The Update body and a filter per column                                                         |
| `delete`  | `DELETE /rest/v1/<table>` | A filter per column                                                                             |

A column filter takes an operator and a value, such as `eq.42`,
`in.(a,b)` or `is.null`. A column named like a reserved parameter (`select`,
`order`, `limit`, `offset`, `columns`, `on_conflict`, `or`, `and`, `not`)
gets no filter. Writes answer with the rows when the request sends
`Prefer: return=representation`, and without a body otherwise.

## Functions [#functions]

Each function is a `POST /rest/v1/rpc/<name>` whose body holds the
arguments by name, typed from their Postgres types. An overloaded function's
body is a `oneOf` of its signatures. The response is the function's result:
rows of a table (as its `DataApi...Row` component), the columns of a
`returns table (...)`, or a scalar, and an array for a set-returning
function. A function that returns `void` answers `204`.

## Servers and security [#servers-and-security]

The Data API runs on your Supabase project, not on your app's servers, so
every Data API operation lists `supabaseUrl` as its own server. Without
`supabaseUrl` the paths resolve against the document's `servers`, with a
`data-api-server-missing` warning.

Every operation needs the project's publishable key in the `apikey` header,
and takes a user's access token as a bearer token: the requirements are
`supabaseApiKey` with `supabaseJwt`, or `supabaseApiKey` alone. The document
gets both schemes, whatever `security` says for your own routes.
`requirePermissions` skips Data API operations, because row-level security
guards them.

Errors use the PostgREST error body (`code`, `message`, `details`, `hint`),
the shared `DataApiError` response for every `4XX` status.

## Diagnostics [#diagnostics]

| Code                        | Severity | Cause                                                                  |
| --------------------------- | -------- | ---------------------------------------------------------------------- |
| `data-api-table-unknown`    | error    | `tables` or `operations` names a table or view the schema doesn't have |
| `data-api-function-unknown` | error    | `functions` names a function the schema doesn't have                   |
| `data-api-server-missing`   | warning  | `supabaseUrl` is not set                                               |

Each Data API operation has the source `{ kind: "data-api", table, operation }`
or `{ kind: "data-api", function, operation: "rpc" }`, so `enrich` and
`operationId` can tell them apart from your resources.

# 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                                                                      |
| `data-api-table-unknown`    | error    | `dataApi.tables` or `dataApi.operations` names a table or view the schema doesn't have                 | Fix the app key                                                                     |
| `data-api-function-unknown` | error    | `dataApi.functions` names a function the schema doesn't have                                           | Fix the key, or add the function's schema to `schemas` and run `gen`                |
| `data-api-server-missing`   | warning  | `dataApi` is set without `supabaseUrl`, so the `/rest/v1` paths resolve against the document's servers | Set `supabaseUrl`                                                                   |

## 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 `database.realtime.tables`, or `cloudEvents.rows` an unknown table | Fix the key, or add the table to `database.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-topic-undocumented`        | warning  | `spec`: a topic in `realtime.policies.from` has no channel in the document                                     | Pass the module with `events: { topics: topicsFrom(topics) }`                        |
| `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. It is the one entry for API
documents: it also exports the OpenAPI, AsyncAPI, Arazzo and Overlay renderers
(`createOpenApi`, `createAsyncApi`, `createArazzo`, `applyOverlays` and the
formats), so every import on these pages comes from `better-supabase/spec`.

```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/spec` 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/spec";

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): `asyncapiFormat` and `createAsyncApi`,
  for Realtime channels, CloudEvents and webhooks.
- [Arazzo](/docs/specs/arazzo): `arazzoFormat`, `createArazzo` and
  `defineWorkflow`, for workflows over
  the OpenAPI and AsyncAPI documents.
- [Overlays](/docs/specs/overlay): `defineOverlay`, `applyOverlays` and
  `overlayFromDiff`.
- [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.
- [Data API](/docs/specs/data-api): `dataApi`, for the `/rest/v1` tables and
  RPCs that PostgREST serves, with database column names.

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/spec` 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/spec` 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/spec, 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).

The overlay functions in `better-supabase/spec` read 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/spec";

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, defineApi, loadJsonPath } 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/spec";

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/spec";

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 bsb = createHono(betterSupabase);
const { document } = api.openapi();

export const app = bsb
  .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 `api.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 `bsb.resource(table, options)`,
[Edge Functions](/docs/frameworks/edge#rest-resources) and
[Next.js](/docs/frameworks/next#rest-resources) with
`bsb.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", bsb.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": "about:blank",
  "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.

# Changelog

> Every better-supabase release with its changes, newest first, built from the CHANGELOG.md at the repository root.

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

## 0.15.0 (2026-10-24) [#0150-2026-10-24]

- Framework adapters now come in two tiers. Next.js, Hono, Expo Router, SvelteKit, TanStack Start and React Router are supported adapters: they get the full test matrix, an example app and every new guide. Astro, Edge Functions, Elysia, H3, Nuxt and Nitro 2 (`better-supabase/h3/v1`), NestJS, Node and SolidStart are community adapters: they keep shipping in every release with a smoke test that serves a request, and their docs pages carry a community note. No adapter is removed and no import changes. Read more under [adapter tiers](https://bettersupabase.com/docs/frameworks#adapter-tiers) and in ADR 0094.
- Blocks declare what they give `approvals`, `bulk`, `pipelines`,
  `share-links` and `subjects` in a new `contributes` field, and those generic
  blocks no longer name their consumers. A second instance contributes to the
  generic instance its `uses` names. Two contributions with the same subject or
  action name fail at render. Run `better-supabase upgrade` and `db diff`; the
  rendered functions change only in the order of their branches.
- Block clients are keyed by instance. `gen` writes a `blocks.generated.ts`
  entry for every instance, with the block it installs, its `uses` and the
  function names its SQL writes, plus a renamed client such as `ticketsBlock`.
  `createBlocks` reads the entry under the instance key, binds a port that two
  blocks provide through the consumer's `uses` or the new `ports` option, and
  `createBlockClient` takes `instance`. Block queries use the table key space
  `["bsb", instance, method, ...args]`, so `invalidateTables` makes table and
  instance queries stale, optionally narrowed to methods.
- Blocks that attach to records take their record types from `on` on the block
  entry instead of `options.subjects`, and `field-history` takes
  `on: { <type>: { fields: [...] } }`. `better-supabase upgrade` moves the
  value. A config that still sets `options.subjects` fails with the key to move,
  and the new doctor check BS346 reports an `on` entry that `config.records`
  doesn't register.
- Breaking: 26 blocks take plain names. The block name changes in the config, the CLI, the subpath (`better-supabase/blocks/<name>`), the exports, the operation names (`<block>.<action>`) and the CloudEvent source (`better-supabase/<block>`). Table names, permission keys, event types, hint codes and queue names don't change. `better-supabase codemod 0.15` renames the config keys, the imports and the subpaths, and lists event sources for review. `better-supabase upgrade` moves the installed blocks to their new names and renames two tables that clashed: hiring's `jobs` becomes `job_openings` and catalog's `collections` becomes `product_collections`. A 0.14 name in the config stops `add` and `gen` with a message that names the codemod.

  | 0.14                 | 0.15               |
  | -------------------- | ------------------ |
  | `work-items`         | `tasks`            |
  | `parties`            | `contacts`         |
  | `engagements`        | `activities`       |
  | `targets`            | `goals`            |
  | `number-sequences`   | `numbering`        |
  | `actor`              | `created-by`       |
  | `ensure-rls`         | `rls-guard`        |
  | `shared-signals`     | `security-events`  |
  | `service-principals` | `service-accounts` |
  | `support-sessions`   | `impersonation`    |
  | `controls`           | `compliance`       |
  | `ai-tasks`           | `ai-schedules`     |
  | `ai-providers`       | `ai-keys`          |
  | `workflow-builder`   | `workflow-editor`  |
  | `jobs`               | `queue`            |
  | `access`             | `permissions`      |
  | `rules`              | `automations`      |
  | `tenant`             | `memberships`      |
  | `activity`           | `timeline`         |
  | `workflow-sdk-world` | `workflow-sdk`     |

  The docs pages move with their blocks, and `/docs/blocks/<old>` redirects to the new page.
- Breaking: the SQL engine says block. `SqlModule` is `BlockSql`, `ModuleDefinition` is `BlockSqlDefinition`, and the other `Module*` types from `better-supabase/sql` are `BlockSql*` (`BlockSqlContext`, `BlockSqlLayout`, `BlockSqlNames`). `renderModules`, `resolveModules`, `resolveModule`, `isModuleIdType`, `sameModuleFile`, `InstalledModule` and `SkippedModule` are `renderBlocks`, `resolveBlocks`, `resolveBlock`, `isBlockSqlIdType`, `sameBlockFile`, `InstalledBlock` and `SkippedBlock`.

  Four exports take their glossary names: `SubjectRef` is `RecordRef`, `entityForm` is `recordForm` (with `useRecordForm` and the `RecordForm*` types), `ShipmentCarrier` is `ShippingProvider` (`testShippingProvider`), and `QueueBackend` is `QueueProvider` (`sqlQueueProvider`, `transportQueueProvider`, `vercelQueueProvider`, `testQueueProvider`). `better-supabase codemod 0.15` renames these imports.

  Installed versions live in `better_supabase.blocks` instead of `better_supabase.modules`. `sql upgrade` creates the new table and copies the rows before the schema diff drops the old one, and doctor reads the old table while the new one is missing. Block files carry `-- @bsb-block`, `-- @bsb-block-data`, `-- @bsb-block-test`, `-- @bsb-names` and `-- @bsb-tables` lines; `sql upgrade`, `sql sync` and doctor read the `@bs-*` lines of files written by 0.14 and older.
- Breaking: the runtime instance is `bsb`, matching the CLI's short name, so `const db = await bsb.db()` replaces `bs.db()` in the docs, skills, examples, generated files and the `ctx.bsb` key on request contexts (`c.var.bsb`, `locals.bsb`, `context.bsb`). Run `better-supabase codemod 0.15` to rename the instance where a file creates, imports or destructures it; it lists cache tags and query keys that start with `bs` for review.

  Strings on the wire move with it, so deploy the new package and run `better-supabase sql upgrade` together: realtime topics start with `bsb:t:`, the request and job headers are `x-bsb-actor`, `x-bsb-job` and `x-bsb-signature`, workflow run attributes are `bsb.*`, the debug header is `x-bsb-db-calls`, the stats route is `/api/bsb-stats`, and Server-Timing names are `bsb-proxy` and `bsb-verify`. Workflow runs started before the upgrade keep their `bs.*` attributes, so failure alerts don't count them. The support session cookie is `bsb-support` and the replica cookie is `bsb-primary-until`, so staff in a support session start it again after the deploy.

  The triggers, policies, constraints, indexes and functions the blocks install are named `bsb_` instead of `bs_` (`bsb_audit`, `bsb_session_active`, `bsb_json_<table>_<column>`). `sql upgrade` renames the installed `bs_` objects in place before it applies the new block files, so nothing is created twice; an object on a table the migration role doesn't own (`auth.users`, `storage.objects`, `realtime.messages`) gets a warning naming the rename to do by hand. Doctor and the error mapper read both prefixes.

  Stored names keep `bs`, because renaming them would orphan existing data: Vault secret names (`bs:field-key:`, `bs:cred:`, `bs:signing:`, `bs:marketplace:`), the queue envelope key `$bs` and the API key prefix `bs`, which existing keys are verified against.
- Breaking: the CLI has one command per job: `init`, `add`, `remove`, `upgrade`, `blocks`, `gen`, `dev`, `db`, `doctor`, `config`, `spec`, `scaffold`, `mcp`, `codemod`, `skills` and `completion`. Running a moved command exits 2 and names the new one, and `better-supabase codemod 0.15` rewrites the calls in `package.json` scripts and GitHub workflows.

  | 0.14                          | 0.15                                          |
  | ----------------------------- | --------------------------------------------- |
  | `apply --preset <p>`          | `add --preset <p>`                            |
  | `adopt <block>`               | `add --adopt <block>`                         |
  | `sql add <block>`             | `add <block>`                                 |
  | `sql sync`, `sql upgrade`     | `upgrade`                                     |
  | `sql data`                    | `add --adopt`                                 |
  | `sql list`                    | `blocks list --installed`                     |
  | `sql print <block>`           | `blocks view <block> --sql`                   |
  | `preset decode <code>`        | `blocks list --preset <code>`                 |
  | `preset info`                 | `config --preset`                             |
  | `introspect`                  | `gen --save-snapshot`                         |
  | `seed`                        | `db seed`                                     |
  | `env`, `run`, `keys`, `stack` | `dev env`, `dev run`, `dev keys`, `dev stack` |

  `remove <block...>` is new: it takes blocks out of the config and `lib/blocks.ts`, deletes their module files and points to `db diff` for the migration that drops their tables. `add --adopt --check` exits 1 when the data migration is out of date, and a comment-only change to a data file no longer needs a new data migration. The MCP tools follow the commands: `config_preset`, `blocks_installed`, `add_preset_dry_run`, `upgrade`, `add_preset` and `remove`. The CLI docs move to `/docs/cli/dev` (env, run, keys and stack), `/docs/cli/db#seed` and `/docs/cli/gen#saving-a-snapshot`; the old pages redirect.
- Block instances live on `db`. `.blocks(blockSet)` on the definition puts each instance on the repository object (`db.comments`), running through the same executor as the tables and acting in the active organization. An instance whose name a table already uses stays at `db.$blocks.<instance>`, and `db.$api(fn, args)` calls a function in the API schema. `blocks.generated.ts` exports `blocks`, the block for each instance, so `defineBlocks(blockConfig, blocks)` lists them all.

  `gen.hooks` writes `hooks.ts` with a list and a record hook per table (`useCustomers`, `useCustomer`) for React, Vue, Svelte or Solid, built on `defineDataHooks`. The hooks share reads, update cached rows at once on `create`, `update` and `delete`, and roll back on an error. They keep reads in memory by default; `createClient(definition, { engine: queryEngine(queryClient) })` from `better-supabase/query` keeps them in TanStack Query.

  The `requireTenant` option and helper are now `requireOrganization`. Run `better-supabase codemod 0.15` to rename them; doctor reports a `requireTenant` that is left.
- Blocks stop copying tables other blocks own. Leases drops its maintenance
  requests (install a `tasks` instance named `maintenance_request` instead; the
  upgrade renames the old table to `maintenance_requests_legacy`), admission
  pass credits post on the ledger, inventory can keep stock at the assets
  locations with `tables: { locations: null }`, and donations requires
  `numbering` and contributes a `donation_receipt` sequence (`R-{n}`) that an
  app entry replaces. Blocks contribute numbering defaults through
  `contributes.numbering`. A renamed instance now reaches notifications and
  the queue under their own names, and `gen` names its events type after the
  instance (`MaintenanceEvents`) and adds it to `BlockEvents`.
- Breaking: `better-supabase.config.ts` groups its keys into `database` (`source`, `schemas`, `sql`, `seed`, `expose`, `buckets`, `realtime`, `topics`, `vectorSearch`), `auth` (`organization`, `claims`, `authorization`, `entitlements`), `gen` (`output`, `casing`, the generator `tables` overrides and the other type options), `api` (`openapi`, `specs`) and `tooling` (`doctor`, `env`, `keys`, `skills`, `scaffold`). `preset`, `blocks`, `plugins`, `records`, `tables` and `adapters` stay at the top level.

  | 0.14                     | 0.15                       |
  | ------------------------ | -------------------------- |
  | `subjects`               | `records`                  |
  | `appTables`              | `tables`                   |
  | `appTables.<name>.scope` | `tables.<name>.owner`      |
  | `integrations`           | `adapters`                 |
  | `sql.scope`              | `database.sql.owner`       |
  | `claims.tenant`          | `auth.organization.claim`  |
  | `plugins.tenant.column`  | `auth.organization.column` |
  | `blocks.<name>.mode`     | `blocks.<name>.ownership`  |
  | `blocks.<name>.scope`    | `blocks.<name>.owner`      |
  | `blocks.<name>.entities` | `blocks.<name>.names`      |
  | `blocks.<name>.hooks`    | `blocks.<name>.callbacks`  |

  `better-supabase codemod 0.15` regroups a config file, its environment blocks and a `better-supabase.config.json`. Loading a config with a 0.14 key fails with a message that names the key's new place. `resolveConfig` returns the same resolved config as before, and `flattenConfig`, `groupConfig` and `blockEntrySource` from `better-supabase/config` convert between the two shapes. `init` and `add` write the grouped keys, and CLI and doctor messages name the grouped paths. Doctor finding targets keep their 0.14 names, so saved baselines still match. Create designs keep `appTables` and `scope` in this release.
- Blocks install as instances. Each key in `blocks` names an instance; a key
  other than a block's name installs a second instance of the block in its
  `block` setting, `on` lists the record types an attachable block attaches
  to, and `uses` picks the instance of a dependency. The options of a named
  instance are typed from its block. `resolveConfig` reports unknown blocks,
  renamed or repeated core blocks, unknown `uses` keys and a missing `uses`
  together, and `ResolvedConfig.instances` lists the resolved instances. The
  config JSON schema accepts instance keys.
- Repositories and block clients share one data contract. Reads are `list`, `page`, `get`, `count` and `aggregate`; writes are `create`, `update`, `upsert` and `delete`; `attach` and `detach` link a row to a record. `findMany`, `findFirst`, `findOnly`, `findUnique`, `findById`, `paginate`, `exists` and the `*Many` methods go: `get` takes a primary key, a unique key or `{ where }` (with `{ optional: true }` for null), `create` and `upsert` take one row or an array, and `update` and `delete` take a key or `{ where }`.

  Block clients move to the same verbs. `tags.tag` and `untag` become `attach` and `detach`, `tags.create`, `update` and `list` take objects, `forSubject(record)` on tags, channels and scores becomes `list({ on: record })`, and `save` on the block clients and `set` on ai-cache and ai-files `providerFiles` become `upsert`. The lint rules `no-unbounded-find-many` and `no-delete-many-without-where` become `no-unbounded-list` and `no-empty-where`, and the guards `noUnboundedFindMany` and `noDeleteManyWithoutWhere` become `noUnboundedList` and `noDeleteWithoutWhere`.

  Run `better-supabase codemod 0.15` to rewrite the repository calls, the guard options and the lint rule names. It lists the block client calls to review.
- `better-supabase db backfill <table.column>` fills the null values of a column that a migration added to a table that already holds rows. `--from projects.organization_id via project_id` copies each value from the table a foreign key points at, and `--value "'EUR'"` writes one literal. It updates `--batch-size` rows per statement (default 1000) and locks only those rows, prints a line per batch, supports `--dry-run` and the `--json` envelope, and touches only null rows, so a second run fills nothing. Read more under [db backfill](https://bettersupabase.com/docs/cli/db#backfill).

  `doctor` adds BS236, which reports a check added `not valid` that no row fails any more, and the new `doctor --fix` flag runs `alter table ... validate constraint` for it. Against a database that is not local, an agent asks for consent first. JSON findings that `--fix` can resolve carry the SQL in a new optional `fix` field.

  The migration lock lints now follow expand and contract. BS230 flags a `references` or `check` written inline in `add column`, BS231 flags `add column ... not null` without a default, and BS233 no longer asks for `lock_timeout` before a statement that only runs `validate constraint`.
- Breaking: designs replace preset codes. The config's `preset` key takes a preset name or an object with the conventions that differ from the defaults and the blocks, a Create URL is `/create?preset=<name>` or `/create?design=<code>` with the preset inside the design (format 7), and a design file is the design JSON with its preset. `init`, `add` and `blocks list` stop on a 0.14 code and name `better-supabase codemod 0.15`, which converts the config's code and `*.design.json` files.

  | 0.14                                         | 0.15                                                    |
  | -------------------------------------------- | ------------------------------------------------------- |
  | `encodePreset(preset)`                       | `presetSpec(preset)` or `createUrl(resolved)`           |
  | `decodePreset(code)`                         | `presetAndDesignOf(input)` or `presetOfSpec(value)`     |
  | `defaultPresetCode()`                        | `defaultPreset()`                                       |
  | `withPresetField(preset, key, value)`        | `presetFromSpec({ ...presetSpec(preset), key: value })` |
  | `designConfigSource(resolved, design, code)` | `presetConfigSource(resolved, design)` (async)          |
  | `designInstallCommand(resolved, code)`       | `presetInstallCommand(resolved, design)` (async)        |
  | `designApplyCommand(resolved, code)`         | `presetApplyCommand(resolved, design)` (async)          |

  `isPresetCode`, `PRESET_BLOCKS`, `PRESET_FIELDS`, `PRESET_VERSION` and `DecodedPreset` are removed; `CONVENTION_FIELDS` lists the convention fields. The docs page `/docs/presets/codes` moves to `/docs/presets/designs`.
- A `tables.<name>` entry can now name a table your own schema already creates. Instead of refusing it, better-supabase enriches the table: it skips `create table` and adds only the columns the table lacks (the scope column, the timestamps, the id when it has none, and the columns you list) with `alter table ... add column if not exists`, nullable unless they have a default. Enriching never overwrites what your schema wrote: the scope's policies are named `bsb_<table>_<command>` on your table, so a policy of yours named `<table>_select` stays, and your grants stay too, since the entry only adds the grants its scope needs. The scope column gets a `not valid` not-null check, so new rows must fill it while `better-supabase db backfill` fills the old ones. Row level security, the scope's grants and policies, the restrictive `bsb_tenant_scope` policy, subject permissions, events, the `updated_at` trigger, versions, read audits, rollups and lookups render the same as for a table it creates. Each feature works on its own, so `scope: "none"` with only `subject` or only `events` adds no scope column.

  Doctor adds two checks. BS237 reports permissive policies of your own on a scoped app table (a hint when `bsb_tenant_scope` narrows them, a warning in `user` scope) and the grants on an enriched table beyond its scope (a warning when they reach columns the entry restricts). BS238 counts the rows of an enriched table without an owner and names the `db backfill` command. BS228 no longer reports a required column without a default that an enriched table keeps nullable.

  `gen` filters a table on the tenant column its subject names (`subjects.<type>.tenant`), so a block over your table with its own tenant column gets the memberships filter too.
- Every block that owns tables now declares the nouns it names its tables, indexes, policies, triggers, functions and events after, so `blocks.<name>.entities` renames them. That covers 43 more blocks, among them `quotes`, `invoices`, `orders`, `inventory`, `bookings`, `forms`, `pipelines`, `hiring`, `ai-chat`, `knowledge`, `agents`, `teams`, `tags`, `templates` and `automations`. Quotes, invoices, orders, bookings and forms are also record types, so comments, attachments, tags and activity can point at them.

  Run `better-supabase upgrade` after updating. The new record types widen the subject switches in the blocks that read record titles, and three internal keys move: the orders advisory lock key, the relations lock key and the `better_supabase.restoring_version` setting that `versions` sets while it restores a row.

  A config that declares `records.quote`, `records.invoice`, `records.order`, `records.booking` or `records.form` now fails. Remove the entry when it points at the block's own table, or set `blocks.<name>.names.<noun>.subject` to `false` to keep a table of your own.
- The per-block framework subpaths go: `better-supabase/blocks/<block>/react`, `/vue`, `/svelte` and `/solid` (120 subpaths) and `better-supabase/chat-sdk/react`. A block's React hooks are now a registry item that shadcn copies into the app: `npx shadcn add @better-supabase/use-<block>` writes `hooks/use-<block>.ts`, with the same hook names, options and return values, and the app imports from `@/hooks/use-<block>`. Presets install the hook item with each block.

  Vue, Svelte and Solid call `useBlockController(create<Name>Controller, options)` from `better-supabase/vue`, `better-supabase/svelte` or `better-supabase/solid`, with the controller from the block's main entry. It returns what the per-block hooks returned. `better-supabase/react` now exports `useController`, `useLatest`, `useBlockEnv` and `useBlockTenant` for your own hooks, and `better-supabase/blocks` exports `envBlockOptions`, `envBlockSettings` and the `BlockEnv` type.

  Doctor's BS418 finds imports of the removed subpaths and names the registry item.
- Sessions verify once per request across cache scopes, and the Next.js proxy and sign-in take fewer round trips.

  - `createServer` keeps a session memo keyed by the token's hash. `server.snapshot(request)` returns a verified `SessionSnapshot`, and `server.contextFromSession(snapshot)` builds a context from it with no cookie read, no network call and no second verification. In Next.js, `bsb.getRequestSession()` returns the snapshot, so ten `'use cache: private'` islands verify the token once. `sessionCacheKey`, `sessionIdTag`, `sessionMemo` and `bsb.sessions.stats()` are new; `createNext(..., { sessions })` sizes the memo or turns it off. See the [island pattern](https://bettersupabase.com/docs/frameworks/next-islands) and ADR 0095.
  - `contextFromSession` accepts only a snapshot the server made: a copy (a spread, `structuredClone`, a cached function's arguments) gets the `invalid` context. Pass plain values into cached functions.
  - The proxy refreshes a token only within `refreshSkewSeconds` of its expiry (`auth.leeway`, else 60), stores a refreshed session in the memo, and treats `refresh_token_already_used` as `raced`: it keeps the cookies the other request set instead of signing the user out.
  - `serverTiming: true` now reports `bsb-auth` for resolving the session and `bsb-parse`, `bsb-verify`, `bsb-refresh` and `bsb-ended` per phase. `bsb-verify` used to be the whole resolution; read `bsb-auth` for that number now.
  - `isPrefetch` also recognizes `next-router-segment-prefetch`, `x-middleware-prefetch` and `x-moz: prefetch`. Expo's `bsb.middleware()` and API routes no longer refresh on a prefetch, like the other adapters.
  - `bsb.signIn({ email, password, next })` signs in from a Server Action in one Auth call, writes the cookies, stores the session for the next render and returns a same-origin `redirectTo` (and `mfa`), so the form calls `router.replace` once. `safeRedirectPath` and `passwordGrant` are exported from `better-supabase/server`.
  - `AuthSession` and `SessionSnapshot` gain `issuedAt` (from `iat`) and `authzVersion` (from an `authz_ver` claim, or `null`).
  - `createServer(def, { versions })` takes an authorization library's version cache. Authorizers get `{ versions, memo }` as a runtime, `invalidateSession(userId)` evicts the user from it, and `subscribeAuthzVersions({ client, topic, event, userIdKey, versions })` evicts users when the database broadcasts a change on a private Realtime channel. See [the version cache](https://bettersupabase.com/docs/extending/authorization-providers#version-cache) and ADR 0096.
  - SvelteKit, TanStack Start, React Router, Hono (`c.var.snapshot`) and Expo hand loaders and handlers the snapshot.
- Installed blocks now work together where they overlap. `ai-chat` records each
  run's tokens on the `ai.input_tokens` and `ai.output_tokens` meters when
  `usage` is installed (turn it off with `meterUsage: false`). `knowledge`
  queues ingestion through `queue` (`ingest.queue` and `ingestJob()`). With
  `invoices` installed, a paid order creates, issues and pays an invoice and
  returns its id. Quote lines that name a catalog variant take its price and
  title when they leave them out. Time entries and leave requests carry an
  `employee_id` when `employees` is installed. `attachments` takes a
  `publicBuckets` option and `Attachments.publicUrl()` for files anyone may
  read. The registry adds `@better-supabase/ai-chat-next`, the AI chat route
  with its stream and stop routes.
- PowerSync, SQLite and TanStack DB run as engines behind the generated data hooks. `powersyncEngine(powersync, { connector, local })` from `better-supabase/powersync`, `sqliteEngine(database)` from the new `better-supabase/sqlite` (Expo SQLite through `expoSqliteDatabase`, Supabase Lite through `liteDatabase`) and `tanstackDbEngine({ collections })` from `better-supabase/tanstack-db` go to `createClient` or `createNativeClient`, so native screens read `useCustomers(...)` like web screens. With a device engine, `bsb.db` runs on the local database for the tables in `local` and on PostgREST for the rest. `useSyncStatus()` and `useConflicts()` move to `better-supabase/react` and read the engine.

  `memoryEngine` and `queryEngine` take `live`, a change feed such as `realtimeChanges(betterSupabase, supabase)` from `better-supabase/realtime`, and `tables` to map a hook name to its tables. `where` on every hook and repository takes a filter tree from a list query or saved view, checked against the filterable columns.

  Breaking: `better-supabase/powersync/react` (`useWatch`), `better-supabase/expo-sqlite` (`expoSqliteExecutor`) and `better-supabase/tanstack-db/supabase` (`supabaseCollection`) are removed, along with `watch` and `sqliteTables`, and `@supabase-labs/tanstack-db` is no longer a peer. Doctor reports what is left as BS418. See the 0.14 to 0.15 guide.
- `defineApi` publishes every table and every block instance by default. Without `resources`, each table gets its CRUD operations; a map now publishes only the tables it names, and `resources: false` publishes none. Each table also gets the contract verbs `page`, `count`, `aggregate` and `upsert` as `POST /<table>/<verb>` (operation ids such as `countCustomers`), served by `defineResource`, `bsb.resource` and `bsb.resources` too; `verbs` limits them, and a write hook drops `upsert`. Each block instance gets `POST /<instance>/<method>` per client method, configured with the new `blocks` option (`verbs`, `path`, `tag`, `permissions`).

  `createOrpc(betterSupabase).router(api, { guard, meta })` serves that model as oRPC procedures, with routes that match the document. `bsb.blockRouter` is removed: serve `bsb.router(defineApi(betterSupabase, options))` instead. Doctor reports BS418 for a `blockRouter` call that is left.
- One automation model. Automations, workflow action steps and sequence action steps run actions from
  one registry: `defineAction` declares an action (`apiVersion: 1`, with an optional config schema)
  and `createActionRegistry` collects them; `createRuleDispatcher` takes `{ actions }` instead of
  `handlers`. The action hints are now `ACTION_UNKNOWN` and `ACTION_CONFIG_INVALID`.

  `better-supabase/flows` is removed. Blocks ship default automations instead: the inbox opens a task
  for each new conversation, hiring preboards each hired candidate and quotes orders each accepted
  quote (turned off). Each organization gets its own copy, which it may turn off, edit or reset with
  `resetDefault`. The conversions are actions of the target block (`taskFromConversationAction`,
  `employeeFromApplicationAction`, `orderFromQuoteAction`). `createRuleSink` falls back to
  `eventTarget(event)` when `load` returns nothing.

  Workflow definitions lose their event triggers (`triggers.onEvent`, `builder.sink()`); an
  automation's `workflow` action starts them. Block timers (SLA checks, sequence waits, AI schedules,
  workflow schedules) run on the queue through `blockSchedules` and `blockTickHandler`. Doctor
  BS418 names each removed API and its replacement.
- `better-supabase/spec` is the one entry for API documents. It exports the
  OpenAPI, AsyncAPI, Arazzo and Overlay renderers (`createOpenApi`,
  `createAsyncApi`, `createArazzo`, `defineWorkflow`, `applyOverlays`,
  `defineOverlay` and the rest) next to `defineApi`. The subpaths
  `better-supabase/openapi`, `better-supabase/asyncapi`,
  `better-supabase/arazzo` and `better-supabase/overlay` are removed: import
  the same names from `better-supabase/spec`. `better-supabase codemod 0.15`
  rewrites the imports, and doctor reports BS418 for any that are left.
- Breaking: `ownership: "adopt"` is removed, because blocks now find your tables themselves. Keep writing your own declarative schema files next to the block files: a block runs over each table your schema already has and creates the rest. To upgrade, run `better-supabase codemod 0.15`, which deletes `ownership` and keeps `tables` and `columns`, then `better-supabase upgrade`, and generate a migration. A config that still sets `ownership` fails validation. Read [Your own schemas](https://bettersupabase.com/docs/blocks/own-schemas).

  A table your own schema files create (any declarative file better-supabase didn't write), or a table in the saved snapshot (`source.snapshot`) that no block file or module migration created, is the app's table. Over it, the block skips its `create table`, adds only the columns it needs that your table lacks with `alter table ... add column if not exists` (nullable unless they have a default), and writes its functions, policies and triggers over it. It never revokes your grants or changes your constraints. Text written into a typed column of such a table goes through `better_supabase.as_column_type`, so the column can be an enum or a domain. A module runs over the app's table for each table its spec allows (most block tables); for any other table it refuses to create a table your schema already declares and names the file.

  The policies a block adds to your table are named `bsb_<table>_<command>` (such as `bsb_teams_select`) and its triggers start with `bsb_`, so they never replace a policy or trigger of yours with the same name. When your table already keeps `updated_at` current with its own trigger, the block adds no `bsb_updated_at` trigger there. Doctor adds BS347 (warning), for a block policy on your table next to your own policies for that command when none of them repeats its condition, and BS348 (info), for a table whose own `updated_at` trigger the block keeps.

  Block files carry a `-- @bsb-hash` line. `upgrade`, `add` and `remove` leave a block file you edited by hand alone and exit 1 naming it, unless you pass `--force`. Block file positions are padded to 3 digits (`900_better_supabase_012_tasks.sql`); `upgrade` renames the 2-digit files and removes block files no installed block writes, when they weren't edited.

  `add --adopt` still proposes `tables` and `columns` from the database, but no longer writes `ownership`, and a column the app's table lacks is a warning, since the block's migration adds it. The enum-column check and doctor BS342 now cover the app's tables in every mode, and migration-only options such as `tokenStorage: "plain"` are accepted for a block over a table the app has.

  | 0.14                                                         | 0.15                                                           |
  | ------------------------------------------------------------ | -------------------------------------------------------------- |
  | `blocks.<name>.ownership: "adopt"`                           | delete the key (`better-supabase codemod 0.15`)                |
  | `mode: "adopt"` in `BlockConfig` (`BlockMode`)               | `"managed"`, with the app's tables in the layout's `ownTables` |
  | a block's table the app lacks, in adopt mode                 | created by the block                                           |
  | a column the app's table lacks, in adopt mode                | added by the block (`add column if not exists`)                |
  | migration-only options, in adopt mode only                   | accepted for a block over a table the app has                  |
  | BS330 "Adopted table without a mapped column"                | BS330 "App table without a column a block uses"                |
  | BS344 "Adopted webhook endpoints without a format"           | BS344 "App webhook endpoints without a format"                 |
  | `teams_read` on an app-owned `teams` table                   | `bsb_teams_select`                                             |
  | `team_members_read` on an app-owned `team_members` table     | `bsb_team_members_select`                                      |
  | `bsb_organizations_read` on an app-owned organizations table | `bsb_organizations_select`                                     |
- The built-in plugins become block extensions, and the `plugins` config key and the six `better-supabase/plugins/*` subpaths go. The `updated-at`, `created-by`, `trash` and `memberships` blocks carry `updatedAtExtension`, `createdByExtension`, `trashExtension` and `membershipsExtension`, and `gen` installs the extensions of the installed blocks in the generated schema, with `validation()` and the generated validators when a schema generator is configured. `auth.organization.column` turns the memberships extension on for an app's own tenancy. The query rules move to `better-supabase/lint` as `guards()`, `safeGuards()`, `recommendedGuards()` and `strictGuards()`, and `validation()` is exported from `better-supabase`. `definePlugin` stays the versioned interface for your own plugins.

  `.use()` of an extension the schema already installed replaces it, so passing options still works. A query on an organization table without an organization is now left to RLS by default; set `auth.organization.onMissing: "error"` to fail it. Without Temporal and a `now` option, the updated-at extension leaves `created_at` and `updated_at` to the column defaults and the block's trigger.

  Run `better-supabase codemod 0.15` to move the config and the imports. A config that still has `plugins` fails with a message that names the blocks.
- Presets now install the automations and queue blocks their default automations and block timers need: `helpdesk`, `people`, `erp` and `automation` gain automations, and `crm`, `helpdesk`, `cms`, `itsm`, `claims`, `ai`, `ai-chat` and `ai-agents` gain the queue. `erp` turns on the `quotes.quote_to_order` default. The automations block takes `enable` and `disable` options with default automation keys. The content block publishes scheduled entries from a `content.publish` block timer, and its `publishSchedule` option and pg_cron job are removed.
- The `crm` preset now installs mail-sync, sequences, segments, assignment,
  enrichment, forecasts and lead-scoring next to deals, so leads (contacts in
  the `lead` lifecycle) are synced, scored, assigned and enrolled in email
  sequences. The new `cms` preset installs content with publish approvals,
  versions and public media; `erp` installs a catalog, stock, quotes priced
  from the catalog, orders that invoice when paid, and employees with time and
  leave; `pim` installs a catalog with channels, translations and public
  product media, plus custom fields, imports and bulk edits. Write the old
  `crm` block list as a preset object to keep the 0.14 selection.
- Presets write block instances, and doctor finds the 0.15 APIs left in app code.

  - A preset object takes `instances`: `blocks` entries with `block`, `names`, `options`, `tables`, `on` and `uses`. An instance named after a block selects it; one with another `block` installs only what that block requires next to it. Your own `blocks` entry for the same key is merged over the preset's.
  - `helpdesk` and `itsm` name the tasks block's work items `ticket` and its projects `issue`, and `property` adds a `maintenance` instance of tasks for maintenance requests. Set `blocks.tasks.names` in your config to keep the old names.
  - The design JSON reads format 8, which adds the preset's `instances`, and `design-v1.json` and `config-v1.json` describe them.
  - `presetBlocksSource` wires each extra instance in `lib/blocks.ts` (`maintenance: tasksBlock`), and the config source lists it.
  - Doctor reports BS418 for imports of a renamed block or plugin subpath, an export named after a renamed block, and the record feature, leases maintenance and pass credit APIs 0.15 removed.
  - The 0.14 to 0.15 guide ends with the upgrade order, and the consumer skill covers instances.
- Record sections become record bindings. A block declares one `record` binding,
  named by its key in the config, and `gen` writes one `records/<type>.ts` per
  record type instead of the `features/` folder, the navigation and the command
  files. `bsb.record(type)` replaces `recordServer` and `bsb.sectionAction`, and
  `gen.recordTypes` replaces `gen.generateFeatures`. `better-supabase codemod 0.15`
  renames the section exports and lists the calls to move.
- `better-supabase scaffold api`, the `scaffold` config key (`tooling.scaffold`) and the `scaffold` step of `gen` are removed. `defineApi` from `better-supabase/spec` now includes every app table and block instance by default, and `createOrpc(betterSupabase).router(api)` from `better-supabase/orpc` serves it, so there is no API module to generate. Running `scaffold api` prints that replacement, and a config that still sets `scaffold` fails to load with a message that tells you to delete the key. Delete `lib/api.generated.ts` and the routes `scaffold api` wrote, and serve the router instead: https://bettersupabase.com/docs/frameworks/orpc#serve-the-api
- Removes `better-supabase scaffold screens`. The library writes no pages: build a table's screens
  on its generated data hooks (`useTasks`, `useTask`) and start from the `record-*` UI recipes.
  Running the old command exits with a message that says so. `scaffold api` is unchanged.
- In `tenant` scope, every block table a client can reach and every tenant
  table in `tables` gets a restrictive `bsb_tenant_scope` policy. It keeps rows
  to the caller's organizations and to what the block's own policies grant, so
  a permissive policy the app adds, even `using (true)`, can no longer read or
  write another organization's rows. Run `better-supabase upgrade` and generate
  a migration to apply it.
- Each block instance renders its own SQL. A second instance writes files
  named after it, prefixes the table, function, trigger and index names its
  entity renames leave unchanged with the instance name, reads the dependency
  instance its `uses` names, and records itself in `better_supabase.blocks`
  with the new `block` column. Its block marker names the block, the default
  roles grant its renamed permission keys, and `doctor` and `upgrade` track
  each instance's version.
- TypeScript 7.1 can get the schema types from better-supabase's content mapper, so the repository no longer holds `database.types.ts` or the type half of `generated.ts`.

  - `gen.contentMapper: true` turns on mapper mode. `gen` writes a `schema.bsb` marker and a `generated.ts` that keeps the runtime values and re-exports the types with `export type * from "./schema.bsb"`, and writes no `database.types.ts`. `Database` (the enriched one) now comes from `generated.ts`. Files mode stays the default, and its output is unchanged.
  - `init --content-mapper` sets the option, adds `{ "package": "better-supabase", "extensions": [".bsb"] }` to `contentMappers` in `tsconfig.json` and adds `--runExternalCode` to the `typecheck` script. It prints the change instead when a file holds comments or has no such script.
  - The mapper renders the same types `gen` writes in files mode, from the same config and snapshot, and caches them in `node_modules/.cache/better-supabase`; `gen` fills that cache, so the first `tsc` after it reads the types. It watches the config, the snapshot, `supabase/config.toml` and a stamp `gen` rewrites when the types change, so `gen --watch` reloads the editor. A failure becomes a diagnostic (BS603 to BS606) instead of stopping `tsc`, and a config or snapshot that fails to load keeps the last types.
  - Doctor's new BS602 check reports a missing `contentMappers` entry, a TypeScript older than 7.1 and a `typecheck` script without `--runExternalCode`.
  - The content mapper protocol version is pinned as `SPEC_PINS.typescriptContentMapper` (`"1"`).

  Content mappers need TypeScript 7.1, which is in beta (`7.1.0-beta`). See the [content mapper](https://bettersupabase.com/docs/content-mapper) page and ADR 0097.
- Blocks declare their wiring. A block manifest now lists its `env` keys, `providers`, `routes` and `workers`, and the preset catalog carries them. `better-supabase add` and `init` append missing keys to `.env.example`, write commented provider stubs into `providers.ts` next to `blocks.generated.ts`, and print a numbered checklist with the registry command for the routes and the crons to schedule. `add` also renders installed blocks again when a new block changes their SQL, such as entitlements once billing is installed. Two doctor checks fail on missing wiring: BS504 for a required env key no env file lists, and BS505 for a required provider (enrichment, mail-sync) that no source passes with `.with(...)` or `.using(...)`.
- Fixes for blocks that run over the app's tables, and for the tenant scope.

  - Blocks install in any order again. A block file that runs before memberships (such as audit) used to fail with "function better_supabase.member_organization_ids() does not exist"; each block that writes the tenant scope now creates an empty `member_organization_ids` first when it is missing.
  - The rendered block files are the same whatever order the blocks are listed in, so their `@bsb-hash` no longer changes between runs.
  - Channels cast the visibility they write, and the conversation access they set, to the column's type, so both can be enums of the app's. The hiring and deals pipelines use the tenant column `columns.<table>.tenant` maps, and inventory moves stock without a ledger `memo` column when `ledger.columns.transactions.memo` is `null`.
  - The tags block no longer writes `create table if not exists  (` without a name when `tables.taggings` is `null`.
  - When tenant and platform invitations share one table, the invitations block writes one `bsb_invitations_read` policy that reads both.
  - The drive block's read policy calls `drive_readable_ids()` once per statement instead of `drive_can(id, ...)` for every row.
  - Over your own tables, `profiles` no longer adds an `avatar_path` column it doesn't use, and `forms` scores submissions unless `columns.submissions` maps `score`, `maxScore` or `passed` to `null`. `add --adopt` lists a required column the block adds as `<column> (the block adds it)`.
  - Doctor reports fewer false positives on the app's tables. BS309 skips an old name on a table of yours, BS207 no longer counts your policy next to a block's `bsb_` policy (BS347 reports it), BS347 skips a block policy whose condition is yours with more `and` operands, BS107 counts only your own policies, and BS206 no longer counts a helper called as `(select helper('constant'))`.
  - A block adds no `bsb_` policy on your table for a command one of your permissive policies already allows for its roles (`true`, the same condition, or the block's with fewer `and` operands, as BS347 compares them). Postgres no longer evaluates two policies for the same rows, so the `multiple_permissive_policies` advisor stays quiet. A wider block policy still renders, and BS347 reports it.
  - `better-supabase/sql` exports `hasColumnAccess`, `notNullCheckName`, `scopePolicyName` and `TENANT_SCOPE_POLICY`, the names doctor checks the tenant scope of your tables with, and `declaredPolicies` and `widenedCommands`, which read the policies your schema files leave and compare a block's policy with them.
- The package README now leads with `db.customers.list` and `useCustomers`, and points at the docs catalog instead of listing every block.
- Search no longer creates its index triggers on the tables of blocks that render after it. Each of those blocks adds the trigger to its own file, so a design that searches a later block's table (the helpdesk preset's tickets, for example) applies on a fresh database.

## 0.14.0 (2026-10-22) [#0140-2026-10-22]

- Fix seven issues an adopter reported, around `$rpc` types, `findMany`, `DbException` and PowerSync.

  - `gen` types a `returns table` column through a `not null` domain as non-null, and prints a notice that points to `@notNull` for a function whose columns stay nullable. Function arguments stay `| null`, which only widens what you can pass.
  - A top-level `_count`, `_sum`, `_avg`, `_min` or `_max` in `findMany` and the other reads is now a type error and returns an `invalid_request` error. Before, the key was silently ignored, so code that passed one gets an error now: move it into `include: { _count: { notes: true } }`, or call `aggregate()` for the table itself.
  - `DbException` has the `DbError` fields as its own properties (`kind`, `status`, and `code`, `details`, `hint` and `table` when set), and the `DbError` as its `cause`.
  - PowerSync and Expo SQLite run includes (nested, filtered, ordered, limited and `required`), related counts and related aggregates as one SQL statement. `checkSqlite` also takes a query spec from `betterSupabase.spec`.
  - `createUploadConnector` takes `onError`, which reports a failed `getSession` (`phase: "credentials"`, kind `unauthorized` or `network`) and uploads that stay queued for a retry (`phase: "upload"`).
  - `UploadRepository<Insert, Key>` is typed by the table, so generated repositories fit it without a cast. `uploadRoute(repository, { put: "create" })` makes an insert-only route for write-once tables: a `PUT` calls `create`, and a conflict counts as applied.
- Add the `assets` block: a location hierarchy and an asset registry with assignment, coordinates and optional PostGIS.

  - `better-supabase add assets` installs locations in a tree (site, building, unit) without cycles, and assets with a kind, a tag, a serial number, a status (`active`, `maintenance`, `retired`) and an assignment to a member or any record, with its history. `createAssets` from `better-supabase/blocks/assets` manages them, and `assetsSection` shows the assets a record holds on its page.
  - `near` finds assets and locations within a radius. The `postgis` option adds geography points and location areas with GiST indexes, and `locationsAt` returns the locations whose area contains a point (ADR 0066).
  - With bookings or work items installed, booking resources and work items get an `asset_id` that `link` sets.
  - New permissions: `assets.read`, `assets.manage` and `assets.assign`.
- Add the `billable-time` block (`better-supabase/blocks/billable-time`). It keeps an hourly rate card per organization, member and record, previews the unbilled time with `unbilled()`, and bills it as lines on a draft invoice with `invoice()`, in bigint minor units. Billed entries can't be changed or deleted until the invoice is voided, and the `billableTime` section shows a record's unbilled time. Install it with `better-supabase add billable-time`; it needs `time-tracking` and `invoices`.
- The `billing` block can bill tenants as Stripe Accounts v2 accounts with the `customer` configuration, and the module stores the account next to the customer.

  - `createBilling({ customerAccounts })` creates or reuses the tenant's `v2.core.account` (`resolve` returns an existing one, `params` adds `v2.core.accounts.create` parameters) and passes `customer_account` to Checkout and the customer portal. A tenant that already has a v1 customer keeps it. `account(organizationId)` and `ensureAccount(organizationId, details)` are new, and `ensureCustomer` fails with `BILLING_CUSTOMER_PENDING` for an account until Stripe creates its customer.
  - `handleStripeEvent` resolves a tenant by its customer or its account, and records the `cus_` id Stripe creates for the account from the first Checkout session, subscription or other event that carries both ids. Outcomes, `billing.*` block events and `BillingStatus` report `accountId`, and the new `billing.account_linked` event is recorded when an account is linked.
  - `connectEvents: "ignore"` makes `handleStripeEvent` skip Connect events (`event.account`), for an endpoint that receives platform and connected account events together. The default still handles them.
  - The `billing` SQL module is at version 3: `billing_customers` has a nullable `stripe_customer_account_id` (`^acct_`), `stripe_customer_id` is nullable, and a row needs one of the two. `link_billing_account(tenant, account)` and `billing_account(tenant)` are new, `link_billing_customer` records the customer on a row that has only an account, `billing_customer_tenant` matches either id, and `billing_status` returns `account`. Run `better-supabase sql upgrade` and generate a migration.
  - `StripeClient` takes an optional `v2.core.accounts`, and the portal session accepts `customer_account`.
  - `stripeInboxVerify` and `verifyStripeWebhook` are now tested with Connect events and with the thin event notifications of event destinations, which Stripe signs the same way.
- Extend three blocks. Each extension is behind a new option that is off by default, so existing installs render the same SQL.

  - `engagements` with `recordings: true` stores call and meeting recordings as `ai-files` files, keeps transcript segments with speakers and timestamps, and attaches AI notes and a summary through the AI-neutral interfaces. New events: `engagement.recorded`, `engagement.transcribed` and `engagement.summarized`.
  - `orders` with `kitchen: true` gives every order line a preparation state (`queued`, `preparing`, `ready`, `served`) and a station, published for Realtime, with the `kitchen-display` and `order-state-board` recipes. New events: `order.prepared` and `order.served`.
  - `bookings` with `recurring: true` books a series from a recurrence rule (`bookSeries`, with `parseRecurrence` and `formatRecurrence` for the RRULE subset), moves one occurrence or every following one (`reschedule`) and cancels a series (`cancelSeries`). Every occurrence goes through the same exclusion constraint. New event: `booking.rescheduled`.
  - `passes` with `credits: true` pays for classes with a pack of credits (`bookWithCredit`), keeps every credit in a ledger (`credits`), and refunds the credit when the booking is cancelled at least `creditRefundBefore` (default 12 hours) before it starts. New events: `pass.credit_used` and `pass.credit_refunded`.
- The calendar block can now tell you when members are free, and bookings can run appointment schedules.

  - Events take a `kind` (`event`, `out_of_office` or `focus_time`), a `transparency` (`opaque` or `transparent`, mapped to the iCalendar `TRANSP` property) and `autoDecline`.
  - `setWorkingHours` and `workingHours` store each member's weekly hours in a time zone, in the new `calendar_working_hours` table.
  - `freeBusy(organizationId, userIds, range)` returns busy, out-of-office, focus-time and working-hours ranges per member. Postgres computes them, so recurring series, overrides, accepted invitations and approved leave all count. `commonFreeTime` finds the ranges when everyone is free.
  - An out-of-office block, and a focus time block with `autoDecline`, declines new invitations it covers.
  - Bookings resources take `hostUserId` and `checkBusy`, so their slots skip the times the host's calendar is busy. Use them for appointment schedules.

  Run `better-supabase sql sync`, then generate a migration. The new columns have defaults, so existing rows keep working.
- Add presence on any record, record mentions with backlinks, and save for later.

  - The subjects module authorizes Realtime presence on `presence:<type>:<id>` for anyone who can read the record. `subjectPresence` and `subjectPresenceValues(subject)` from `better-supabase/realtime` join it with `usePresence`, so a channel, a comment thread's record and a document share one helper. The `presence-avatars` UI recipe shows who else has a channel open.
  - Messages and comments mention a record as `@[Name](subject:<type>/<id>)`. `messaging.recordMentions(subject)` lists the messages that mention a record, for its readers only, and `message.sent` carries the mentioned records as `recordMentions`. `mentionSegments`, `parseMentions`, `recordMention` and `userMention` from `better-supabase/blocks/messaging` parse and write the syntax, and the `message-list` recipe renders record mentions as chips.
  - The collections block keeps a saved items list per member: `saveForLater(organizationId, subject, { note, remindAt })`, `saved`, `completeSaved` and `removeSaved`. `remindDue()` (service role) sends the `notification` you configure through the `notifier` port when a reminder is due and records `saved_item.reminded`. The block now consumes `notifier` as an optional port.

  Run `better-supabase sql sync` and generate a migration to get the new tables, functions and policies.
- The comments, attachments, reactions, engagements, documents, calendar and reminders blocks declare their entities, so `blocks.<name>.entities` renames them like the other entity blocks: `{ comments: { entities: { comment: "note" } } }` turns the `comments` table into `notes`, `create_comment` into `create_note` and `comment.created` into `note.created`. Their table indexes and policies are now named after the table, so they follow a rename.

  Installing comments, engagements, documents or calendar registers `comment`, `engagement`, `document` and `calendar_event` as subjects, so reactions, attachments, search, relations and the other subject blocks accept them with no `subjects` entry. Attaching a file to a comment checks `comments.create` instead of `attachments.upload`, the key the comments table checks for writes; a block entity's subject can name its own `upload` action this way. Engagements and calendar events are indexed for search when the search module is installed. Comments, attachments, engagements and calendar export input schemas: `commentInputSchema`, `commentChangeSchema`, `attachmentInputSchema`, `engagementInputSchema`, `engagementChangeSchema` and `calendarEventInputSchema`.

  To upgrade:

  1. Remove `subjects.comment`, `subjects.engagement`, `subjects.document` and `subjects.calendar_event` from your config when the block that owns them is installed: a `subjects` entry with a block entity's name is an error. To keep your own entry instead, set `blocks.<name>.entities.<entity>.subject` to `false`.
  2. Run `better-supabase sql sync`, then generate a migration. Reactions now checks a comment through the comment subject, which also requires `comments.read`, and refuses a reaction to any subject whose `deletedAt` is set. Its backfill setting is now `better_supabase.bs_reactions_backfill`.
  3. If you set `tables` for one of these blocks, its policies get new names that follow the table; drop the old `<block>_read` style policies by hand after the migration, or leave them, since they are the same permissive select policies.
- Comments and messaging notifications can be turned off again, and they no longer write a notification nobody receives.

  - `blocks.comments.options.notify` (default `true`) restores the opt-out the 0.14 comments move removed: `false` sends no `comment.posted`. `blocks.messaging.options.notify` (default `true`) does the same for `message.mentioned`, `message.direct` and `message.replied`; following records and threads still works.
  - `comment.posted` and `message.replied` are sent only when the record or thread has a follower besides the author and the mentioned members, or a `notification_audience` hook can add recipients.
  - Every comment and message notification carries a `subject_label`: the record's `subject_title` from `config.subjects`, or the conversation's title. An adopted notifications table that requires a label no longer makes `create_comment` fail, and one that restricts types can turn the comment and message types off with `notify: false`.
- Move comments onto the messaging core, and the activity feed into its own `activity` block. A record's comments are now its messaging conversation, so comments get resolve, pins, edit history, anchors, attachments and read markers from messaging, and `assign` turns a comment into a work item. See [Comments](https://bettersupabase.com/docs/blocks/comments), [Activity](https://bettersupabase.com/docs/blocks/activity) and the upgrade steps in [From 0.13 to 0.14](https://bettersupabase.com/docs/migration/0.13-to-0.14#comments-on-the-messaging-core).

  - The comments block now requires `tenant`, `access`, `subjects` and `messaging`, and runs in the `tenant` scope only. Register each record type you comment on in `config.subjects`.
  - The comments module goes to version 8. `better-supabase sql upgrade` moves each record's comments into one conversation, keeps every comment's id, author, body, document, mentions, thread and timestamps, moves reactions and attachments on a comment to the message, and drops the `comments` table and its functions.
  - Followers of a record get the `comment.posted` notification, and mentioned users get messaging's `message.mentioned`.
  - `better-supabase/blocks/activity` exports `createActivity`, `activitySink`, `activityListQuery` and `activityBlock`. The crm, projects, helpdesk and docs presets include it.

  | Before                                                                           | After                                                                                                |
  | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
  | `comments.read`, `comments.create`, `comments.moderate`                          | `messages.read`, `messages.send`, `messages.moderate`                                                |
  | `comments.activity`, `blocks.comments.permissions.activity`                      | `activity.read`, `blocks.activity.permissions.read`                                                  |
  | `comment.created`, `comment.deleted`                                             | `message.sent`, `message.deleted`, with `conversationKind`, `recordType` and `recordId` on a comment |
  | `comment.mentioned`                                                              | the `message.mentioned` notification                                                                 |
  | `commentsEvents`, `CommentsEvents`                                               | `messagingEvents`, `MessagingEvents` from `better-supabase/blocks/messaging`                         |
  | `comments.history(organizationId, subject?, options?)`, `ActivityHistoryOptions` | `createActivity({ transport, tenant }).list(subject?, options?)`, `ActivityListOptions`              |
  | `activitySink`, `activityListQuery` from `better-supabase/blocks/comments`       | the same names from `better-supabase/blocks/activity`                                                |
  | `blocks.comments.options.subjects`                                               | `config.subjects`                                                                                    |
  | `blocks.comments.options.maxBodyLength`, `extraColumns`                          | `blocks.messaging.options.maxBodyLength`, `extraColumns`                                             |
  | `blocks.comments.options.documentSchema`                                         | removed                                                                                              |
  | `blocks.comments.entities` (the `comment` entity)                                | the `message` entity, `blocks.messaging.entities`                                                    |
  | `comment.mentions`, `comment.createdAt`                                          | `comment.mentionIds`, `comment.sentAt`                                                               |
  | `commentsBlock.schemas`                                                          | removed; `commentInputSchema` and `commentChangeSchema` stay                                         |
  | reactions on `{ type: "comment", id }`                                           | reactions on `{ type: "message", id }`                                                               |
  | `edit_comment`, `delete_comment`, `comment_subject_readable`, `comment_authors`  | `edit_message`, `delete_message`, `can_subject(type, id, 'read')`                                    |
- The comments controller and its React, Vue, Svelte and Solid hooks fetch only the comment a Realtime ping names, and its thread's root for the reply count, instead of loading the whole thread again. A client passed to `createCommentsController` now needs `get`.
- `better-supabase/preset` adds the `community` and `feedback` presets to the `verticals` group, both on the `posts` block. `community` is `saas` plus forum boards of voted posts, messaging and channels, comments, reactions, moderation, post scores, tags and search. `feedback` is `saas` plus feedback boards of voted ideas with roadmap statuses, comments, reactions, moderation, work items linked to the ideas they ship through relations, tags and search. Each has a page under [Presets](https://bettersupabase.com/docs/presets). Existing presets and preset codes keep their blocks.
- Add the `contracts` block (`better-supabase/blocks/contracts`). A contract with a registered counterparty moves from draft through review and e-signature (the contract is frozen as a version and signed with the `signatures` block) to active, expired or terminated. Values are bigint minor units, renewal terms (auto-renew, a notice period) schedule a reminder for the owner before the notice deadline, and `runRenewals()` renews or expires contracts past their end date. Obligations carry due dates and owners, and the `contracts` section lists a record's contracts. Install it with `better-supabase add contracts`; it needs `subjects`, `versions`, `signatures` and `reminders`.
- Add the `controls` block (`better-supabase/blocks/controls`). It keeps compliance frameworks and their controls, evidence linked to a control (an attachment or a note, valid until a date), and recurring evidence tasks with an assignee and an overdue state that `notifyOverdue()` reports once per lapse. A control's status (passing, failing, not started or not applicable) is computed from its evidence and tasks and rolled up per framework. Policies are published as versions, and members acknowledge each one. Install it with `better-supabase add controls`; it needs `versions`.
- New `courses` block (`better-supabase/blocks/courses`) for courses with modules, lessons, scored quizzes, learner progress and certificates. It runs on the `enrollments` block: a learner's enrollment in a course is an enrollment in the program `course:<id>`, with a step per required lesson, so the enrollments block keeps one record of who is enrolled in what.

  - `save`, `saveModule` and `saveLesson` build the outline; modules and lessons order by fractional rank keys (ADR 0021), and a new one goes last. A lesson is `content`, `video` or `quiz`; a quiz lesson names a `forms` form.
  - `enroll`, `unenroll`, `start`, `complete`, `reset` and `submitQuiz` track progress per lesson in `course_lesson_progress`. Postgres grades a quiz through the forms block's scoring, so the browser never sees the answer key and can't change the score; a passed quiz completes its lesson.
  - Completing every required lesson completes the course and, when the course gives one, issues a certificate with a random code. `verifyCertificate(code)` checks it without signing in; `revokeCertificate` withdraws it.
  - `outline`, `lesson`, `progress` and `learners` read the course; members read published courses, `courses.enroll` enrolls others, `courses.manage` builds them.
  - The block emits `course.*`, `course_lesson.completed` and `course_certificate.*` events, and every write is audited. Its five tables rename through `blocks.courses.entities`.
  - The enrollments block refuses to complete or reset the steps of a `course:` program, so progress only changes through the courses block.

  Install it with `courses: {}` next to `forms` and `enrollments`, then run `better-supabase sql sync` and `db diff`. The `course-outline` and `lesson-player` recipes render it with ReUI.
- Add `blocks.engagements.options.bookings`. With the bookings block installed and the option on, every confirmed booking becomes a `meeting` engagement with the external ref `booking:<id>`: rescheduling moves it, and cancelling completes it with the outcome `cancelled: <reason>`. With parties installed, the meeting sits on the person whose user or email matches the booker's, and on their company. A trigger on the bookings table does this, so bookings made through the public link or by a direct write are logged too.
- Add the `enrichment` block (`better-supabase/blocks/enrichment`). `createEnrichment({ transport, provider })` looks a parties company or person up and stores the profile per record and provider. A match fills the record's empty `domain` and `website`, or `phone` and `title`, and never overwrites a value. `enrichDue()` works through records with no result or a stale one from a cron, and every result writes `party.enriched`. `peopleDataLabsEnrichment({ apiKey })` calls People Data Labs over `fetch`; any other source plugs in as an `EnrichmentProvider` with `apiVersion: 1`. The block adds the `enrichment.read` and `enrichment.run` permissions.
- Add the `forecasts` block (`better-supabase/blocks/forecasts`). `createForecasts({ transport })` snapshots the deals forecast for a period and currency with `snapshot()`, keeps a copy of the deals behind each number, and lists, opens and compares snapshots with `list()`, `get()` and `changes()`, which labels each deal added, removed, won, lost or changed. Every snapshot writes `forecast.snapshotted`, and the block adds the `forecasts.read` and `forecasts.snapshot` permissions.

  Add the `field-history` block (`better-supabase/blocks/field-history`). Name the database columns to track per subject type in its `subjects` option, and a trigger records each change with the old and new value, the author and the time. `createFieldHistory({ transport }).of(subject)` reads a record's changes, newest first, for members with `history.read` and read access to the record.
- Add the `mail-sync` block (`better-supabase/blocks/mail-sync`). Members connect their own Gmail or Outlook mailbox, and `syncDue()` pulls new mail on a cron: each thread lands as an `email` engagement on the people whose addresses appear in it and, with `matchDomains`, on the companies whose domain matches. `threadsOf(record)` lists a record's mail, `send()` sends a new message or a reply and stores it on its thread, and a private mailbox stays visible only to its owner.

  `gmailMail()` and `outlookMail()` call the Gmail API and Microsoft Graph over `fetch`, so no SDK is needed. Another host implements `MailProvider` (`apiVersion: 1`). Tokens come from your `CredentialProvider`, such as `vercelConnectCredentials()`, asked for the mailbox owner, and the block never stores one. The block adds the `mail.read`, `mail.connect`, `mail.send` and `mail.manage` permissions and the `mail_account.connected`, `mail_account.disconnected`, `mail_message.received` and `mail_message.sent` events. Deleting a member's data purges their mailboxes and revokes the credential reference.
- Track opens and clicks of mail sent through the `mail-sync` block, and record which template version a message came from. With `tracking: { url }` on `createMailSync`, `send()` rewrites the HTML body's links through your tracking route and adds an open pixel; the database keeps the original HTML and only a SHA-256 of the message's tracking token. `track(request)` answers that route: it returns the pixel for an open, redirects a click to the stored link, and answers 404 for an unknown token. `track: false` sends one message untracked.

  `send()` takes a `templateVersionId`, such as a `templates` version, and `templateStats(organizationId, { since })` returns the messages sent, opened, clicked and replied to per version. Sent messages carry `tracked`, `openCount`, `clickCount`, `openedAt` and `clickedAt`. The block adds the `mail_message.opened` event (once per message) and the `mail_message.clicked` event (each click, with its `url`).
- The crm preset now installs deals, quotes and sales targets next to pipelines, so a new CRM app starts with the full sales flow.
- Add the `segments` block (`better-supabase/blocks/segments`). A segment saves a rules filter tree over one subject type, such as companies in the `customer` lifecycle, and keeps the records it matches in Postgres. `refresh(segmentId)` or `refreshDue()` on a cron re-evaluates it and writes `segment.entered` and `segment.left` for each record that joined or left, so a workflow can react. `members()`, `of(record)` and `list()` read the result. The block adds the `segments.read` and `segments.edit` permissions and the `maxMembers` option.

  Add the `assignment` block (`better-supabase/blocks/assignment`). A queue covers one subject type, a `round_robin` or `weighted` strategy and an optional territory filter. `assign(organizationId, { type, id, fields })` picks an owner from the first matching queue, logs the pick and writes `subject.assigned`; the app writes the owner onto the record. The block adds the `assignment.read`, `assignment.manage` and `assignment.assign` permissions.
- Add the `dispatch` block (`better-supabase/blocks/dispatch`). It schedules work orders at a location or asset into time slots for technicians who are members, orders a technician's day as a route of stops, moves each order through `unscheduled`, `scheduled`, `en_route`, `on_site`, `done` and `cancelled` with an event for every step, and links the parts an order uses to assets or inventory items. `board()` returns the schedule and the backlog for a date range, and `dispatchSection` shows a record's work orders. Install it with `better-supabase add dispatch`.

  Add the `shipments` block (`better-supabase/blocks/shipments`). It stores packages, addresses and labels, keeps a tracking timeline per shipment, and derives the status and delivery time from the latest tracking event, and `shipmentsSection` shows a record's shipments. Carriers plug in through the versioned `ShipmentCarrier` interface (`apiVersion: 1`); `flatRateCarrier` covers fixed prices, and `ingest()` records a carrier's signed tracking webhook once, however often it is delivered. `better-supabase/testing` adds `testShipmentCarrier`, the conformance case for carrier adapters. Install it with `better-supabase add shipments`.

  The Next.js example adds a dispatch board, a technician's day and shipment tracking as UI recipes.
- Documents now take suggestions and named versions.

  - `suggest(documentId, update, summary)` stores a Yjs update as a suggestion without changing the document. Members get the new `documents.suggest` permission, so someone who can only read a subject can still suggest.
  - `suggestions(documentId)` lists them, and `decideSuggestion(id, decision, note)` accepts, rejects or withdraws one. Accepting appends the update to the log with the author as its actor.
  - The provider from `better-supabase/yjs` takes `mode: "suggest"` and `setMode()`, keeps edits as a draft in that mode, and adds `submitSuggestion()` and `acceptSuggestion()`. `open` returns `canSuggest`.
  - `nameVersion(documentId, versionId, label)` names or unnames a version, `versions(documentId, { named: true })` lists the named ones, and each version carries `source` and `frozen`.
  - New events: `document.suggested`, `document.suggestion_accepted` and `document.suggestion_rejected`.

  Run `better-supabase sql sync`, then generate a migration. If you list role permissions yourself, add `documents.suggest` where members should suggest.
- The drive takes labels and file approvals.

  - With the tags block installed, `label(id, tagId, applied?)` puts a tag on a node or takes it off, and `labeled(organizationId, tagId)` lists the nodes with a tag. Every node carries `labels`.
  - With the approvals block installed, `requestApproval(id, { attachmentId, reason })` asks for approval of the file's current content or of one earlier revision, and `approvals(id)` lists the requests with `attachmentId`, `revision` and `current`. Every node carries `approval`, the request for its current content, so a new upload clears it and restoring an approved revision brings it back. Add a policy for the `approve` action on `drive_node`.
  - New types: `DriveLabel`, `DriveApprovalState` and `DriveApproval`.

  With a managed drive installed, the tags and approvals functions now check the drive grants for `drive_node` subjects: reading tags or listing requests needs read access to the node, and tagging or requesting approval needs write access. Before, any member of the organization could do both. Run `better-supabase sql sync`, then generate a migration.
- Add the `enrollments` block: enroll a party, member or any record in a program of steps, and track its progress and completion.

  - `better-supabase add enrollments` installs programs per organization. A program has ordered steps and the subject types it enrolls. Saving changed steps raises the program's version, and enrolling again picks up the new steps while keeping the completed ones.
  - `createEnrollments` from `better-supabase/blocks/enrollments` saves programs, enrolls subjects, completes and resets steps, cancels enrollments and lists them by program, subject or status. Changes emit `enrollment.created`, `enrollment.step_completed`, `enrollment.completed` and `enrollment.cancelled`, and `enrollmentsSection` shows a record's enrollments on its page (ADR 0077).
  - New permissions: `enrollments.read`, `enrollments.enroll` and `enrollments.manage`.
- `sql upgrade` renames an installed block's tables when you change `blocks.<name>.entities`, instead of refusing the change.

  - It compares the module SQL under the installed names (from the `@bs-entities` line) and the new ones, and writes guarded renames for tables, columns, indexes, policies, triggers and functions, so the rows stay. Permission keys in a role and permission catalog and stored `subject_type` values move to the new names too. Each statement checks the old name first, so a second run changes nothing.
  - `sql upgrade --check` reports `<block> has entity renames to apply to its tables`, and its JSON `data` gains `renames` when there are any.
  - Number sequence keys, recorded outbox events and your own hook functions keep their names; rename those yourself.
  - SQL modules can declare a renamed table or column in `NAMES.migrations`. `sql upgrade` renders the rename after your table and entity renames.
  - `entityRenameChanges` is removed from `better-supabase/sql`. Use `entityRenamePlan(names, installedModules, installedSql, layout)`, which returns the rename steps and the modules it can't pair up.
- Add the `field-encryption` block and audited reads, for apps that keep values such as a diagnosis or a birth date.

  - `field-encryption` keeps one data key per tenant, user or app in Supabase Vault and encrypts values with pgcrypto (AES-256). `drop_field_key(owner)` deletes a key, which shreds every value sealed under it, and records `field_key.dropped`.
  - An app table column takes `encrypted: true`. It is stored as ciphertext, hidden from signed-in users, and read through `public.<table>_reveal(ids)`, which returns the configured types for the rows in the caller's scope. `gen` types the column as its configured type. An encrypted column can't be unique, indexed, a reference, an identity, `text[]`, or used where the database reads the value.
  - Custom fields take `encryptDefinition(id)` for `text`, `email` and `url` fields, and `listDefinitions` reports `encrypted`. The definitions table gains an `encrypted` column, added to an adopted table too unless you map `blocks.custom-fields.columns.definitions.encrypted` to `null`; run `sql sync` and generate a migration.
  - `appTables.<t>.auditReads` and the `custom-fields` option `auditReads` write one audit entry per tenant and call, with the new category `read`, the acting member and the row ids. They need the `audit` block.
  - Designs that use `encrypted` or `auditReads` are design format 6, and turning encryption on or off is a breaking change in the design diff.

  There is no searchable equality on encrypted values; see the Field encryption docs page for the limits.
- Anchor messages and comments to a file. `fileAnchor(attachmentId, { page, region, start, end })` from `better-supabase/blocks/messaging` or `better-supabase/blocks/comments` points a top-level message at a page, a region of a page or video frame, or a moment or span of an attachment on the same record or conversation. `send_message` checks the shape and the file and fails with `MESSAGING_ANCHOR_INVALID` otherwise, and `anchoredTo(attachmentId)` on both clients lists a file's messages or comments. Other anchors, such as text ranges, are stored as before.
- Turn an event in one block into a record in another, and build forms from a block's entity schema with the organization's custom fields. See [Flows](https://bettersupabase.com/docs/blocks/automations), [Entity forms](https://bettersupabase.com/docs/blocks/entity-forms) and [Composing blocks](https://bettersupabase.com/docs/blocks/composing-blocks).

  - `better-supabase/flows` is a new subpath. `defineFlow({ name, on, target, source, load, input, map, create })` describes a flow, and `createFlows({ flows, clients, enqueue })` runs the listed flows from the outbox through `flows.sink()`, or as jobs through `enqueue` and `flows.handler()`. A flow runs once per source record: it records a `created_from` relation and finds it again on a redelivered event. Flows carry `apiVersion: 1`.
  - Four built-in flows, each off until listed: `conversationToWorkItem`, `applicationToEmployee`, `bookingToEngagement` and `quoteToOrder` (which takes your `create`, since orders have no conversion from a quote yet).
  - `entityForm(schema, { fields, labels, customFields, timeZone })` in `better-supabase/records` lists the form fields of an entity schema and the custom fields, turns input text into typed values and validates both, returning the entity input and the custom values apart.
  - `useEntityForm(form, { initial, onSubmit })` in `better-supabase/react` holds the values, errors and pending state of an entity form.
  - The `entity-form` recipe renders the work item create dialog with the organization's custom fields.
- The `forms` block scores quizzes and rolls up Net Promoter Scores. Existing forms keep working: scoring and NPS only apply to versions that use them.

  - A field takes a `correct` answer and `points` (1 by default), and a definition takes `scoring.passMark`, a percentage. `publish` moves the correct answers into the new `form_answer_keys` table, which no client role reads, so respondents never see them; callers with `forms.manage` get them merged back.
  - Postgres scores a final submission inside `submit` and stores `score`, `max_score` and `passed` on `form_submissions`. Submissions return them as `score: { score, maxScore, passed }`, and the `form_submission.submitted` event carries them. `formScore(definition, answers)` computes the same grade in the app.
  - The `nps` field type takes an integer from 0 to 10. `forms.nps(formId, { field, since, until, bucket })` (SQL `form_nps`) returns the promoters, passives, detractors and score, overall and per UTC day, week, month, quarter or year. `npsCategory` and `npsScore` do the same in the app.
  - On adopted tables, map `tables.answerKeys` and the `score`, `maxScore` and `passed` submission columns to score; without them, `publish` refuses a correct answer with `FORM_SCORING_UNAVAILABLE`.

  The forms module moves to version 2. Run `better-supabase sql sync`, then `db diff`, to add the table and columns.
- `gen` writes a record feature per subject, so a record page needs no hand-written loader or actions.

  - With a block that adds record sections installed, `gen` writes `features/<subject>/` next to the main module: the feature (`defineRecordFeature`), and for Next.js a server file (`recordServer`), one Server Action per section write and a client provider. With TanStack Query installed it also writes the record's query options. Set `generateFeatures: false` to turn it off. No UI is generated; the cards stay recipes.
  - The share links, relations, versions and SLA blocks add sections (`shareLinks`, `relations`, `versions`, `sla`), and every section can declare its writes.
  - `better-supabase/records` adds `defineRecordFeature`, `featureWrites` and `runSectionWrite`; `better-supabase/next` adds `recordServer`; `better-supabase/react` adds `createRecordProvider`; `better-supabase/query` adds `recordQueries` and `invalidateOnRecordEvents`.
- Add the `guests` block: clients and contractors invited by email who reach only the records you grant them, and a portal for the party they act for.

  - `better-supabase add guests` installs guests and per-record grants. `createGuests` from `better-supabase/blocks/guests` invites by email (a one-time token whose email must match on accept), grants `read`, `comment` and `upload` on a record, revokes, and lists a record's guests or the caller's own access; `guestsSection` shows a record's guests on its page.
  - Comments, attachments, invoices and bookings let guests in through the new `ctx.scope.guest` and `ctx.scope.guestParty` checks: a granted record, and the invoices addressed to the guest's party. Guests are never members, and without the block the policies don't change.
  - An authorization provider can answer for guests through the optional `portalSubjects` and `portalParties` templates in `authorization.functions`; `apiVersion` stays 1.
- Move the inbox onto the messaging core. Inbox conversations are core conversations of kind `inbox`, and messages and internal notes are core messages, so the inbox shares read markers, typing, mentions, files and reactions with channels and comments. See [Inbox](https://bettersupabase.com/docs/blocks/inbox) and the upgrade steps in [From 0.13 to 0.14](https://bettersupabase.com/docs/migration/0.13-to-0.14#the-inbox-on-the-messaging-core).

  - The inbox block now requires `messaging` and `attachments`, and reactions on inbox messages need the `reactions` block.
  - Notes are messages with `kind: "note"` that only staff read. Read markers live in `conversation_members.last_read_at`, and typing goes through messaging's `set_typing`.
  - Files are scanned attachment rows. Upload with `messages.createUpload` and `confirmUpload`; channel files wait in `files.pending()` until the chat-sdk `importFiles` fetches and confirms them.
  - `useConversation`, `useInboxWidget` and their controllers take `get(messageId)` and fetch only the message a Realtime ping names.
  - The inbox module goes to version 4. `better-supabase sql upgrade` moves conversations, messages, notes, participants, read markers, files and reactions with their ids, then drops the old tables and functions.

  | Before                                                                 | After                                                                      |
  | ---------------------------------------------------------------------- | -------------------------------------------------------------------------- |
  | `inbox_messages`, `conversation_participants`, `conversation_reads`    | `messages` with `inbox_message_details`, `conversation_members`            |
  | `set_inbox_typing`                                                     | `set_typing(conversation, typing, actor)`                                  |
  | reactions on `{ type: "inbox_message", id }`                           | reactions on `{ type: "message", id }`, through `react_to_inbox_message`   |
  | `messages.react` and `InboxMessage.reactions` as `{ [emoji]: actors }` | arrays of `{ emoji, count, reacted, actors }`                              |
  | `InboxMessage.mentions`                                                | `mentionIds`                                                               |
  | `authorId`, `editedAt`, `deletedAt` as `null`                          | `undefined`; a contact's id is `conversation.contactId`                    |
  | `MessageAttachment`, paths in `MessageInput.attachments`               | upload ids in `attachments`, channel files as `InboxFileSource` in `files` |
  | `PurgedContact.attachments` as paths                                   | `{ bucket, path }` records                                                 |
  | the topic `inbox:<conversationId>`                                     | `messages:<conversationId>`                                                |
- The inbox block schedules staff replies and wakes snoozed conversations on time.

  - `inbox.messages.schedule(conversationId, input, at)` saves a reply for later in the new `scheduled_inbox_messages` table. Only its author sees it (`messages.scheduled`), moves it (`reschedule`) or deletes it (`cancelScheduled`). `inbox.publishDue()` (service only) sends the due replies through the normal reply path as their authors, and drops a reply whose author lost `inbox.reply`.
  - Snoozing needs a time in the future: `set_conversation_status` with a past `snoozed_until` now fails with `INBOX_SNOOZE_UNTIL` (`22023`).
  - `wakeSnoozed()` logs `unsnoozed` in the conversation's events, emits the new `inbox_conversation.unsnoozed` event and notifies the assignee, or the inbox's members while nobody is assigned (`inbox.snooze_ended`).
  - With the jobs block, snoozing and scheduling queue a delayed job on `inbox_schedule` (the new `scheduleQueue` option) for the right time, so a handler that calls `wakeSnoozed()` and `publishDue()` runs them without a cron.
  - The chat-sdk `maintain()` also sends the due replies and returns their count as `published`.

  Run `better-supabase sql sync` and generate a migration for the new table and functions.
- Add the `incidents` block (`better-supabase/blocks/incidents`). Incidents carry a severity, a status timeline of updates, the components they affect and a postmortem. A component's status follows its open incidents and keeps an uptime history. Each organization can publish a status page, and `publicStatusPage(slug)` returns it to anyone, signed in or not: the public components with their daily uptime and the public incidents with their public updates, and nothing else. Install it with `better-supabase add incidents`.
- Add the `leases`, `expenses` and `donations` blocks, which post on the ledger instead of keeping balances of their own.

  - `leases` (`better-supabase/blocks/leases`) lets units from the assets block to parties, with rent in bigint minor units per week, month, quarter or year. `postDueRent()` posts each rent period on the ledger once, so running it again posts nothing new, and `recordPayment()` settles the balance due. Deposits are held through ledger holds until `terminate()` captures a deduction and returns the rest. Leases renew, and members file maintenance requests on the unit. The `leasesSection` lists a party's leases.
  - `expenses` (`better-supabase/blocks/expenses`) collects reports with lines in categories and receipts as attachments on the lines. `submit()` checks the spend policy (a limit per category and a receipt threshold) and opens an approval through the approvals block. An approved report posts on the ledger against the member's payable account, and `reimburse()` settles it.
  - `donations` (`better-supabase/blocks/donations`) records gifts and pledges from donors to funds and campaigns, and posts each gift on the ledger. `give()` and `chargeDuePledges()` charge through a `PaymentProvider`, once per pledge period, and `webhook()` applies the provider's events. Gift and yearly tax receipts are numbered per organization, with number-sequences when it's installed, and `givingSummary()` returns a donor's year. The `givingSection` shows a donor's giving.

  Each block takes `extraColumns` on its main table (`lease_agreements`, `expense_reports`, `donations`), typed with the client's `fields` schema.

  Install them with `better-supabase add leases`, `add expenses` or `add donations`; each needs `ledger`.
- Add the `ledger` block: double-entry accounts, balanced transactions, running balances and holds. Inventory stock, leave balances and invoice payments move onto it (breaking for apps that read those tables directly).

  - `better-supabase add ledger` installs accounts per organization in a currency (minor units, `Money`) or a custom unit, transactions whose entries sum to zero per unit, append-only entries corrected by `reverse`, and holds that are captured or released. `createLedger` from `better-supabase/blocks/ledger` opens accounts, posts and reads balances; `ledgerSection` shows a record's accounts on its page. Credits, loyalty points, gift cards and trust balances are account kinds.
  - Inventory, leave and invoices require `ledger`. Their client methods and SQL functions keep their names and results; `sql upgrade` copies the old rows into ledger transactions with the same ids and drops the old tables.

  | Before                                   | After                                                              |
  | ---------------------------------------- | ------------------------------------------------------------------ |
  | `stock_movements`                        | `ledger_transactions` of kind `stock.<reason>`                     |
  | `leave_ledger`                           | `ledger_transactions` of kind `leave.<entry>`                      |
  | `invoice_payments`                       | `ledger_transactions` of kind `invoice.payment` / `invoice.refund` |
  | `blocks.<name>.tables` key for the above | removed; read the ledger instead                                   |

  See the 0.13 to 0.14 upgrade guide.
- Add the `channels` block: public and private team channels on the messaging core, with topics, purposes, membership, archiving and an optional linked record. See [Channels](https://bettersupabase.com/docs/blocks/channels).

  - `createChannels` in `better-supabase/blocks/channels` creates, updates, archives, joins, leaves, invites, lists, browses and finds channels, and lists the channels linked to a record.
  - Channels are an entity, so search, share links and reactions accept them, and `blocks.channels.entities` renames them.
  - `useMessages` and `useConversations` in `better-supabase/blocks/messaging/react` (and the Vue, Svelte and Solid entries) keep a conversation's messages, a thread, who is typing and the conversation list live over Realtime. `createMessagesController` and `createConversationsController` run them without a framework.
  - The `chat` preset installs `saas` with messaging, channels, reactions and attachments.
  - The `channels.create` and `channels.manage` permissions join the default roles.
  - The recipes `channel-sidebar`, `message-list`, `message-composer` and `thread-panel` build a chat screen.
- Add the `messaging` block: conversations, members, threaded messages, edits, pins, mentions, scheduled messages, read markers and typing, which channels, comments and the inbox build on (ADR 0062). See [Messaging](https://bettersupabase.com/docs/blocks/messaging).

  The inbox renames the names that now belong to the core. Run `better-supabase sql upgrade` (the inbox module moves to version 3) and update your calls:

  | Before                                        | After                                                           |
  | --------------------------------------------- | --------------------------------------------------------------- |
  | the `conversations` table                     | `inbox_conversations`                                           |
  | `send_message`, `edit_message`, `get_message` | `send_inbox_message`, `edit_inbox_message`, `get_inbox_message` |
  | `list_messages`, `list_conversations`         | `list_inbox_messages`, `list_inbox_conversations`               |
  | `set_typing`                                  | `set_inbox_typing`                                              |

  `createInbox` and the Chat SDK inbox adapter call the new names, so apps that go through them need no change beyond the upgrade.

  - `createMessaging` in `better-supabase/blocks/messaging` opens channels, direct and group messages and record conversations, sends, edits and threads messages, lists conversations with unread and mention counts, and moves read markers.
  - Messages and conversations are entities, so reactions, attachments, share links and search accept them, and `blocks.messaging.entities` renames them.
  - Mentions notify readers of the conversation, direct messages notify the other people in them, and thread replies notify the thread's followers.
  - The `messages.read`, `messages.send` and `messages.moderate` permissions join the default roles.
  - `ctx.enqueue` in module code takes a `delay`.
- Add the `moderation` block: reports, a review queue, decisions and bans for any subject.

  - `better-supabase add moderation` installs moderation items, reports, a decision log and bans per organization. Members report a subject for one of the configured `reasons`. At `autoHideThreshold` open reports (3 by default, 0 turns it off) the content is hidden until a moderator decides.
  - `createModeration` from `better-supabase/blocks/moderation` reports content, reads the queue and one item with its reports and actions, decides (approve, hide, remove, restore, or ban, which removes the content and bans its author), reads the statuses of a list of subjects, and bans and unbans users. Decisions emit `moderation_item.decided`, reports emit `moderation_report.created`, and bans emit `moderation_ban.created` and `moderation_ban.lifted`. `moderationSection` shows a record's item on its page.
  - With the messaging block, report a message or a comment as `{ type: "message", id }`. Hidden, removed and pending messages stay readable only to their author and moderators, removing a message empties it, and banned members can't send messages or comments.
  - New permissions: `moderation.read`, `moderation.report`, `moderation.manage` and `moderation.ban`. Members get `moderation.report` by default.
- Breaking: onboarding checklists run on the enrollments block.

  - The onboarding block now requires `enrollments`. Each checklist is the program `onboarding:<checklist>`, with the organization or the user enrolled in it.
  - `better-supabase sql upgrade` moves the rows of `onboarding_progress` into enrollments and their completed steps, then drops the table. `createOnboarding`, `useOnboarding` and the onboarding functions keep their signatures.
  - Queries on `onboarding_progress` read `enrollment_steps` joined to `enrollments` instead. See the 0.13 to 0.14 guide.
- Add the `passes` block (`better-supabase/blocks/passes`): admission passes on QR codes and NFC tags. Issue a ticket for a booking or a pass for a resource, sign its token with the signing-keys block (`token`, `typ: vnd.pass+jwt`), and check holders in with `checkIn`, which verifies the signature against your JWKS before the database decides. Every scan is logged with why it was turned away, and passes without a booking count against the resource's capacity with its confirmed bookings and live holds. A passes section lists a record's passes, and a merged record keeps its passes. Install it with `better-supabase add passes`.
- The app can now check permissions and show any subject's title without writing its own queries.

  `better-supabase/blocks/access` calls the new `my_permissions(tenant, keys)` SQL function, which returns the keys the caller holds in a tenant. `access.can(key)`, `access.flags({ edit: key })` and `permissionFlags(set, map)` return `canX` booleans typed by the keys you pass, such as the generated `permissions` constant. In React, `usePermission(key)` and `<Can permission={key}>` from `better-supabase/blocks/access/react` share one call per render, and the Vue, Svelte and Solid bindings export `usePermissions`.

  `better-supabase/blocks/subjects` resolves `SubjectRef` lists to a title and a path through the new `subject_titles(refs)` SQL function, which runs as the caller and leaves out rows the caller can't read. `useSubject` and `useSubjects` batch every lookup of one render into one call. The new SubjectLink, SubjectChip and Can recipes show them in the Next.js example.

  `gen` now writes a `urls` entry for every subject type with a `url` (block entities, `config.subjects` and app table subjects), and `better-supabase add client` passes the generated `blockConfig` to the browser client when the project has blocks.

  To upgrade, run `better-supabase sql sync` and generate a migration. With `blocks.access.mode: "custom"`, write `my_permissions(tenant, keys text[] default null) returns text[]` yourself before you use `<Can>`.
- Breaking: reactions carry up and down votes.

  - `vote(subject, value)` sets the caller's vote (`1`, `-1`, or `0` to clear it) and returns the subject's tally, and `votes(subjects)` reads tallies keyed `type:id`. The `reaction_scores` table keeps each subject's score, up and down counts.
  - Reaction rows gain `kind` (`emoji` or `vote`) and `value`, and the unique key includes `kind`. `better-supabase sql upgrade` fills them for existing rows. A custom-mode reactions table needs both columns.
  - `reaction.added` and `reaction.removed` also fire for votes, with `kind` and `value`. Filter on `kind === "emoji"` to keep the old meaning. See the 0.13 to 0.14 guide.
- Show record sections as list columns, merge a record's sections into one timeline, and list what waits on the caller across blocks. See [Record lists and My work](https://bettersupabase.com/docs/repository/record-lists).

  - `defineRecordList(feature, list, { columns })` in `better-supabase/records` runs a list query and fills each section column (`count` or `data`) for the whole page in one wave, with `can` flags and the filter fields. `recordListServer(bs, recordList)` in `better-supabase/next` loads it as plain data.
  - `loadTimeline(source, subject)` merges every section with a `timeline` hook, newest first, and lists the sections it skipped. The comments, versions and attachments sections map their rows to timeline entries, and the comments section counts rows for list columns. `recordServer(...).timeline(id)` loads it after the permission check.
  - The subjects block installs `my_work(tenant, page_size)`: assigned work items, approvals the caller can decide, due reminders, mentions and SLA warnings, each only when its block is installed. `blocks.subjects.myWork()` calls it, and `loadMyWork` groups the items by kind with counts.
  - `useRecordListState(list, useQueryStates(parsers))` in `better-supabase/react` keeps a list's sort, facets, filter and page in the URL through nuqs and returns the typed query.
  - With record features on, `gen` writes `navigation.generated.ts` and `commands.generated.ts`: a sidebar item and a "Go to" command per subject with a list route, each with its read permission.

  Run `better-supabase sql upgrade` to add `my_work` to an installed subjects block.
- Load a record page's sections with `better-supabase/records`.

  - Blocks declare the parts of a record page they own with `defineSection` (an extension interface, `apiVersion: 1`) on the manifest's `sections`. The comments, attachments and tags blocks ship `commentsSection`, `attachmentsSection` and `tagsSection`, each with an optimistic reducer for `useOptimistic`.
  - `loadSections` loads several sections of one record in parallel, each settled as `ok`, `error` or `hidden`; `sectionCan` hides the sections the caller has no permission for, without running their query. `countSection` counts one section's rows for many records. `createRecordController` keeps the sections current over one realtime topic.
  - The subjects block's `summary` calls the new `record_summary` function: the record's title and path with a count and unread flag for `comments` and `attachments` in one round trip. Tags are not counted, because taggings have no read policy. Apps in custom access mode that install `subjects` get `record_summary` in the subjects contract.
  - `better-supabase/next` adds `recordTag`, `sectionTag`, `invalidateSection` and `bs.sectionAction`, a block action that drops the cached section after a write.
  - `better-supabase/testing` adds `testSection`, which checks a section against the `defineSection` contract.
- Breaking: review moderation moves to the moderation block.

  - The reviews block now requires `moderation`. Each review has a moderation item, and moderators can decide on reviews from the moderation queue next to other content. `publish_review` and `reject_review` keep working and approve or hide the review's item.
  - The `moderated_by` and `moderated_at` columns of `reviews` are gone. `better-supabase sql upgrade` moves them onto moderation items and logs each past decision, and `better-supabase sql data` puts the reviews written before the upgrade under moderation. Read the last decision from the item (`decidedBy`, `decidedAt`) instead.
  - A custom-mode reviews table drops both columns, and `moderatedBy` and `moderatedAt` are no longer column names you can rename. See the 0.13 to 0.14 guide.
- Add the `scores` block: tenant-editable scoring models that write versioned scores to any record.

  - `better-supabase add scores` installs score models per organization. A model has rules (a rules filter tree worth points), metrics (a number field or the vote score times a weight, with a cap) and bands. Saving changed rules, metrics or bands raises the model's version and keeps the old definition.
  - `createScores` from `better-supabase/blocks/scores` saves models, computes a record's score from its fields with a breakdown, and reads current scores, history and the top scores per band. A changed score emits `score.changed`, and `scoresSection` shows a record's scores on its page. Lead scoring and health scoring are models (ADR 0067).
  - New permissions: `scores.read`, `scores.manage` and `scores.compute`.
- Add three blocks: `sequences` for multi-step outreach, `lead-scoring` for scores fed by segments, mail and enrichment, and `posts` for forum and feedback boards.

  - `better-supabase/blocks/sequences` runs email, wait and task steps per contact as an enrollment program (`sequence:<id>`). `runDue()` sends due emails from the contact's mailbox through the new `mailer` port, which `mail-sync` provides, retries failed sends and ends a run when the contact replies. Task steps create work items when `work-items` is installed.
  - `better-supabase/blocks/lead-scoring` scores people, and optionally companies, under a `scores` model. `leadRuleFields(subjectType)` adds the `segments`, `mail.*` and `enrichment.status` fields to the rule catalog, a trigger marks records whose columns, segments or mail changed, and `scoreDue()` rescores them.
  - `better-supabase/blocks/posts` keeps boards and posts with votes from `reactions`, replies from `comments` and reports, hiding and bans from `moderation`. Moderators set a roadmap status, pin, lock and merge duplicates, and `moderation: "pre"` holds new posts for review.
  - `mail-sync` lets the service role record a sent message for any connected mailbox, so a server-side sender such as the sequences runner can send on a member's behalf.
  - The enrollments block refuses to complete or reset a step of a `sequence:` program, as it does for `course:` programs.
  - The `sequence-builder` and `post-board` UI recipes show the blocks in the example app.
- Give signatures their own block at `better-supabase/blocks/signatures`. The SQL module, its tables and `blocks.signatures` stay the same, so only imports change. See [Signatures](https://bettersupabase.com/docs/blocks/signatures) and [From 0.13 to 0.14](https://bettersupabase.com/docs/migration/0.13-to-0.14#signatures-get-their-own-block).

  | Before (`better-supabase/blocks/forms`) | After (`better-supabase/blocks/signatures`) |
  | --------------------------------------- | ------------------------------------------- |
  | `createSignatures` and its types        | `createSignatures` and its types            |
  | `signaturesBlock`                       | `signaturesBlock`                           |
  | `signaturesEvents`, `SignaturesEvents`  | `signaturesEvents`, `SignaturesEvents`      |
- Merging two companies or two people now moves the dropped record's comments, attachments, reactions, tags, relations and guest grants to the kept record in the same transaction.

  - Each module that stores rows per subject installs a merge handler, and `better_supabase.merge_subject_refs(subject_table, keep, drop)` runs them all. Rows the kept record already has (the same reaction, tag or guest grant) are dropped instead of duplicated.
  - Apps can move their own tables in the same merge with a `security definer` function marked `better-supabase:subject-merge`. See Merging subject records on the SQL modules page.
  - Run `better-supabase sql sync` and generate a migration to install the handlers. The `company.merged` and `person.merged` events still fire, so existing consumers keep working.
- Every block method that works on a subject now takes a `SubjectRef` (`{ type, id }`) as its first argument, and reads the tenant from the block's new `tenant` option instead of an `organizationId` argument. This covers comments, attachments, tags, reactions, relations, versions, sla, share links, custom fields, reminders, time tracking, approvals and the state machine. Rows carry `subject: SubjectRef` in place of `subjectType` and `subjectId`, and batch methods take a list of subjects and return a record keyed `type:id`. A call without a tenant fails with `invalid_request` and the hint `TENANT_REQUIRED`.

  `createBlockClient(ctx.db, commentsBlock, { config: blockConfig })` and `blockSet.client(ctx.db, "comments")` build one block for a request: they call through `ctx.db` and read the tenant from `db.$context.tenant`, so `lib/blocks.ts` no longer needs a wrapper per block that passes the organization. In the browser, pass `blocks: blockConfig` to `createClient`, and the hooks read each block's schema and realtime topic from it and the tenant from the client's `tenant` option or the session's tenant claim; the hooks no longer take `schema` or `organizationId`.

  Comments and attachments return their author on `list`: `author` holds the name and avatar URL from the profiles module, through the new `comment_authors` and `attachment_authors` functions, which return only authors of rows the caller may read. It is `undefined` without the profiles module.

  | Before                                                                                                | After                                                                                                                           |
  | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
  | `comments.create({ organizationId, subjectType, subjectId, body })`                                   | `comments.create(subject, { body })`                                                                                            |
  | `comments.list(organizationId, subjectType, subjectId, options?)`                                     | `comments.list(subject, options?)`                                                                                              |
  | `comments.counts(organizationId, subjectType, subjectIds)`, keyed by id                               | `comments.counts(subjects)`, keyed `type:id`                                                                                    |
  | `comments.copy(organizationId, from, to)`                                                             | `comments.copy(from, to)`                                                                                                       |
  | `comments.history(organizationId, subject?, options?)`                                                | `comments.history(subject?, options?)`                                                                                          |
  | `attachments.upload({ organizationId, subjectType, subjectId, ... })`                                 | `attachments.upload(subject \| null, attachment)`                                                                               |
  | `attachments.put({ organizationId, subjectType, subjectId, ... }, file)`                              | `attachments.put(subject \| null, attachment, file)`                                                                            |
  | `attachments.list(organizationId, subjectType?, subjectId?)`                                          | `attachments.list(subject?)`                                                                                                    |
  | `versions.snapshot({ organizationId, subjectType, subjectId, ... })`                                  | `versions.snapshot(subject, input?)`                                                                                            |
  | `versions.list(organizationId, subjectType, subjectId, options?)`                                     | `versions.list(subject, options?)`                                                                                              |
  | `shareLinks.create(organizationId, subjectType, subjectId, options?)`                                 | `shareLinks.create(subject, options?)`                                                                                          |
  | `shareLinks.recordView(organizationId, subjectType, subjectId, viewerKind, viewer?)`                  | `shareLinks.recordView(subject, viewerKind, viewer?)`                                                                           |
  | `shareLinks.list(subjectType, subjectId)`, `setState(subjectType, subjectId, starred)`                | `shareLinks.list(subject)`, `setState(subject, starred)`                                                                        |
  | `tags.create(organizationId, kind, name, ...)`, `tags.list(organizationId, kind?)`                    | `tags.create(kind, name, ...)`, `tags.list(kind?)`                                                                              |
  | `tags.tag(organizationId, subjectType, subjectId, tagId)`, and `untag`                                | `tags.tag(subject, tagId)`, and `untag`                                                                                         |
  | `tags.forSubject(organizationId, subjectType, subjectId)`                                             | `tags.forSubject(subject)`                                                                                                      |
  | `reactions.react(organizationId, subjectType, subjectId, emoji, options?)`                            | `reactions.react(subject, emoji, options?)`                                                                                     |
  | `reactions.summary(organizationId, subjectType, subjectIds)`, keyed by id                             | `reactions.summary(subjects)`, keyed `type:id`                                                                                  |
  | `reactions.customEmoji(organizationId)`, `saveCustomEmoji(organizationId, input)`                     | `reactions.customEmoji()`, `saveCustomEmoji(input)`                                                                             |
  | `customFields.listDefinitions(organizationId, entity)`, `saveDefinition(organizationId, entity, ...)` | `customFields.listDefinitions(entity)`, `saveDefinition(entity, ...)`                                                           |
  | `customFields.readValues(organizationId, subjectType, subjectId)`                                     | `customFields.readValues(subject)`                                                                                              |
  | `customFields.setValue(organizationId, subjectType, subjectId, definitionId, value)`                  | `customFields.setValue(subject, definitionId, value)`                                                                           |
  | `relations.link({ organizationId, source, target, kind, ... })`, and `unlink`                         | `relations.link(source, target, { kind, data?, fields? })`, and `unlink(source, target, { kind })`                              |
  | `relations.of(organizationId, subject, kind?)`, `edges(organizationId, kind)`                         | `relations.of(subject, kind?)`, `edges(kind)`                                                                                   |
  | `relations.ancestors(organizationId, kind, subject)`, and `descendants`                               | `relations.ancestors(subject, kind)`, and `descendants`                                                                         |
  | `relations.rollup({ organizationId, subject, ... })`                                                  | `relations.rollup(subject, { kind, of, agg, column? })`                                                                         |
  | `relations.convertedInto(organizationId, origin, intoType)`, `lineage(organizationId, subject)`       | `relations.convertedInto(origin, intoType)`, `lineage(subject)`                                                                 |
  | `sla.savePolicy(organizationId, policy)`                                                              | `sla.savePolicy(policy)`                                                                                                        |
  | `approvals.request({ organizationId, subjectType, subjectId, action, ... })`                          | `approvals.request(subject, { action, reason?, payload? })`                                                                     |
  | `approvals.list(organizationId, options?)`                                                            | `approvals.list(options?)`, with an optional `subject` filter                                                                   |
  | `approvals.isApproved(subjectType, subjectId, action)`                                                | `approvals.isApproved(subject, action)`                                                                                         |
  | `stateMachine.transition(subjectType, subjectId, to, reason?)`                                        | `stateMachine.transition(subject, to, reason?)`, and the same for `pause`, `resume`, `setFlag`, `state`, `history` and `stages` |
  | `reminders.save(organizationId, subjectType, subjectId, offsetMinutes?, fields?)`                     | `reminders.save(subject, offsetMinutes?, fields?)`                                                                              |
  | `reminders.list(organizationId, subjectType, subjectId)`                                              | `reminders.list(subject)`                                                                                                       |
  | `timeTracking.start(organizationId, fields?)`, `stop(organizationId)`, `running(organizationId)`      | `timeTracking.start(fields?)`, `stop()`, `running()`                                                                            |
  | `timeTracking.log`, `list`, `timesheet`, `submit` and `timesheets` with `organizationId` first        | the same calls without it; entries take `subject` instead of `subjectType` and `subjectId`                                      |
  | `row.subjectType`, `row.subjectId`                                                                    | `row.subject.type`, `row.subject.id`                                                                                            |
  | `useComments({ organizationId, subjectType, subjectId, schema?, topic?, limit? })`                    | `useComments({ subject, topic?, limit? })`                                                                                      |
  | `useReactions({ organizationId, subjectType, subjectIds, schema?, topic? })`                          | `useReactions({ subjects, topic? })`                                                                                            |
  | `useTagInput({ organizationId, subjectType, subjectId, kind, schema? })`                              | `useTagInput({ subject, kind })`                                                                                                |

  To upgrade:

  1. Pass `tenant` to blocks you create yourself (`createComments({ transport, tenant: () => ctx.tenant })`), or build them with `createBlockClient` or `blockSet.client`, which pass the request's tenant.
  2. Replace each `organizationId, subjectType, subjectId` argument list with one `{ type, id }` object, and read `row.subject` instead of `row.subjectType` and `row.subjectId`. Read batch results by `` `${type}:${id}` ``.
  3. Pass `blocks: blockConfig` to the browser `createClient` and drop `schema` from the hooks.
  4. Run `better-supabase sql sync`, then generate a migration, for the comments and attachments author functions.
- Attachments keep versions and show image previews, and work items get checklists, repeat rules, watchers and attachments. See [Attachments](https://bettersupabase.com/docs/blocks/attachments) and [Work items](https://bettersupabase.com/docs/blocks/work-items).

  Run `better-supabase sql upgrade` (or `sql sync` and a new migration): `create_attachment` takes three more arguments, so the upgrade drops and recreates it.

  - `attachments.replace(id, file)` uploads a new file under the same attachment and keeps the current one as an earlier version. `versions(id)` lists them and `restore(id, version)` makes one current again. Both emit `attachment.replaced` and `attachment.restored`.
  - Attachments record `width`, `height` and `pages`, and `attachments.images(options)` signs resized URLs for `useImage`, behind the scan gate.
  - `createAttachmentUploadController` and `useAttachmentUpload` in `better-supabase/blocks/attachments/react`, `/vue`, `/svelte` and `/solid` upload files side by side with progress, wait for the scan, and retry or dismiss a failed upload.
  - Work items take `attachmentIds` on `create` and an `attachments` change on `update`, which link files the caller uploaded without a subject.
  - Work items have checklists: `saveChecklistItem`, `removeChecklistItem` and `checklist`, and each item carries `checklist: { total, done }`. `get(id)` reads one item. The `checklist` section (`workItemChecklistSection`, with the `workItemChecklistOptimistic` reducer) shows the steps on the item's page.
  - With the reminders block installed, `setRecurrence(id, { rrule, tzid })` gives an item a repeat rule, and `workItemRecurrenceSink(workItems)` creates the next item when a repeating one is completed. `repeat(id, dueAt)` runs once per item, so a redelivered event changes nothing.
  - With the notifications block installed, `watch(id, level)` and `watchers(id)` follow an item through notification subscriptions.
- Add AI thread summaries to the messaging block, without tying it to an SDK.

  - `messaging.summarize(target, summarizer, { force, maxMessages })` summarizes a conversation (`{ conversationId }`) or a thread (`{ threadId }`) from the messages the caller can read, leaving out internal notes and deleted messages, and saves it as the service role. `messaging.summary(target)` reads it. A saved summary is `stale` once a message in its scope is sent after it, edited or deleted, and `summarize` returns a current one without calling the model.
  - The summarizer is a versioned `ThreadSummarizer` (`apiVersion: 1`). `summarizeWith(model, { providers, instructions, gateway, maxOutputTokens })` from `better-supabase/ai-sdk` provides one over `generateText` with an AI Gateway model string, tags the spend with the tenant and the `summary` feature, and sends the tenant's own keys as BYOK when you pass the ai-providers block.
  - `createMessaging` takes an optional `service` transport for the summary writes; `createBlocks` passes it.

  Run `better-supabase sql sync` and generate a migration to get the `thread_summaries` table and the `thread_summary` and `save_thread_summary` functions.
- Rename a block's entities once in the config and get typed config keys, renamed clients, automatic subjects, generated name constants, input schemas, block hooks and server helpers. See [Typed blocks](https://bettersupabase.com/docs/blocks/typed-blocks).

  This release breaks the config and a few exports. Update them as follows:

  | Before                                                                                                                  | After                                                                                                                                                |
  | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `sql: { naming: { tenant: "workspace" } }`                                                                              | `blocks: { tenant: { entities: { organization: "workspace" } } }`                                                                                    |
  | `workItemsSubjects()`, `dealsSubjects()`, `partiesSubjects()`, `catalogSubjects()`, `employeesSubjects()` in `subjects` | nothing: installed blocks register their entities as subjects. Remove the spread.                                                                    |
  | the helpers' `url` option                                                                                               | `blocks.<name>.entities.<entity>.url`, such as `catalog: { entities: { product: { url: "/products/{id}" } } }`                                       |
  | `CatalogSubjectsOptions`, `SqlNaming`                                                                                   | `EntityConfig`, `EntitiesConfig`                                                                                                                     |
  | `blockConfig[name].naming` in `blocks.generated.ts`                                                                     | `blockConfig[name].names` (run `better-supabase gen`)                                                                                                |
  | `work_item` methods, types and events after `entities: { work_item: "task" }`                                           | `saveWorkItem` becomes `saveTask`, `workItemId` becomes `taskId`, `work_item.created` becomes `task.created`, `work_items.read` becomes `tasks.read` |

  A `subjects` entry with the name of a block entity is now an error, and changing `entities` on an installed block makes `sql upgrade` refuse.

  - `blocks.<name>.entities` renames a block's nouns in its tables, columns, functions, events, permission keys, subjects and generated types. Every config key under `blocks` is typed from the block's names, and `defineConfig` keeps the config's literal types.
  - `renameBlock(block, names)` in `better-supabase/blocks` renames a block client's methods, argument keys, result keys and events. `gen` writes `tasksBlock` and type aliases such as `Task` for each renamed block.
  - `blocks.generated.ts` also exports `permissions`, `PermissionKey`, `RoleName`, `blockEvents`, `urls`, `topics` and `paths`.
  - The deals, parties, work-items, employees and catalog blocks export Standard Schema input schemas, such as `workItemInputSchema`, `workItemChangeSchema` and `workItemListFilterSchema`.
  - The `zod`, `valibot`, `standardSchema` and `jsonSchema` generators write schemas for SQL functions (`<function>Args`, `<function>Result`) and for block entities (`<entity>Input`, `<entity>Change`, `<entity>ListFilter`). `fieldMeta: true` adds labels, required flags, enum options and check bounds.
  - `blockQueries(blocks)` and `invalidateOnBlockEvents` in `better-supabase/query` make TanStack Query options for every block method and invalidate reads when a block event arrives.
  - `useBlockQuery`, `useBlockMutation` and `useBlockEvent` in `better-supabase/react`, `/vue`, `/svelte` and `/solid` work with any block client, renamed or not.
  - `bs.blockAction(client, method, options)` in `better-supabase/next` and `blockRouter(client, options)` in `better-supabase/orpc` expose block methods as server actions and oRPC procedures.
  - The `nuqs()` generator writes typed search params for your tables, and the `factories()` generator writes test factories, with `defineFactory` and `factoryTime` in `better-supabase/testing`.
- `better-supabase/preset` adds 17 vertical presets in a new `verticals` group (`PRESET_GROUPS.verticals`), so `/create` and `init` list them under Verticals: `property`, `field-service`, `clinic`, `lms`, `events`, `legal`, `spend`, `contracts`, `customer-success`, `logistics`, `pos`, `fitness`, `nonprofit`, `itsm`, `compliance`, `construction` and `claims`. Each one is `saas` plus the blocks of one kind of business app, such as leases on the ledger for property management, a dispatch board for field service or controls with recurring evidence for compliance, and each has a page under [Presets](https://bettersupabase.com/docs/presets). The `clinic` preset installs `field-encryption`; its page shows the config for encrypted columns and read audits, which name your own tables and so stay in your config. Existing presets and preset codes keep their blocks.
- Security fix: a record's conversation and a channel linked to a record stay in the record's tenant. `open_conversation` with kind `subject` (and so `create_comment`) and `create_channel` with `subjectType` and `subjectId` checked only that the record exists and that the caller holds its read permission in the record's own tenant, and they read the record past its table's policies. A member of two tenants could post a comment in one tenant on a record of the other, and any member could when the record type has no read permission in `config.subjects`. Both now refuse a record whose tenant column in `config.subjects` names another tenant, with `MESSAGING_NOT_FOUND` or `CHANNELS_NOT_FOUND`, for the service role as well. Types with `tenant: false` are not compared.

## 0.13.0 (2026-10-21) [#0130-2026-10-21]

- Structured, non-throwing design APIs, a provider-neutral OAuth subpath, deploy records and a doctor rule for dropped library functions. See [From 0.11 to 0.12](https://bettersupabase.com/docs/migration/0.11-to-0.12).

  - `parseDesign`, `decodeDesign`, `designFileSource` and `readDesignFile` in `better-supabase/preset` throw a `DesignParseError` with a stable `code` and a JSON Pointer `path`. `safeParseDesign` and `safeDesignFileSource` return `{ ok: false, issues }` instead of throwing. `DesignTooLongError` is a `DesignParseError` with the code `too_long`.
  - `DesignIssue` from `checkDesign` gains `code` and `path`, and a successful `DesignCheck` gains `fitsUrl`.
  - `designSchema` is `parseDesign` as a Standard Schema, and `schemas/design-v1.json` publishes the design as JSON Schema.
  - `DEFAULT_PRESET_NAME`, `defaultPreset()` and `defaultPresetCode()` name the preset Create and `init` start from.
  - `better-supabase/create/oauth` holds PKCE, sealed cookies and an `OAuthSession` shape for any OAuth provider. `seal` and `unseal` take an optional HKDF `info`. `better-supabase/create/supabase` re-exports them, and `SupabaseSession` and `isSession` stay as aliases.
  - `newDeployId()`, `deployQuery(files, { id })` and `DeployRecord` in `better-supabase/create` let a host app match a project to the preset it deployed. `DeployResponse` gains `id`, `preset` and `design`.
  - Doctor's BS235 warns when an unapplied migration drops a `better_supabase` function without creating it again with the same number of arguments, as a schema diff did for `audit()` in 0.11.
- `codemod 0.9` now pins `defaultFormat: "standard"` on an adopted
  `webhooks-out` block that maps no `columns.endpoints.format` and sets no
  `defaultFormat`, which keeps the 0.8 body, and lists `sql add` calls in
  scripts and workflows for review (`add` also wires clients). A new
  `codemod 0.11` pins `defaultFormat: "cloudevents"` under the same conditions,
  since adopt mode requires the key from 0.11. Both list the edit for review.
- Add `better-supabase db diff`, which turns the declarative schema into a migration with the Supabase CLI's command for the workflow and, when the shadow database can't start because no System V shared memory segment is left, diffs inside the running stack instead. `--in-stack` skips the shadow database, `-f <name>` writes the migration, and `$BSB_PG_BIN` names the directory with `pg_dump` and `psql` when the CLI can't find them next to the postmaster.
- Add doctor checks for the machine's System V shared memory, which every local stack and shadow database needs.

  - BS700 warns when 80% of the segment limit is in use (32 on macOS) and names the stacks that hold segments, so `supabase db diff` failing with `could not create shared memory segment` points at `stack prune`.
  - BS701 warns when a stack holds more than its one interlock segment, which means its Postgres sets `shared_memory_type` or `dynamic_shared_memory_type` to `sysv`.
- `doctor`, `gen`, `db query` and `seed --apply` no longer guess port 54322 for the local stack. When `supabase/config.toml` sets no `[db] port` and `supabase status` fails, they stop with `not_found` and name both sources, so a per-worktree stack (`supabase start --runtime native`) can't reach another project's database on 54322. `--stack <name>` (or `$BSB_STACK`) reads `supabase status --stack <name>` when `supabase/stacks/<name>/config.toml` sets no port.
- A `permission` on an action, route, loader or oRPC middleware can now be checked against the row it acts on. Pass `resource: (input, ctx) => AuthorizationResource` next to `permission`: it runs after the session and `requireTenant` checks, gets the validated input and the request's context (`db`, `session`, `tenant`), and its return value replaces the default `{ type: "action" }` or `{ type: "route" }` resource. A loader that throws refuses the caller: a `DbException` keeps its error (such as `not_found`), and any other throw refuses with `forbidden` and `code: "RESOURCE_UNAVAILABLE"`. Every adapter supports it, and `bs.middleware()` in `better-supabase/orpc` now passes the procedure's input to the checks. The loader's context type is exported as `ResourceLoaderContext`.
- Add `better-supabase stack ls` and `stack prune` for the Supabase CLI's managed stacks.

  - `stack ls` lists every stack with its branch, project root and running state, marks stacks whose worktree or branch is gone, and counts the System V shared memory segments each one holds against the system limit.
  - `stack prune` destroys the stacks whose worktree or branch is gone, never the one for this checkout. `--stop-idle` also stops the running stacks of other branches, and `--dry-run` prints the plan. Destroying a stack needs consent in agent mode.
  - The command manifest has a new effect kind, `stacks`, with `read` or `write` access, in `schemas/cli-commands-v1.json`.
- Crop, show and download stored images through Storage image transformations. See [Image transformations](https://bettersupabase.com/docs/platform/storage#image-transformations).

  - `defineBucket` takes `variants`, named transforms such as `thumb: { width: 160, height: 160, resize: "cover" }`. `renderUrl`, `signedUrl` and `publicUrl` accept a variant name wherever they took a transform, and the names are typed. `defineBucket` refuses a side outside 1 to 2500 or a quality outside 20 to 100.
  - `avatarBucket()` and `organizationLogoBucket()` come with `sm`, `md` and `lg` variants: center-cropped squares of 48, 96 and 256 for avatars, and squares of 64, 128 and 256 that fit the whole logo. Pass `variants` to replace them.
  - `useImage(bucket, target, variant)` in `better-supabase/react` returns `src`, a `srcSet` at 1x and 2x, `width` and `height`. Public buckets build the URLs while rendering; private buckets sign them and sign again before they expire.
  - `storageImageSrcSet({ url })` in `better-supabase/storage` builds a `srcset` for a public Storage URL.
  - New types: `ImageTransform`, `ImageVariant`, `ImageSource`, `StorageImage` and `UseImageOptions`.
  - Fix: the `better-supabase/next/image` loader and `storageImageUrl` keep a crop's shape. When `src` carries a `width` and `height`, the height scales with each requested width, so a square crop stays square. A size over 2500 now shrinks both sides by the same factor instead of clamping the width alone.
- Add `authorization.functions.teamIdsOf`, a template that returns the team ids
  a user belongs to. Under the `provider` access model, `team_ids_of()` in the
  `teams` block returns its set instead of reading `team_members`, and
  `saved-views` and `collections` share with those teams even without the
  `teams` block. Doctor BS417 warns when the provider declares a `team` scope,
  one of those blocks is installed, and the template is unset.
- Seed each tenant with the same demo rows. `defineTenantSeed(betterSupabase, (tenant) => fixtures)` in `better-supabase/testing` builds typed fixtures from a tenant id, and `better-supabase seed --tenant <slug>` (or `--tenant '*'`) inserts them for organizations from the organizations block, all in one transaction. The file comes from the new `seed.perTenant` config key (`supabase/seed.tenant.ts` by default). `seedTenantOnCreate(betterSupabase, seed, { sql })` applies the seed to each organization created in development; it runs after the create call and never changes its result.
- Add `codecs: { timestamptz: "utc" }`. `timestamptz` columns stay `string`,
  but every value is rewritten in UTC as ISO 8601 with a `Z` and keeps all its
  fraction digits (`2026-10-21 07:10:00.123456+02` reads as
  `2026-10-21T05:10:00.123456Z`). `infinity` passes through. The default stays
  `"string"`.
- The Vercel Marketplace block serves the Partner API. `marketplace.partnerApi({ resources, plans })` returns one handler for the installation, resource, secret rotation and plan routes. It verifies Vercel's OIDC token (signature against `/.well-known/jwks`, issuer, audience and expiry) and requires its `installation_id` to match the path, answering 401 or 403 as Problem Details. The installation's access token is sealed in Vault, so `accessToken` is now optional. Tenants are tracked per installation and resource, and an empty tenant the integration created is removed with it. A rotation revokes the previous secrets only after the new ones are issued.

  `reportUsage` now sends one request per billing period, with the period total as `periodValue` and the part not reported before as `dayValue`, and no longer sends `planValue`. It moves the reported amount only after Vercel answers 2xx. `marketplace.usageRoute({ secret, usage })` runs it from a cron caller. The SSO exchange reads keys from `https://marketplace.vercel.com/.well-known/jwks`, the path Vercel's discovery document names. Run `better-supabase gen` to apply the module's version 2 upgrade.
- Add `withClaims(postgres, claims, fn)` to `better-supabase/testing`. It runs
  raw SQL as the user the claims describe (`role` and `request.jwt.claims` are
  set, so `auth.uid()`, `auth.jwt()` and RLS apply) inside one transaction that
  always rolls back.
- Two SQL fixes. See [From 0.11 to 0.12](https://bettersupabase.com/docs/migration/0.11-to-0.12) for what to check.

  - `better_supabase.acting_member()` is granted to `service_role` only; `anon` and `authenticated` get `permission denied` instead of an RPC that returned null. `better_supabase.set_actor()` is `security definer`, so the trigger still records the actor for every writer. Run `sql sync` to get the new grant in a migration. If your own SQL calls `acting_member()`, run it as `service_role` or from a `security definer` function.
  - Module file headers no longer carry the package version, so a release doesn't rewrite every module file. The `@bs-module` marker keeps the module version, and a header written by an older release still counts as current. `ModuleLayout.version` is deprecated.
- The Record grid recipe, on the UI recipes page and in the skill reference, now notes that a copy of `@reui/data-grid` installed from ReUI 2.8.0 or earlier doesn't repaint after an edit that keeps the row count. Reinstall it with `npx shadcn add @reui/data-grid --overwrite`, or add `table.options.data` to the dependency list of the `value` memo in `DataGridProvider`.
- Security fix for support session upload claims. In 0.12.0, `claimSignedUpload`, `claimReserved` and the blocks' `confirm` and `commit` claimed any object without an owner on a path the target may insert into. That could include a file the service role stored earlier, such as an export or an import, which the claim then handed to the target and stamped with the session. A claim now only takes an object without an owner whose `updated_at` is at or after the session's `startedAt`, and in a versioned bucket only the current version. Older objects are refused with `403` `AccessDenied`. An upsert reservation that replaces such an object during the session can still be claimed.

  Required change for direct callers: `SupportStorageOptions.session` now needs `startedAt`. The server already passes the whole `SupportSession`; pass it too when you call `supportStorage` yourself. `uploadToSignedUrl` on the server removes what it stored when the claim fails or is refused. A refused claim after a browser upload, including `upload` with `onProgress`, removes nothing, because the claim can't tell that upload from another object without an owner; `sweep` removes what is never claimed. A claim of an object the target already owns now succeeds even when it is older than the session. The impersonation page documents the limits of the claim's insert rehearsal and how to sweep uploads that are never claimed.

## 0.12.0 (2026-10-20) [#0120-2026-10-20]

- Check a Create design in one call, and hand designs too large for a URL to `init` as a file.

  - `checkDesign(input, preset)` in `better-supabase/create` checks a design object, design code or
    `/create` URL against a preset with every rule the canvas and `sql sync` apply. It returns
    `{ ok: true, design, code }`, where `code` is `null` for a design too long for a URL, or
    `{ ok: false, issues }`, and never throws for a bad design.
  - `parseDesign(value)` in `better-supabase/preset` checks the shape of a design object.
  - `designFileSource` and `readDesignFile` write and read a design file
    (`better-supabase.design.json`, `DESIGN_FILE`), which holds the preset code and the design as
    JSON with no length limit.
  - `init --preset ./better-supabase.design.json` and `apply --preset ./better-supabase.design.json`
    read a design file like a Create URL. `DesignTooLongError` now points to it.
- Doctor's BS503 warns when the shell exports a `SUPABASE_*` variable (plain or with a browser prefix) that differs from the running local stack, so an app started from that shell would talk to another project. It reads `supabase status` only when such a variable is exported, and shows redacted values.
- `better-supabase env` writes the full stack env: `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` under their plain names for server code, the same two with the browser prefix, and the new `SUPABASE_JWKS_URL`. The secret key, database URL and JWT secret never get a browser prefix. `--write <file>` names the file (`--out` still works), and `--prefix` takes a framework name (`next`, `vite`, `expo`, `nuxt`, `astro`, `sveltekit`), `none`, or the prefix itself. Help lists a long alias after the flag it stands for.
- Add `better-supabase run -- <cmd>`. It starts the command with the running stack's URL and keys set, over the shell's exported variables and every `.env` file, passes its output and exit code through, and forwards Ctrl+C and `SIGTERM`. When an exported `SUPABASE_*` variable differs from the stack, `run` warns and names it, showing the host and port of a URL or a `sha256:` fingerprint of a key, never the value.
- Server auth can run hermetic. `auth.jwks: false` (or `null`) verifies against no keys and never fetches one, ignoring `SUPABASE_JWKS` and `SUPABASE_JWKS_URL`; `null` used to fall through to the JWKS URL. `createServer({ env })` also takes the variables as an object (`{ SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY }`) and loads only those, never filling a missing one from `process.env`. `createTestSigner().jwks` is typed so it passes to `auth.jwks` without a cast.
- Signed uploads work in a writable support session. `supportStorage` used to refuse `createSignedUploadUrl` and `uploadToSignedUrl` with `SUPPORT_STORAGE_WRITES`, so `bucket.connect(storage).reserve(...)` and the blocks' browser uploads failed, and an admin could not upload a file as the user. Now `createSignedUploadUrl` runs the same dry run as `upload` as the target (an insert, or an upsert with `upsert: true`, refused with Storage's `403`), then the service client signs the URL.

  An object uploaded to a signed URL has no owner, so a claim step finishes the upload. It checks again that the target may insert at the path, then makes the target the owner and adds `supportSessionId` and `supportAdminId` to the object's `user_metadata`. It claims only an object without an owner; one another user owns is refused with `403` `AccessDenied`. The `StorageApi` that `supportStorage` returns has `claimSignedUpload(bucket, path)`, and a connected bucket has `claimReserved(reservation)`, which succeeds without a request outside a support session, so call it after a reserved upload and before you record the file. `uploadToSignedUrl` on the server and `upload` with `onProgress` claim on their own.

  The blocks claim in their own finish step: `attachments.confirm`, `drive.commit` and the AI files block's `files.confirm` claim the upload before they record it. A refused claim keeps the record pending and returns the block's `forbidden` (`ATTACHMENT_FORBIDDEN`, `DRIVE_FORBIDDEN`, `AI_FILE_FORBIDDEN`) or `not_found` (`ATTACHMENT_NOT_UPLOADED`, `AI_FILE_NOT_UPLOADED`) error. Outside a support session they make no extra request. Read-only sessions still refuse every write with `SUPPORT_READ_ONLY`.
- Create moved to the cloud app. `createUrl()` and the comment `init` writes above a preset now link to `https://app.bettersupabase.com/create`. Old `bettersupabase.com/create` links redirect there with their query string, and `presetOf` and `designOf` still read both.

## 0.11.0 (2026-10-20) [#0110-2026-10-20]

- `bs.admin()` returns an `AdminDb` with `$authAdmin`, the typed Auth admin API (`auth.admin`) of the secret-key client, so cron jobs and webhook handlers can create, invite, ban and delete users without a second client. Request repositories don't have it. The `AdminDb` type is exported from `better-supabase/server`.
- Fixes and additions from adopter feedback.

  New API:

  - `matchDbError(error, matchers)` maps a `DbError` kind to a value, for `orThrow` factories.
  - `paginate({ withCount: true })` returns `total` on offset and cursor pages and narrows its type to `number`. On a cursor page after the first, it sends one extra request to count the matching rows.
  - `db.$buckets.<name>` is each bucket registered with `betterSupabase.buckets()`, connected to `db.$storage`. The type follows `$with()`, `$withoutPlugins()` and the `db`, `sql` and `admin()` repositories of `createServer(betterSupabase)`, and `db.$storage` on a server `Db` now uses the caller's Storage client. Every framework adapter (`next`, `hono`, `orpc`, `edge`, `mcp`, `astro`, `sveltekit`, `react-router`, `solid-start`, `tanstack-start` and `expo`) carries the registry through a new trailing `B` type parameter, so `ctx.db.$buckets.<name>` is typed in handlers, actions and routes. `ctx.db.$storage` and `ctx.db.$buckets` now work on request contexts.
  - `better-supabase/testing` adds `scopedSeed`, `withRollback` and `TestUser.context`. `asUser` also turns off supabase-js trace propagation.
  - `gen` emits `WritableColumnOf` and `GeneratedColumnOf`, and the schema types export `WritableColumns` and `GeneratedColumns`.

  Fixes:

  - `update` with no columns to write reads the row and returns it, and `updateMany` returns a count of 0, instead of failing with `invalid_request`. A caller that relied on that error needs to check for empty data itself. A plugin that leaves the patch empty still gets `invalid_request`.
  - `createMany`, `upsertMany`, `updateMany` and `deleteMany` type their result by `returning`. `orderBy` accepts `undefined` values and `updateMany` data no longer infers readonly arrays.
  - The client follows auth state only when the supabase client has it.
  - Text filter escaping and case rules are documented, and `$rpc` typing is pinned as stable API.

  The generated column `memberships.tier_role` in `supabase/` is a repo-only test fixture and does not ship in the package.
- Attachments and comments subjects taken from the subject registry now carry the registry's per-action keys: `subjects.<type>.permissions.upload` and `manage` for attachments, `create` and `moderate` for comments. The registry's `read` stays the key for reading the subject itself. `blocks.<block>.options.subjects.<type>.permissions` overrides them key by key.

  Doctor `BS345` warns when an attachments subject has no `read` or `upload` key of its own while its table's policies check more than tenant membership, so any member of the tenant would get the files of rows they can't open.
- Adds `better-supabase db query <file...>`, which runs SQL files as multi-statement scripts against the local stack or the database in the connection string. `--single-transaction` keeps all files or none.
- `seed --apply` and `db query` accept `--stack <name>` (or `$BSB_STACK`) to connect to the stack in `supabase/stacks/<name>/config.toml` instead of the default local stack.
- Closes the gaps found on 0.9.0: `audit()` takes `tenant_path` and `tenant_function`, `admin({}, { as })` names the acting member, optional RPC arguments accept `undefined`, `createHono` and `createNext` share one server, OAuth server resources follow RFC 8707, `jwtType` validates `typ`, billing webhooks cover subscriptions, usage meters count distinct or computed values, and the doctor flags partitions without RLS (BS343).
- `bs.middleware()` from `better-supabase/expo` hands the session it refreshed to the loaders and `bs.request` calls of the same request. They read the new cookies without calling Auth again, so a loader with `refresh: false` renders signed in on the first paint after an expired token, and each request makes at most one Auth call. Loaders that run without middleware still refresh on their own.
- Add the `ieq` filter operator for case-insensitive equality, on text columns and jsonb `path` filters. It sends `ilike` with `%`, `_` and `\` escaped, so `where: { email: { ieq: input } }` matches only that address in any case.

  Add the `require-escape-like` rule to `better-supabase/lint`, on as a warning in `configs.recommended`. It flags a `like` or `ilike` value taken from a variable, a property, or a template or concatenation without `escapeLike(...)`.
- Realtime topic triggers read less and send less.

  - A generated trigger function reads each parent row once per changed row, into a local record, however many values, payload keys and topics use it. A custom payload is built once for all topics. Before, every lookup ran again for the payload, for each topic's values and for each nested level. Run `sql sync` to rewrite the functions; the messages they send don't change.
  - `topic.trigger(table, { when: { changed: ["status"] } })`, and `when` on a `realtime.triggers` entry, send an update only when one of the listed columns changed. On a trigger for updates alone it becomes the trigger's `when` clause, otherwise an early return in the function. Inserts and deletes always send.
  - A delete whose lookup finds no parent row, such as a child removed by `on delete cascade`, raises a warning that starts with `better-supabase: realtime lookup found no row` instead of skipping the topic silently. The Realtime docs have a new "Deletes and cascades" section.
- New block `better-supabase/blocks/vercel-marketplace` (`better-supabase add vercel-marketplace`): installations, resources, plans, invoices and meter reports for an app listed on the Vercel Marketplace. `createVercelMarketplace()` verifies webhooks (`x-vercel-signature`, HMAC-SHA1), applies `integration-configuration.removed` and invoice events (the newest event wins), exchanges SSO codes and verifies the `id_token` against Vercel's JWKS, submits invoices and reports usage with retries. Only the service role calls it.
- `vercelQueueBackend(client)` in `better-supabase/blocks/jobs` runs jobs on Vercel Queues. Pass a `PollingQueueClient` from `@vercel/queue` (new optional peer, `>=0.8 <0.9`). It passes `testQueueBackend`. Vercel Queues keeps no dead letters and has no schedules, so `schedule` still needs the SQL backend.
- An adopted `webhooks-out` endpoints table has to choose its body format.

  Breaking config change: in adopt mode, when `columns.endpoints.format` maps no column, `sql sync` now refuses the config unless `blocks.webhooks-out.options.defaultFormat` is set. Without a format column every endpoint gets `defaultFormat`, whose default changed from the 0.8 body (`standard`) to `cloudevents` in 0.9, so receivers broke without any change to the config. Set `defaultFormat: "standard"` to keep the 0.8 body, `"cloudevents"` if your receivers read CloudEvents, or map a format column.

  Doctor reports the same config as `BS344` (warning), and says whether the table has no `format` column or one the config doesn't map. The webhooks-out page lists `defaultFormat` and the version 3 columns, and the new 0.9 to 0.10 guide covers the change.
- `seal` from `better-supabase/create/supabase` now accepts any object, so other OAuth connections on the Create page can reuse the sealed session cookie.

## 0.10.0 (2026-10-20) [#0100-2026-10-20]

- Adopt mode takes enum columns where a block writes kinds and subject types. In adopt mode the tags block casts the `kind` and `subjectType` values it writes and compares, and `next_number` casts the `entity`, to the adopted column's type through the new `better_supabase.as_column_type(value, target)`, so `kind public.organization_tag_kind` or `entity public.workflow_subject_type` works. An enum on any other text column a block writes a parameter or computed value into is refused instead of failing at runtime (columns a block writes only string literals into, such as the teams `status`, may stay enums): `sql sync` and `better-supabase adopt` report it when the schema files declare the enum, and doctor reports it as BS342. A module table spec lists its castable columns in `typed`, and `ModuleContext.cast(table, column, value)` renders the cast.
- App table columns take `access: "read"` or `access: "server"`. `sql sync` then grants `select`, `insert` and `update` on the columns signed-in users may use, by name, and the `<table>_view` lists only the readable columns. `gen` marks a server column `selectable: false`, so a default select leaves it out and the row type makes it optional; select it by name with the service role client. `appTables` refuses a required restricted column without a default, and a server column used as the subject title, a search column, a rollup or lookup source, or in `events.include`. Tightening a column's access is a breaking change in the design diff, and Create design codes with `access` use format 5.

  `doctor` counts column grants for BS106, so a table whose grants list columns no longer reports "Table not granted to the Data API".
- App tables emit outbox events, and rules and the workflow builder react to them (ADR 0048).

  - `appTables.<name>.events` (`true` or `{ on, include }`) adds a trigger that writes `<type>.created`, `<type>.updated` and `<type>.deleted` to the outbox in the same transaction as the write. The payload is an `AppTableEventData` with the id, the tenant, the subject, the `include` columns and, on update, the changed columns. It needs the outbox block. `gen blocks` writes `appTableEvents`, a typed event map.
  - `createRuleSink` runs the rules for each outbox event and dispatches their actions, and `WorkflowBuilder.sink()` starts the event triggers that match.
  - Rules get the built-in handlers `notifyAction`, `setFieldAction`, `webhookAction` and `workflowAction`.
  - A change to `events` is the safe design change `table.events`, and a design with `events` is format 4.

  `gen` tags `appTableEvents` and `appTableEventSchemas` with `@public`, so Knip doesn't report the one an app leaves unused.
- App tables get record history with per-field restore, upsert imports and a bulk set action.

  - `appTables.<name>.subject.versions` (`true` or `{ columns, auto }`) registers the table with the versions block, so its rows get snapshots without an entry in `subjects`. It needs the versions block, and a design with `versions` is format 4. A change to it is the safe design change `table.versions`.
  - `versions.restoreFields(id, fields)` writes back only the named fields. It fails with `VERSION_FIELDS_EMPTY`, `VERSION_FIELD_UNKNOWN` or `VERSION_RESTORE_UNSUPPORTED`.
  - `appTableImport` builds an import keyed on one of the table's unique sets that updates existing rows (`update: "fill"` or `"overwrite"`), and `previewImport` counts the rows it would create, change and leave unchanged.
  - The bulk action `appTables.set` sets columns on the selected rows through `app_table_set`, as the caller. A row fails with `APP_TABLE_SET_EMPTY`, `APP_TABLE_SET_COLUMN`, `APP_TABLE_SET_SUBJECT` or `APP_TABLE_SET_NOT_FOUND`.
  - New recipes: record history and upsert import.
- Add `better-supabase mcp`, which serves the CLI to a coding agent as an MCP server on stdio. The read-only tools (`doctor`, `blocks_list`, `config`, `gen_check`, `apply_dry_run` and others) run by default, and `--allow-writes` adds `gen`, `sql_sync`, `apply` and `add`, each of which asks for the user's consent with a token before it writes. `better-supabase/mcp` now exports `answerMessage` and `answerLine` for serving a `ProtocolServer` over any transport, and `createMcp` logs one warning naming the tools a missing authorizer hides.
- Add `better-supabase/create` and `better-supabase/create/supabase`. They hold the design model, schema parsing, previews, deploy plans, Supabase OAuth and the Management API client that `/create` used to keep inside the marketing app, so other apps can build on them.
- Add `better-supabase/create/lite`. `validateDesign(files)` applies the files Create generates to an in-memory PGlite database through `@supabase/lite` and reports what Postgres rejects. Modules that need `pgmq` or `btree_gist` are reported as skipped.
- Doctor checks the migrations that haven't run for statements that lock a table with rows: an index built without `concurrently` (BS229), a foreign key or check added without `not valid` (BS230), a column type change or `set not null` without a validated check (BS231), a column added with a volatile default (BS232), a migration that takes an exclusive lock before `set lock_timeout` (BS233), and drops or renames (BS234, info). With `--db-url` it checks the migrations the database hasn't applied; offline, the ones after the new `doctor.migrationSafety.since` setting, or the newest one. `init` and `adopt` set `since` to the newest migration.
- `loadEnv`, `parseEnv` and `envSchema` read `dbUrl` from `POSTGRES_URL` and then `POSTGRES_URL_NON_POOLING` when `SUPABASE_DB_URL` and `DATABASE_URL` are unset, the names the Vercel Supabase integration and Vercel Postgres set. `createPostgres()` falls back to the same names in the same order.
- Add `migrationBetween(code, base, next)` to `better-supabase/create`. It returns the migration that takes a deployed design to the next one for added tables and columns, and refuses breaking or unsupported changes with the changes that caused it.
- `number-sequences` accepts `in` and `not in` lists of string or number literals in `assignOn`, such as `new.status in ('sent', 'overdue')`, which its comma ban refused. A comma anywhere else outside a quoted string is still refused, and so is an unterminated quote, which could only fail at migration time.
- `number-sequences` entities take `permission`: the key `preview_number` checks for that entity, such as `invoices.write` for invoices and `quotes.write` for quotes. An entity without one keeps checking `blocks.number-sequences.permissions.assign`. `modulePermissionKeys` lists the entity keys, so `sql sync` and doctor check them against the authorization provider, and `ModulePermissionKey` gains an optional `setting` with the config path that sets the key.
- `sql sync` now keeps `number-sequences` counters in step when a tenant changes its period. For an entity with `table`, `column` and `periodColumn` that sets `periodFrom`, or `formatFrom` with `period: "format"`, it adds an `after update` trigger, `bs_sync_number_period_<entity>`, to each tenant table those columns name. When an update changes the tenant's effective period, the trigger runs `sync_number_sequences(tenant, entity)` in the same transaction, so no number repeats; other updates leave the counters alone. It compares the old and new rows through the new `number_period_kind(period, format)`, so a generated column works too. Without `periodColumn` no trigger is added, as sync cannot run for such an entity. Drop a hand-written trigger that called sync on a period change.
- `number-sequences` adds `preview_number(tenant, entity, at)` and `createNumberSequences().preview()`: the formatted number the next document would get, in the tenant's format and period, without assigning it. Signed-in users may call it when they hold the block's `assign` permission (`blocks.number-sequences.permissions.assign`, default `number_sequences.assign`), which until now no function checked; others get `NUMBER_FORBIDDEN`. `next_number` stays service-only, and documents keep getting their numbers from the trigger.
- `number-sequences` takes the period of a new number from the row: with `periodColumn` set, the trigger reads the period and the date tokens of the format from that column (a `date`, `timestamp` or `timestamptz`, in UTC), such as an invoice's issue date, and falls back to the time of assignment while it is null. `sync_number_sequences` reads a `date` column in UTC too.
- The number-sequences block adds `sync_number_sequences(p_tenant, p_entity)`, a service-only function that raises each counter to the highest number its table already holds, per tenant and period, and never lowers one. Run it after seeding (`select better_supabase.sync_number_sequences();` at the end of `supabase/seed.sql`), after an import, or once after adopting tables whose documents already have numbers, so the next number does not collide. It reads the entities that set `table` and `column` and parses each number with the entity's `format`; the period comes from `{yyyy}`, `{yy}` and `{mm}` in the format, or from the new `periodColumn` option.
- `number-sequences` numbers each tenant in its own format. `formatFrom: { column: "schema.table.column", key }` reads a tenant's format from a settings column and falls back to the entity's `format`; a tenant format without `{n}` fails with `NUMBER_FORMAT_INVALID`. The new `period: "format"` restarts the counter by the format's tokens: monthly with a year and `{mm}`, yearly with only a year, never without either, so one tenant can number continuously and another per year. `sequenceColumn` also stores the bare number next to the formatted one. `sync_number_sequences` reads per-tenant formats and sequence columns, and the orders, invoices, quotes, deals and work-items modules use the tenant's format when they number through the block.
- `number-sequences` entities take `periodFrom: { column, key? }`, the column that holds each tenant's reset period (`none`, `year`, `month` or `format`), so a tenant can keep a format such as `INV-{yyyy}{mm}-{n}` and still number on through the year. A null or empty value falls back to the entity's `period`, so `period: "format"` keeps deriving the period from the format; any other value fails with `NUMBER_PERIOD_INVALID`. `number_period(tenant, entity)` returns a tenant's choice. `next_number`, `preview_number` and `sync_number_sequences` use the same period, and sync needs `periodColumn` for such an entity.
- `realtime.triggers` entries take everything `topic.trigger()` does: lookups through a parent row (`{ from, via, select, key }`), a payload object whose values are columns, lookups or `{ sql }` expressions, one event name per operation, extra `topics` with their own values, and `functionSchema`. `sql sync` passes each entry to the topic's `trigger()`, so the config and the call write the same SQL. `topic.trigger()` without `betterSupabase` takes lookups by database names too, with `key` defaulting to `id`, and `realtime-tables` is required only for triggers that call `better_supabase.broadcast_topic()`.
- The number-sequences and teams docs name the adopt-mode table keys the blocks read: `tables.sequences` (not `tables.numberSequences`) and `tables.members` (not `tables.teamMembers`). A unit test now checks every `tables.*` and `columns.*` key on the blocks pages against the block's SQL modules.
- Fix block findings from the no-code research review.

  - `create_share_link` checks that the subject exists in the link's tenant and that the caller can read it, and raises `SHARE_SUBJECT_NOT_FOUND` or `SHARE_SUBJECT_UNKNOWN` otherwise. Links take `maxUses`, and `resolve_share_link` counts each use, so a spent link stops resolving. `drive_link` and `get_shared_ai_chat` also check the link's tenant.
  - `runImport` marks an import `failed` with the rows it inserted when a batch throws, instead of leaving it `running`. A new `running` hook (with `mark_import_running` in SQL) marks it running before the first batch.
  - A number sequence refuses a number set by hand unless the service role sets it (`NUMBER_MANUAL`).
  - `save_view` drops `isDefault` when the view moves to another scope, list or team.
  - The app tables pgTAP file inserts a row of another tenant or user before it checks that a signed-in user can't read it, so the check fails when a policy leaks rows.
  - App table and column names are at most 58 characters, and a table is refused when a constraint or index name it derives would pass Postgres's 63-character limit. A column default must fit the column's options and bounds. `better-supabase/preset` exports these checks (`appIdentifierError`, `appNameLengthError`, `appDefaultError`, `isAppDefault`, `appReservedColumns`, `APP_NAME_MAX`) so builders validate with the same rules.
- `toDbError` now classifies PostgREST and Postgres error objects, and a thrown `PostgrestError`, through `mapDbError`, so a unique violation becomes `conflict`, `PGRST116` becomes `not_found`, and permission and check failures get their kinds instead of `unexpected`. Other errors and unknown values map as before.
- `diffAppTables` marks every change that rows already in the table may not pass as breaking, with steps: a new `unique` column or unique set, a removed option, a raised `min` or lowered `max`, a checked format, a new reference target, a new scope, and removing `timestamps`, `rank` or `softDelete`. These get their own kinds (`column.unique`, `column.options`, `column.bounds`, `column.format`, `column.reference`, `table.scope`, `table.unique`, `table.columns`). Indexes, comments, the subject and `onDelete` stay safe.

  `apply` compares the whole design with `appTables`. It reports the tables the design leaves out as kept (`data.kept`), and it refuses a table or column rename without `--allow-breaking`. With the flag it prints the `alter table ... rename` statements to run before `sql sync` (`data.renames` under `--json`).
- `gen` makes the `column` and `sequenceColumn` of each `number-sequences` entity optional on insert, so adopters no longer list them in `tables.<table>.insertOptional`.
- `number-sequences` refuses a client that writes `sequenceColumn` with `NUMBER_MANUAL`, as it already did for `column`. A signed-in user could send the bare number on a draft, which stayed visible until the trigger overwrote it on issue. Only the trigger and the service role write it now.
- `createNumberSequences().next()` sends `p_entity`, the name `next_number` declares, so the call reaches the function instead of failing to find it.

## 0.9.0 (2026-10-19) [#090-2026-10-19]

- A record's activity timeline now includes its notification deliveries (with the `notifications` module) and its workflow runs (with the `workflows` module), as `delivery.<status>` and `run.<status>` entries with `source` set to `delivery` or `run`. Deliveries need `activity.read` and `notifications.send`, the same as the notifications client's `subjectDeliveries`; runs follow the `workflow_runs` read policy. `createComments().history()` takes `sources` to pick which kinds of entry to return, and `list_activity` gains a matching `sources text[]` argument. The comments module moves to version 6, so re-run `better-supabase sql sync`.
- `better-supabase adopt <block> --tables <schema.table>` maps a block onto tables the app already has. It reads the tables like `introspect`, matches the block's logical tables and columns to them (including likely renames such as `organization_id` for `tenant`, and `null` for optional columns the table lacks), prints the mapping with a doctor-style report of what can't map, and writes `blocks.<block>` in adopt mode when everything maps. `--dry-run` writes nothing and `--json` prints the entry, the mapping and the report.

  `apply --preset <code> --adopt` proposes the same mapping for every block of the preset that has a table under its default names, shows it before the config diff, and writes nothing when one of those tables can't be adopted. `apply` now also takes `--snapshot`, `--db-url-stdin` and `--project-ref`, and `--json` prints the config it writes and the blocks it adopted.
- Agent actors match support sessions. An agent `act` level reads `read_only` (`readOnly` on the actor; a non-boolean value makes the chain invalid), and `agentClaims(claims, { agent, tools, readOnly })` writes it. `tools` is now optional: a level without it delegates every tool and `tools: []` none. `createAgentRuntime({ actor })` drops the tools a delegating agent didn't list and denies a call to them with `AGENT_TOOL_NOT_DELEGATED`, which `assertToolDelegated` throws too, and a read-only agent makes the run read-only.

  `actor_may_write()` decides by `kind` first: support writes only with `read_only: false`, an agent unless `read_only` is `true`, and impersonation or an unknown `kind` never. An impersonation token with `read_only: false` could write before; allow it through the provider's `authorization.functions.actorMayWrite` template instead. `read_only` is compared as a JSON boolean.

  `better-supabase/testing/act-cases.json` (and `actCases` from `better-supabase/testing`) lists `act` chains with the expected verdict, so other parsers can run the same cases.
- An RFC 8693 `act` level may be `{ kind: "agent", sub, tools }`. `agentClaims(claims, { agent, tools })` from `better-supabase/server` builds it on top of the user's claims, and agent runs carry it as `run.act`. An agent writes with the user's row level security unless its level sets `read_only: true`, and a `scope` on its token limits it like an OAuth client. The read-only check in every SQL module knows the new kind, so `sql sync` rewrites each installed module.

  `inboxAdapter({ identities })` resolves each inbox contact before the handlers run and issues a run ticket for a contact linked to a signed-in user, which `ticketOf(message)` returns for `forContext({ ticket })`. `testAuthorizationHooks` also checks `automation.mayPublish` and that a support start cannot approve itself.
- `createAgentRuntime` from `better-supabase/ai-sdk/agents` takes `caller` (the invoking user, always the principal; the agent is only the actor), `authorizeModel` (checked before every model call, refusing with `forbidden` before tokens are spent), `results` (filters knowledge hits before the model sees them), `operations`, `permissions` and `authorizeTool` (each tool is the operation `ai.tool.<name>`, the provider's entry wins, and `high` risk waits for approval), and `gateway` (AI Gateway `user` and tags from the caller). `searchTool` and `recallTool` take `results` too. Breaking: `gatewayOptions` drops a `user` passed in its second argument, so `user` only ever comes from `context.userId`; pass the caller's id there. `GatewayContext` takes `tags`.
- AI chat sharing moves onto the `share-links` block. `share_ai_chat`, `revoke_ai_chat_share`, `list_ai_chat_shares` and `get_shared_ai_chat` stay callable and use `share_links`. Existing `ai_chat_shares` rows are copied, including the leaf. New links are stored only as a hash.
- API keys have a `kind`. List the allowed kinds in `blocks.api-keys.options.kinds` (a check constraint enforces them) and pass `kind` to `create`. `create_api_key` takes `kind` as its last argument; the module's upgrade drops the old signature. `apiKeyResolver`, `apiKeyClaims` and `withApiKey` take a `claims(key)` hook that adds claims but cannot set `sub`, `role`, `aud` or `api_key`, and `serviceRoles` and `claims` receive the key with the `fields` columns.
- A Bearer token that starts with the API key prefix and `_` now counts as an API key even when the rest is malformed. `apiKeyResolver` and `withApiKey` verify it, and `verify` answers `invalid`, so `bs_bad_key` gets `INVALID_API_KEY` like a revoked or unknown key instead of `API_KEY_REQUIRED` or a JWT error. `createApiKeys` returns `isApiKey(token)` for code that routes a Bearer token to either API key or JWT verification. A `keys` object without `isApiKey` keeps the old rule.
- App table columns take `format` (`email`, `url`, `phone`, `long_text`, `color`, `rating`, `percent`, `currency`), `options` for a single or multi-select, `min` and `max`, `comment` and `identity`. Tables take `id: "bigint"`, composite `unique` keys, `indexes` and a `comment`. `sql sync` renders each as a named constraint, index or comment, and the pgTAP file checks them; `email`, `url`, `rating`, `percent`, options and bounds add a check. No key takes free SQL.

  A Create design that uses one of these keys gets `version: 1` in its JSON (`designFormat`, `DESIGN_FORMAT` from `better-supabase/preset`); older designs encode to the same code as before, and a code newer than the installed release fails with a message to update. Create's column type picker is grouped (Text, Number, Date and time, Choice, Relation, Other) with Single select, Email, Rating, Autonumber, Person and Currency, which splits into `<name>_minor` and `<name>_currency`.
- A reference between two app tables scoped to the same tenant (a table that references itself included) now checks the tenant. `sql sync` adds a unique `(tenant, id)` index on the target and a composite foreign key `(tenant, column)`, so a row can no longer point at a row of another organization or workspace; `on delete set null` clears only the referencing column. The pgTAP file checks each composite key.

  Migration: run `better-supabase sql sync` and generate a migration. If an existing row already references a row of another tenant, the new foreign key fails to apply; fix or delete those rows first.

  Create can link two tables many to many from a table's menu ("Add many-to-many with…"), which adds a junction table with two required references, `on delete cascade` and a unique pair. A Tree primitive adds a table with a `parent_id` that references itself. When you select a table, its edges show the delete action, and a reference that is unique ends in a one-to-one marker.
- App tables take `rollups` and `lookups` (ADR 0044). A rollup counts, sums or takes the minimum, maximum or average of a column of another app table that references this one, optionally only for the rows whose select column equals one option. A lookup reads a column of the row a reference points at. Both are structured keys, so a design never carries SQL, and `resolveConfig` lists the valid tables and columns when a key is wrong.

  `sql sync` writes `public.<table>_view` with `security_invoker = true`, so each rollup counts only the rows the caller can read. Only `authenticated` and `service_role` can read the view. The pgTAP file checks that the view runs as the invoker and shows a stranger no rows. `gen` reads it as a read-only model (`db.projectsView.findMany()`).

  A design with rollups or lookups writes design format 3. `diffAppTables` reports a change to them as the safe `table.computed`. Create shows rollups and lookups as computed rows on the table and edits them in a Computed section of the table inspector.

  `better-supabase/config` exports `APP_ROLLUP_AGGREGATES` and the `AppRollupConfig`, `AppRollupAggregate` and `AppLookupConfig` types.
- An app table can be a subject: `appTables.<name>.subject` takes a `type`, a `title` and `url`, `search`, `permissions`, `rank` and `softDelete`, and is merged into `subjects`. Comments, attachments, tags, versions, watchers, activity and search then reach its rows. `rank: true` adds a `rank` column with an index and `softDelete` adds `deleted_at`. A design that uses `subject` writes design format 2.

  Migration: none for existing configs. A type in `subject` that is already in `subjects` fails `resolveConfig`; remove one of the two.

  Create gets a Features tab on the table inspector: turning on comments, attachments, tags, versions or search adds the block and makes the table a subject, and the table shows a badge for each feature.
- `appTables` declares the tables your app owns. `better-supabase sql sync` writes them into `900_better_supabase_app_tables.sql` with the scope column (per tenant, per user or open to signed-in readers), row level security, grants, indexes and the `updated-at` trigger, plus a pgTAP test that checks each table's policies and that a stranger can't read or insert outside their scope. Columns take a closed list of types, defaults from an allowlist and references to another app table, `auth.users` or a block's table, which follows the block's renames.

  `better-supabase/preset` encodes a Create design (app tables, extra columns on block tables and canvas positions) into a code that rides on the `/create` URL as `&design=`: `encodeDesign`, `decodeDesign`, `designOf`, `designConfig`, `designConfigSource`, `designInstallCommand` and `designApplyCommand`. The preset catalog lists the table each block's `extraColumns` extends. `init --preset <url>` writes the design's `appTables` and `extraColumns` into the new config, and `apply --preset <url>` merges them into the config you have.
- `approval_eligible` also asks the provider's `isApprover` for the kind `<type>.<action>`, and never for the requester. `createApprovals` takes `approverOf`, `wakeOnApproval` from `better-supabase/workflow-sdk` resumes a run waiting on a request id when the request is approved, rejected, expired or cancelled, and a workflow-builder approval node can file an approvals request and wait on its id.
- With the `approvals` SQL module installed, AI-chat tool calls that wait for approval (`ai_tool_approval`, action `run`) and support access requests for a tenant (`support_request`, action `start`) also file an approval request. Deciding either side decides the other: `decide_approval` approves or denies the tool call and approves or closes the support request, and `decide_ai_tool_approval` or `approve_support_request` settles the approval request. The owning module still decides who may approve, these requests can't be delegated, and `approvals.read` alone doesn't list them, so tool inputs and support reasons stay with their approvers.
- AsyncAPI channels now come from the same `defineTopic` definitions that `sql sync` turns into Realtime policies. Pass `topicsFrom(module)` (from `better-supabase/asyncapi`) to `defineApi({ topics })`. Each channel gets its broadcast events, its presence schema and an `x-better-supabase-realtime-status` of `beta` for private topics (Realtime Authorization is still in public beta) or `ga` for public ones. A topic gets a send operation only when it sets `clientSend: true`; before, every topic got one. `defineTopic` takes the new `presenceSchema`, `clientSend` and `status` options, and a topic exposes `events`, `clientSend`, `permission` and `status`.

  `better-supabase spec` loads the `realtime.policies.from` modules with the loader `sql sync` uses. It warns `asyncapi-topic-undocumented` for each topic the AsyncAPI document leaves out, and `--watch` renders again when a topic module changes.

  The new `dataApi` option documents the Supabase Data API next to your routes: `/rest/v1/<table>` operations and `/rest/v1/rpc/<function>` calls, with database column names, the API key and JWT schemes, the PostgREST error body and their own `servers` from `supabaseUrl`. Operations take an optional `servers` list, which the OpenAPI formats render. New diagnostics: `data-api-server-missing`, `data-api-table-unknown` and `data-api-function-unknown`.
- The audit block reverts row changes: `audit.revert(entryIds)` (SQL `revert_audit_entries`) writes the old values back as the caller, newest entry first, all or nothing, and refuses with `ROW_CHANGED_SINCE` when a row changed after the entry. Members need the new `audit.revert` permission, which admins have by default. The result carries an undo token that reverts the revert. The saved-views read policy now evaluates `auth.uid()` once per query when the teams module is installed.
- Fixes from an audit of the blocks, the jobs worker, the commerce adapters and the CLI.

  Breaking: `fromStripeAmount` returns a `Result<Money>`. It refuses an amount that isn't a safe integer, or an ISK or UGX amount with a fraction of the minor unit, with `invalid_input` and the hint `STRIPE_AMOUNT_PRECISION`, where it used to truncate. The Stripe payments and tax adapters return that error instead of a wrong amount.

  The notifications and settings blocks split their own-row `for all` policies into read, insert, update and delete policies, so `actor_may_write()` guards writes only and a read-only support session can still read. Run `better-supabase sql sync` and generate a migration.

  - The data-lifecycle purge checks the legal hold and the due date before it deletes storage objects or calls a webhook.
  - A jobs worker aborts a job's signal when its lease can't be extended, and reports `JOB_LEASE_LOST` through `onError` instead of completing a job another worker may hold. A failed `complete` is reported too.
  - The idempotency middleware answers `409` with `IDEMPOTENCY_KEY_LOST` when the lock expired before the response was saved, and the request fingerprint includes the query string.
  - The webhook inbox passes its handler an `AbortSignal` that aborts when the lease can't be renewed, and then leaves the message to the next worker.
  - The outbox treats a handler that returns a failed `Result` as a failure, so the event is retried.
  - `deleteAccount` succeeds on a retry for a user that Auth already deleted, and still removes the listed storage objects.
  - Invoice deposits are computed in `numeric`, so line totals round half away from zero past 2^53.
  - The search block adds a partial index for rows waiting for an embedding.
  - `env --out`, `env.output` and `env --from` refuse a path outside the project root.
  - A `42501` error says "permission denied" to the client, and keeps the object name and PostgREST's grant recipe in `details`.
  - `queueBackend({ client, schema })` calls the wrappers `blocks.jobs.api` writes into that schema, for the full feature set over the Data API. `client` alone still uses the `pgmq_public` RPCs.
  - `supabaseCollection` from `better-supabase/tanstack-db/supabase` works with `casing: 'camel'` and with tables outside `public`: it renames filters, bodies, rows and Realtime payloads, and runs requests and Realtime listeners in the table's schema.
  - A subject's `deletedAt` column becomes the table's `softDelete` flag in `gen` (it wins over `plugins.softDelete.column`), and declared metrics skip those rows unless the metric sets `includeDeleted: true`.
- `createServer(betterSupabase, { authorization })` takes `AuthorizationHooks` (`defineAuthorizationHooks` from `better-supabase/server`): optional `automation`, `support`, `delivery`, `identities`, `chat`, `approverOf` and `tickets` hooks, so one authorization provider decides publishing, support sessions, fan-out, identity links, chat actions, approvals and run tickets. `testAuthorizationHooks` from `better-supabase/testing` checks that they fail closed.

  The `authorization` config gains `functions.isApprover` (`{kind}`, `{subject}`, `{tenant}`), `functions.permitted` for checks on one instance, and `operations`, one table of the permission each workflow node, MCP tool, AI tool and inbox command needs.

  Breaking: with `authorization` set, `blocks.access.model` defaults to `provider`, so `can()`, `tenant_ids_with()` and every module check answer from the provider. Set `model: "roles"` to keep the built-in roles; doctor BS414 then reports policies and functions that call the access contract. `functions.canApprove` is deprecated in favour of `isApprover` and still read when `isApprover` is unset; doctor BS415 warns while a provider sets it, and `better-supabase codemod 0.9` lists each use.
- Add client controllers and hooks for the `billing`, `usage`, `entitlements` and `settings` blocks, each with `/react`, `/vue`, `/svelte` and `/solid` entries. `usePlanPicker()` (`better-supabase/blocks/billing/react`) lists your plans with the current one and starts Checkout through a server action you pass, so Stripe is only called on the server. `useUsageMeter()` (`better-supabase/blocks/usage/react`) reads this period's usage per meter with its fraction of the quota. `useFeatureGate()` (`better-supabase/blocks/entitlements/react`) tells whether the active organization's plan includes a feature from the session's features claim, and `hasEntitlement()` now also takes the client's `useAuth()` snapshot. `useSettings()` (`better-supabase/blocks/settings/react`) edits one scope of a `defineSettings()` definition with `fields` generated from each key's Standard JSON Schema, and `setField()` for generated forms. The plan picker, usage meter, feature gate and settings layout recipes show them in the Next.js and Expo examples.
- Blocks sit in 16 categories instead of 10, so the catalog, `blocks list --category` and `/blocks` group them the way an app is built. `BlockCategory` takes the new ids:

  | Old category                 | New category                                                                                                                                                                                                                                                                                  |
  | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `foundation`                 | `identity` (access, actor, identities, mfa, sessions, sso, subjects), `tenancy` (tenant, organizations, teams, profiles, settings, reserved-slugs) and `database` (updated-at)                                                                                                                |
  | `collaboration`              | `collaboration`, and `messaging` (notifications, push, inbox)                                                                                                                                                                                                                                 |
  | `work`                       | `work`, `people` (employees, hiring), `identity` (access-requests) and `database` (rank)                                                                                                                                                                                                      |
  | `scheduling`                 | `scheduling`, and `people` (time-tracking)                                                                                                                                                                                                                                                    |
  | `operations`                 | `growth` (onboarding, waitlist, announcements, flags), `admin` (audit, data-lifecycle, metrics, support-sessions) and `automation` (workflows, workflow-builder, workflow-sdk-world)                                                                                                          |
  | `platform`                   | `developer` (api-keys, credentials, service principals, signing keys, shared signals, the webhook blocks, rate-limit, idempotency), `automation` (jobs, outbox, streams), `identity` (grants) and `database` (ensure-rls, pgtap, jsonb-schemas, number-sequences, read-sets, realtime-tables) |
  | `ai`                         | `ai`, and `automation` (connectors)                                                                                                                                                                                                                                                           |
  | `crm`, `commerce`, `content` | unchanged                                                                                                                                                                                                                                                                                     |

  `better-supabase/preset` adds 14 named presets, so `/create` and `init` offer 22 in five groups (`PRESET_GROUPS`, `NamedPreset.group`): `consumer` (per-user data with `scope: "user"`), `helpdesk`, `marketplace`, `people`, `drive`, `docs`, `invoicing`, `forms`, `ai-chat`, `ai-agents`, `developer-api`, `automation`, `enterprise` and `admin`. Every preset after the starters builds on `saas`. The existing presets keep their blocks, and `NamedPreset.conventions` holds the conventions a preset sets over the defaults.

  The UI recipes in the skill's `references/ui-recipes.md` grow from 15 to 32, with 46 more listed as planned, each naming what it waits for. A recipe now has a `kind` (`screen` or `component`), can span several blocks, and lists only the platforms it has code for. Each block manifest's `recipes` names the recipes that use the block.
- More blocks meet the block contract. `createAuditLog` is now `createAudit`. The clients of agents, ai-cache, ai-chat, ai-files, ai-providers, ai-tasks, announcements, api-keys, attachments, audit, billing, bookings, bulk, calendar, comments, connectors and custom-fields take `hooks` (and `logger`) and run them around every method, and audit's purge calls and `activitySink` go through `blockCall`, so error mappers apply.

  Access-requests, agents, ai-chat, ai-files, ai-tasks, announcements, api-keys, approvals, attachments, bookings, calendar, catalog and collections take `options.extraColumns` on their main table and a `fields` Standard Schema on the client. The module adds the columns, its writes take them (columns a write leaves out keep their value or default) and its reads return them under `fields`; the client validates them on writes, parses them on reads and merges them into the rows it returns. Without `extraColumns` the SQL is unchanged.
- `comments`, `connectors` and `rate-limit` follow the block contract.

  `createRateLimit` takes one options object: `createRateLimit({ sql: postgres.admin })` or `createRateLimit({ transport: rpcTransport(adminClient) })` replaces `createRateLimit(source, options)`, with `schema`, `mappers`, `hooks`, `logger` and `temporal` next to it. `createBlocks` builds the `rate-limit` block from its shared `transport`.

  `comments` and `connectors` take `options.extraColumns` (on `comments` and `connector_servers`) and a Standard Schema `fields` option on `createComments` and `createConnectors`. `create` and `edit` of comments, and `servers.create` and `servers.update` of connectors, validate the `fields` they get and write them; every comment and server read returns the columns parsed under `fields`, and `comments.copy` copies them. `create_comment` and `edit_comment` take a `fields jsonb` argument only when extra columns are configured; `save_connector_server` takes them in its existing `fields` object.

  Every SQL module that takes a `fields jsonb` argument for its extra columns now names it `fields`: `request_access`, `reserve_ai_file`, `store_ai_file`, `create_api_key`, `request_approval`, `create_attachment`, `save_product`, `create_collection` and `rename_collection` took `extra_fields` before. Each module drops the signature it doesn't create, so turning `extraColumns` on or off never leaves two overloads a call matches, and the module contracts list the argument when it exists.
- Blocks from `notifications` to `settings` follow the block contract.

  `onboarding`, `outbox` and `settings` have a `create<Name>(options)` creator, and their manifests build it. `createOnboarding({ transport })` replaces `checklist.connect(...)`: its methods take the checklist first (`onboarding.complete(gettingStarted, "invite", organizationId)`), and `createOnboardingController` takes `client` and `checklist` instead of `scope`. `createSettings({ settings, transport })` replaces `settings.connect(...)`, with flat methods (`getUser`, `setUser`, `resetUser`, `getOrganization`, `setOrganization`, `resetOrganization`, `getPlatform`, `setPlatform`, `resetPlatform`); `defineSettings` returns only the `schemas`. `createOutbox({ transport, source })` replaces `createOutbox(source, options)`: pass `sqlTransport(postgres.admin)` for a server connection, and `service` for the consumer calls when `transport` acts as a user.

  `hooks` (and `logger`) now work on `createNumberSequences`, `createPush`, `createRank`, `createRateLimit`, `createRelations`, `createReminders`, `createSavedViews`, `createServicePrincipals`, `createOnboarding`, `createSettings` and `createOutbox`; job handlers call their methods through the hooks. `createNotifications` sends its delivery calls through `blockCall`, so `deliver()` rejects with the mapped `DbError`.

  `options.extraColumns` and a typed `fields` option cover the main table of `invitations` (as `invitationFields` on `createOrganizations`), `notifications`, `orders`, `pipelines`, `promotions`, `push`, `relations`, `reminders`, `saved-views` and `service-principals`, and `fields` now types the columns `parties` and `quotes` already took. Writes take `fields` and validate them before the call; reads return them in `fields`, built with `jsonb_build_object`, and parse them with the schema. Without `extraColumns` the SQL is unchanged.
- The 14 blocks with a headless controller (ai-chat, announcements, bookings, calendar, forms, inbox, notifications, onboarding, reactions, saved-views, search, undo, workflows and workflow-builder) get `better-supabase/blocks/<name>/vue`, `/svelte` and `/solid` entries. Each exports the React hook names (`useInbox`, `useUndoStack` and so on) over the same controllers: Vue returns a shallow ref of the snapshot, Svelte a readable store and Solid an accessor from `from`, with the controller's actions next to it. They take the controller's options; `auth` and `ports` come from `betterSupabase(bs)`, `setBetterSupabase(bs)` or `<BetterSupabaseProvider>` unless you pass them. The work starts on mount, never during server rendering, and the controller is disposed with the component.

  `testControllerBinding` from `better-supabase/testing` checks that a framework binding renders a controller's snapshot, follows its changes, passes its actions through and stops it on unmount. The React, Vue, Svelte and Solid bindings run it.
- Every block exports a manifest built with `defineBlock` (`commentsBlock` from `better-supabase/blocks/comments`): its SQL modules, option keys, its own events, the ports it provides and consumes, its creator, category and UI recipes. `createBlocks(options, blocks)` takes manifests and wires them on their ports (`notifier`, `files`, `attachments`, `workflows`, `sink`) instead of on names. `block.with(options)` sets a block's own options, and `block.using(create)` builds it with your own call to its creator. `defineBlocks(blocks, ...appEvents)` types the events of every block and the app's.

  Breaking:

  - `createBlocks` no longer takes factories or the `aiTaskNotification`, `reminderNotification`, `metricEvents` and `metricTime` options. Use `aiTasksBlock.with({ notification })`, `remindersBlock.with({ notification })` and `metricsBlock.with({ sink: { on, time } })`.
  - The closed `BlockEventMap` is gone. Each block exports its own event map (`CommentsEvents` and `commentsEvents`), `onBlockEvent(betterSupabase, events, pattern, handler)` takes the events to type by, `blockEmitter`, `emitBlockEvent`, `BlockEvent`, `BlockEventSchemas` and `SubjectEventType` take an event map, and `defineEvents` declares app events.
  - `createDrive` takes the attachments block as `attachments` instead of building one (`attachmentsSchema`, `requireScan` and `downloadTtl` move to that block), and the workflow builder takes the workflows block as `workflows` for schedule triggers.
  - `createBuilder` is renamed `createWorkflowBuilder` and `createPushDevices` is renamed `createPush`.
- Blocks are configured under a top-level `blocks` key in `better-supabase.config.ts`, the foundation blocks (`tenant`, `access`, `updated-at`, `ensure-rls`, ...) included. `sql.modules` is removed; `sql` keeps the SQL-wide settings (`workflow`, `dir`, `prefix`, `testsDir`, `schema`, `api`). The resolved config carries `blocks` and `blockNames`, `sql add` edits `blocks`, and `schemas/config-v1.json` describes the new key. The config types are renamed to match: `BlocksConfig`, `BlockConfig`, `AccessBlockConfig` and `BlockMode`. Code that reads `config.sql.modules` from a loaded or resolved config must read `config.blocks` instead.
- More blocks meet the block contract. `createFlagAdmin` is now `createFlags`. `createJobs({ queues, sql })` replaces `createJobs(source, queues, options)`, taking its store as `backend`, `sql`, `client` or `transport`, and the jobs block is built by `createBlocks`. `createIdempotency({ sql })` and `createIncomingWebhooks({ sql })` take their connection as an option, and the entitlements block gets `createEntitlements` with `has` and `value`.

  Deals, drive, employees, engagements, flags, forms, hiring, inventory and invoices take `options.extraColumns` on their main table and a Standard Schema as `fields`, which types and parses the columns on reads and validates them on writes. `create_form`, `update_form` and `save_location` take a `fields` argument. Data-lifecycle, entitlements, flags, idempotency, identities, imports, inbox, incoming webhooks, jobs, knowledge and memory accept `hooks`; inbox, knowledge and memory also take hooks per method group. Calls over a transport go through `blockCall`, so `mappers` apply to them.
- SQL module comments and error messages name the `blocks` config key instead of the old `modules` key, for example `blocks["support-sessions"].options.allowWrites` in the `SUPPORT_WRITES_DISABLED` error.
- Add the bookings block (`better-supabase/blocks/bookings` and `better-supabase/blocks/bookings/react`): bookable resources with weekly hours, exceptions and a capacity, services with a duration and buffers, open slots from the `available_slots` SQL function, holds that a `bookings.expire-holds` job cancels, an exclusion constraint that refuses double bookings (the module installs `btree_gist`), public booking through a share link with a rate limit and an access token, confirmed bookings copied into the calendar block, customer reminders, and a `useAvailableSlots` hook typed for headless time pickers such as ReUI's.
- The bulk module adds a `trash.trash` action when the trash module is installed, so a list's bulk bar can move many rows to the trash in one call or a background run, each row through the trash block's own checks.
- Add bulk actions (`better-supabase/blocks/bulk` and the `bulk` SQL module). `bulk.run` applies `tags.set`, `stateMachine.transition`, `relations.link` or an app's own action to up to 500 rows in one call with a result per row, and `bulk.start` puts larger sets on the jobs queue `bulk`, where each step runs as the user who started the run and broadcasts its progress on a Realtime topic.
- Adds the calendar block (`better-supabase/blocks/calendar` and `better-supabase/blocks/calendar/react`, SQL module `calendar`). It stores calendars, events with RFC 5545 recurrence, overrides and attendees. `occurrences()` expands series with the reminders block's `expandRule`, `editOccurrence` and `editFollowing` override one occurrence or split a series, `importIcs` and `exportIcs` read and write iCalendar files, and a share link to a calendar serves an iCal feed. `useCalendar` returns events in the shape an event calendar renders.

  `expandRule` takes a `window` so long-running series expand only the occurrences in a range, and `revoke_share_link` no longer fails with an ambiguous `id` column.
- Close the gaps CentraKit hit while adopting better-supabase:

  - Support sessions work with an app-owned request context, and the proxy prunes stale cookie chunks and takes an auth-outage hook.
  - `createJobs`, `createIdempotency` and `withLease` accept a block transport (`rpcTransport`), so job stats, dead jobs, retries, idempotency keys and leases work through the `blocks.<name>.api` wrappers without a direct Postgres connection.
  - Jobs, idempotency, rate limits, outgoing webhooks and usage get TypeScript `purge` methods and `purgeJob` handlers for the `purge_*` functions; `purgeAuditLog` and the webhook inbox purge accept a transport.
  - Behaviour change: `cancel_organization_deletion` asks the access contract for the `cancelDelete` permission (`organization.delete` by default) through the new `member_can_while_deleting()`, instead of hard-coding the `owner` role. With the default roles nothing changes; with an authorization provider, any member the provider grants the key can cancel. An access module in `custom` mode keeps the owner check.
  - Attachments take `subjects.<type>.permissions` (`read`, `upload`, `manage`), and attachments and comments take `subjects.<type>.scope`: a scope the authorization provider declares, so a grant on the subject record counts next to one on the tenant. Comments' `readableBy` also takes `{ sql, enforce: true }`, which applies it to the read policy.
  - The `realtime-tables` module adds `better_supabase.broadcast_topic()`, a row trigger that fills a topic's `{column}` placeholders from the row and sends the change. `topic.trigger(table, options)` returns the `create trigger` statement, and `realtime.triggers` makes `sql sync` write them next to the topic policies.
  - Incoming webhooks hand verified deliveries to an `IncomingDestination` (`apiVersion: 1`, checked by `testIncomingDestination`): `inboxDestination` stores them in the webhook inbox as before, and `jobsDestination` enqueues them on a jobs queue once per endpoint and message id, with the endpoint's tenant and metadata. `jobs.enqueueOnce` (`enqueue_job_once` in SQL) is the queue side: a key stays taken after its job completes.
  - `webhooks-in` and `webhook-inbox` gain `mode: "adopt"` over an app's own endpoint and message tables, with renamed columns and optional ones mapped to `null`. Their functions stay in `better_supabase`. In managed mode a column mapping is an error.
  - `credentials` gains `mode: "adopt"` over an app's own secrets table (`provider`, `name` and optional `tenant`, `scope`, `vaultId`, `secret`, `updatedAt` columns), with `secretStorage: "vault"` (a Vault id per row, deleted with its row) or `"column"`. `credential_get`, `credential_set` and `credential_delete` keep their signatures, so `vaultCredentials()` reads the table unchanged; a `vault` ref names another provider's row as `<provider>:<name>`. Doctor reports a table or column an adopt-mode module maps that the live database lacks (BS330).
- `supabaseClaimFixtures` adds `agentRunReadOnly`, an agent level with `read_only: true`. The new `supabaseInvalidClaimFixtures` (with the `SupabaseInvalidClaimFixture` type) lists the claims better-supabase refuses, each expecting `{ kind: "invalid", reason: "actor", code: "ACTOR_INVALID" }`: a 0.5.0 support token (`session_id` without `kind`), a support or agent `read_only` that is not a boolean, an agent with a `session_id`, an empty tool name or `tools` that is not a list, an unknown `kind`, and a nested level without `sub`. They are plain data, so other libraries can run the same cases.
- The CLI has an agent mode. When a coding agent runs it (Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot and the others `@vercel/detect-agent` knows), it prints the JSON result envelope by default, never prompts, and answers what it can't decide alone with exit code 3 and `status: "action_required"`. `--agent auto|yes|no` and `$BETTER_SUPABASE_AGENT` override the detection, and `--no-json` keeps text output.

  A missing value returns `input_required` with `missing`, `choices` and a `next` command that fills them in. Destructive work returns `consent_required` with a `token`: `--force` over files the CLI didn't write, and `seed --apply` against a database that isn't on this machine. Rerun with `--confirm <token>` once the user agrees; `--yes` doesn't replace it.

  `init` at a workspace root without `--package` now exits 3 with `input_required` instead of 2, and `skills install --agent` is now `skills install --for`, so the global `--agent` keeps one meaning.
- The CLI's command groups (`sql`, `blocks`, `preset`, `skills`, `spec`, `scaffold` and `codemod`) are real subcommands, each with its own help, and a flag has one meaning everywhere. `openapi emit` moves to `spec emit openapi` and `spec check` to `spec emit --check`. These flags are renamed:

  | 0.8                              | 0.9                            |
  | -------------------------------- | ------------------------------ |
  | `init --with`                    | `init --integration`           |
  | `add --framework`                | `add --integration`            |
  | `adopt --from`                   | `adopt --tables`               |
  | `gen --tasks`                    | `gen --only`                   |
  | `gen --out` (a directory)        | `gen --out-dir`                |
  | `introspect --format`            | `introspect --kind`            |
  | `doctor --strict`                | `doctor --fail-on warning`     |
  | `env --print`                    | `env --out -`                  |
  | `env --from`                     | `env --status-file`            |
  | `skills install --from`          | `skills install --source`      |
  | `skills install --agent`         | `skills install --for`         |
  | `sql add --force` (hook overlap) | `sql add --allow-hook-overlap` |

  An old command or flag exits with code 2 and a `command_moved` error whose `suggestion` is the new call. `better-supabase codemod 0.9` rewrites these calls in `package.json` scripts and GitHub workflows and lists them in other shell files. The package adds a `bsb` bin next to `better-supabase`.
- Add `better-supabase completion bash|zsh|fish`. It prints a script that
  completes commands, subcommands, options and their values (block names,
  presets, `gen --only` tasks, doctor rule codes) for both `better-supabase` and
  `bsb`. Add `eval "$(better-supabase completion zsh)"` to `~/.zshrc`, or the
  line for your shell from the [shell completion](https://bettersupabase.com/docs/cli/completion)
  page.
- `better-supabase/cli` exports `createCli({ commands })` in place of `registerCommand`. It returns a CLI with your commands next to the built-in ones, so two CLIs in one process share no state. `defineCliCommand` now marks each command with `apiVersion: 1`, and `createCli` refuses a command without it or one that takes a built-in or reserved name (`login`, `logout`, `whoami`, `link`, `projects`, `deploy`, `cloud`).

  The 0.2 command form, `(context) => CommandResult`, and the `Command`, `CommandContext` and `ParsedArgs` types are removed. Wrap such a command in `defineCliCommand`:

  ```ts
  // Before
  registerCommand("schemas", ({ args }) => ({
    code: 0,
    output: args.rest.join(" "),
  }));

  // After
  const cli = createCli({
    commands: {
      schemas: defineCliCommand({
        meta: { name: "schemas" },
        args: {},
        run: async (args) => ({ code: 0, output: args._.join(" ") }),
      }),
    },
  });
  await cli.run(process.argv.slice(2), { cwd: process.cwd(), env: process.env });
  ```

  `doctor --metadata` now reports a conflicting flag with the same message as `gen --metadata`, and `doctor` lists `--snapshot`, `--db-url-stdin` and `--project-ref` first in its help.
- The CLI reports three more specific problem codes, all exit 2: `unknown_flag` for an option the command does not take, `invalid_value` for an option or environment value it does not accept (an enum, `--agent`, `--cwd`), and `flag_renamed` for a renamed flag, which was `command_moved` before. A script that matched `usage` for these cases should match the new codes. `ExitCode` now includes `65` and `143`. On SIGINT or SIGTERM a run gets 2 seconds to clean up before the process exits, and a second signal exits at once.
- The CLI reports every failure with an error code and a matching exit code. The errors page lists each code, and `--json` puts it in `code`.

  - Exit code 3 means you have to act first, 130 means a cancelled prompt or Ctrl-C, and 143 means SIGTERM. A cancelled prompt no longer counts as "no".
  - A path flag outside the project root (`outside_root`) exits 2, like other usage errors, instead of 1.
  - `--cwd` has to name an existing directory.
  - `--db-url-stdin` and `gen --metadata -` fail at once when stdin is a terminal, instead of waiting for input.
  - `$CI=false` and `$CI=0` no longer turn prompts off.
  - The CLI never downloads the Supabase CLI through `npx --yes`. It runs `$SUPABASE_BIN`, then `node_modules/.bin/supabase`, then `supabase` on the `PATH`, and fails with `supabase_cli_missing` otherwise. The child process gets the run's environment and stops on Ctrl-C.
  - Install commands quote version ranges and directories that the shell would split or expand.
  - `tsconfig.json` and `package.json` are read as JSONC, so comments inside strings and trailing commas parse correctly.
  - The config file is discovered once per run, and the local stack's database URL is looked up once.
  - The package no longer ships `dist/cli/bin.d.ts`.
- Every CLI command that writes files now goes through one file plan. Each file gets one line: `Created`, `Updated`, `Unchanged`, `Skipped` (with the reason) or `Removed`, and `Would create` or `Would update` under `--dry-run`. These lines replace `Wrote`, `Kept` and `Would write`, so update any script that matched the old words. Under `--json`, `data.files` lists every file with its outcome; `seed`, `env` and `openapi emit` no longer return `data.wrote`, and `gen` no longer returns `data.removed`.

  Every writing command takes `--dry-run`, which prints a diff and writes nothing, and `--check`, which exits 1 on drift. `gen`, `introspect`, `scaffold`, `seed`, `env`, `keys`, `skills`, `spec`, `codemod`, `apply`, `adopt` and `openapi emit` gain the flags they were missing. A command writes its files together, so a failed run leaves none half written. It refuses paths outside the project root and never prints the contents of `.env.local` or the signing keys in a diff.

  A generated file without the `Generated by better-supabase` header was written by hand. `gen` used to overwrite it; now every command skips it and exits 1 with the new `file_conflict` error code. Pass `--force` to overwrite it.
- The CLI keeps stdout for the command's output. Prompts, spinners, warnings and notices go to stderr, and each stream is colored only when it is a terminal. Outside a terminal, progress prints one plain line instead of a spinner.

  - `--no-color` turns colors off on both streams, like `NO_COLOR`.
  - `--debug` prints the stack trace of an error, each child command the CLI runs with its exit code, and how long the run and each step took, all on stderr.
  - `gen --watch --json` and `spec emit --watch --json` print one result envelope per run on its own line (NDJSON) instead of text.
  - `spec emit --watch` now waits for a config that fails to load, the way `gen --watch` does, instead of running with the old one.
  - Commands registered with `defineCliCommand` get `log` (`info`, `warn`, `error`, `debug`, `progress`), `paint` and `emit` in their context. `CliIo` takes `stderrColor` and `spinner`, and `Prompter` no longer has a `spinner` method.
- Breaking for scripts that read `--json`: every command now prints one result envelope that follows `schemas/cli-result-v1.json`. It has `status` (`success`, `error` or `action_required`), the command's result under `data`, Problem Details under `error`, and the commands to run next under `next`. Read `.data.stale` where you read `.stale` before, and `.error.code` where you read `.code`. A failed `--check` keeps its `data`, so the stale files and the error come in one document.

  Every command returns data now, including `init`, `add`, `env`, `keys`, `skills`, `openapi`, `scaffold`, `seed` and `sql`. `--check` drift reports the `check_failed` code, doctor and spec findings report `findings`, and a usage error keeps the `usage` code and prints the command's usage in a terminal. In a terminal, `init`, `add`, `sql add` and `sql upgrade` list the commands to run next under `Next:`.
- The CLI checks its arguments before a command runs. An unknown option, a value outside an option's choices, or an argument a command doesn't take is now a usage error (exit code 2) with a suggestion, such as `Unknown option --chek for better-supabase gen. Did you mean --check?`. Under `--json` the error is a Problem Details document with the `usage` code.

  Help is grouped by task (set up, generate, local stack, inspect, maintain), and each command's help lists its arguments, options, examples, side effects and docs page. `better-supabase --help --json` prints every command, option and side effect as one document that matches `schemas/cli-commands-v1.json`, and `better-supabase <command> --help --json` prints one command.
- `better-supabase codemod 0.9` moves `sql.modules` to the top-level `blocks` key in `better-supabase.config.ts` (and `.mts`, `.js`, `.mjs`, `.json`), keeping the comments around it and dropping `sql` when nothing else is left in it, and renames `ModulesConfig`, `ModuleConfig`, `AccessModuleConfig` and `ModuleMode` to `BlocksConfig`, `BlockConfig`, `AccessBlockConfig` and `BlockMode`. A config that sets both keys, or keeps `sql.modules` in an environment block, is listed for review. A config that still sets `sql.modules` fails to load with `config_invalid` and names the codemod.
- Add the `access-requests` block (`better-supabase/blocks/access-requests` and the `access-requests` SQL module): members ask for a role, for a set time if they like, and the request goes through the approvals module. The final approval grants the role, `end()` and `expire_access_grants()` put the previous role back, and the module records `access.requested`, `access.request_approved` and `access.ended`. With the `provider` access model the adapter grants the role from the events. Approvals gains an internal `open_approval` that files a request under an explicit policy.
- Add the `approvals` block (`better-supabase/blocks/approvals` and the `approvals` SQL module): approval requests with steps per subject type and action, approvers by permission, role, team or user, a required count, delegation, self-approval off by default, expiry and reminders. The final approval runs a state-machine transition or enqueues a job, approvers and requesters are notified through the notifications module, and the module records `approval.*` events. State machines gain `requiresApproval`, which reserves transitions for an approved request (`STATE_APPROVAL_REQUIRED`).
- Add the `documents` block (`better-supabase/blocks/documents` and the `documents` SQL module) and the `better-supabase/yjs` provider. A document is a Yjs update log and a compacted snapshot attached to any subject, with a private `documents:<id>` Realtime topic for edits and presence under the subject's read and write rules. `createDocumentProvider` connects a `Y.Doc`: it loads the stored state, stores and broadcasts each edit, and catches up after a reconnect. `compactDocument` merges the log for the `documents.compact` job, and with the versions module a document's state can be saved as a version. `yjs` (`>=13.6 <14`) is a new optional peer.
- Add the `reactions` block (`better-supabase/blocks/reactions`, `better-supabase/blocks/reactions/react` and the `reactions` SQL module): emoji reactions on any registry subject, comments and inbox messages, checked against each subject's own read policies, with counts, `reacted` and the first actors per emoji, custom emoji per organization, a `reactions:{organizationId}` realtime topic and `useReactions()`. The module records `reaction.added` and `reaction.removed`. With it installed, the inbox's `react_to_message` stores reactions in the table and keeps the message's `reactions` column as a copy; installing it copies existing message reactions.
- Add the `trash` block (`better-supabase/blocks/trash` and the `trash` SQL module): soft delete through each subject's `deletedAt` column, children trashed in the same batch, restore with unique column checks, a list per organization, `purge()`, `purge_expired_trash()` after `retentionDays`, and `is_trashed()` for policies. The module records `subject.trashed`, `subject.restored` and `subject.purged`.
- Add the `versions` block (`better-supabase/blocks/versions` and the `versions` SQL module): numbered snapshots of any subject with a content hash, labels, debounced auto snapshots, versions frozen when a status column changes, retention, restore through columns or an apply function, and `diff()` as an RFC 6902 JSON Patch. `createPatch()` and `applyPatch()` are exported for any JSON value. The module records `version.created`, `version.restored` and `version.deleted`.
- The `collections` SQL module and `better-supabase/blocks/collections` add favorites and lists on any record registered in `config.subjects`. Favorites are a member's own stars, ranked for a sidebar (`star`, `unstar`, `starred`, `reorderStar`). Lists are named, static collections of one record type owned by a user, a team or the organization (`create`, `rename`, `archive`, `add`, `remove`, `reorder`, keyset-paged `entries` and `listsOf`); an entry is visible only when the caller can read its record, and deleting a record with `cascade: true` deletes its entries and stars. A saved view can keep to a list through `state.collectionId` and `collections.where(id)`, and the bulk module gains the `collections.add` action.
- The comments module (version 4) filters activity per record when the `subjects` module is installed: the read policy on `activity_entries` checks `can_subject(subject_type, subject_id, 'read')` for every registered subject type, and `list_activity` returns an empty timeline for a subject the caller can't read. Members no longer see entries on records they can't read, or on deleted records. Entries without a subject, or on a type the registry doesn't list, keep the tenant check alone.

  With the audit module installed with `options.readPolicy: true`, a subject's timeline also returns the audit log entries for its row, gated by the audit read policy. `ActivityEntry` has a new `source` field (`ActivitySource`, `"event"` or `"audit"`), so code that builds `ActivityEntry` values by hand needs to set it.

  With the notifications module installed, the author of a comment and the users it mentions follow the subject as `participating` watchers in `notification_subscriptions`; an existing row, such as `ignore`, is kept. `options.follow: false` turns this off. Run `sql sync` to rewrite the installed module.
- Add client controllers and hooks for the `comments` and `organizations` blocks. `createCommentsController` and `useComments()` (`better-supabase/blocks/comments/react`, `/vue`, `/svelte` and `/solid`) load one subject's thread and keep it current after `add`, `edit` and `remove`, which return a `Result`. `createOrganizationsController` and `useOrganizations()` (`better-supabase/blocks/organizations/react`, `/vue`, `/svelte` and `/solid`) list the caller's organizations, read the active one from the tenant claim, and switch with `switchTo()`, which refreshes the session when `switch_organization` asks for it. Both hooks run in React DOM and React Native, and the comment thread and organization switcher recipes show them in the Next.js and Expo examples.
- Add the `catalog` block and SQL module (`better-supabase/blocks/catalog`): products with a draft, active and archived status, options and variants with per-organization SKUs, prices in bigint minor units per currency and price list with validity windows, a category tree, manual and rule collections, and an opt-in public storefront. `catalogSubjects()` registers products and variants as subjects. Grant `catalog.read` and `catalog.manage` to the roles that use it.
- Add `better-supabase/blocks/commerce`. It holds money as a `bigint` count of the currency's minor units, using ISO 4217 List One (pinned as `SPEC_PINS.iso4217`), with arithmetic, half-to-even rates, allocation, parsing, formatting and JSON. It also defines the versioned `PaymentProvider` and `TaxProvider` interfaces. `stripePayments` implements the first on Stripe PaymentIntents and webhooks. `stripeTax` and `fixedRates` implement the second. `better-supabase/testing` adds their conformance kits, `testPaymentProvider` and `testTaxProvider`.
- Add the inventory block (`better-supabase/blocks/inventory`) and its `inventory` SQL module: locations, stock levels per catalog variant and location kept by an append-only movement ledger, reservations that expire (lazily and through a service-only `expire_reservations` job), transfers between locations and purchase orders with partial receiving and backorders. It emits `stock.*`, `reservation.*`, `transfer.*` and `purchase_order.*` events, and the catalog refuses to delete variants with stock history.
- Add the `orders` block and SQL module (`better-supabase/blocks/orders`). Carts belong to the signed-in user, anonymous users included, and `mergeCarts` moves them to the account the shopper signs in to. `checkout` prices the cart from the catalog, quotes tax through a `TaxProvider`, places the order with a number from `number-sequences`, reserves inventory and creates the payment through a `PaymentProvider` with an idempotency key. Paid orders turn their reservations into sale movements and get an invoice number. Fulfillments, returns and refunds (with a proportional tax reversal) follow, and orders emit `order.*`, `fulfillment.*` and `return.*` subject events. Grant `orders.read` and `orders.manage` to the roles that run the shop.

  `sqlTransport` now sends an array of objects as JSON, so `jsonb` arguments that take a list work over it.
- Add the `promotions` SQL module and `better-supabase/blocks/promotions`. Promotions take a percentage or a fixed amount off, or ship for free, for the whole cart or for products, categories (with their children) or manual collections. They apply automatically or by code, with single codes or generated batches, a minimum subtotal, a start and end, usage and per-customer limits, stacking rules and a priority. `orders` prices them into `priceCart`, lists the applied promotions, reports a code that doesn't apply as a cart problem, and redeems them at checkout with a row lock; cancelling an order releases its redemptions. Applied promotions in `PricedCart` and `Order` now carry their `kind`, and cart problems carry the `promotionId` and `reason`. `merge_carts` moves the promotion codes of an anonymous cart to the account's cart.
- **Breaking:** share-links, signatures, sla, tags, targets, teams, templates, time-tracking, trash, versions, waitlist, webhook-inbox, work-items and workflow-builder take `options.extraColumns` on their main table and a Standard Schema `fields` option on the creator; reads return the columns parsed under `fields`. share-links, sso, state-machine, tags, teams, undo, usage, waitlist, webhooks, workflow-builder and workflows take `hooks` (grouped by namespace for workflows and workflow-builder). `createWebhookInbox` takes `{ transport, ... }` instead of a SQL client: it no longer takes the client as its first argument, so `createWebhookInbox(postgres.admin, { source })` becomes `createWebhookInbox({ transport: sqlTransport(postgres.admin), source })`, and `receive_webhook`, `claim_webhooks` and `list_webhooks` return jsonb (webhook-inbox module version 7). `shareLinks.list` returns typed links. The money and payment helpers move from `better-supabase/blocks/commerce` to `better-supabase/commerce`.
- `createBlocks` returns `sinks`: a `searchSink` over the search block, so trashed, purged and restored subjects update the index, and a `metricsSink` over the metrics block for the events in the metrics block's `sink` option. Pass them to `outbox.relayRoute({ consumers: { ...app.sinks } })`.
- Create, the config and the CLI now offer the same conventions under the same names.

  `init` and `apply` take `--casing`, `--scope`, `--tenant-name`, `--access`, `--id-type` and `--api`. With a preset, a flag changes that field and the CLI writes the new preset code, so `preset info` and Create still read it. Without a preset, `init` writes the matching config keys, and it asks for each convention in Create's order and with Create's labels; `--yes` and CI use the defaults. `casing` defaults to `snake` everywhere, as it does on Create. With `--access provider`, `init` writes the same `authorization` comment Create's config does.

  `apply` takes `--package`, and both commands read `package=` from a Create URL. A URL with `authz=` prints a note that the authorization library isn't part of the preset.

  `plugins.tenant.column` and the tenant column of a subject without `tenant` now default to `<sql.naming.tenant>_id`, such as `workspace_id`, instead of always `organization_id`. Set the column to keep the old name.

  The config check now rejects what the types reject: a `json` entry without `import` or `type`, a malformed bucket (`path`, `policy`, `lifecycle`) and an unknown `sql.workflow`. `schemas/config-v1.json` is generated from the same schema, so the editor checks match the CLI, and it drops `triggerPrefix` and `disabled.tenantKey`, which the config never read.
- The custom-fields client gains `listDefinitions(organizationId, entity)`, which returns an entity's field definitions in rank order with their rank keys, so a settings page can show and reorder them. It calls the new SQL function `list_field_definitions(tenant, entity)`, which needs `custom_fields.read`. Run `better-supabase sql sync` to install it.
- The `deals` SQL module and `better-supabase/blocks/deals` add deals on pipelines. Installing deals binds the `deal` type in the pipelines module with a Qualified to Closed won or Closed lost ladder. A deal has an amount in minor units, a company and contacts from `config.subjects`, a team, line items, a motion, a priority, a forecast call, flags and a next step. `update(changes)` applies grid edits one by one and returns the refused ones with their hint. The table refuses edits to a locked deal, keeps the open date, counts close-date slips and stamps `closedAt` with `deal.closed`. `win`, `lose` and `reopen` move a deal through its pipeline's stages, `forecast` returns won, commit, best case, pipeline, not called and later with attainment and gap, and `health` flags rotting, slipped, missing and overdue next steps.

  Line items share one shape across blocks: `set_<record>_lines` replaces a record's lines and a trigger keeps its list, discount, subtotal, tax and total.
- Delegated tokens are narrowed inside the access functions. A token with a `client_id`, or an `act` chain that is not a support or impersonation session, passes `member_can`, `member_can_while_deleting`, `platform_can` and `tenant_ids_with` only for the permission keys its scopes map to, through the new `better_supabase.cap_allows(permission)`. Map scopes in `blocks.access.scopes` or `authorization.delegation.scopes` (a scope without an entry grants the key with its own name, `["*"]` grants every key). `forContext(context, { permissions })` sets a `bs_permission_cap` claim that caps the work the same way, and a provider can map scopes at runtime, per client or from RFC 9396 `authorization_details`, with the `delegation.permissions` hook.

  Breaking: an OAuth client or MCP server whose scopes map to no permission key is now refused where it used to pass with the user's roles. The 0.8 to 0.9 guide lists the mapping to add.
- `session.delegation.scopes` and `delegationOf(claims).scopes` leave out the OpenID Connect identity scopes, exported as `IDENTITY_SCOPES` from `better-supabase/server` (`openid`, `email`, `profile`, `phone`, `address`, `offline_access`). They grant no permission. A token whose `scope` names only identity scopes still has a delegation, with `scopes: []`, so an agent on it stays limited to its scopes. Code that looked for `openid` in `delegation.scopes` should read `claims.scope` instead.
- Delivery fails closed on records under an authorization provider. Without `delivery.subscribers` or `delivery.audience`, webhooks, notifications and reminders deliver an event about a registered subject only to users and service principals who may read it, through the new `can_subject_for(user, type, id, action)` in the `subjects` module and `subjectReaders(transport)`. Webhooks check each delivery against its stored subject, and a share link never counts.

  Breaking: realtime `fanOut` under a server with authorization hooks and no subscriber policy sends an event with a `subject` to no one and returns `forbidden` with the hint `REALTIME_SUBSCRIBER_POLICY_REQUIRED`; pass `policy: subjectReaders(transport)`.
- `doctor` reports BS223 (info) for functions in an exposed schema that return a scalar `int8` or `numeric`, or an array of them. PostgREST sends those as JSON numbers, so values past 2^53 lose precision before `$rpc` sees them; return them as `text` instead.
- Doctor warns with BS416 when the `support-sessions` block runs under the provider access model and `authorization.functions.isApprover` never handles `{kind} = 'support-session'`, in the template or in a function it calls. Such a template answers false, so no admin can approve a support request. BS413, which warns when support sessions run under a provider without `actorMayWrite`, now looks for the `support-sessions` block; it checked a block name that doesn't exist and never fired.
- New block `better-supabase/blocks/domains` (`better-supabase add domains`): the hostnames an organization serves your app on. A custom domain is verified by a DNS TXT record that `createDomainsAdmin().verify()` looks up over DNS-over-HTTPS, and with `blocks.domains.options.platformDomain` set, one label below your domain is a subdomain verified when added (checked against `reserved-slugs` when that module is installed). Each organization has at most one primary hostname, only one organization can hold a verified hostname, and `resolve(host)` returns the organization behind a request's `Host` header for the proxy. The `access` module adds `domains.read` (admins and members) and `domains.manage` (admins); re-run `better-supabase sql sync` to pick up the new default permissions.
- Adds the drive block (`sql add drive`, `better-supabase/blocks/drive`): folders and files on attachments, with grants to users, teams or roles that apply to everything under a folder. The same check runs in the drive functions, the attachment policies and restrictive `storage.objects` policies. Moves and renames follow a name-conflict policy. With versions, trash and share-links installed, the drive also keeps revisions, a bin, starred and recent items and folder share links. `driveTree` shapes nodes for a tree view, and `driveSubject()` lets other blocks attach to nodes. Adopt mode runs the drive on an existing tree and grant table. A SQL module can now set `before` to render ahead of another module without renumbering the generated files, and `drive_share` takes a team id from the teams table, so an adopted uuid team column accepts it.
- The `employees` SQL module and `better-supabase/blocks/employees` add the people an organization employs, with or without an account. An employee has a manager, team, job title, employment type, start and end date, and a status of preboarding, active, on leave or offboarded; the database refuses a manager who reports to the employee. `update` applies batched cell edits, `orgChart` and `reportsOf` read the tree (managers read their own reports), and `offboard` sets the end date and moves the reports up. `fromApplication` makes one employee from a hired application of the hiring block. Private details and a contract history with salaries in minor units sit behind `employees.view_sensitive`, declared to `data-lifecycle` for exports and anonymize rules. `employeesSubjects()` registers employees in `config.subjects`.
- The `engagements` SQL module and `better-supabase/blocks/engagements` log calls, emails, meetings, notes and tasks on one or more records registered in `config.subjects`, with an owner, a due time, an outcome and an external id that makes a repeat log an update. `of(subject)` reads a record's engagements, `next(subject)` its next planned step with an overdue flag, and `open(organizationId)` a member's open tasks. An engagement is visible to callers who can read one of its records, and assigning it to someone else notifies them.
- The `entitlements` module reads the Stripe customer from the `billing` or `organizations` module through their configured names: a renamed `billing_customers` table, its `customer` and `tenant` columns, or an `organizations` table in another schema or with another id column now resolve correctly instead of falling back to `stripe_customer_id`, `organization_id` and `better_supabase.organizations`. `entitlements` lists both modules in `integrates`.
- `evaluateFilterQuery(query, record, fields, clock)` from `better-supabase/list` tests a `FilterQuery` tree on one record in memory, with the same operators, case rules, relative dates and null handling as `filterWhere` compiles to SQL. It returns `matched` and the result of every rule and group by node id (`null` when a comparison met a missing value). A field's `column` may be dotted to read a nested value, and `FilterRecordField` describes fields that aren't table columns.
- `actorOf(claims)` and `delegationOf(claims)` are exported from `better-supabase/server`, with the `ActorOutcome` type, so another library can read an `act` and `scope` claim the way `toSession` does without building an `AuthState`.
- `blocks.data-lifecycle.options.omitPattern` leaves columns out of data exports by name. `true` matches `token`, `secret`, `_hash`, `password`, `api_key` and `license_key`, and a string is a Postgres regular expression; `data_lifecycle_tables()` then appends the matching columns of each listed and `autoTables` table to its `omit` when an export runs. It is off by default, so existing exports don't change. Doctor's new BS341 warns about exported columns that match the built-in pattern and that no `omit` or `omitPattern` leaves out.
- `expose` privileges can grant every column but a few: `select(*, -token_hash)`. `sql sync` turns the list into the columns the table's `create table` declares, in order, and fails when no schema file declares the table or the exclusions leave nothing. `better-supabase/sql` exports `resolveExposeColumns` for apps that render the grants module themselves, and the module refuses an unresolved `*`. Doctor's new warning BS339 reports an excluded column the table no longer has.
- Custom fields gain six types: `email`, `url`, `multi_option`, `user`, `rating` and `currency`. `CUSTOM_FIELD_TYPES` and `CustomFieldType` are exported from `better-supabase/blocks/custom-fields`, and `saveDefinition` takes a `CustomFieldType`. A form field that names a custom field reads the matching forms type: `multi_option` becomes `multi`, `rating` becomes `integer` from 1 to 5, `currency` becomes `integer` (minor units) and `user` becomes `text`.

  App table formats and custom field types now come from one list. `FIELD_FORMATS` from `better-supabase/config` gives each format its column types, its check and the input a form renders, and `scaffold screens` reads the input from it.

  Migration: run `better-supabase sql sync` and generate a migration. The custom-fields module replaces the inline `field_definitions_type_check` constraint with a named `bs_field_definitions_type` check that lists the new types.
- Add `better-supabase/flags-sdk`, a [Flags SDK](https://flags-sdk.dev) adapter over the flags block. `betterSupabaseAdapter()` evaluates `flag()` declarations from the flags table with the same rules as `flag_enabled()`, `createIdentify()` returns the user, the tenant and custom contexts from verified claims (the same entities `vercelAdapter` targets), and `getProviderData()` feeds Flags Explorer. See [Flags SDK](https://bettersupabase.com/docs/flags-sdk).

  The flags module moves to version 3. Run `better-supabase sql upgrade` to add:

  - Custom contexts, such as a brand: declare them in `blocks.flags.options.contexts`, target them with a rule's `contexts` or an override with `context` and `contextKey`, and pass their values through `flagContext({ contexts })` or `contextClaims`. `flag_evaluation()` takes a fourth `contexts` argument and reads a missing value from the tenant.
  - `description` and `archived` on flags. An archived flag serves its default variant; `save` and `FlagInput` accept both.
  - `createFlagsProvider().definitions()`, which returns the cached definitions.

  `better-supabase doctor` warns about flags past their `expires_at` extra column that aren't archived (BS336), and `expiredFlagsQuery()` in `better-supabase/sql` returns the query it runs.
- Add the `forms` block (`better-supabase/blocks/forms`). A form has numbered versions of steps and fields with `showIf` conditions, and each version stores a JSON Schema 2020-12 that `pg_jsonschema` checks on every submission. Submissions can be saved as drafts, linked to a record, or turned into one by a handler per form key. With `share-links`, a `respond` link opens the form to anyone who holds it, rate limited per caller with `rate-limit`, and `before_public_form_submission` receives a bot-check token. `useQuestionnaire` from `better-supabase/blocks/forms/react` drives a multi-step form from a version, and `validateAnswers` checks answers in the browser with the same schema.
- The frameworks the adapters support are now optional peer dependencies: `react-router` (7.9 to 8), `@sveltejs/kit` (2 and 3), `h3` (1 and 2), `nuxt` (3 and 4), `astro` (7), `@solidjs/start` (2), `@tanstack/react-start` (1), `elysia` (1), `express` (5), `fastify` (5) and `koa` (3). Nothing new is installed, but your package manager now warns when a framework is outside the majors the conformance tests cover. The ranges are on the [Peers](https://bettersupabase.com/docs/getting-started/peers) page.

  The conformance tests found these fixes:

  - `better-supabase/h3` and `better-supabase/solid-start` read the Cloudflare bindings from `event.req.runtime.cloudflare.env`, where Nitro 3 puts them. Before, `getEnv` found no bindings on Cloudflare with H3 2 or SolidStart 2.
  - The SvelteKit `handle` type-checks as SvelteKit's `Handle` (SvelteKit 2 and 3), so it works inside `sequence()`.
  - The SvelteKit adapter calls `error(status, message)` instead of the `error(status, { message })` form SvelteKit 3 deprecated. A custom `kit.error` now receives the message as a string.
  - The TanStack Start middleware accepts a `next()` that returns its result without a promise.
  - The Astro image service accepts any `quality` string. A string other than `low`, `mid`, `high` or `max` uses the default quality.
  - The Nuxt module type-checks as a module for Nuxt 3 as well as Nuxt 4.
- Block hooks now wrap headless controllers, so they run in React DOM and React Native alike. Each block with a `/react` entry exports `create<Name>Controller` from its main entry (`createNotificationsController`, `createCommandSearchController` and the rest), with `subscribe`, `getSnapshot`, `actions` and `dispose`; the hooks are `useSyncExternalStore` wrappers over them.

  Controllers reach the platform only through ports on the client, `bs.ports`: realtime topics (`topicPort` from `better-supabase/realtime`), storage, the keyboard, file picking and upload, push tokens and deep links. `createClient` from `better-supabase/client` fills the web ports (`webStorage`, `webKeyboard`, `webFilePicker`, `webDeepLinks`, `storageUpload`, and `webPushToken` on request), and `nativePorts()` from `better-supabase/client/native` builds the React Native ones from the Expo modules you pass to `createNativeClient(betterSupabase, supabase, { ports })`.

  Breaking changes: `useCommandSearch` listens for Cmd+K through the client's keyboard port instead of `window`, so React Native has no shortcut. `useInboxWidget` keeps the conversation id in the storage port instead of `localStorage`, and reads it after the first render. `groupHits` and its types moved from `blocks/search/react/groups.ts` to `blocks/search/groups.ts` (still exported from `better-supabase/blocks/search/react`).
- The `hiring` SQL module and `better-supabase/blocks/hiring` add an applicant tracking system on pipelines. Installing hiring binds the `application` type in the pipelines module with one pipeline per job (Applied, Screening, Interview, Offer, then Hired, Rejected or Withdrawn). Jobs open and close, a candidate is any record in `config.subjects` (a person by default) and applies once per job, and interviewers score the application at its current stage. `hire`, `reject` and `withdraw` end an application, and `application.hired` carries the candidate for the app to turn into an employee.
- `httpSink` aborts a request after `timeoutMs` (10 seconds by default) instead of waiting on an endpoint that never answers. `doctor` gives up on the splinter download and the Management API advisors after 30 seconds.
- `createIdempotency().handle` now renews the key's lock at half its length while the handler runs, so a handler slower than `lock` keeps its key instead of losing it to a retry. The new `extend(key, holder, scope)` does the same for callers of the lower-level protocol. Rerun `better-supabase sql sync` and generate a migration to add `better_supabase.extend_idempotent`.
- Add the `identities` SQL module and `better-supabase/blocks/identities`: `identityResolver` maps an email address or chat user to an app user or contact, linked only through a one-time code from `beginLink` that the service redeems with `redeemLink`, or a confirmed `auth.users` address. `better-supabase/chat-sdk` adds `botJobs`, which enqueues a linked author's job with a ticket and an unlinked author's with none, `chatActions`, which decides buttons through `chat.onAction` and refuses unlinked authors, and `approvalLink` and `approvalPrompt`, which link to the app's approval page without approving.
- The inbox no longer owns `inbox_teams` and `inbox_team_members`. It requires the `teams` block and assigns a conversation to a `teams` row. Installing both copies the old rows and drops the old tables. `create_inbox_team` and `set_inbox_team_member` call the teams functions. Team ids stay `uuid` while the inbox is installed.
- Jobs and workflow runs that an OAuth or MCP client queues keep the client's limits: the envelope records the token's `client_id`, `scope`, `authorization_details` and full RFC 8693 `act` chain, and `bs.forContext` runs with them instead of the user's full rights. The new SQL helper `better_supabase.token_scope_allows(scope)` in the `actor` module checks a delegated token's scope in policies.

  `enqueue` fails with `forbidden` for a context with an invalid `act` chain or malformed delegation claims, and `forContext` refuses such a stored context. `ServerOptions.claimsFor` can no longer set `act`, `client_id`, `scope` or `authorization_details`. A job's `delay` must be a whole number of seconds, else `enqueue` fails with `validation`.

  Jobs and workflows take an opaque `ticket`: the `ticket` enqueue option, `startFor(ctx, workflow, args, { ticket })`, `workflowContext(ctx, { ticket })` and `startOnEvent({ ticket })`. Runs carry it as the `bs.ticket` attribute, and `forContext` resolves it through `ForContextOptions.ticket` or the provider's `tickets.subject` hook, failing with `forbidden` when nothing resolves it. `startOnEvent`'s `args` now gets the event's context as a second argument.

  `better-supabase/workflow-sdk` exports `approvalWaiter`: `wait(token, { expiresAt })` pauses a run until `wake(token, payload)` or the RFC 3339 deadline.
- New block `better-supabase/blocks/leave` (`better-supabase add leave`): leave types in days or hours, requests that go through the `approvals` module, and a balance ledger per member and year. A request books the working days in `blocks.leave.options.workingDays` (Monday to Friday by default) or a half day, can't overlap another pending or approved request, and can't exceed the available balance unless `allowNegative` is on. The final approval books it as taken leave, and cancelling approved leave gives the days back. `createLeave()` adds `accrue` (idempotent with a key, for a monthly cron), `adjust` and `closePeriod`, which carries each balance up to the type's `carryoverMax` into the next year and expires the rest. The `access` module adds `leave.request` (members), `leave.read`, `leave.approve` and `leave.manage` (admins); re-run `better-supabase sql sync` to pick up the new default permissions.
- Add `filters` to `defineListQuery`: an allow-list of typed fields that a ReUI Filters `FilterQuery` tree may name. `parse` checks each rule's operator and value against the field type, caps depth and rule count, drops unfinished rules, and takes a `timeZone` that relative dates resolve in. The tree compiles to repository `where` input, so it runs through PostgREST, the Postgres executor and SQLite. `filterFields(labels)` returns ReUI `FilterField` objects, the URL keeps the tree in one base64url `filter` parameter that `toSearchParams` and the nuqs parsers handle, and `openapi` and `jsonSchema` describe it.
- Add client controllers and hooks for members, invitations, the audit log, API keys and tags. `useMembers()` and `useInvitations()` (`better-supabase/blocks/organizations/react`) list an organization's members and open invitations; `setRole()` shows a role change at once, `remove()` removes a member, and `invite()` keeps the token for the accept link in `sent`. `useAuditLog()` (`better-supabase/blocks/audit/react`) pages through the audit trail with `setFilters()` by actor, action or text and `loadMore()`. `useApiKeys()` (`better-supabase/blocks/api-keys/react`) lists, creates and revokes keys and shows a new token once. `useTagInput()` (`better-supabase/blocks/tags/react`) adds, removes and creates a record's tags. Each has a `create<Name>Controller` in the block's main entry and `/vue`, `/svelte` and `/solid` bindings, and runs in React DOM and React Native. The tags module gains `list_tags` and `subject_tags`, which `tags.list()` and `tags.forSubject()` call. The members table, invite dialog, API keys table, audit log table and tag input recipes are now available in the Next.js and Expo examples.
- Adds the `metrics` block (`better-supabase/blocks/metrics`). `defineMetric` declares a count, sum or average over a table with dimensions, a time zone and a permission. Metrics roll up into `metric_points` per grain through a `metrics.rollup` job or `metricsSink`, or are read from the table directly. `series()` and `kpi()` compare with the previous range, and `toChartData` returns Recharts rows. Usage events can be a metric's source.
- Add the `better-supabase/blocks/mfa` and `better-supabase/blocks/sessions` entries, with React, Vue, Svelte and Solid
  bindings. `useMfa` enrols, verifies and removes TOTP factors, and `useStepUp` raises a session to `aal2` after an
  `INSUFFICIENT_AAL` refusal (`needsStepUp`). `useOAuthConsent` serves the consent page of the Auth OAuth server, and
  `useOAuthGrants` lists and revokes the clients a user approved.
- Breaking: every amount with a currency in the deals, quotes, invoices, pipelines and employees blocks is a `Money` value (`{ amount: bigint, currency }` from `better-supabase/commerce`) instead of a bare `bigint`. Build one with `money(amount, currency)`. Line inputs, totals, payments, aging buckets, forecasts, board totals and contract salaries all change, and the separate `currency` input of a contract is gone because `salary` carries it. Target goals and progress stay `bigint`.

  | Old                                                                  | New                                                                           |
  | -------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
  | `deals.create(org, { amount: 4_800_000n, currency: "EUR" })`         | `deals.create(org, { amount: money(4_800_000n, "EUR") })`                     |
  | `{ description, unitAmount: 9_500n }`                                | `{ description, unitAmount: money(9_500n, "EUR") }`                           |
  | `invoices.recordPayment(id, { amount: 137_940n })`                   | `invoices.recordPayment(id, { amount: money(137_940n, "EUR") })`              |
  | `deals.forecast(org, { currency: "EUR", target: 60_000_000n })`      | `deals.forecast(org, { currency: "EUR", target: money(60_000_000n, "EUR") })` |
  | `employees.addContract(id, { salary: 5_400_000n, currency: "EUR" })` | `employees.addContract(id, { salary: money(5_400_000n, "EUR") })`             |
  | `invoice.totals.total`, `invoice.balance`: `bigint`                  | `Money`; read `.amount` for the `bigint`                                      |
  | board `totals: [{ currency, amount: bigint, weighted: bigint }]`     | `totals: [{ currency, amount: Money, weighted: Money }]`                      |

  Breaking: an amount in another currency than its record returns an `invalid_request` error with hint `CURRENCY_MISMATCH` before any database call, and an unknown ISO 4217 code returns `CURRENCY_INVALID`. A deal, quote or invoice created without a `currency` takes it from its amounts. The SQL checks the same rule: the line-items setters refuse a line in another currency (`LINES_CURRENCY`), `update_deals` rejects an amount change in another currency (`DEAL_CURRENCY_MISMATCH`), and `record_invoice_payment` is now `(invoice, amount, currency, paid_at, method, reference)` and refuses a payment in another currency (`INVOICE_CURRENCY_MISMATCH`). The old five-argument function is dropped.

  Breaking: the block SQL functions return every `int8` as text, and the clients read amounts only from an integer string or a `bigint`. Amounts past 2^53 now round-trip exactly, and a missing or malformed amount is an error instead of `0n`. Code that calls these functions through `$rpc` or `queryRaw` gets strings where it got numbers. Rerun `better-supabase sql sync` and generate a migration to install the new functions.
- The notifications module tracks what an email provider reports after a send. `notification_delivery_events` stores each `delivered`, `opened`, `clicked`, `bounced`, `complained` or `delayed` event once per provider event id, and deliveries gain `provider_status`, `provider_status_at` and `template_key`. `notifications.recordDeliveryEvent(input)` calls the service-only `record_delivery_event`, which moves the status forward, suppresses the address on a hard bounce or a complaint, and emits `notification.<kind>` with the notification's subject. `notifications.subjectDeliveries({ subject })` lists the deliveries of every notification about one record. A `templateKey` returned from the email channel's `compose` is stored on the delivery.

  `resendWebhook` now records every Resend delivery status before it writes suppressions, and `resendDeliveryEvent(event, id)` maps one event without the handler. `notification.delivered` and `notification.failed` now carry `subjectType`, `subjectId`, `organizationId` and `deliveryId`, and the five new events are subject events.

  `complete_notification_delivery` takes a seventh argument, `template_key`, and the six-argument signature is dropped. Run `better-supabase sql sync` to install module version 6; `resendWebhook` answers 500 against a database without `record_delivery_event`. An adopted module tracks delivery events only when `tables.deliveryEvents` names your table.
- `send` accepts `permission` (with a tenant) and reaches the users who hold that key. A provider supplies them through `authorization.functions.holdersOf`. The roles and catalog models derive holders from memberships. A provider model without `holdersOf` raises `NOTIFICATION_PERMISSION_AUDIENCE_UNAVAILABLE` instead of notifying every member.
- The `notifications` module takes `options.subjects` like the other subject-bound modules. A subject type with `cascade: true` gets an `after delete` trigger on its table that deletes the subject's notifications, with their recipients and deliveries, and the subscriptions to it. Without `options.subjects` the module reads `config.subjects`, so a registered type with `cascade: true` now cleans up its notifications too. The keys may contain hyphens (`time-off-request`), because `subject_type` in `notification_events` takes any text.
- `createNotifications` takes `email: { transport, from, compose?, unsubscribe? }`, an email channel over a `DeliveryTransport` (`apiVersion: 1`) that sends with the delivery id as the idempotency key, sets `Auto-Submitted: auto-generated` on all mail and `List-Unsubscribe` with one-click `List-Unsubscribe-Post` on notification mail only. `better-supabase/blocks/notifications` exports `resendTransport` (fetch-based, `Idempotency-Key` header), `resendWebhook` (verifies the Svix signature and records bounces and complaints), `mailHeaders` and `resendSuppressions`, and `better-supabase/testing` gets `testDeliveryTransport`.

  The notifications module (version 5) adds `notification_suppressions` with RLS and `suppress_notification_address`, `unsuppress_notification_address` and `list_notification_suppressions`; the block gets `suppress`, `unsuppress` and `suppressions`, and `deliver` skips a suppressed address. `createNotifications({ audience })` checks recipients before sending and falls back to `server.authorization.delivery.audience`. `defineTopic` takes `permission` (an instance permission from a topic segment, checked with the provider's `permitted` template or the access contract) and `subscribers`, and topics get `fanOut`.

  Breaking: run `better-supabase sql sync` and create a migration for the module upgrade; an adopted module honours suppressions only when `tables.suppressions` is mapped. `deliver()` counts suppressed and audience-denied deliveries in `skipped`. A hand-written `Topic` object needs `fanOut`.
- `gen` reads `@notNull` and `@notNull(a, b)` from a function's comment, so a function can say its result is never null next to its SQL instead of in `functions.<name>.notNull`. A config entry still wins. Snapshots record the comments that carry the tag (`extras.functionComments`), and doctor's new info rule BS338 lists the `notNull` entries a tag could replace or already matches.
- `accessTokenResolver({ jwks, issuer, audience })` from `better-supabase/server` verifies RFC 9068 access tokens that another authorization server issued, next to `apiKeyResolver`. A verified token resolves to a user with `source: "oauth"`, and its `sub`, `client_id` and `scope` reach `ctx.sql` and `forContext` as claims, so the request runs as the user under row level security and a `scope` limits it like any OAuth client. The resolver fails closed on a wrong issuer or audience, an expired token, a missing required claim, a `service_role` role or a sender-constrained token, and can check a revocation list keyed by `jti` and grant. JWS verification also accepts RS256 and PS256 keys.

  The new `oauth-server` block (`better-supabase/blocks/oauth-server`) stores what an authorization server needs: an RFC 7591 client registry with hashed secrets and registration tokens, authorization codes bound to an S256 PKCE challenge, RFC 8628 device codes, grants per user and client, and the revocation list. A replayed authorization code revokes its grant. The block issues no tokens; sign them with `createSigningKeys`. Install it with `better-supabase add oauth-server`. The pins are `SPEC_PINS.rfc9068`, `rfc7591`, `rfc7636`, `rfc8628` and `rfc9700`.
- A block's SQL and TypeScript halves now read one config. `better-supabase gen` writes `blocks.generated.ts` next to `generated.ts`: each installed block's schema, API schema and the options both halves share (`attachments.requireScan`, `bulk.limit`, the realtime topics of notifications, announcements, inbox, reactions and ai-chat, `time-tracking.timeZone`, `waitlist.codeField`, `webhooks-out.allowHttp`), with the module defaults filled in. `defineBlocks(blockConfig, blocks, ...appEvents)` takes it as its first argument and passes each block its schema (the API schema when there is one) and those options, so the app no longer repeats them. `createBlocks` takes it as `config`, and its shared `schema` option is removed. `generatedOptions(entry)` gives a creator you call yourself the same values, and `fillTopic(template, values)` fills in a topic template such as `notifications:{userId}`.

  `blocks.<name>.options` is typed from each block manifest's options schema: an unknown block, an unknown option or a value of the wrong kind is a type error, and the SQL renderer refuses it too. Manifests describe their options with `blockOptions({ key: option.string(), ... })` in place of `optionKeys([...])`, which is removed, and `schemas/config-v1.json` is generated from the same schemas. `BlocksConfig` is the typed input; the resolved config and the SQL renderer use `AnyBlocksConfig`. Preset wiring (`init --preset`) and the `blocks` registry item build on `defineBlocks(blockConfig, ...)`, and `add` edits a `defineBlocks` call as well as a `createBlocks` one.
- Every action a module checks is an operation named `<module>.<action>`, and `better_supabase.operation_permission(op)` returns its permission key in SQL, after `blocks.<module>.permissions` renames and with the provider's `operations` over them. MCP tools take an optional `operation`, agents take `toolOperations`, and workflow-builder step library entries take `operation`, each naming the entry to check in place of `mcp.tool.<name>`, `ai.tool.<name>` or `workflow.run`.
- `@openfeature/server-sdk`, `@supabase-labs/middleware-openfeature`, `@vercel/connect`, `@ai-sdk/anthropic`, `@powersync/common` and `flags` are now optional peer dependencies. better-supabase still imports none of them, so nothing new is installed, but your package manager now warns when one of them is outside the major versions the conformance tests cover. The ranges are on the [Peers](https://bettersupabase.com/docs/getting-started/peers) page.
- `otel()` records a `BATCH` span for the round trip when an executor runs the reads of one `db.$many` call together, as the direct Postgres executor does. The span carries `db.operation.batch.size` and is marked failed when any read fails. Plugins that wrap `executor.batch` now see these batches too.
- The `parties` SQL module and `better-supabase/blocks/parties` keep the companies and people an organization works with: lifecycle stages, owners, who works where with a role and one primary contact, dedupe by domain and email, `mergeCompanies` and `mergePeople`, and keyset-paged `listCompanies` and `listPeople`. `partiesSubjects()` registers both tables in `config.subjects` so comments, tags, files and pipelines attach to them, and the owner watches the record when notifications is installed.
- With the inbox module installed, the parties module links inbox contacts to people. `personForInboxContact(contactId)` returns the linked person, links the person with the contact's email when there is none, and with `create: true` saves an unknown contact as a new lead. `linkInboxContact` and `unlinkInboxContact` change the link by hand.
- The `pipelines` SQL module and `better-supabase/blocks/pipelines` put any record in `config.subjects` on pipelines an organization edits. Bind a type in `blocks.pipelines.options.subjects` with its categories, each mapped to `open`, `success` or `failure`, and the module keeps several pipelines per type (or one per scope, such as a job), checks every stage change, stamps the time a record entered its stage, writes a stage history and records `pipeline.stage_changed` with the record as its subject. A binding can set a status column from the stage category through the state machine, and the owner watches the record when notifications is installed. `createPipelines` manages pipelines, stages and transitions, and reads the board (counts and per-currency totals weighted by probability), stats (entries, time in stage, win rate) and health (days in stage, rotting).
- Lists and resources page by cursor by default. `defineListQuery` and the default list of `defineResource` and `defineApi` read the same number of rows however deep the page is, and rows inserted between requests don't shift it. Set `pagination: "offset"` to keep page numbers and totals; the 0.9 codemod adds it to calls that set no pagination in files that read page numbers, and lists the other calls for review. A table or view without a primary key stays on offset.

  | 0.8 (offset default)                       | 0.9 (cursor default)                  |
  | ------------------------------------------ | ------------------------------------- |
  | `?page=3&size=50`                          | `?after=<nextCursor>&size=50`         |
  | `page.page.number`, `.total`, `.pages`     | `page.hasMore`, `page.nextCursor`     |
  | `ListPage<R, F, C>` is an `OffsetPage`     | `ListPage<R, F, C>` is a `CursorPage` |
  | `list.args(query)` returns `page`, `count` | `list.args(query)` returns `after`    |
  | OpenAPI and MCP list `page`                | OpenAPI and MCP list `after`          |

  The World's `world.analytics` lists return `(created_at, id)` keyset cursors instead of offsets. Pass back the cursor the previous page returned; an offset number from 0.8 fails with "not an analytics cursor".

  Time tracking rates are `Money` (ADR 0037). An entry's `rate` is an hourly amount in minor units with its currency, and the timesheet totals return `amounts`, one `Money` per currency, instead of `amount`. The client returns `CURRENCY_INVALID` before any database call for an unknown code, and `time_entry.*` events carry `rate` as text with `rateCurrency`.

  | 0.8                              | 0.9                                                 |
  | -------------------------------- | --------------------------------------------------- |
  | `time.start(org, { rate: 120 })` | `time.start(org, { rate: money(12_000n, "EUR") })`  |
  | `entry.rate` is `120`            | `entry.rate` is `money(12_000n, "EUR")`             |
  | `sheet.totals.amount` is `480`   | `sheet.totals.amounts` is `[money(48_000n, "EUR")]` |

  The module upgrade turns `rate` into a `bigint` with `round(rate * 100)`, adds `rate_currency`, and drops the `numeric` signatures of `start_timer` and `log_time`. Set `rate_currency` on existing rows after the upgrade; the 0.8 to 0.9 guide has the statements.

  Tagging a record needs `tags.apply` or `tags.manage`. In 0.8 any signed-in user could tag or untag a record. The `role_permissions` defaults give `tags.apply` to members; apps with their own roles grant it to the roles that tag.

  | 0.8                                          | 0.9                                                        |
  | -------------------------------------------- | ---------------------------------------------------------- |
  | `tag()` and `untag()` check the subject only | they also need `tags.apply` or `tags.manage` on the tenant |

  Audit, webhooks and notifications write a batch in one call. `audit.recordMany(events)`, `webhooks.publishMany(events)` and `notifications.sendMany(items)` take up to 1000 items each, run the single-item checks per item, and return one `Result` per item in order, so a bad item never drops the others. The audit, webhook and notification sinks use them, one call per batch. `auditSink` takes the batch function:

  | 0.8                                      | 0.9                                                              |
  | ---------------------------------------- | ---------------------------------------------------------------- |
  | `auditSink(record, options)`             | `auditSink(recordMany, options)`                                 |
  | `record: (event) => AsyncResult<string>` | `recordMany: (events) => AsyncResult<readonly Result<string>[]>` |

  The outbox, usage, notifications and webhooks modules take `partitioned: true`, as the audit module does. It partitions a new events, `usage_history`, `notification_delivery_events` or deliveries table by month on its time column, with a primary key of `(id, created_at)`, and retention drops whole months. It applies to new tables only; the default stays unpartitioned (ADR 0038).

  Direct Postgres (`better-supabase/postgres`) honors `count: "planned"` and `count: "estimated"` the way PostgREST does: `planned` reads the row estimate from `explain`, and `estimated` counts exactly up to `estimatedCountThreshold` (default 1000) and uses the estimate above it. Counts are cast to `bigint`.

  | 0.8                                          | 0.9                                                            |
  | -------------------------------------------- | -------------------------------------------------------------- |
  | `planned` and `estimated` ran an exact count | `planned` reads `explain`; `estimated` switches at 1000 rows   |
  | `idleInTransactionTimeout` unset             | defaults to 15000 ms; `0` turns it off                         |
  | an idle connection error crashed the process | it arrives as an `error` event, or the logger without handlers |

  `createPostgres` takes `events` and `logger` for those errors. The World's pool has limits now: `maxConnections` (10), `connectionTimeout` (30 s), `idleTimeout` (10 s), `statementTimeout` (30 s) and `idleInTransactionTimeout` (15 s), also read from `WORKFLOW_POSTGRES_MAX_CONNECTIONS`, `WORKFLOW_POSTGRES_CONNECTION_TIMEOUT_MS`, `WORKFLOW_POSTGRES_IDLE_TIMEOUT_MS`, `WORKFLOW_POSTGRES_STATEMENT_TIMEOUT_MS` and `WORKFLOW_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS`.

  Realtime trigger functions include the schema, so tables with the same name in two schemas no longer replace each other's function. The trigger keeps its name. Drop the old functions in the migration; grants on them don't carry over.

  | 0.8                              | 0.9                                       |
  | -------------------------------- | ----------------------------------------- |
  | `bs_broadcast_<topic>_<table>()` | `bs_broadcast_<topic>_<schema>_<table>()` |

  The key payload of `broadcast_topic()` reads only the primary key and the placeholder columns and looks the key up once per statement.

  Doctor has five new rules:

  | Code  | Severity | Finds                                                                                           |
  | ----- | -------- | ----------------------------------------------------------------------------------------------- |
  | BS117 | warning  | a policy without a `to` role, so it applies to `public`                                         |
  | BS224 | warning  | `anon` or `authenticated` without `statement_timeout` or `idle_in_transaction_session_timeout`  |
  | BS225 | info     | `pg_stat_statements` not installed                                                              |
  | BS226 | warning  | postgres.js without `prepare: false`, or Prisma without `pgbouncer=true`, next to a `:6543` URL |
  | BS227 | info     | `pagination: "offset"` in a list or resource config, or `count: "exact"` on a list              |

  BS216 accepts a `where col is not null` partial index as the foreign key's index. BS335 now also
  checks the outbox, usage history, webhook deliveries and notification delivery events tables, not
  only the audit log.

  `calendar_attendees.role` is checked against the RFC 5545 roles, `notification_events.priority` against the configured priorities, and `reminders.offset_minutes` is limited to a year either way. Each check is added `not valid` and then validated, so existing bad rows leave it unvalidated with a warning instead of failing the migration. `comments.list` takes `cursorId` with `cursor`, so comments with the same timestamp no longer repeat or skip between pages. The audit list ignores `offset` when it gets a cursor.

  Line totals on quotes, invoices and deals are recounted once per parent per statement instead of once per line. Trigger functions and `broadcast_changes()` are no longer executable by `anon` or `authenticated`, and the storefront helpers are revoked from `public` and granted to the roles that call them. `bs_reserved_slugs_read` names its roles, and `scope.platform()` checks the platform permission once per statement. The reminders, calendar, bookings, imports, bulk, share-links, tags, memory and workflow-builder modules index the columns their policies and lookups filter on. Search writes embeddings in one statement, the pgmq backend archives a batch in one call, and `metrics_rollup` locks per tenant.
- `createPostgres()` session options take `statementTimeout`, which overrides the role's timeout for `asUser`, `executorFor` and `transaction`. `transaction()` without claims now applies `settings`, `readOnly` and `statementTimeout` too, and an `admin` query with no timeout to set runs without `begin` and `commit`.
- **Breaking:** the CLI uses `@supabase/postgrest-typegen` 0.6.0, and the optional peer now accepts `>=0.6.0 <0.7` instead of `>=0.4.0 <0.5`. Upgrade it with `pnpm add -D @supabase/postgrest-typegen@0.6.0`. `database.types.ts` again matches `supabase gen types` from Supabase CLI 2.121, which writes the same `ComputedFields` keys. View columns are typed with the nullability postgrest-typegen 0.6 reads from the view definition, so run `better-supabase gen` to regenerate.

  The `GeneratorMetadata` type gains the optional `rpcFunctions` collection that postgrest-typegen 0.6 derives from `functions`, and the `RpcFunction` types it uses are exported next to it. Snapshots and `gen --metadata` leave it out, and the generator resolves it again from the ordered, stable functions of the generated schemas only.
- `watch` and `useWatch` from `better-supabase/powersync` take `refresh: { everyMs, onReconnect }`, so a read that runs on the executor's server `fallback` reruns without waiting for a local change: on an interval (skipped while PowerSync reports it is offline) and when PowerSync connects again. `PowerSyncDatabaseLike` gains the optional `currentStatus` and `registerListener` members that `refresh` reads.
- The CLI starts and changes projects from presets. `init --preset <name|code|url>` writes the config with `preset`, the module files of every block it installs and a `lib/blocks.ts` that wires their clients, and prints the registry glue for the framework. `apply --preset` sets `preset` in an existing config (or, with `--only blocks`, lists its blocks; with `--only conventions`, takes the conventions alone), shows the diff and writes the modules. `preset decode` and `preset info` print what a code holds and how the config differs from its preset, and `blocks list`, `blocks view` and `blocks search` read the block catalog.

  `add <block...>` now adds blocks: it lists them in `blocks`, writes their modules and what they require, wires their clients into `lib/blocks.ts` and names their registry glue. The framework glue moves to `add --framework <integration,...>`; `add` without arguments still sets up the integrations the config lists. In a terminal, `init` first asks for a preset. `better-supabase/preset` adds `presetApplyCommand`, `presetGlue` and `glueCommand`, and `presetInstallCommand` now returns `init --preset <code>`.
- `better-supabase/preset` describes an app as a preset: a block selection plus its conventions (framework, platforms, casing, id type, scope, tenant name, access model, API schema and integrations). `encodePreset` and `decodePreset` turn one into a short code, and `resolvePreset` returns every block to install with the reason it is there, the soft integrations between them and the conflicts. It reads a generated catalog of the blocks, so it runs in the browser. The named presets are `minimal`, `saas`, `crm`, `projects`, `commerce`, `scheduling`, `ai` and `everything`.

  `defineConfig({ preset, blocks })` takes a named preset, a code or a `/create?preset=` URL and expands it into `blocks` and its conventions first; the keys you set win. The docs Recipes section is now Presets, at `/docs/presets`.
- The `promotions` module calls `before_promotion_activate(promotion uuid)` when you define it, after every write that leaves a promotion active: `set_promotion_active(id, true)` and each `save_promotion` of an active promotion. Raising refuses the change, which holds a promotion for approval. The commerce recipe uses it to hold discounts above 30 percent, and now also indexes products for search and saves order grid views.
- The `quotes` and `invoices` SQL modules and their blocks add the sales documents after a deal. A quote has lines and totals the database keeps and a revision chain. It is sent as a link the customer opens without an account and answers with accept, reject or changes requested, and it expires after its valid date. `quotes.fromDeal` makes one draft per deal. An invoice gets a gapless number when it is issued and collects payments (a payment reference is recorded once, and a refund larger than the amount paid fails with `INVOICE_REFUND_TOO_LARGE`). Credit notes reduce its balance, and it can be voided, written off and aged per currency. `invoices.fromQuote` makes deposit invoices and one final invoice that subtracts them. With the relations module installed, both link to their origin with `created_from`.
- Add fractional rank keys. `better-supabase/blocks/rank` exports `rankBetween`, `rankKeys`, `isRankKey` and `createRank`, and the new `rank` SQL module adds `rank_between`, `move()` and a `rank.rebalance` job for subjects with a `rank` entry in `config.subjects`. `move()` runs as the caller and can change the row's group, so a state-machine trigger on that column checks the change.

  Tags and custom fields now order definitions by a `rank` key instead of an integer `position`. Both modules move to version 2: the upgrade backfills keys in the old order and drops `position`. `tags.reorder` and `customFields.reorderDefinition` take the neighbours' keys. Adopt configs that mapped `position` map `rank` instead. An adopted table without a rank column maps it to `null` (`columns: { tags: { rank: null } }`, and `definitions` and `options` for custom fields); `sql sync` names the column until you do.

  A state-machine config without `initial` now names `initial` in its error instead of `column`.
- Add `rateLimit()` middleware to the Hono and oRPC adapters, built on `createRateLimit`. Responses carry the
  `RateLimit` and `RateLimit-Policy` fields of draft-ietf-httpapi-ratelimit-headers-11, and a refused request gets a
  429 with `Retry-After`. `better-supabase/server` exports `rateLimitHeader()` and `rateLimitPolicyHeader()`.
- A read-only support or impersonation session can no longer write. Every security definer function granted to `authenticated` or `anon` that writes checks `better_supabase.actor_may_write()`, or `authorization.functions.actorMayWrite` when a provider sets it. The same check is added to generated write policies. Writes that succeeded during those sessions in 0.8.0 now fail with `ACTOR_READ_ONLY` (`42501`). `read_only: false`, an agent actor and a signed-in user without `act` still write. Reads are unchanged. `start_support_session`, `end_support_session` and `expire_support_sessions` stay callable so the session can be left.
- Topic triggers stop sending whole rows. This changes behaviour: the default message is now `{ operation, schema, table, id, values }`, the primary key and the topic values, so subscribers refetch the row through the Data API and their column grants apply. Before, every subscriber of the topic received every column.

  - `topic.trigger(table, options)` is the one trigger API. Pass `betterSupabase` for typed table keys and parent-row lookups, `payload` for a custom object or `"row"` to keep whole rows on purpose, and `topics` to send the same change to more topics.
  - `triggerSql(betterSupabase, table, options)` is deprecated and calls `trigger(table, { ...options, betterSupabase })`. Its default payload changes too.
  - `rowKeys` reads the new message on the client. `rowChange` reads the `payload: "row"` shape.
  - `realtime.triggers` entries take `payload: "keys" | "row"`, and the config loader now accepts `realtime.triggers` at all.
  - `better_supabase.broadcast_topic()` sends the key payload, takes a fourth `'row'` argument, accepts `{name=column}` placeholders, and refuses to run as a `before` trigger. A topic value with characters topics don't allow raises a warning and skips that send instead of failing the write; generated trigger functions do the same per topic.
  - Doctor BS337 warns about trigger functions that broadcast whole rows without the `payload: "row"` marker, such as 0.8 `triggerSql` functions. Snapshots record trigger arguments, and the source and comment of broadcasting trigger functions.

  Run `better-supabase sql sync` and create a migration to update `broadcast_topic()`. See [Realtime triggers](https://bettersupabase.com/docs/migration/0.8-to-0.9#realtime-triggers).
- Glue for your app now installs with the shadcn CLI from the `@better-supabase` registry at `https://bettersupabase.com/r/{name}.json`: block wiring (`blocks`, `postgres`, `outbox`, `onboarding-checklist`), Next.js server actions for notifications, the `use-current-organization` and `push-native` hooks, route handlers for the outbox relay (Next.js and Hono), Stripe webhooks and the jobs drain, the Expo config, and the `saas-next` and `saas-expo` bundles. The registry holds no UI components, config or SQL.

  The UI comes from recipes instead: the notification bell, notification list, command search and onboarding checklist show the shadcn components to add and the web and React Native code side by side, with the same hook call. They are on bettersupabase.com/recipes, in the docs at `/docs/registry/ui-recipes` and in the `better-supabase` skill (`references/ui-recipes.md`). `SPEC_PINS.shadcnRegistry` pins the shadcn registry schema the items follow.
- The `relations` module declares a fourth default kind, `created_from` (inverse `converted_to`, one origin per record, no cycles), for records that turn into other records. `converted_into(tenant, origin_type, origin_id, into_type)` and the client's `convertedInto` return the record an origin was already converted into, so a conversion runs once, and `lineage(organizationId, subject)` returns a record's origins and conversions. `sql sync` rewrites the relations module.
- Add the `relations` block (`better-supabase/blocks/relations`). It links two subjects with a declared kind (`blocks`, `parent` and `relates` by default), stores inverse names from one end, refuses cycles for acyclic kinds under an advisory lock, replaces the target of one-target kinds, and reads ancestors, descendants and rollups. `link` and `unlink` return an undo token, and `toGanttTasks` and `toTreeItems` shape links for a Gantt chart or a tree.
- Retention policies and legal holds in `data-lifecycle`: `createRetention` sets how long each kind of record is kept (`audit`, `outbox`, `webhook_deliveries`, `webhooks`, `inbound_events`, `job_archive`, `usage`, `notifications`, `workflow_runs`), for every tenant, a plan's tenants or one tenant, and places and releases legal holds that every module purge and `purge_organization` (`ORGANIZATION_LEGAL_HOLD`) respect, including chats and trash. Holds emit `legal_hold.placed` and `legal_hold.released`. Without `data-lifecycle`, each purge keeps its `older_than`.

  The audit module (version 8) gains `options.chain`, which hash-chains each tenant's entries, and `createAuditChain` in `better-supabase/blocks/audit` verifies the chain and records signed checkpoints of its heads with any `signJwt` signer. Without a `to`, verification runs up to the latest checkpoint, so deleting the newest entries reports a gap. `options.partitioned` creates a new managed log partitioned by month, `better_supabase.maintain_partitions()` adds the months ahead, and `purge_audit_log` drops expired months when no hold or `audit_retention` hook applies. `better-supabase doctor` reports a log that asks for partitions but isn't partitioned (BS335).
- New block `better-supabase/blocks/reviews` (`better-supabase add reviews`): ratings and reviews of catalog products and other subjects. Any signed-in user can review an active product on a public storefront, a product in a catalog they read, or a registered subject in an organization they belong to, once per subject; a new review replaces their earlier one. `verified` is true when the `orders` module shows a paid order for the product, and `blocks.reviews.options.requirePurchase` makes that a requirement. Reviews wait for a moderator under `moderation: "pre"` (the default) or publish at once under `"post"`. `createReviews().summary()` returns each product's count, average and histogram, which anonymous visitors can read for a public storefront. The `access` module adds `reviews.manage` (admins); re-run `better-supabase sql sync` to pick up the new default permissions.
- Under the provider access model, `member_organization_ids()`, `has_organization_role()` and `organization_member_role()` read membership from the provider's `memberIds` and `memberIdsFor` templates, so a user the provider no longer lists stops counting as a member. Doctor rule BS334 (info) reports app policies and functions that still call them and points at `tenant_ids_with` or `member_can`.
- `db.$rpc` now casts result columns that have a codec to `text`, as repository reads do, over both PostgREST and the Postgres executor. An `int8` above 2^53 in a `returns table` or `returns setof` result now reaches its `bigint` codec intact, and a `numeric` keeps its digits. `{ raw: true }` calls send no column list. Executors receive the list as `RpcContext.select`; an executor that ignores it returns the full row, as before.
- New block `better-supabase/blocks/rules` (`better-supabase add rules`): saved, versioned condition trees over the fields you register per subject type. `defineRuleFields()` declares each field's type, column type and operators, and `createRulesAdmin().syncFields()` stores them for the database. A rule has a trigger, a `FilterQuery` condition, ordered actions, a rank and `stopProcessing`. `matching()` evaluates every enabled rule for a subject in Postgres (`better_supabase.rule_matches`), and `test()` evaluates one rule locally with `evaluateFilterQuery`; both give the same answer for every operator. `createRuleDispatcher()` runs the matched actions through your handlers, and `approvalsAction()` opens an approval request from a rule. Every save keeps a version, every evaluation can be logged, and `rule.saved`, `rule.enabled`, `rule.disabled`, `rule.removed` and `rule.matched` go to the outbox. The `access` module adds `rules.read` (members), `rules.edit`, `rules.publish` and `rules.admin` (admins); re-run `better-supabase sql sync` to pick up the new default permissions.
- The rules block has an editor controller. `useRuleBuilder` from `better-supabase/blocks/rules/react` (and `/vue`, `/svelte`, `/solid`) loads a subject type's fields and a rule, edits its condition tree, reports the problems `checkRuleConditions` finds and whether anything changed, and saves the rule with its version. Nodes named in `locked` can't be changed, moved or removed. `createRuleBuilderController` is the same state without a framework, and the tree helpers it uses (`findNode`, `insertNode`, `moveNode`, `removeNode`, `setRuleField`, `setRuleOperator` and others) are exported from `better-supabase/blocks/rules`.
- The Rule builder UI recipe renders a rule editor with shadcn components: field, operator and value rows, nested And and Or groups, drag handles and a locked first condition. The `use-rule-editor` registry item installs `useRuleEditor`, which reads the organization from the session and the rules schema from `blocks.generated.ts`, and the preset catalog lists it.
- Add eight blocks, each with managed, adopt and custom modes: `number-sequences` (gapless numbers assigned in the document's transaction), `teams`, `tags`, `state-machine`, `custom-fields`, `share-links`, `reminders` (RFC 5545 expansion) and `imports` (RFC 4180 CSV). Subject link and history tables can be adopted per subject.
- `diffAppTables` and `diffDesigns` from `better-supabase/preset` list what changes between two sets of app tables. Added tables and optional columns are safe. A dropped or renamed table or column, a type change (of a column or of the table's id) and a column made required without a default are breaking, and each comes with its expand-and-contract steps.

  `apply --preset` with a design now lists the breaking changes to tables the config already has, writes nothing and exits 1. Pass `--allow-breaking` to write them anyway; `--dry-run` shows them without it.

  Doctor gains BS228 (warning): a column of an app table whose type or nullability in the database differs from `appTables`.

  Create shows the changes since the page loaded its link on a Changes tab in Get code.

  Migration: a script that runs `apply --preset` with a design that retypes, drops or renames app table columns now needs `--allow-breaking`.
- `blocks.tenant.options.sameTenant` takes `{ auto: true, entries? }`. `sql sync` then derives an entry for every single-column foreign key between two tables with the tenant column, from `references`, `foreign key` constraints and `alter table ... add constraint` in the schema files. A listed entry for the same column wins, and a derived entry renders the same trigger as the listed one. The list form is unchanged. Doctor's new BS340 lists tenant foreign keys without a `bs_same_tenant_<column>` trigger. `better-supabase/sql` exports `sameTenantLayout`, `sameTenantColumn` and `sameTenantTrigger`.
- Saved views keep the whole view state and per-user preferences. `useSavedViews` takes `getState` and `onApplyState`, so `saveCurrent` stores the columns, grouping, density and layout with the URL's query, `apply` restores them, and `active` and `isDirty` compare them; the `save` callback now also receives `state`, the value `savedViews.save()` takes. The `saved-views` SQL module adds a `saved_view_preferences` table and `set_view_preference`, so a member can make a shared view their own default, hide it or pin it without the right to change it, and `list_views` returns the caller's `preference` with each view. `defaultView` opens the caller's preferred view first and skips hidden views. `duplicate_view` and `savedViews.duplicate()` copy a view the caller sees into a new view, with the same checks as saving one. The client adds `setPreference()` and `duplicate()`, and the hook adds `duplicate` and `setPreference`. Run `better-supabase sql sync` to install module version 2.
- Add the `saved-views` block. The SQL module stores named list views per user, team or organization, with one default per slot and rank order; sharing a view with a team or the organization needs `views.share`, and team views use the `teams` module. `better-supabase/blocks/saved-views` exports `createSavedViews`, which checks a view's query against its `defineListQuery` definition before saving, plus `savedViewStateSchema`, `defaultView` and helpers that convert between nuqs URL state and a stored query. `better-supabase/blocks/saved-views/react` exports `useSavedViews`, which opens the default view and saves the URL's query. The default roles gain `views.read` for members and `views.*` for admins.
- `better-supabase scaffold screens <table>` writes Next.js pages for an app table: a list, a create form with an input per column (from its type, `format`, `options`, `min` and `max`, and a select of rows for a reference to another app table) and a detail page. The pages read and write through the generated repository with `bs`, so RLS checks every request, and each file is written once and then kept as yours. `--base-path` sets the route, `/<table>` by default. The command refuses a table outside `appTables`, a table with `scope: "none"` and a required column the form has no input for.

  The UI recipes gain an App tables group: `record-grid`, `record-form`, `record-page`, `record-board`, `record-calendar`, `record-timeline` and `record-gantt`, built on projects and tasks in the Next.js example. Create lists the `scaffold screens` command for each app table in Get code, and its AI instructions link the record recipes once the design has an app table.

  Migration: none.
- `scimHandler` takes a `store`: a versioned `ScimStore` with `list`, `get`, `save` and `delete` for users and groups, which may apply the filter and paging itself. `sqlScimStore(transport)` is the default, and `transport` still works. The store gets the raw resource, extension schemas included. `blocks.sso.options.syncLog` adds a `scim_sync_log` table; pass `sqlScimSyncLog` as the handler's `log` to record each write.
- Blocks run per tenant, per user or in one shared space. `sql.scope` sets the scope of every block and `blocks.<name>.scope` the scope of one. Outside `tenant` scope a block needs neither the `tenant` module nor the access contract, its scope column is nullable (the user's id by default in `user` scope), and its functions set the scope id themselves. `tags` and `comments` run in every scope, the blocks without tenant data (such as `notifications`) run anywhere, and blocks that need tenants declare `scopes: ["tenant"]`, so `sql add` and `resolvePreset` refuse them in another scope.

  `sql.naming.tenant` renames the tenant concept in the tables, columns and functions the blocks write: `workspace` gives `workspaces`, `workspace_id`, `create_workspace` and `member_workspace_ids`. `blocks.<name>.functions` renames single functions. A tenant name that would collide with a block's own names is refused. `gen` writes the names into each block's `naming` in `blocks.generated.ts`, and `defineBlocks` wraps the block's transport with the new `blockNames()` middleware, so the block clients call the renamed functions. The preset model's `tenantName` field takes `workspace` and `team`, and presets write `sql.scope` and `sql.naming`.

  The SQL modules check access only through `ctx.scope` (`can`, `ids`, `check`, `canUser`, `platform`, `members`), and `canIn` and `tenantIn` are removed from the module helpers. `tags.update_tag` no longer fails with an ambiguous `name` column.
- Adds the `search` block (`better-supabase/blocks/search`). Triggers generated from each `search` entry in `config.subjects` keep a `search_documents` index, and `search(q)` runs as the caller with websearch, prefix and trigram matching, filtered by `can_subject`, and ranked hybrid when the index keeps embeddings. Recents and favorites come from the share-links user state, trashed rows drop out, `searchSink` handles trash events, and `useCommandSearch` (`better-supabase/blocks/search/react`) drives a Cmd+K dialog.
- Add the `service-principals` block (`better-supabase/blocks/service-principals` and the `service-principals` SQL module): organization members that are not people, each an `auth.users` row that cannot sign in and holds its role through a membership. `create`, `setRole`, `disable`, `defaultFor` and `list` manage them; a member who asks for the change needs `service_principals.manage` and may only assign a role they could assign. A workflow-builder version the organization owns runs as its default principal, capped to the permissions the version stored, and a service webhook endpoint gets an event only when the default principal may read it.
- Users can now manage their own sessions and reminders. The `sessions` module adds `list_my_sessions()` and `end_my_session(id)`, which list the caller's unexpired sessions (with the current one marked) and end one of them. The `reminders` module adds `list_my_reminders` and `delete_reminder`, and the reminders client adds `list(organizationId, subjectType, subjectId)` and `remove(id)`. Run `better-supabase sql sync` and generate a migration to install the new functions.
- `create_share_link` takes a `version_id` that pins the link to a version of its subject and must belong to it, and `shareLinks.create(..., { versionId })` passes it. `shareLinks.resolve(token)` is typed and returns `versionId`. The `versions` module adds `get_shared_version`, granted to anon, and the client gains `versions.getShared(token)`, which reads a pinned version through its link.
- Share links expire. `shareLinks.create(..., { expiresAt })` takes a `Temporal.Instant` (`SHARE_EXPIRY_INVALID` when it is not in the future), and `resolve` returns the link's `organizationId` and `expiresAt`. Every module that reads through a link (calendar feeds, booking pages, public forms, drive links, shared AI chats) now checks expiry the same way; shared AI chats used to ignore it. `create_share_link` takes `expires_at` as a sixth argument and drops the five-argument signature.

  `revoke_share_link` lets only the link's creator or a member with `share_links.create` revoke a link, and returns `SHARE_NOT_FOUND` to anyone else. `list_share_links` returns the caller's own links and those in organizations where they hold `share_links.read`. `record_view` refuses a caller who is not a member of the organization (`SHARE_FORBIDDEN`), and no longer fails with an ambiguous `subject_type` column.

  `authorization.functions.isApprover` takes an optional `{requester}` placeholder: the uuid of the user who asked (the approval's `requested_by`, the support admin, the chat's owner), so a provider that resolves out-of-office covers can refuse the requester's own delegator. Under the `provider` model, `delegate_approval` checks the delegate's membership through the provider's `memberIdsFor`.
- New `shared-signals` block (`better-supabase sql add shared-signals`, `better-supabase/blocks/shared-signals`): an OpenID Shared Signals Framework 1.0 transmitter and receiver, with every CAEP 1.0 and RISC 1.0 event type. `createSsfTransmitter` serves the well-known metadata, stream management, status, verification and RFC 8936 poll routes, and its `authorize` option names the receiver behind each request. `emit` mints one Security Event Token (RFC 8417) per stream that delivers the event, signed by any `signJwt` signer such as a `signing-keys` signer. With `webhooks-out` installed, a push stream (RFC 8935) gets an endpoint with the new `set` format, so SETs go through the outbox with its retries and dead letters. Poll streams queue SETs until they are acknowledged, and a paused stream holds its SETs until it is enabled again. `createSsfReceiver` verifies SETs against the transmitter's JWKS, drops replays through the service-only `ssf_received` table, runs a handler per event type, and serves the push endpoint or polls. Stream changes are recorded as `ssf_stream.*` events. The pins are `SPEC_PINS.rfc8417`, `rfc8935`, `rfc8936`, `ssf`, `caep` and `risc`.
- Add the `signatures` SQL module and `createSignatures` in `better-supabase/blocks/forms`: simple electronic signatures on a frozen version of a record, signed by click, typed name or drawn image, in parallel or in order. Each signature stores a hash over the version's content, so `get` reports whether the signed content or a signature changed and whether the record has a newer version. With the approvals module, a signer's signature can approve the step of a linked approval request.
- New `signing-keys` block (`better-supabase sql add signing-keys`, `better-supabase/blocks/signing-keys`): Ed25519 and ES256 keys per owner, with the private key in Supabase Vault and the public JWK in a service-only `signing_keys` table. `create` and `rotate` keep one active key per owner and retire the previous one. A retired key stays in `jwks(owner)` until the last `exp` that `signJwt` noted plus `jwksGrace` (`5 minutes`). `jwksResponse` serves the set as `application/jwk-set+json`, and `purge()` deletes keys no token can still need. `signer(owner)` signs raw bytes or compact JWTs in TypeScript with WebCrypto, and `verifyCompact` checks them against a JWK Set. Ed25519 keys sign as `EdDSA` by default, or as `Ed25519` (RFC 9864, pinned as `SPEC_PINS.rfc9864`).

  Outgoing webhooks can sign with Ed25519 (Standard Webhooks `v1a`). `webhooks-out` 4 adds `signing` (`v1` or `v1a`), `format` and `credential_ref` columns to `webhook_endpoints`. A `v1a` endpoint is signed by the new `asymmetricSigner` option, which takes `standardWebhooksAsymmetric({ signer })`. It needs no secret and is dead-lettered when no signer is set. `verifyWebhook` accepts a `whpk_` public key, which `webhookPublicKey(jwk)` builds, in place of a `whsec_` secret.
- The `sla` and `targets` SQL modules and their blocks add service levels and goals. A service-level policy per record type and priority sets first response, next response and resolution minutes, optionally in business hours with a time zone. Binding a record type's status and priority columns starts, pauses, resumes and resolves its clocks, `record(subject, "responded")` meets a response clock, and `check()` records `sla.warned` and `sla.breached` and notifies the record's watchers. A target is a goal per period for a member, a team or the organization. Its progress is computed for won deal amounts and counts, completed work items and paid invoices, or read from a value the app keeps.
- `functionRoute(name, fn, options?)` from `better-supabase/spec` documents a `defineFunction` Edge Function as `POST /functions/v1/<name>`, with its input as the request body and its output as the 200 response. `defineFunction` now keeps its `input`, `output` and a new optional `summary` on the handler it returns, and `TypedFunction` requires them, so `functionRoute` accepts only `defineFunction` handlers.
- `better-supabase sql add` lists the new modules in `blocks` of the config file, as `name: {}` in an object or a name in a list, and edits `better-supabase.config.json` too. When `blocks` isn't a literal it can follow, it leaves the file alone and prints the entries to add. `--dry-run` says what it would add.
- `sql.api` and `sql.schema` set the Data API schema and the module schema for every module that leaves its own unset, so a config no longer repeats `api: "api"` per module. A module's `api: false` keeps it out of the API schema when `sql.api` is set.
- The SQL modules support three workflows through the new `sql.workflow` config key: `pg-delta` (Declarative Schemas 2.0, the default), `migra` (declarative schemas 1.0 with `supabase db diff`) and `migrations`. When the key is unset, the CLI reads `supabase/config.toml` and keeps a project that already uses migra on migra. Under `migrations`, `sql add`, `sql sync` and `sql upgrade` write the modules into one `<stamp>_better_supabase_modules.sql` migration instead of `supabase/schemas`, `gen` writes the read sets into a migration, and doctor compares against the newest module migration. `better-supabase init --workflow <name>` picks the workflow, and `sql add` and `sql sync` turn pg-delta on in `config.toml` under the pg-delta workflow. Doctor BS316 stays silent when the config chooses `migra` or `migrations`, and the BS404 grant hint names a new migration under `migrations`.
- A `state-machine` machine can keep a lifecycle row per record with `lifecycle: true`: `pause(subjectType, id, { until, reason })`, `resume()` and `setFlag(subjectType, id, flag, enabled)` on `createStateMachine()`, a pinned `version` and a list of allowed `flags`. While a pause holds, the trigger refuses transitions with `STATE_PAUSED`; a pause with an end clears on the next transition after it. The module records `status.paused`, `status.resumed` and `status.flagged` next to `status.changed`.

  The block reads as the caller: `state()` returns the status with its `step` and `progress` through the machine's new `order`, and the lifecycle; `history()` pages status changes newest first; `stages()` returns the stages timeline. `status_history`, `status_stages` and the new `subject_lifecycles` table have select policies that show a row when the caller can see its record. A machine without `table` takes its table, id type and tenant from `config.subjects`, and `idType` compares ids in their own type.
- Breaking: `better_supabase.transition()` in the `state-machine` module now runs as the caller instead of as a security definer function. Before, any signed-in user could change the status of any record of a machine, in any tenant. Now the subject table's row level security decides: a caller who can't update the record gets `STATE_SUBJECT_NOT_FOUND` (`P0002`, a `not_found` error from `createStateMachine().transition()`) instead of `true`. Give members an update policy on each machine's table, or call `transition()` with the service role where a server action moved records on their behalf. Approval-gated transitions and `bulk` transitions keep working; a bulk row the caller can't update now fails. The stages timeline only clears and numbers stages within the record's own tenant and machine. Run `better-supabase sql sync` to install module version 2.
- New `better-supabase/stripe` entry with `stripeFromKey({ secretKey, apiVersion })`. It returns a `stripe` source for `createBilling`, `reportUsageToStripe`, `stripePayments` and `stripeTax` that creates the client once, on first use, from a literal `import("stripe")`, so Next.js, Vite and worker bundlers resolve the package and file tracing ships it. `stripe` stays an optional peer.

  Bundled apps that passed `stripe: { secretKey }` got an empty module, because the loader's `import(specifier)` was too dynamic for the bundler, and every Stripe call threw that the stripe package was missing even when it was installed. The optional peer loader now carries `webpackIgnore` and `@vite-ignore` comments, so bundlers leave a native import that resolves in Node, Bun, Deno and apps that keep the package external, and the error names the fix: pass `stripe: stripeFromKey({ secretKey })` from `better-supabase/stripe`, or a Stripe client. `{ secretKey }` keeps working as the plain Node shortcut.
- Add `config.subjects`, an app-wide subject registry. Modules that take `subjects` (tags, comments, attachments, reminders and the others) use the registered types when their own option is unset, accept a list of registered names, and merge an object entry onto the registered one. The new `subjects` SQL module writes `subject_title`, `subject_url` and `can_subject`, which run as the caller. `gen` emits `subjectTypes`, `SubjectType` and `SubjectRef`, and doctor reports a registered table or column the database lacks (BS331) and a registered table without RLS (BS332).
- **Breaking:** `@supabase/supabase-js` 2.118.0 is the new minimum, and the `@supabase/postgrest-js` dependency moves to `^2.118.0`. It is the first release that reads the `ComputedFields` key and the no-argument function overloads in the types postgrest-typegen 0.6 writes.
- Repository calls on `ctx.postgres` and `ctx.postgresAdmin` now return `@supabase/server`'s pool errors (`POSTGRES_POOL_BUSY` and `POSTGRES_CONNECT_PAUSED`) as a `network` error with status 503 instead of `unexpected` with 500. `network` errors carry `retryAfter` in seconds when the pool reports how long it is paused, and the problem response sends it as `Retry-After`. A pool failure at the start of a `$many` transaction now returns an error for each operation instead of rejecting.
- Support sessions now deny by default. `supportSessions` refuses every start unless `authorize` or the `support.authorize` authorization hook allows it, and `authorize: "store"` leaves the decision to the store. A start also needs a second admin by default (`policy.approval: "distinct"` and the `support-sessions` module option `approval: "distinct"`): the admin calls `support.request`, a different admin calls `support.approve`, and the SQL module refuses self-approval in `approve_support_request` and in a check constraint on the new `support_requests` table. Set both options to `"none"` to keep the old behaviour.

  `approverOf` and the SQL `isApprover` template decide who may approve, with kind `support-session` for support requests and kind `ai-tool` (subject the tool's name) for `decide_ai_tool_approval`, where `isApprover` always refuses the approval's owner. The ai-chat module still reads `canApprove` when `isApprover` is unset.
- `ctx.storage` writes in a writable support session. `upload`, `update`, `remove`, `copy`, `move` and `purgeCache` first run the row change Storage would make as the target, inside a savepoint that is rolled back, so the policies on `storage.objects` decide with `owner_id` set to the target. A refused dry run resolves to Storage's `403` error (`AccessDenied`) with the policy's message. Then the service client writes, the admin Postgres connection makes the target the owner of the stored object, and its `user_metadata` records `supportSessionId` and `supportAdminId`. `remove` deletes only the paths the target may delete. Signed uploads still resolve to `SUPPORT_STORAGE_WRITES`, and a read-only session still refuses every write with `SUPPORT_READ_ONLY`.

  `supportStorage` takes `adminSql` and `session` for writes. `attachments.put` no longer creates a signed upload URL before it uploads, so it works in a writable support session.
- The tags module's `untag` qualifies its columns, so its delete no longer fails with "column reference `tag_id` is ambiguous" against the function's own `tag_id` parameter, on the polymorphic `taggings` table and on adopted link tables.
- Add the `templates` SQL module and the `better-supabase/blocks/templates` block: typed, versioned templates that create records. `createTemplates({ types })` takes a Standard Schema for each type's settings and body and an `apply` that creates the record. `apply(templateId, { into })` uses the newest published version, records which version made which record (`template.applied`), and starts the workflow-builder workflows attached to the template on the new record. System starters are kept by key with `sync(starters)`, and `fork(source, organizationId)` copies one into an organization with `forkedFrom`, `forkedFromVersion` and a `behind` flag. Each organization, type and language has at most one default.

  `renderMergeFields(text, values, { escape })` and `defineMergeFields(subjectType, fields)` render `{{ field }}` placeholders with no dependencies, and `composeTemplateEmail` renders an email template for the notifications email channel. The `templates.apply` workflow step (`TEMPLATES_APPLY_STEP`, `templatesApplyStep`) creates recurring records from a scheduled workflow.

  Workflow-builder versions can store an input JSON Schema: `versions.save(definition, graph, { inputSchema })` sets `input_schema`, which `WorkflowVersion.inputSchema` returns and a fork copies. `save_workflow_draft` takes a third `input_schema` argument. Templates check the inputs of the workflows they attach against it.
- Time tracking and orders ship metric definitions. Time entries get a generated `seconds` column and carry `startedAt` in their events; `timeTrackingMetrics()` sums tracked seconds per start day, and `timeEntryMetricTime` buckets entry events by that day. `orderMetrics({ currency })` defines orders placed, revenue and average order value, with `ORDER_METRIC_EVENTS` for the sink. `metricsSink` takes a `time` option and `createBlocks` a `metricTime` option for events whose rows sit at another time.
- Add the `time-tracking` SQL module and `better-supabase/blocks/time-tracking`: timers and logged time on any record, with one running timer per member and no overlapping entries enforced by an exclusion constraint. A member submits a week as a timesheet that goes through the approvals module; the week is locked while the request is pending and once it is approved, and `reopen` unlocks it. `toWeeklyGrid` turns a week's entries into rows per record and day for a weekly data grid.
- New `better-supabase/blocks/undo` applies the undo tokens that `relations.link`, `relations.unlink` and `audit.revert` return, checking that every call is an accepted inverse, and `better-supabase/blocks/undo/react` adds `useUndoStack`, a stack of tokens for a toast's Undo button or a Cmd+Z handler.
- Subject events share one shape, and watchers can follow them.

  - Every subject event (`comment.*`, `subject.tagged`, `subject.untagged`, `status.changed`, `field_value.set`, `share_link.created`, `subject.viewed`, `reminder.sent`) carries `organizationId`, `subjectType` and `subjectId`, typed as `SubjectEventData` from `better-supabase/events`.
  - `watcherHandler(notifications, { types })` from `better-supabase/blocks/notifications` turns outbox rows (`outbox.consume`) into notifications for the subject's watchers, keyed by the event id, and leaves out the actor the row records. `watcherSink` does the same for CloudEvents, leaving out an actor the event data names.
  - `status.changed` now carries the subject's tenant, the previous status in `from` and the `reason`. The table trigger records it, so a direct update or `rank.move()` into a new status emits it too, and an update that keeps the status emits nothing.
  - `transition()` clears its reason when it returns, so a later direct update in the same transaction no longer writes that reason to the history row.
  - `reminder.sent` now carries `subjectType`, `subjectId` and `userId`.
  - `mark_reminder_sent` no longer fails with an ambiguous `occurrence_at` reference, and a second delivery of the same occurrence returns `false` and records no second event.
  - `reminders.deliver(payload)` and `reminders.job` deliver one occurrence. With `reminderNotification`, `createBlocks` wires them to the `notifications` block (`REMINDER_DELIVERY_QUEUE` names the queue).
- Outgoing webhooks write each endpoint's `format` with a versioned `WebhookEncoder`. `cloudEvents()` stays the default; `otlpLogs()` sends OTLP/HTTP JSON logs to a collector and `splunkHec()` sends Splunk HTTP Event Collector events. An endpoint's `credential_ref` adds the token from the new `credentials` option, and an encoder's `classify` reads responses such as an OTLP partial success or an HEC error code inside a 200.
- Outgoing webhooks pick the body per endpoint. The new `standard` format (`standardEnvelope()`) writes the 0.8 body, `{ type, timestamp, data }` or an array of them for a batch endpoint, signed per Standard Webhooks like `cloudevents`. `sql upgrade` from 0.8 gives existing endpoints `format = 'standard'`, so their receivers keep working, and new endpoints get `blocks.webhooks-out.options.defaultFormat` (default `cloudevents`), which also covers an adopted table that doesn't map `format` or holds `null`. Set an endpoint's `format` to `cloudevents` once its receiver reads CloudEvents.
- Breaking: outgoing webhooks send a CloudEvents 1.0 event in structured mode (`content-type: application/cloudevents+json`) instead of `{ type, timestamp, data }`, and `transform` now returns the event's `data`. The event's `id` is the delivery id; `source`, `subject`, `time`, `dataschema` and `traceparent` come from the new `source`, `appUrl`, `typePrefix`, `dataschema` and `traceparent` options and from `publish({ subject })`. An endpoint with `batch = true` gets a CloudEvents batch (`application/cloudevents-batch+json`) per delivery run.

  Breaking: `publish_webhook_event` is now `(event_type, payload, tenant, event_id, subject, read_permission)`, and the old four-argument function is dropped. It queues an event only for endpoints whose owner may read it: a user owner needs `read_permission` (else the module's view permission) through the authorization provider or the access contract, and a service endpoint (`created_by` null) needs it in its new `permissions` column. User endpoints get nothing when neither the provider nor the access contract is installed. `createWebhooks` and `createOutbox` take `subscribers` (a `SubscriberPolicy`) and fall back to `authorization.delivery.subscribers`; deliveries it leaves out are canceled, and outbox events it leaves out are counted as `refused`. Adopted webhook tables keep working: the new `permissions`, `batch` and `subject` columns are used only when mapped. Rerun `better-supabase sql sync` to install version 3 of the `webhooks-out` module.

  Breaking: Problem Details for `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `rate_limited` and `unexpected` at their default status use `"type": "about:blank"` and the HTTP status phrase as the title. Other kinds keep `https://bettersupabase.com/problems/<kind>`. `better-supabase codemod 0.9` does not rewrite these strings, so update clients that compare on `type` or `title`.

  | Old title           | New title               |
  | ------------------- | ----------------------- |
  | `Invalid request`   | `Bad Request`           |
  | `Not found`         | `Not Found`             |
  | `Too many requests` | `Too Many Requests`     |
  | `Unexpected error`  | `Internal Server Error` |

  `verifyWebhook` and `verifyStripeWebhook` take a `seen(id, until)` hook and refuse a replayed message id with a `conflict` error (`WEBHOOK_REPLAYED`); a throwing hook gives `WEBHOOK_DEDUPE_FAILED`. A malformed `whsec_` secret now fails with `WEBHOOK_INVALID_SECRET` instead of throwing.
- The `work-items` SQL module and `better-supabase/blocks/work-items` add issues and tasks on pipelines. Installing work-items binds the `work_item` type in the pipelines module with one pipeline per team, Linear's categories (triage, backlog, unstarted, started, completed, canceled) and the stage's category as the item's `state`. Items have an identifier, a parent the table keeps free of cycles, several assignees (capped by `maxAssignees`), a priority from 0 to 4, an estimate, progress, start, end and due dates, a lock that keeps the dates, and optional projects and iterations. `update(changes)` applies grid edits and Gantt drags one by one, closing stamps `closedAt` and a close reason, `assign` notifies new assignees and makes each watch the item, and `tree` rolls progress up for a Gantt group.
- A workflow-builder `condition` node can test a filter tree: `config: { filter, fields }`, where `filter` is a `FilterQuery` (the format of list filters and the rules block) and each field's `id` is the path it reads under `input`, `results` or `subject`. The single-path form (`{ path, op, value }`) keeps working. Event triggers take the same shape in `config.when`, over `type`, `tenant` and `payload...`: `triggers.onEvent` skips a trigger whose `when` doesn't match, and `triggers.save` refuses one it can't use with `invalid_input`. New exports: `filterConditionOf`, `matchesFilter`, `triggerMatches` and the types `WorkflowFilterCondition`, `WorkflowFilterField`, `WorkflowNodeCondition` and `TriggerEvent`. `conditionOf` now returns a `WorkflowNodeCondition`.
- The workflow builder adds record-bound runs, test runs, system workflows and restricted definitions. A definition lists `subjectTypes`, and `run({ subject })` starts it on a record through `workflow_subject_start_target`, which checks the type and `can_subject`; event triggers attach the event's record and conditions read a `subject` root. `test({ definition, stubs })` runs the draft with stubbed steps, skipped sleeps and stubbed approvals, filling missing stubs from each library step's `outputSchema` example. A graph may have several trigger nodes: a trigger picks one with `config.node`, and the run starts there. `scope: "system"` definitions belong to the app; organizations see them, `definitions.fork` copies one into the tenant (lists show `upstreamVersion` and `behind`), and `definitions.optOut` stops its runs there (new `workflow_opt_outs` table). `restricted: true` limits running and editing to `workflow.admin` and `definitions.grants` (new `workflow_definition_grants` table). Static executors spread `graphRunFields(meta)` and set `executorRunsTests` on `graphStarter`, which refuses test runs otherwise.

  Breaking: `save_workflow_definition`, `workflow_definitions_list`, `workflow_start_target` and `workflow_run_principal` take new arguments, `versions.runAs(version, tenant?)` takes the tenant for system definitions, and `validateGraph` no longer reports a second trigger node. Run `better-supabase sql sync` to install module version 2.
- Published workflow-builder versions now run with their publisher's rights. `publish_workflow_version` records `published_by`, `owner_kind` and the `permissions` the version's steps need, and refuses a publisher who lacks one (`WORKFLOW_PUBLISHER_FORBIDDEN`). With `createWorkflowBuilder({ authorization })`, `automation.mayPublish` also decides each `versions.publish(version, { claims })`.

  Breaking: each step must call `builder.versions.runAs(call.version)`, which fails with `WORKFLOW_PUBLISHER_REVOKED` once the publisher loses a permission or their membership, or when `automation.runAs` returns `null`. Versions published before this release have no recorded publisher and fail until they are published again. An organization-owned version, published by the service role, runs only as the service principal `runAs` returns. `GraphStepCall.version` and `GraphRunMeta.versionId` carry the version id to each step.

  Workflow nodes, MCP tools and inbox commands read the provider's operations table first: `workflow.node.<step>`, `mcp.tool.<name>` (through the new `createMcp({ operations })`) and `inbox.command.<name>`. Doctor reports `admin()` in a file that runs workflow-builder steps (BS333).
- `startOnEvent` and the schedule starts of `workflowStarter` take `runAs`: a publisher and the permission keys the run may use, or a function of the event or start call that returns them. The run starts as the publisher, capped to those keys, and the event's actor or the schedule's creator is recorded as `bs.trigger` for audit only. `WorkflowStartCall` gains `source`, and `WORKFLOW_PUBLISHER_FORBIDDEN` errors carry the missing keys as `permissions`, in the `DbError` and in Problem Details.

  Breaking: a schedule start without `runAs` throws, so a schedule no longer runs with the rights of the member who created it.
- Workflow runs can belong to a record. `workflow_runs` gains `subject_type`, `subject_id`, `principal_kind`, `principal_id`, `mode` (`live` or `test`) and `trigger_node`. `runs.list({ subject, mode })` filters by them, `admission.request({ subject })` queues a start for a record, outbox events carry `subjectType` and `subjectId`, and `startFor(ctx, workflow, args, { subject })` stores the record as the `bs.subject` attribute (`subjectAttribute` builds it). Test runs emit no outbox events and are left out of run lists by default.

  Breaking: `record_workflow_run`, `workflow_runs_list` and `request_workflow_start` take the new arguments, and `sql sync` drops the old signatures. Runs that `startOnEvent` and `workflowStarter` start with `runAs` now record the user who triggered them as `bs.actor` (and `actor_id`), not the publisher; the publisher is in `principal_kind` and `principal_id`, and `runAs` takes `principalKind`. `startFor` drops `bs.principal_kind` and `bs.principal` from caller attributes. Run `better-supabase sql sync` to install version 2 of the `workflows` and `workflow-sdk-world` modules.
- `better-supabase add <block>` is the documented way to install a block: it writes the module files like `sql add`, then wires the block's client into `lib/blocks.ts` and prints the glue command. `add` now takes `--tests-dir`, so it accepts every option `sql add` does. `sql add` stays as the low-level command that writes only the SQL files and the config entry. The header of generated module files, the doctor hints for grants, MFA and realtime triggers, and the Data API grant error now name `better-supabase add`. Re-run `better-supabase sql sync` to refresh the header in existing module files; it is a SQL comment, so the schema doesn't change.
- CLI fixes. `doctor --claims` without `--explain` exits with code 2 instead of being ignored. A failed database connection suggests `supabase start` only when a local host refused or dropped the connection, not for a remote host or a rejected password. `openapi emit --check`, `seed --check`, `skills install --check`, `scaffold api --check` and `spec check` print the diff of each stale file. `codemod --help` lists the 0.9 codemod.

  `bs.resource` in `better-supabase/hono` matches paths with the same rules as `resourceRouter`: a malformed id returns a 400 `invalid_input` problem, and an unknown path under the resource returns a 404 problem instead of Hono's plain-text 404.
- `clientIp(request)` reads the last `x-forwarded-for` hop, the one your own edge appended, instead of the first. A client controls the hops to the left, so it could send any IP as `Sb-Forwarded-For` to Auth on a refresh and dodge Auth's per-IP limit or spend another IP's. Behind more than one proxy, pass your own `clientIp` reader.
- `better-supabase config` no longer prints `source.accessToken`. Every string under a key that names a credential (`token`, `secret`, `password`, `apiKey`, `privateKey`) prints as `<redacted>`, and the password in any URL is replaced, in text and in `--json`.
- `better-supabase env` writes `.env.local` (or the `--out` file) with mode `0600`, and tightens an existing file's mode even when its contents didn't change. The file holds the secret key, the database URL and the JWT secret, and it used to be created readable by every user on the machine.
- Every CLI request to the Management API and GitHub now goes through one HTTP
  client. It sends a `better-supabase/<version>` user agent, times out after 30
  seconds, and logs each request under `--debug`. A request that gets no response
  fails with the new `network_error` code. A command that reads a hosted project
  without `SUPABASE_ACCESS_TOKEN` now fails with `auth_required` (exit code 3)
  instead of `missing_value`.
- `better-supabase introspect --snapshot <file>` now converts a saved snapshot without connecting to a database, and rejects `--snapshot` next to `--db-url-stdin` or `--project-ref` with a usage error.
- A missing positional (`preset decode`, `blocks view`, `sql add`, `sql print`) now fails with `input_required` and exit code 3, with the positional in `missing` and its values in `choices`, instead of a usage error. A bare group, or no command under `--json`, returns a `usage` envelope that lists the subcommands in `choices["<command>"]`, and `--agent` is checked before the root help prints.
- The CLI starts faster. The bin turns on Node's module compile cache and answers a bare `better-supabase --version` without loading the CLI.
- `better-supabase codemod` runs when the config file fails to load, so `codemod 0.9` can migrate a config that still sets `sql.modules`. It walks the directory that holds the discovered config file, or `--cwd` when there is none. Commands built with `defineCliCommand` opt in to the same behaviour with `runsWithInvalidConfig: true`.
- `better-supabase codemod 0.9` puts the moved `blocks` key in sorted position when the config's top-level keys are already sorted, so a config checked by a `sort-keys` lint rule stays valid. A config with unsorted keys still gets `blocks` right after `sql`.
- The comments block docs say that `options.notify: false` leaves following on, that `options.follow: false` turns it off, and that an adopted subscriptions table must accept the commented subject types while following is on.
- `encodeDesign` now throws a `DesignTooLongError` (still a `TypeError`, with the code's `length`) when a valid design is too long for a URL, so a caller can tell it apart from an invalid design. `better-supabase/preset` also exports `appReservedColumns(tenant)`, the columns every app table can get from its scope, and `isAppDefault(type, value)`, the default check `appTables` runs, so an editor can refuse a column name or a default before encoding.

  Create uses them: a key to `auth.users` gets `owner_id` instead of the reserved `user_id` (which made the design fail to encode with a misleading "too large for a link" message), the Default field says which defaults a type takes, and changing a column's type drops a default the new type can't take. A table can now reference itself (`parent_id`, `on delete set null`) and reference the same table twice (`owner_id`, `assignee_id`, or `<table>_2_id`). Create also starts without blocks, from "Draw your own tables". `/create/prompt.md` takes `&design=<code>`, and the AI instructions link to it with the design.
- Doctor BS330 no longer reports an adopted module's opt-in tables that `blocks.<name>.tables` does not map: the notifications `deliveryEvents` and `suppressions`, the audit `restricted` table and the invitations `platformInvitations`. The generated SQL uses those tables only when they are mapped, so the check now matches it and still reads a mapped table's columns. `ModuleTableSpec` gains `adoptOnlyWhenMapped` to mark such a table.
- Doctor's BS213 check no longer counts a subject's `deletedAt` column from `config.subjects` as a column that grants access. `can_subject` runs as the caller and only hides soft-deleted rows, so a user who may write that column could already read the row. A column the authorization provider lists in `decidingColumns` still counts.
- Doctor BS213 no longer flags a single-column primary key, or a tenant column that every write policy pins to `better_supabase.current_tenant_id()` without an `or`, because neither lets a caller grant itself access. A column the authorization provider lists in `decidingColumns` is still flagged.
- Doctor's BS321 check no longer reads `create extension` inside a string literal as a statement. The jobs module's error message that tells you to create `pg_cron` was reported as an extension no migration creates.
- Doctor BS208 no longer warns about temporary files written by better-supabase's own catalog reads. `introspect`, `gen` and `doctor` now start each query they send over a database connection with the comment `/* better-supabase */`, and BS208 leaves those statements out, so running `introspect` before `doctor --strict` on a large schema no longer fails the check.
- Doctor BS208 no longer warns when the only statements that spilled to temporary files ran as Supabase's service roles, such as PostgREST's schema cache query on a large schema. With `pg_stat_statements` installed, the top statements it lists are the app's own.
- `doctor` reports fewer false warnings on block schemas. BS206 no longer counts a policy that already calls a security definer helper as `(select helper())` with no arguments, such as `session_active()`. BS213 counts a tenant column pinned by `better_supabase.can('tenant', column, ...)` or `column in (select better_supabase.tenant_ids_with(...))` like one pinned to `current_tenant_id()`, and leaves out a block's soft-delete column that a write can set but never clear, such as `comments.deleted_at`. Blocks declare those columns with the new `SqlModule.hidingColumns`, and `better-supabase/sql` exports `moduleHidingColumns`.

  The generated app-tables pgTAP test now expects the `bs_session_active` policy on the tables that `blocks.sessions.options.policies` covers.
- `doctor` no longer reports BS204 for a view's tenant column, since a query on a view uses the indexes of its tables. BS213 now counts a tenant column that every write policy holds to `column in (select better_supabase.member_organization_ids())` as pinned, the same as `tenant_ids_with(...)`, so app tables that are subjects no longer warn.
- The drive module drops `drive_insert_node` before it recreates it, so moving drive from a managed table to an adopted one (or renaming the table) no longer fails with "cannot change return type of existing function".
- The Flags SDK adapter serves a `null` variant when the declaration's `defaultValue` is `null` or left out, and treats a default variant that is missing from the variants as a missing flag instead of `null`. `evaluateFlag` returns `found`, so callers can tell the two apart. A flag row with an unknown `type` always serves its fallback: the OpenFeature provider resolves it with `PARSE_ERROR` (or `GENERAL` when the `errorCodes` map has none), and `getProviderData` shows it as a hint in Flags Explorer.
- `better-supabase gen` writes the read-sets module again when `sql.dir` points outside the project, as in a monorepo whose apps share one Supabase stack. It failed with "is outside the project root" while `gen --check` passed.
- Fix `finish_import` in the `imports` SQL module: its `id` parameter clashed with the runs table's `id` column, so finishing a run raised "column reference is ambiguous".
- Three SQL modules pass `supabase db lint` again when more blocks are installed. `save_view` and `set_user_state` no longer fail with an ambiguous column reference, and the forms module reads an adopted custom field options table without a constant query that plpgsql_check rejects when the table is missing. The saved-views indexes are now named after their tables, so renaming `views` or `preferences` no longer collides with another install's index names. Run `better-supabase sql sync` and generate a migration.
- The HMAC and HS256 helpers type their imported keys from `crypto.subtle` instead of the global `CryptoKey`, so the published types check under `@types/node` 24 without the DOM lib.
- `bs.middleware()` from `better-supabase/orpc` now type-checks in `.use()` on a single contract procedure with a typed output, so a contract-first router can guard one procedure with `permission`, `roles` or `requireTenant`. The oRPC docs show the pattern and explain that the `authorizer` passed to `createOrpc` decides every permission.
- The optional `@supabase/mcp-server-supabase` peer now accepts 0.14 as well as 0.13 (`>=0.13.0 <0.15`). `supabaseMcp()` and `supabaseMcpHandler()` are tested against 0.14.0.
- The direct Postgres executor runs a counted read's rows and count queries in one transaction, so both see the same snapshot. On `createPostgres()` clients this also halves the round trips of `findMany({ count: "exact" })`.
- The direct Postgres executor looks a function up in `pg_proc` once per executor instead of on every call without generated metadata (block calls included), and looks it up again after an error that says the function changed. Arguments for `OUT` and `TABLE` parameters are dropped from the call, as PostgREST drops them.
- The teams module's `update_team` function qualifies its column references, so plpgsql_check no longer reports `name` as ambiguous and the update no longer fails at runtime.
- `move()` in the `rank` module now writes the group column as its own type, so a board grouped by a `uuid` stage id or an enum column moves rows instead of failing with `column ... is of type uuid but expression is of type text`. `sql sync` rewrites the rank module.
- The block manifests and the `ui-recipes` skill reference list 46 new UI recipes, from the CRM deal board and the quote builder to the drive explorer, the form builder and the webhook endpoints table. Each one points to working code in the Next.js example.
- `sql sync` no longer stops with `blocks.share-links.api: found no definition of share_ai_chat(uuid, text) to wrap` when `share-links` and `ai-chat` are installed together with `sql.api` set. The four ai-chat sharing functions in the share-links module now put `returns` on its own line, so the Data API wrappers are written for them. Re-run `better-supabase sql sync` and generate a migration; the function bodies don't change.
- The `better-supabase-api` skill now shows the files `better-supabase init` writes: the Expo native client is `src/lib/supabase/client.native.ts`, and each Edge Function creates `bs` in its own `server.ts`.
- `gen`, `doctor`, `seed` and `snapshot` now find a local stack that runs on the Supabase CLI's stack engine (`[experimental] stack = true`), which picks its ports per checkout. When no database URL is configured and `supabase/config.toml` sets no `[db] port`, they read `DB_URL` from `supabase status` before falling back to port 54322.
- Moving a time entry to another day now rolls up both days. `time_entry.updated` carries `previousStartedAt`, a metric's `time` function may return several times, and `metricsSink` rolls up one window per day they fall on.
- `toSession` refuses an invalid `act` claim on a hand-built `AuthState` the same way `resolveAuth` does: the session is `{ kind: "invalid", reason: "actor" }` with code `ACTOR_INVALID`, never the user without an actor. Before, a malformed agent level (a `session_id`, an empty tool name, `tools` that is not a list) read as the plain user there. The `ACTOR_INVALID` message now names `agent` among the accepted kinds.
- The `updated-at` block is now titled "Update timestamps" in the preset catalog, in place of "updated_at triggers", so it reads like the other block titles. The block's key stays `updated-at`, so configs and preset codes keep working.
- The outgoing webhooks block's default retry backoff uses equal jitter: each wait is at least half its step and at least a second. Full jitter could schedule a retry for the same instant as the failure, so a struggling receiver got hit again at once.
- The `workflow-sdk-world` SQL module follows `@workflow/world-postgres` 5.0.4: its DDL adds the `seq_in_band` column to `workflow.workflow_event_slots`, from the `0026_add_seq_in_band` migration.

## 0.8.0 (2026-10-12) [#080-2026-10-12]

- New `better-supabase/arazzo` subpath: `createArazzo` and `arazzoFormat` render Arazzo 1.0 and 1.1 workflows from the API model, with source descriptions that point at the OpenAPI and AsyncAPI files. `defineWorkflow` types each step by the `operationId` it calls, and `paginationWorkflows`, `createThenGetWorkflows` and `authWorkflow` build common workflows from your resources' operations.
- New `better-supabase/asyncapi` subpath: `createAsyncApi` and `asyncapiFormat` render AsyncAPI 3.0 and 3.1 from the same API model as OpenAPI. The document describes Realtime table changes and broadcast topics on the websocket server, CloudEvents with a header trait (`CLOUD_EVENT_TRAIT`) and a `dataschema`, outgoing webhooks, and the events that resource actions emit.
- `audit_event` and `audit_event_trusted` take `support_session`, `impersonated_by` and `impersonation_reason`, stored ahead of the values in the caller's `act` claim. The service role may pass any values; another caller only itself as `impersonated_by`, from a token without an `act` claim. `sql sync` drops the previous signatures. Module actions pass them through `ModuleAudit.supportSession`, `impersonatedBy` and `impersonationReason`, so the `support-sessions` module's `support.started` and `support.ended` entries now carry the session id, the admin and the reason in the impersonation columns instead of only in metadata. `AuditEventInput` gains `supportSessionId`, `impersonatedBy` and `impersonationReason`, and `auditEventOf` fills them for `support.*` events from `sessionId`, `adminId` and `reason`.
- `createServer` takes an `authorizer`: a runtime decision point (`apiVersion: 1`) that receives each request in the AuthZEN information model (subject, action, resource and context) and returns `granted`, `denied` or `approval-required`. better-supabase builds the subject from the verified request only (`subject.properties.claims` carries a read-only copy of the verified access-token claims, so an adapter can read memberships and roles without a database read) and refuses anything but `granted`. `defineAuthorizer` and its types (`Authorizer`, `AnyAuthorizer`, `AuthorizationRequest`, `AuthorizationOutcome`, `AuthorizationSubject`, `AuthorizationResource`, `PermissionRef`) are exported from `better-supabase/server`, and `better-supabase/testing` adds `testAuthorizer`, `staticAuthorizer` and the `testDocumentFormat` conformance kit.

  Routes, actions, `bs.require` and the guards in every adapter take a `permission` option that the authorizer checks. `defineResource` adds `hooks` that run around the repository call and `actions` (`defineAction`) on an item or a collection, served next to the collection and item routes by Hono `resource()`, edge `resources()` and the new Next.js `bs.resources()`, which returns the handlers for a catch-all route. MCP tools take a `permission` too, and `tools/list` hides the tools the authorizer denies.
- The CLI reads its options from `better-supabase.config.ts`, so `better-supabase gen` is the one command a project runs.

  - `gen` runs every generator the config sets up, in order: the typed client, `sql sync`, `spec emit`, the seed client, `scaffold api` and, when listed, `env`. `gen.tasks` or `--tasks` picks the list, `gen --check` checks them all, and `gen --watch` reruns the steps whose inputs changed. `gen.watchInterval` sets the watch interval.
  - New config keys give each repeated flag a default: `env.output` and `env.prefix`, `doctor.only`, `doctor.format` and `doctor.output`, `specs.manifest`, `scaffold.api`, `skills.agents` and `keys.output`. A flag still wins for one run.
  - `$env.<name>` and the `$development`, `$production`, `$test` and `$ci` blocks override the config per environment. The CLI applies the one named by the new global `--env` flag, then `$BETTER_SUPABASE_ENV`, then `ci` when `$CI` is set. `better-supabase config` shows the active environment.
  - `init` records the integrations it set up under `integrations`, and `add` without an argument sets up the listed ones whose files are missing. `scaffold api` and `add` print the config entry for the flags you passed.
- A `Db` carries Storage as the same caller: `db.$storage` is the new `storage` option of `connect(client, context, { storage })`, or the client's `storage`, and it throws on a connection with neither. `bucket.connect(db.$storage)` and blocks given `db.$storage` then follow whatever the `Db` runs as. In a support session, `ctx.db.$storage` and `ctx.sql.$storage` are `ctx.storage`, which is now typed `StorageApi`, so the same code reads Storage as the caller in a normal request and as the target in a support session.
- `dbTransport(db, { schema })` from `better-supabase/blocks` (and every block entry) calls block functions through a `Db`'s executor, so a block follows whatever the `Db` runs over: the Data API for `ctx.db` in a normal request, and direct Postgres as the target user in a support session or for an API key. It rejects with the raw database error like `rpcTransport` and `sqlTransport`, so block error mappers see the same codes and hints, and a `DbError` that a transport rejects with now passes through block calls unchanged.
- Table and column comments now reach the generated meta as `description` and `deprecated`, and the JSON Schema output marks deprecated tables and columns. `jsonSchema()` takes a `target` (`draft-2020-12`, `draft-07` or `openapi-3.0`, typed as `JsonSchemaTarget`). MCP tools take a `jsonSchemaTarget`, and `tools/list` converts tool schemas to draft-07 for clients on protocol revisions before 2025-11-25. `zod()` takes `flavor: "mini"` (typed as `ZodFlavor`) to generate Zod Mini schemas, and CloudEvents take a `dataschema` option that receives a `DataschemaContext`.
- New `better-supabase/overlay` subpath for OpenAPI Overlay 1.0, 1.1 and 1.2 documents. `applyOverlay` and `applyOverlays` apply them to any JSON document, with RFC 9535 JSONPath targets, a diagnostic for each target that matches nothing, and the 1.2 reusable actions (`expandReusableActions`). `defineOverlay` types an overlay in code, and `overlayFromDiff` writes the overlay that turns one document into another. JSONPath comes from the optional `jsonpath-rfc9535` peer, loaded on first use, or from an engine you pass. The subpath imports no Node built-in, so it runs on every WinterTC runtime.
- A cleanup of shared code inside the package, with a few fixes.

  - Fix `doctor` BS303 comparing generated files with a different line-ending rule than `gen`, fix `sql upgrade` ignoring the provider defaults for modules, and flush pending events in `finally` in the React Router, SvelteKit, Hono, Edge and oRPC adapters when a handler throws.
  - Bound the HS256 key cache and the verified-token secret ids, and strip the event type prefix the same way in every block.
  - Move the Standard Webhooks code to `core/signatures` and the HMAC helpers to `core/hmac`, share one optional-peer loader and one bounded TTL cache, and add `methodNotAllowed()` to `core/problem`. None of these paths are public exports.
  - Split `config/config.ts` into `config/types/*`, `expose.ts`, `json.ts`, `resolve.ts` and `defaults.ts`. `resolveConfig` now resolves `gen.watchInterval` (default 2000), the SQL registry and the CLI read their fallbacks from `CONFIG_DEFAULTS`, and `GenTask`, `IntegrationName` and `SkillsAgent` derive from the new `GEN_TASKS`, `INTEGRATION_NAMES` and `SKILLS_AGENTS` tuples, which `better-supabase/config` now exports.
  - Split `sql/registry.ts` into `module-types.ts`, `registry.ts`, `module-context.ts`, `render.ts`, `paths.ts`, `markers.ts`, `upgrade.ts`, `introspect.ts` and one file per inline module under `sql/modules`. A module declares `extras` for SQL that depends on the whole layout, replacing the name switch in the renderer, and the tenant and provider entitlement checks share one template.
  - Share one cron route guard (`core/cron-route`), one time budget (`budget`), one lease heartbeat, one `blockEmitter` and one `toBlockError` across the jobs, outbox, webhooks, notifications, organizations and billing blocks.
  - Split the jobs, billing, notifications, data-lifecycle and webhooks blocks into type, helper and per-factory files; the public subpaths are unchanged.
  - Split `server/resource`, `server/server`, `core/repository`, `core/define`, `next/create`, `testing/conformance`, `ai-sdk/mcp`, `ai-sdk/messages` and `workflow-sdk/world` into type, helper and per-kit files, and share one `isRecord` from `core/guards`.

  - Share the AI SDK HTTP helpers, add `withFlush` to flush events after a response, and run the React hooks on the same bindings as Vue, Solid and Svelte (`ClaimsOf` and `ProfileOf` now live in the bindings).
  - Build the MCP table tool schemas from the same page schemas as the OpenAPI document, so list pages describe their page counts, and share `integer` and `whereClause` between the Postgres and SQLite compilers.

  - Split server client construction into `server/clients.ts` (with an injectable `createClient`), `db.$search` into `core/db-search.ts`, the topic SQL into `realtime/topic-sql.ts` and the page helpers into `core/paginate.ts`. The incoming webhook endpoint now shares `hitRateLimit` and `receiveWebhook` with the rate limiter and the inbox, and `gen` builds the spec step with `specOptionsFromConfig`.
- `powersyncExecutor` takes a `fallback` executor. Reads that SQLite can't run (an include, full-text search, a function source) and `db.$rpc` go to the fallback, usually `postgrestExecutor(supabase)`; writes never fall back. `checkSqlite(betterSupabase, list, { all: true })` returns every unsupported feature a list uses instead of the first. `db.$rpc` on an executor without `rpc` now returns a `DbError` of kind `unsupported` (HTTP 501) instead of `invalid_request`. The PowerSync docs add a guide to porting a PostgREST list.
- New `better-supabase spec emit`, `spec check` and `spec validate` commands. `spec emit` renders every output in the new `specs` config key (format, version, output file, overlays, JSON or YAML, and an optional reference page), applies the overlays and caches renders by content hash. `spec check` fails when a file on disk is out of date, `spec validate` checks each document against the official JSON Schema for its version, and every run writes a diagnostics manifest. `--fail-on`, `--ui`, `--watch` and `--manifest` cover CI, reference pages and local work. `openapi emit --check` now compares text the same way `spec check` does.

  `better-supabase scaffold api` writes the resource routes and an OpenAPI route for Hono or Next.js: `lib/<name>.generated.ts` on every run and `lib/<name>.ts` once, for your own changes.

  The `openapi` config key is deprecated. It still works and maps onto `specs`; move it to `specs.outputs` with `format: "openapi"`.
- New `better-supabase/spec` subpath: `defineApi` builds one API model from the schema, resources, routes and plugin `describe` hooks, and renders it in any registered document format and version, with transforms, overlays and lint diagnostics. `defineDocumentFormat` adds a format of your own (`apiVersion: 1`), `serializeDocument` and `specResponse` serve a rendered document with a strong ETag, and `specReference` serves a reference page for Scalar, Swagger UI, Redoc, Stoplight Elements or RapiDoc, from jsDelivr at pinned versions with SRI hashes or from your own assets, with a CSP nonce.

  `better-supabase/openapi` now renders OpenAPI 3.0, 3.1 and 3.2 from that model, and `3.3-preview` when you ask for it. `createOpenApi` keeps its signature and takes a `version` option. It now emits OpenAPI 3.1.2 by default (it emitted 3.1.1), so a snapshot of the `openapi` field changes. 3.0 output lowers schemas to the 3.0 Schema Object, and the 3.2 fields travel as `x-oai-*` extensions below 3.2. `openapiFormat` lints a document for duplicate operation ids, path parameters that don't match the template, unknown security schemes and `$ref`s that don't resolve. The tenant, soft delete and timestamps plugins describe their columns and headers in the document.
- Add the `standardSchema()` generator. It writes `<output>.standard.ts` with a Standard Schema per table for its Row, Insert and Update shapes, built on the new `tableSchema()` runtime helper, so apps can validate writes without installing zod or valibot. Each schema also implements Standard JSON Schema for the `draft-2020-12`, `draft-07` and `openapi-3.0` targets, and the file exports `validators` for the validation plugin. `gen --metadata --emit standard-schema` prints it. Generators now receive `runtimeImport` in their input.
- `bucket.connect()` and `buckets.connectStored()` take any `StorageApi`, not only a supabase-js client: `StorageClient` is now `StorageApi | { storage: StorageApi }`, where `StorageApi` is an object whose `from(bucket)` returns a `StorageBucketApi`, the file methods of `supabase.storage.from(bucket)` typed structurally with the supabase-js `{ data, error }` results. A supabase-js client and its `storage` still type-check unchanged. `bucket.apply()`, `defineAnalyticsBucket().connect()` and `defineVectorBucket().connect()` call the admin API, so they take the new `StorageAdminClient` (a supabase-js client) instead.
- Supabase Lite support. `createServer(betterSupabase, { backend: "lite" })` verifies Lite's HS256 tokens locally against `SUPABASE_JWT_SECRET` (a new `env` setting, at least 32 characters), skips the JWKS prefetch, and with `liteDriver` set to `sqlite-postgres` or `sqlite` returns an `unsupported` error for `$rpc` without sending a request. `liteStack()` in `better-supabase/testing` runs Lite in the test process on in-memory SQLite or PGlite, applies migrations, schemas and seed SQL, and signs `asUser` tokens with the Lite secret. `gen` and `doctor` read a Lite project's `[db] driver`: the `postgres` driver's `[db] url`, or the migrations and declarative schemas replayed into an in-memory PGlite (`source.lite: false` turns this off). Doctor adds BS328 (`$rpc` on a Lite SQLite driver) and BS329 (Realtime or Edge Functions on Lite), and skips BS403 on Lite projects. `init --lite` prints Lite's install and CLI steps. `@supabase/lite` is an optional peer (`>=0.11.0 <0.12`).
- Connect agents to Supabase's MCP server and serve it from your app. `supabaseMcp()` in
  `better-supabase/ai-sdk/mcp` returns the server URL for a project and the tool schemas from
  `@supabase/mcp-server-supabase`, and `connectTools` takes them as `schemas` to type each tool's
  input and output. `supabaseMcpHandler()` in `better-supabase/mcp/sdk` serves Supabase's MCP server
  behind `createMcpAuth`: it checks the caller with `authorize`, reads the Management API token
  through a `CredentialProvider` and stays read-only unless you pass `readOnly: false`.
  `@supabase/mcp-server-supabase` is an optional peer (`>=0.13.0 <0.14`) loaded on first use.
- Support sessions that pass their expiry are now ended and recorded. The `support-sessions` SQL module adds `expire_support_sessions()`, for the service role only, which ends every running session past `expires_at` through `end_support_session(id, 'expired')` (so each gets its `support.ended` audit entry) and returns them. `bs.support.expire()` runs it through the store's new optional `expire()` (the SQL store calls it over `postgres.admin`) and emits `support.ended` with `endedBy: "expired"` for each session, and `bs.support.expireRoute({ secret })` serves it to a cron caller. A store without `expire()` fails with `SUPPORT_EXPIRE_UNSUPPORTED`, and `testSupportSessionStore` checks that `expire()` leaves running sessions alone.
- `supportStorage()` and `ctx.storage` return a `StorageApi`, the file API of `supabase.storage`, so `bucket.connect(ctx.storage)` and blocks that take `storage` work in a support session. `list` and `listV2` call `storage.search` and `storage.search_v2` over Postgres as the target, so RLS filters each page as Storage would: a page is no longer shorter than `limit` when some entries are hidden (which ended `bucket.list` and `sweep` early), and the prefix matches without case, as Storage matches it. `exists` and `info` read `storage.objects` as the target, and `download`, `createSignedUrl` and `createSignedUrls` check visibility there before the service client serves the object, now with `transform`, `versionId`, `cacheNonce` and `download` passed through. A hidden object answers like a missing one (`NoSuchKey`), and paths are normalised as storage-js normalises them before the check. `getPublicUrl` is built without a check. Every write resolves to a `403` error with `SUPPORT_READ_ONLY` or `SUPPORT_STORAGE_WRITES`, which a connected bucket maps to `forbidden`. The `SupportStorage`, `SupportStorageAdmin`, `SupportStorageBucket`, `SupportStorageEntry` and `SupportStorageListOptions` types are removed.
- In a support session, `ctx.storage` reads Storage as the target user, where `ctx.supabase` throws. `supportStorage({ admin, sql, readOnly })` is exported from `better-supabase/server` for the same reads outside the server context.
- `better-supabase/tanstack-db/supabase` adds `supabaseCollection(betterSupabase, supabase, table, options)`, a TanStack DB collection on `@supabase-labs/tanstack-db` (a new optional peer, `>=0.1.0 <0.2`). Live queries load on demand through PostgREST, `realtime: true` keeps them current through `postgres_changes`, and inserts, updates and deletes go through the table's repository, so RLS, hooks, events and plugins apply. `writes: "direct"` keeps the package's own handlers. It needs `casing: "snake"` and tables in the `public` schema; `collectionOptions()` from `better-supabase/tanstack-db` stays the choice for camel casing.
- Call Edge Functions with a typed input and output. `defineFunction()` in `better-supabase/edge`
  checks the JSON body with a Standard Schema before the handler runs as the caller (422 with the
  issues when it fails) and can check the output too. `ContractOf<typeof fn>` exports its contract,
  and `functions<Contracts>(supabase).invoke(name, input)` in `better-supabase/client` calls it
  through supabase-js's `functions` client and returns an `AsyncResult`: Problem Details come back
  as their `DbError`, and network and relay failures as `network` errors.
- `attachments.upload` and `attachments.put` delete the record they created when Storage refuses the signed upload URL or the upload, such as a bucket policy or the read-only Storage of a support session, and return the refusal. Before, the record stayed `pending` with no object behind it.
- The CLI reads `$SUPABASE_DB_URL` when `$DATABASE_URL` is unset, so `gen`, `introspect`, `doctor` and `seed --apply` connect with the variable the Supabase CLI and `better-supabase env` write. `$DATABASE_URL` still wins when both are set. The `gen` docs now list the real source order: a configured `source.snapshot` comes before `source.dbUrl`.
- `bs.loader()` without a request now names `createStaticLoader` from `expo-server` and the SDK 57 `unstable_useServerRendering` option. The Expo docs say which `app.json` options SDK 57 needs and that SDK 58 needs none, and show how to call `bs.request` inside `createServerLoader` or wrap `bs.loader`, which is an `expo-server` `LoaderFunction`.
- Bind `json` and `jsonb` arguments of `rpc()` correctly over direct Postgres when `database.types.ts` is absent: argument types now come from the catalog, so string arguments are JSON-encoded like they are with generated types.
- `bs.authed()` in `better-supabase/orpc` now checks `roles` (and the new `permission` option) on their own. Before, it checked them only when `requireTenant` or `authorize` was also set, so a procedure guarded by `roles` alone let every signed-in caller through.
- The `@supabase/postgrest-typegen` peer accepts `>=0.4.0 <0.5` instead of exactly 0.4.0, so an app that installs another 0.4 release no longer fails strict peer checks. The CLI is still tested against 0.4.0, and `gen` prints a notice when another release is installed. Doctor's new BS601 reports an installed peer outside the range better-supabase declares, and the new [Peers](https://bettersupabase.com/docs/getting-started/peers) page lists every peer, its range and the subpaths that need it.
- The Postgres executor (`better-supabase/postgres`) now sends values for `json` and `jsonb` columns as JSON text in inserts, updates and upserts. Arrays, arrays of objects and plain strings failed before with `invalid input syntax for type json`, because node-postgres sends a JavaScript array as a Postgres array literal. A string is stored as a JSON string, as it is over PostgREST, `null` stays SQL `null`, and each element of a `jsonb[]` column is encoded the same way. Postgres array columns such as `text[]` still receive arrays. Comparison filters (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`) on a `json` column send an array or object operand as JSON text too.
- Postgres error `25006` (a write in a read-only transaction, such as in a read-only support session) maps to `forbidden` with code `25006`, the message "This connection is read-only, so the write was refused" and a hint, instead of `unexpected` with the raw "cannot execute ... in a read-only transaction" text. The original text is kept in `details`.
- Queries through `ctx.db` and `server.dbFor` with credentials that did not verify now return the unauthorized `DbError` instead of running as anon, so a handler without a route guard cannot read data with a bad token.
- The README now names `@supabase/postgrest-typegen` next to the runtime packages, and links the new [Supabase packages](https://bettersupabase.com/docs/supabase-packages) page, which lists every Supabase package better-supabase uses, how it is installed and how the CLI relates to the `supabase` CLI.
- The `support-sessions` module's platform-target check honours `sql.modules.invitations.options.platformRoles.through.where` under the `provider` access model: a target counts as platform staff only through a role that matches the condition, so a roles table that mixes tenant and platform scopes no longer blocks support sessions for users who hold only tenant roles.
- The support session store reads its time values with the definition's `temporal`: `createServer` passes it to `supportSessions(...).create` and on to the store through the new optional `SupportSessionStore.withTemporal`, and `sqlSupportStore(postgres, { schema, temporal })` takes an explicit namespace that wins over it, as block creators do.
- `bucket.sweep({ olderThan })` recognizes a `Temporal.Instant` from any copy of Temporal by its tag, so an instant from a second polyfill copy is no longer read as a duration. `createJobs(source, queues, { now })` takes a clock for `runAt` delays and schedule runs, and block events on `betterSupabase.events` take their `time` from the definition's `now` option. The Temporal docs now lead with `defineSupabase(schema, { temporal })` and keep `temporal-polyfill/global` as the alternative.
- New subpath `better-supabase/better-result`: `withBetterResult(db, toResult)` returns a view of `db` whose repository methods, `$rpc`, `$run`, `$many`, `$search` and list queries (`$list`) return better-result `Result` values, so call sites no longer convert each result. `toResult` is a `defineBetterResultErrors` converter or `{ Result, mapError }`. better-result is an optional peer (`>=3 <4`) that the subpath imports only for types.

## 0.7.1 (2026-10-11) [#071-2026-10-11]

- `suspendAccount` with `suspended: true` bans the user and keeps their sessions, so Auth answers refresh and `GET /auth/v1/user` with `user_banned`, `sessionStatus` returns `"banned"` and `bs.proxy({ endedSession })` redirects with `reason=account_banned`. Deleting the sessions made Auth answer `session_not_found`, so a suspended user read as `"ended"`. The `sessions` SQL module's `session_active()` already refuses a banned user's tokens. `suspended: false` now ends the sessions kept during the ban before it lifts it, so a reinstated user signs in again, and `ended` is `0` on a suspension. Pass `endSessions: true` to delete the sessions on suspension too, for apps without the `sessions` module. Doctor's BS106 description and the docs now say that `fromPolicies` makes only tables with RLS on service-only.

## 0.7.0 (2026-10-10) [#070-2026-10-10]

- `sessionStatus` returns `banned` when Auth rejects a token with `error_code: "user_banned"`, a rejected `refreshSession` outcome carries the Auth `code`, and the resolution reports `sessionEndedReason`. `bs.proxy({ endedSession })` redirects banned accounts with `reason=account_banned`; `endedSession.reasons` renames the `reason` values. `SessionStatus` gains `"banned"`, so a switch over it needs a case for the new value.
- `better-supabase/server` exports `toCsv`, `csvColumns` and `csvResponse`, which answers with a CSV attachment. The `sessions` SQL module adds `better_supabase.last_sign_ins(uuid[])`, callable by the service role only, and `better-supabase/server` exports `lastSignIns` to read many users' last sign-in in one query. Run `better-supabase sql sync` to write the function.
- With `sql.modules.grants.options.fromPolicies`, a table the schema files create that has no permissive policy and no `expose` entry is now service-only: `sql sync` revokes `public`, `anon` and `authenticated` on it and grants `service_role` every privilege. BS106 no longer asks for a `select` grant on those tables. New doctor check BS115 reports such tables that still hold grants on the live database, and BS116 reports `security definer` functions in an exposed schema that `anon` or `authenticated` can execute while no `expose` entry or policy uses them.
- Doctor's BS304 now renders the SQL module files with the same layout as `sql sync` (grants from policies, session policies, audited tables and the vector schema), so the two no longer disagree about stale files. BS313 skips projects that set `sql.modules["rate-limit"].options.preRequest` to `false`.

## 0.6.1 (2026-10-10) [#061-2026-10-10]

- The attachment scanner's `sink()` and `job` no longer fail for an attachment that was deleted before its scan. A missing attachment made the sink throw, so the outbox relay retried the whole batch until it dead-lettered it and every later event for that consumer waited behind it. The sink now skips that event and the job completes; download, transport and `scan` errors still throw and retry. The object scanner does the same for an object deleted from storage (`NoSuchKey`), while a missing bucket still throws. `scanner.scan()` and `objects.scan()` keep returning the `not_found` error.
- Audit entries record the request and correlation ids of the action that made them, so an app can group an event with the row changes of the same request. `createServer` gives every context a request id (the incoming `x-request-id` when valid, else a new UUID) and a correlation id (the incoming `x-correlation-id`, else the request id), exposes them as `ctx.requestId` and `ctx.correlationId`, sends them as headers on `ctx.db` requests and sets them as the transaction-local `better_supabase.request_id` and `better_supabase.correlation_id` settings for `ctx.sql`. The `requestIds` server option renames the headers, ignores the incoming ones or turns the ids off, and `ContextOptions` takes `requestId` and `correlationId` for jobs.

  The audit module (version 6) reads the ids from those settings, then from the request headers (`requestIdHeader` and `correlationIdHeader` options), keeps only ids of 1 to 128 safe characters through `better_supabase.request_id_or_null`, and indexes `correlation_id` on a managed log. The ids are metadata and never grant access. Run `better-supabase sql sync` and generate a migration to pick up the module change.
- The data exporter's `sink()` and `job` no longer fail for an export they can't claim, such as one cancelled, deleted or already finished before its `data_export.requested` event arrived. The sink threw `DATA_EXPORT_NOT_FOUND`, so the outbox relay retried the batch until it dead-lettered it and held every later event for that consumer behind it. The sink now skips that event and the job completes; a failing export still throws and retries. `exporter.run()` keeps returning the `not_found` error.

## 0.6.0 (2026-10-09) [#060-2026-10-09]

- The `agents`, `connectors` and `ai-tasks` SQL modules store assistants, their MCP servers and scheduled prompts, and `better-supabase/eve` runs eve agents on Supabase.

  - `createAgents` in `better-supabase/blocks/agents` stores agents with instructions, model, tools, connectors and knowledge scopes, private until published.
  - `createConnectors` in `/blocks/connectors` stores an organization's MCP servers, a grant per user behind a `credential_ref`, and tool lists an admin approves. `createAiTasks` in `/blocks/ai-tasks` runs prompts on a cron.
  - `better-supabase/ai-sdk/agents` adds `createAgentRuntime` and `moderationMiddleware`, and `/ai-sdk/mcp` adds `authorizeConnector`, `connectTools` and `connectAll` (`@ai-sdk/mcp` is an optional peer).
  - `better-supabase/eve` (Node only, `eve` is an optional peer) adds `supabaseAuth`, `credentialAuth`, `supabaseMemory`, `persistSessions` and `routeInbox`.
  - Only the service role sets a task's `next_run_at`, a task's chat must be its user's and its agent the user's own or published, and `drain()` runs every claimed task before it reports the first error.
- The `ai-chat` and `ai-files` SQL modules store AI conversations and their files, and `better-supabase/ai-sdk` connects them to the AI SDK (`ai`, `@ai-sdk/react` and `@ai-sdk/workflow` are optional peers).

  - `createAiChat` in `better-supabase/blocks/ai-chat` keeps chats, a branching message tree, runs, tool approvals, feedback, share links, a model catalog per plan and moderation events; `/blocks/ai-chat/react` adds `useAiChats`, `useAiChatTree`, `useAiModels` and `useAiShare`. Messages use a canonical format (`aiMessageSchema`, `schemas/ai-message-v1.json`, `SPEC_PINS.aiMessage`).
  - `createAiFiles` in `better-supabase/blocks/ai-files` stores uploads in two steps with policies that follow the file row, and versioned documents. `/ai-sdk/files` adds `aiFileDownload`, `saveGeneratedFiles` and `providerFile`.
  - `better-supabase/ai-sdk` adds message converters, AI Gateway helpers and job handlers, `createAssistant` in `/ai-sdk/chat` serves a resumable chat route, and `useAssistant` in `/ai-sdk/react` is `useChat` wired to it.
  - `/ai-sdk/workflow` runs answers as Workflow SDK workflows with `durableChat` and `durableTurn`, and `useDurableAssistant` is the client half. `createAiChat(...).runs` reads run steps, and `createHarnessSessions` reads harness sessions.
  - Harness sessions are read on the server only, the idle-stop job skips a sandbox whose session a turn holds, and the chat list hooks keep every loaded page on a live update and ignore stale responses.
- The AI blocks share one run-state vocabulary, one sandbox table and one set of permission keys. The ai-chat, ai-tasks, ai-providers and memory modules move to version 2; `better-supabase sql upgrade` brings a database installed from an earlier build forward.

  - Runs end `completed`, `failed` or `cancelled` in ai-chat, ai-tasks and workflows. `RunState`, `FinalRunState` and `FINAL_RUN_STATES` from `better-supabase/blocks` name them.
  - `createAiChat(...).runs` has the run lookups, and durable chats read them from the context's `chats`.
  - ai-chat owns `ai_sandboxes`. Harness sessions keep their sandbox there, and `createAiChat(...).sandboxes.idleStopJob` stops chat and harness sandboxes with one claim. Set the idle time with `sql.modules.ai-chat.options.sandboxIdleAfter`.
  - ai-files, knowledge, memory, agents, connectors, ai-tasks and ai-providers check `ai.*` permission keys. ai-chat keeps its `ai_chat.*` keys, and the default roles grant both.
  - `MemoryScope` gains `project`, with `projectId` on namespaces and memory rows.
  - eve sessions release runs with the new statuses, and the jobs page documents jobs cron, workflow schedules and ai-tasks as three scheduling layers.
  - The eve, memory, ai-files, workflow-builder and ai-providers blocks keep their row decoders in `rows.ts`.
- The `ai-cache` and `ai-providers` SQL modules cache model responses and keep each organization's provider keys, and `better-supabase/ai-sdk` meters model calls.

  - `createAiCache` in `better-supabase/blocks/ai-cache` and `cacheMiddleware` in `/ai-sdk/cache` return a stored response or stream for a repeated call, per tenant with a TTL.
  - `createAiProviders` in `better-supabase/blocks/ai-providers` keeps provider keys as `credential_ref` rows with batch and sandbox registries. `byokOptions` and `tenantGatewayOptions` turn them into AI Gateway BYOK options, `trackedSandbox` records sandboxes, and `aiBatches` in `/ai-sdk/batches` runs provider batches from a job.
  - `meterTelemetry` records each call's tokens and cost on the tenant's usage meters, and `spendReconciliation` compares them with the AI Gateway spend report.
- The API keys block (`better-supabase/blocks/api-keys`) issues keys with a checksum, reports their state and authenticates REST routes.

  - Keys end in a CRC-32 checksum, so `verify` and secret scanners reject a mistyped key without a lookup (`parseApiKey`).
  - Keys report `state` (`active`, `grace`, `revoked`, `expired`) and `successorId` (`ApiKeyState`).
  - `withApiKey({ keys })` is a pipeline entry that verifies the key and contributes `ctx.auth`, and `apiKeyClaims` and `apiKeyResolver` take a `claim` shape so the authorization provider reads the key's scopes.
  - `options.scopes: "catalog"` limits scopes to the provider's permission keys, and the provider model refuses the `*` scope unless listed.
- Every SQL module audits its security-relevant actions. Run `better-supabase sql sync`.

  - `ctx.record` takes a required `audit`: one of the shared categories in `AUDIT_CATEGORIES` (`membership`, `access`, `security`, `configuration`, `billing`, `data`, `ai`, `integration`), or `false` for content and status events. With the `audit` module installed, each action writes one audit entry in the same transaction as its outbox event.
  - Settings, flags, billing customers, credentials, connectors, agents, AI provider keys, tool policies and approvals, chat shares, incoming webhooks, webhook secrets, announcements, published workflows, API keys, invitations, the waitlist, SSO domains and SCIM changes now write an audit entry and an outbox event. The access catalog's tables are audited through the row trigger under `access`.
  - `organizations`, `organizations-suspension` and `support-sessions` record through the same path: organization entries move from `organization` to `configuration` or `membership`, support entries from `support` to `security`, and revealed audit details from `audit` to `security`. An adopted log with a category check maps the names with `sql.modules.audit.options.values.category`. `options.auditCategory` is deprecated and still overrides every category of its module.
  - `sql.modules.<name>.audit: false` keeps a module's actions out of the log; they still reach the outbox.
  - `support.started` records the session's tenant, like `support.ended`.
- `audit.sink()` files events under the categories the SQL modules use: `account.*` and `support.*` events get `security`, and a `category` option maps any other event type to one of `AUDIT_CATEGORIES`.
- `createAuditLog({ transport })` in `better-supabase/blocks/audit` lists, reveals and exports the audit log as the caller, and entries record who acted, from where and on what. Run `better-supabase sql upgrade`.

  - `list` (`list_audit_events`) filters by tenant, event type, actor, target, record, category, outcome, source and correlation id, with `search`, a cursor or `offset`, and `count: true`. `reveal(entryId)` returns restricted details and records `audit.revealed`.
  - `export({ format: "ndjson" | "csv" | "ocsf" })` streams the log (CSV takes `columns`, `preamble` and `formatRow`), `exportToStorage` writes it to a bucket, and `record(event)` calls `audit_event`.
  - Entries record actor kind and label, tenant and target labels, `summary`, `request_id`, `correlation_id` and `scope`. Actor and request details are honoured only from the service role and admin connections; `audit_event_trusted` serves an app's `security definer` functions and `options.trustedRoles`.
  - `better_supabase.audit(...)` keeps a table's settings on its trigger, so schema files need no data rows, and `bs_audit_forget_dropped` clears registrations of dropped tables. Adopted logs map their own values with `options.values`, `tenantLabel` and `metadataColumns`.
  - `sql add` writes a pgTAP file per audited table. Doctor BS315 reports tables without an audit trigger and BS322 registrations of missing tables.
- Authorization libraries plug in through a versioned `AuthorizationProvider` (API v1, from `better-supabase/config`) in the `authorization` config key, and the access module's `provider` model renders the provider's SQL templates.

  - Under the `provider` model, the provider's `idsWithFor`, `isPlatformFor` and `canAssignFor` templates let `can_user()` and `member_can()` answer for any user, the invitations module check the inviter again at accept, and the notifications module filter recipients by their read permission. `sql.modules.access.functions.canAssignFor` defines `can_assign_as` under the provider and custom models.
  - `sql.modules.access.disabled` defaults to the provider's `suspension` rows. `disabled.tenant` and `disabled.user` also take an active-row shape (`{ table, id, disabledAt, status, active }`), where a missing row counts as disabled.
  - `testAuthorizationProvider` from `better-supabase/testing` checks a provider, and `templateFunctions` from `better-supabase/sql` lists the functions a template calls. Doctor BS214 compares a Storage or Realtime policy's scope with the provider's `permissions[].scopes` and reports keys not marked `sqlComplete: true`.
  - The CLI rejects a provider with an unknown scope or a parent cycle, a tenant scope without an `idType` (`SCOPE_ID_TYPES`), a `decidingColumns` entry that isn't `schema.table.column`, or another `apiVersion` (`providerApiProblem`). `testAuthorizationProvider` also checks that `requires` lists every function a template calls and that the token hook claims don't overlap.
  - Without `canAssign`, only the service role assigns roles under the `provider` model. Doctor BS411 names each installed module missing a template it needs.
  - `functions.permissionsFor` fills `member_permissions` and `permission_claims`, and `functions.canApprove` with `approvals.distinctApprover` decides AI tool approvals. Bucket and topic policies take `sql: "provider"` to use the provider's templates, `createAgentRuntime` takes a `toolApproval` hook, the claims config has a `memberships` path (`claimPaths`), and the `apiKey` caller carries `createdAt` and `createdBy`.
  - **Breaking:** every PermDock-specific setting and export is removed. `sql.modules.access.model: "permdock"` becomes `"provider"`, `entitlements.permdock` becomes `entitlements.memberships`, bucket and topic `permdock` policies become `access` policies with `scope` and `sql` templates, `permdockVerifier` and the `permdock` claim option of `apiKeyResolver` become the provider package's job and the neutral `claim` option, and doctor reads the provider instead of `permdock.config.ts`, the manifest and the catalog. The [0.5 to 0.6 guide](https://bettersupabase.com/docs/migration/0.5-to-0.6) has the old-to-new table.
- The billing block (`better-supabase/blocks/billing`) runs a tenant's Stripe subscription over the Stripe Sync Engine and gives platform staff typed lists across tenants.

  - `changePlan`, `cancelAtPeriodEnd`, `invoices`, `paymentMethods`, `voidInvoice`, `customerDetails`, `updateCustomer` and tax ids (`StripeTaxId`).
  - `options.plans` points at your plan catalog with price `variant`s, and `checkout` merges `params` deeply.
  - Staff with `viewAll` read `allSubscriptions`, `allInvoices` and `allCustomers`, also as `billing_platform_*` SQL functions.
  - `StripeSource` accepts a function that returns a client, and `ensureCustomer` is idempotent per organization.
- Blocks can be extended without forking them: add your own columns and type them, steer methods with hooks, wrap the transport and add methods.

  - `options.extraColumns` on the `organizations` and `profiles` SQL modules adds your own columns to the managed table, copies them on writes and returns them from reads. `list_my_organizations` returns the attribute and extra columns in a new `attributes jsonb` column.
  - A Standard Schema `fields` option on `createOrganizations` and `createProfiles` types those columns, parses them on read and turns a bad write into a `validation` error. `createNotifications` parses stored `data` with its type's schema on `get`, `list` and `page`, so `item.data` narrows on `item.type` (`NotificationOf`); data the schema rejects fails with the hint `NOTIFICATION_DATA_INVALID`.
  - A `hooks` option on those blocks, and on `createBlocks` keyed by block name, runs `before` hooks that refuse a call with a `DbError` or replace its arguments, and `after` hooks that observe a copy of the result. `withBlockHooks` does the same for any block.
  - New SQL hooks: `before_organization_update`, `after_organization_update`, `before_profile_update`, `after_profile_update` and `before_notification_send`.
  - `wrapTransport` runs versioned `BlockTransportMiddleware` around every block call, checked by `testBlockTransportMiddleware` in `better-supabase/testing`, and `extendBlock` adds methods and refuses to redefine one.
- `createBlocks` in the new `better-supabase/blocks` subpath builds several blocks from one set of options, and the blocks share their helpers, option names and page shape.

  - `createBlocks({ transport, service, schema, credentials, events, audit }, factories)` builds the blocks you pass and wires their siblings: the AI files block into knowledge, `credentials` into connectors, ai-providers and the workflow builder, notifications into ai-tasks (with `aiTaskNotification`), and block events into an audit sink. The subpath also exports `rpcTransport`, `sqlTransport` and the `BlockOptions` and `CursorPageOptions` types.
  - `audit.sink()` returns an `EventSink` that records each CloudEvent in the audit log, with its type, subject, tenant and data. `createServer({ audit })` sends `account.suspended`, `account.unsuspended`, `account.deleted` and `account.sessions_ended` from `suspendAccount`, `deleteAccount` and `endSessions`, and forwards `support.denied`; a failing sink never changes a result.
  - Block lists page with `{ limit, cursor }`, which replace `size`, `before` and `after`.
  - Notifications, organizations, profiles and outgoing webhooks take `mappers` instead of `errorMappers`.
  - `CredentialProvider` gains an optional `set(ref, value, { subject, description })`, which Vault implements and the workflow builder uses. `credentialRouter(providers)` serves several providers as one, and `revokeIfConfigured(provider, ref, { subject, tenant })` revokes only when a provider is configured, can revoke the ref and the ref is in the tenant.
  - `Embedder`, `EveDocumentBackend`, `AiTaskRunner`, `GraphCompiler` and `BuilderStarter` take an optional `apiVersion: 1`; a block refuses another version. `better-supabase/testing` adds `testEmbedder`, `testEveDocumentBackend`, `testAiTaskRunner`, `testGraphCompiler` and `testBuilderStarter`.
  - The block row decoders moved into the core and every block uses the same ones; `better-supabase/blocks/knowledge` no longer exports `vectorLiteral`.
- The CLI finds its config from any package in a workspace, pg-delta is the default diff engine, and doctor reports each problem once.

  - `better-supabase.config.*` and `supabase/config.toml` are found in the working directory or a parent up to `.git`, and `init` at a workspace root asks for the package or takes `--package <dir>`.
  - `init` turns on `[experimental.pgdelta]` unless it is set to `false`, and doctor BS316 warns projects still on migra. `better-supabase config` prints the resolved config.
  - Doctor skips findings the performance advisor already reports, runs splinter with the `[api] schemas`, and adds BS222 (`int8` decoded as `number`), BS317 and BS318 (statements pg-delta can't order) and BS319 (SQL that alters a reserved role). `tables.<name>.serviceRole: true` marks a server-only table for BS106 and BS107.
- `better-supabase/vue`, `/solid` and `/svelte` bind the browser client with the features of `better-supabase/react`, and new hooks and query helpers cover forms, uploads and optimistic updates.

  - `useAction` and `useActionForm` call a `bs.action()` and track `pending`, `data`, `error` and `fieldErrors`. `better-supabase/react` adds `usePresence`, `useSignIn`, `useSignOut`, `useDebouncedSearch`, `useSignedUrl` and `useUpload`, and `upload()` takes `onProgress`.
  - `better-supabase/query` adds `optimistic` with rollback, `{ maxPages }` for infinite queries and `escapeLike`, and `createQueries` takes a `scope`. `better-supabase/tanstack-db` adds `collectionOptions`.
  - `clearOnUserChange` also resets queries when the token changes tenant, role or assurance level. `bindClient` returns `dispose()`, and `liveQuery` rejoins a failed channel with backoff.
- `gen` writes tighter types from CHECK constraints, documents tables in the generated validators and can run from a metadata document. Run `better-supabase gen`.

  - A nullable column a CHECK requires is typed not null, and `tables.<table>.insertOptional` marks columns the database fills on insert. `relations: { nullableUnderRls: true }` types to-one relations to RLS tables as nullable.
  - `zod()`, `valibot()` and `jsonSchema()` add titles, descriptions and examples from comments, and enforce bounds from simple CHECK constraints.
  - The generated module exports `WhereOf<"table">`, `OrderByOf<"table">` and `OrderTermOf<"table">`, and the metadata module is about 40% smaller.
  - `gen --metadata <path|->` generates from a `GeneratorMetadata` document for `@supabase/typegen`, and `doctor --metadata` checks it.
  - Generators declare `apiVersion: 1` and receive `model`. `gen` deletes files it no longer writes, fails on name collisions, and `gen --watch` reloads the config and retries.
  - **Breaking:** colliding `_by_` relations are named by every key column (`customerByCustomerOrganization`) instead of ending in `_`, and view copies are no longer relations. Keep an old name with `tables.<table>.relations`.
  - **Breaking:** `@supabase/postgrest-typegen` is an optional peer; install it for `gen`, `introspect` and `doctor` with `pnpm add -D @supabase/postgrest-typegen@0.4.0`.
  - **Breaking:** a `GeneratorInput` built by hand needs a `model`.
- The comments and attachments blocks (`better-supabase/blocks/comments`, `/blocks/attachments`) store threads and files on any subject with per-subject permissions.

  - `options.subjects.<type>` takes `permissions: { read, create, moderate }`, `cascade`, and `label`, `path` and `readableBy` for notifications; a mentioned member is notified only when they may read the subject.
  - Comments keep a rich-text `document` (`mentionsOf`, `options.documentSchema`), expand `options.mentionGroups`, and have `copy()`, `history()` and `counts()`.
  - Attachments take a `bucket`, `allowedMimeTypes` and `tenant: false` per subject, keep `metadata`, and have `put(attachment, file)` and `read(id)`. A malware scan gate works for any bucket (`object_clean(bucket, path)`, `createObjectScanner`, `options.scanBuckets`).
- Errors map to typed messages and result libraries, plugins behave the same whoever wrote them, and each definition keeps its own state.

  - `createErrorMessages(messages)` needs a message for every `DbError` kind. `toBetterResult` returns better-result's own `Result<T, E>` when that is the expected type, and `defineBetterResultErrors` maps kinds to `TaggedError` classes. `P0002` maps to `not_found`.
  - Plugins get `enforce: "first"`, a mutation `intent`, `scopes`, and a `context` hook per `connect()`, so two definitions in one process no longer share a tenant. Hooks receive the caller's `signal`, and `db.$withoutPlugins({ keep })` keeps named plugins.
  - `defineSupabase(schema, { temporal })` takes the `Temporal` namespace without patching `globalThis`, and `diagnostics: true` logs a record without tokens or row values for every query.
  - **Breaking:** `DbError` gains the kinds `quota_exceeded` (429), `max_affected` (400) and `unsupported` (501). An exhaustive `switch` over `DbError["kind"]` needs the new cases.
- `better-supabase/credentials` resolves third-party tokens from a `credential_ref`, and `better-supabase/streams` stores resumable output for chats, workflows and agents.

  - `vaultCredentials()` keeps secrets in Vault through the `credentials` module and verifies inbound signed requests. `createServer` takes a `credentials` provider as `ctx.credentials`, `subjectFor(ctx)` gives the subject, `better-supabase/vercel-connect` adds `vercelConnectCredentials()`, and `testCredentialProvider` checks other providers.
  - The `streams` module keeps ordered chunks with idempotent appends and a cancel flag, used by `postgresStreamStore`, `teeToStore`, `writeToStore` and `resumeFromStore`. `better-supabase/streams/redis` adds `redisStreamStore` (`redis` is an optional peer), and `testStreamStore` checks other stores.
  - A tenant's `credential_ref` carries `tenant` (`tenantCredentialRef`, `credentialRefInTenant`): connectors, AI providers, the workflow builder and MCP refuse a ref from another tenant with `CREDENTIAL_REF_FOREIGN`, Vault stores tenant secrets as `tenant/<tenant>/<secret>`, and `vercelConnectCredentials` refuses a tenant ref for the app subject.
- The data-lifecycle block (`better-supabase/blocks/data-lifecycle`) exports, anonymizes and purges a tenant's or a user's data across every module table.

  - Modules declare their tables, so exports and purges cover every installed module, and `options.autoTables` adds a schema's tenant tables. Exports leave out keys, token hashes and secrets.
  - The purge retries referenced tables, stops with `ORGANIZATION_PURGE_BLOCKED` when stuck, and deletes the tenant row last. `createOrganizationPurger({ buckets })` takes path prefixes (`PurgeBucket`).
  - `options.anonymize` rules anonymize rows after a retention period (`anonymizeDue()`), and `createDataExporter` takes `format: "csv"`.
- Block events follow one contract. Run `better-supabase sql sync` and `better-supabase codemod 0.6`.

  - Every type is `<entity>.<past_tense_verb>`. Renamed: `ai_chat.message.completed` to `ai_chat_message.completed`, `inbox.conversation.*` to `inbox_conversation.*`, `inbox.message.received` to `inbox_message.received`, `incoming_webhook.rotated` to `incoming_webhook.token_rotated`, `workflow.alert` to `workflow_alert.triggered`, `workflow.run.*` to `workflow_run.*` and `data_export.ready` to `data_export.completed`.
  - `BLOCK_EVENT_RENAMES` from `better-supabase/events` maps the `org.*` types to `organization.*`, and the 0.6 codemod rewrites string literals that name a renamed type.
  - `BlockEventMap` types every event a SQL module records. Each module declares its events in `NAMES.events`, and `ctx.record` throws for an undeclared type, a subject that is not a kebab-case plural, a missing payload key or a retried event without an idempotency key.
  - Support events carry a camelCase payload with `organizationId` under `support-sessions/<id>`. Push, waitlist and audit subjects are `push-devices/`, `waitlist-entries/` and `audit-entries/`. SCIM events name `scimUserId` or `scimGroupId`.
  - `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 idempotency keys.
  - `notifications.sink({ map })` turns outbox events into notifications, keyed by the event id.
- Adapters for more server frameworks, typed structurally, with the guards and actions of the Next.js adapter. They replace the `@supabase/server` framework adapters, which upstream removes on 2026-12-01.

  - New subpaths: `better-supabase/tanstack-start`, `/sveltekit`, `/react-router`, `/h3`, `/h3/v1`, `/elysia`, `/node` (`toExpress`, `toFastify`, `toKoa`), `/nestjs` (`@nestjs/common` is an optional peer), `/astro`, `/nuxt` and `/solid-start`. `toHono`, `toEdge`, `toOrpc` and `toExpo` bridge the existing adapters.
  - Each factory puts `db`, `bs`, `auth`, `tenant` and `session` on the request, with `bs.require()` and `bs.action()`; every guard takes `roles` and `roleClaim`. The edge gets `bs.routes({ "GET /customers/:id": handler })` with typed `ctx.params` (`RouteParams`), oRPC gets `bs.authed()`, and `createMcp` takes `requiredRoles`.
  - `handle()`, `extendServer`, `flushEvents` and `resolveToken` build an adapter on the shared request path, checked by `testAdapter`. `withServerTiming()`, `withDbStats()` and `tagCache` cover timing headers, database budgets and tag caches.
  - **Breaking:** `createEdge`'s `cors` option runs `withCors` from `@supabase/middleware/cors`. A preflight needs `Access-Control-Request-Method` (other `OPTIONS` requests reach the handler), and an allow-list adds `Vary: Origin`. `corsConfig(options)` returns the config.
- The `inbox` SQL module and `better-supabase/blocks/inbox` add a shared support inbox per organization, and `better-supabase/chat-sdk` runs Chat SDK bots on it (`chat` `>=4.41 <5` is an optional peer).

  - The inbox stores contacts, conversations with assignment, teams, snoozing and bot handoff, messages and notes, read receipts, deliveries and attachments, with `purgeContact` for data subject requests. `useInbox`, `useConversation` and `useInboxWidget` in `/blocks/inbox/react` stay current over private Realtime topics.
  - `createSupabaseState` is a `StateAdapter` on the `chat-sdk-state` module, `inboxAdapter` answers in the in-app widget, and `webhook`, `inboundHandler`, `deliver`, `maintain` and `createChatInstallations` record Slack, WhatsApp, Messenger and SMS conversations. `testChatState` checks any `StateAdapter`.
- Invitations can be edited, answered from an in-app inbox and sent for platform roles under the `provider` model. Run `better-supabase sql upgrade`.

  - `update_invitation` (`organizations.updateInvitation`) changes an open invitation and emits `invitation.updated`; it refuses an expired one, so call `resend_invitation` first.
  - `my_invitations()` lists the signed-in user's open invitations, and `acceptInvitationById` and `declineInvitationById` answer them. Invitations carry `extra` from the `invitation_preview_extra` hook, `createdAt`, `organization` and an `inviter`.
  - `options.platformRoles` names the platform role table, with `through`, `canAssign` and `canAssignFor` checked at invite and accept. `through.where` is required when its table is also `roleThrough`'s, and `bs_role_scope` refuses other roles on direct writes (`PLATFORM_ROLE_SCOPE`).
  - **Breaking:** accepting or updating an expired invitation fails with the hint `INVITATION_EXPIRED` instead of `INVITATION_INVALID`, which now means unknown, accepted, declined or revoked.
  - **Breaking:** `Invitation` has a required `inviter` field (`null` without the profiles module), and the `invitation_preview_extra` hook also runs for invite, resend, update and `my_invitations`.
- Jobs run as the user who enqueued them and report queue health, and `better-supabase/blocks/jobs` adds leases and rate limits. Run `better-supabase sql upgrade`.

  - `bs.forContext(job.context)` returns repositories as the context's user and tenant, so RLS applies in the handler. It keeps a read-only support session read-only, and `claimsFor(userId, context)` rebuilds hook claims.
  - `jobs.stats()`, `listDead()` and `retryDead()` serve admin pages, and `enqueue` takes `dedupe: "waiting"`.
  - Under `scheduler: "drain"`, schedules record a tenant (`listSchedules`, `unscheduleAll`), `ensureSchedules` keeps a named set in step, `drainRoute` takes `monitor` hooks, and `devDrain` drains locally.
  - `withLease(sql, key, fn)` holds one holder per key. `hit_rate_limit(scope, key)` and `createRateLimit().check()` limit any key, and `rateLimited(decision)` answers 429 with `Retry-After`.
  - **Breaking:** `begin_idempotent` returns a `holder`, and `complete_idempotent` and `release_idempotent` (`createIdempotency().complete` and `release`) take it and return false after another caller took an expired key over.
- Kits are now blocks, and every feature module lives under `better-supabase/blocks/<name>`. There are no aliases; the [0.5 to 0.6 guide](https://bettersupabase.com/docs/migration/0.5-to-0.6) lists every rename.

  - **Breaking:** subpaths move. `better-supabase/orgs` is `better-supabase/blocks/organizations`, and `better-supabase/jobs`, `/notifications` and `/webhooks` are `better-supabase/blocks/jobs`, `/blocks/notifications` and `/blocks/webhooks`. `createOutbox` and `outboxCloudEvent` move to `better-supabase/blocks/outbox`, and `purgeAuditLog` to `better-supabase/blocks/audit`. `hasEntitlement`, `EntitlementKey`, `entitlementMembers` and `ENTITLEMENTS_UPDATED` move to `better-supabase/blocks/entitlements` (out of `server`, `next`, `ssr`, `react` and `jobs`), and `useNotifications` moves from `better-supabase/react` to `better-supabase/blocks/notifications/react`.
  - **Breaking:** `sql.kit` and the `kits` key merge into `sql.modules`, an object keyed by module name whose values are the module settings (a list of names still works).
  - **Breaking:** `Kit*` names for features are `Block*` (`BlockEvent`, `onBlockEvent`, `forwardBlockEvents`), and `Kit*` names for SQL modules are `Module*` (`ModulesConfig`, `ModuleConfig`, `renderModules`, `modulePermissionKeys`). `org` is spelled out: `createOrganizations`, `Organizations`, `organizationLogoBucket`, and the `organization.*` event types replace `org.*`.
  - **Breaking:** in SQL, `member_org_ids`, `has_org_role` and `org_member_role` are `member_organization_ids`, `has_organization_role` and `organization_member_role`, the organization functions take an `organization` parameter, `better_supabase.kit_modules` is `better_supabase.modules`, and module files carry `-- @bs-module` and `-- @bs-module-data` markers. Run `better-supabase sql sync`, the schema diff and `better-supabase sql data` to move a database over.

  `list`, `storage`, `realtime` and `events` keep their subpaths. The list, storage and realtime pages move to `/docs/platform`, and the events page to [`/docs/standards/events`](https://bettersupabase.com/docs/standards/events).
- The `knowledge` and `memory` SQL modules give agents retrieval and long-term memory; both require `vector-search`.

  - `createKnowledge` in `better-supabase/blocks/knowledge` stores documents scoped to an organization, agent, project, chat or user, chunks them with embeddings and a `tsvector`, and searches with reciprocal rank fusion. With `jobs` installed new documents are enqueued for embedding; `ingest.file` reads an `ai-files` upload.
  - `createMemory` in `better-supabase/blocks/memory` keeps core memory files under `/memories`, archival facts and one embedding per chat message, plus versioned `memory.documents`.
  - `better-supabase/ai-sdk/embeddings` adds `embedWith`, `supabaseEmbed`, `rerankWith`, `searchTool` and `toSourceParts`, and `/ai-sdk/memory` adds `memoryTool`, `anthropicMemory`, `recallTool`, `withMemory` and the `extractMemories` job handler.
  - An item rewritten while it is being embedded stays pending, `set_memory_embeddings` writes a batch, bad vectors fail with `EMBEDDING_INVALID`, and knowledge `drain` tries each document `attempts` times (3) before marking it failed.
- `better-supabase/mcp/sdk` serves MCP servers built on the official SDK (`@modelcontextprotocol/server` 2.3 or later, an optional peer), and `createMcp` works on hosted Edge Functions.

  - `createMcpAuth(betterSupabase, { resource })` verifies Supabase tokens locally as an `OAuthTokenVerifier`, serves the RFC 9728 metadata and answers 401 and 403 challenges. `withBetterSupabaseMcp(server, auth)` gives every tool callback `db`, `auth` and `bs`.
  - On Edge Functions the `resource` comes from the function slug. Both servers answer CORS preflights (`allowedOrigins`) and take `requiredRoles` and `waitUntil`.
- Expo and React Native get a server adapter, a native client, local SQLite executors and push notifications.

  - `better-supabase/expo`: `createExpo(betterSupabase)` gives Expo Router loaders, API routes and `+middleware.ts` the verified caller; `better-supabase init expo` writes it.
  - `better-supabase/client/native`: `createNativeClient(betterSupabase, supabase)` binds repositories without `@supabase/ssr`, `secureStorage` and `largeSecureStorage` keep sessions in the keychain, and `autoRefreshOnForeground`, `syncQueryWithApp`, `persistQueryCache`, `uploadFromUri` and `handleAuthDeepLink` cover the app lifecycle. `better-supabase/react/native` adds `useOAuth`, `useAuthDeepLinks`, `useProtectedRoute` and `AuthGate`.
  - `better-supabase/powersync`: `powersyncExecutor(db)` runs the same repositories on PowerSync's SQLite, `createUploadConnector` replays queued changes and `syncWithAuth` clears data on a user change; `/powersync/react` adds `useWatch`, `useSyncStatus` and `useConflicts`. `better-supabase/expo-sqlite` adds `expoSqliteExecutor(db)`.
  - The `push` module and `better-supabase/blocks/push` store device tokens and send through the Expo Push API (`registerDevice`, `expoPush`, `expoPushChannel`).
- The Next.js adapter authorizes actions and Server Components, signs out ended sessions and scopes cache tags to a tenant.

  - `bs.action()` and `bs.route()` take `requireTenant` and `authorize(session, input)`, and `bs.require(options)` refuses a Server Component caller with `unauthorized()`, `forbidden()` or `notFound()`.
  - `bs.proxy(request, { endedSession })` asks Auth whether the session still exists and clears the cookies when it doesn't, and `expiredPrefetch: "render"` renders a prefetch with an expired token signed out.
  - `bs.cacheTag`, `bs.cacheTags` and `bs.cached` take `{ tenant }`, so a mutation in one tenant no longer revalidates other tenants' reads, and `bs.context({ tenant })` takes the tenant from route params.
  - Mutations outside a Server Action expire tags with `{ expire: 0 }`, so a read after a write in a route handler is fresh (`nextCache({ revalidate })` keeps `"max"`).
  - `useSessionChange` refreshes the session before an organization switch re-renders, and `tenantOf(session)` reads the active tenant.
  - **Breaking:** the `next` peer range is `>=16.3 <17`, because `createNext` awaits `io()`. Upgrade Next.js first.
- The notifications block reads more of the inbox, hydrates a page in one call and reports who received a send. Run `better-supabase sql upgrade`; a custom-mode module implements the new signatures.

  - `get(id)`, `page({ offset })`, `markUnread`, `subscriptions()` and `preferences()` are new, and `list` filters by subject type, search and read, resolved or dismissed state.
  - `hydrate(items)` loads what `render` needs for a page, and `include: ["actor"]` adds each actor's profile.
  - `send` takes `watchers: false` and `exclude`, and `subscribe({ ifAbsent: true })` auto-follows without overriding a member's choice.
  - **Breaking:** `markRead`, `markUnread`, `dismiss` and `resolve` return `{ count, items }` instead of a number, and their SQL functions return `jsonb`.
  - **Breaking:** `send()` returns `{ id, recipients }` instead of the id, and `onSent` and `notification.created` get the recipients. It calls `send_notification(jsonb)`; `notify(jsonb)` still returns the id.
- Organizations can suspend members, route deletion through data-lifecycle and give platform staff admin rights, and profiles store an avatar path. Run `better-supabase sql upgrade`.

  - `suspend_member` and `resume_member` (`organizations.suspendMember`, `resumeMember`) keep a member's role but drop its permissions through the memberships table's `disabled_at`; they refuse the caller, the last owner and higher members. `transfer_ownership` refuses suspended or disabled new owners.
  - `options.deleteMode: "lifecycle"` sends deletion through data-lifecycle's grace period, and `"none"` writes no delete function.
  - `permissions.updatePlatform`, `deletePlatform`, `updateRolePlatform` and `removeMemberPlatform` let platform staff manage any organization. `options.assignmentGuard: "external"` drops the module's role guard, which now checks only client writes.
  - Profiles get `avatarPath`, `usernameFrom` takes several keys with a separator, and `readPolicy: { platform }` lets staff read every profile. `reserved-slugs` takes `slugs`, `minLength` and `maxLength`.
- The outbox (`better-supabase/blocks/outbox`) hands consumers the raw rows and moves failing events aside. Run `better-supabase sql upgrade`.

  - `outbox.consume(consumer, handler)` passes `OutboxEvent` rows (`OutboxHandler`), and `relayRoute` accepts such a handler.
  - A failing consumer backs off (`maxBackoff`), and after `maxAttempts` failures an event moves to `outbox.deadLetters(consumer)`. `purge` takes `{ ignoreIdle }`, and `createOutbox` takes a `BlockTransport`.
- Realtime topics support presence, write their own policies and build triggers from parent rows.

  - `defineTopic` takes `presence`, and subscriptions get `track`, `untrack`, `members()` and `onPresence`.
  - `realtime.policies: { from, output }` writes every topic's `realtime.messages` policies on `sql sync`, with a `--check` drift test.
  - `topic.triggerSql()` takes lookups through parent rows, an `event` name and a custom `payload` (`TriggerLookup`).
  - `realtime.users` gives live queries on a table a per-user topic, and `useBroadcast` works with a plain supabase-js `client`.
- **Breaking:** 0.6 removes the deprecated aliases 0.5 kept for one minor. A support token whose `act` has `session_id` but no `kind` is refused as `invalid-chain`. The audit module (version 5) drops the read-only `better_supabase.audit_log` view; read `better_supabase.audit_events`. `maxUrlLength` is gone from `defineSupabase` and `postgrestExecutor` (use `urlLengthLimit`), `scopes` from the MCP server options (use `advertisedScopes`), and `createInbox` and the `Inbox*` types from `better-supabase/blocks/jobs` (use `createWebhookInbox` and `WebhookInbox*`). `better-supabase codemod 0.6` now renames the `Kit` and `Org` exports and `maxUrlLength`, and lists imports from the moved subpaths for review.
- Repositories read with more filters and sorts, split long `in` lists instead of failing, and count pages cheaply by default.

  - `findOnly({ where })` returns the one matching row, `null` for none and `multiple_rows` (409) for more.
  - `where` takes json `path` filters (`JsonPathOps`), `match` and `imatch` regular expressions, and json containment with arrays. Reads longer than `urlLengthLimit` (6000) split along their longest `in` list.
  - `orderBy` sorts by a to-one relation's column, `aggregate()` sorts groups by `_count` or a measure (`AggregateOrderBy`), and `paginate({ offset, limit })` returns an offset window. `defineReadSet` takes `auth.uid`.
  - **Breaking:** list queries count with `count: "planned"` by default; pass `count: "exact"` for the old behaviour.
  - **Breaking:** cursors record the table and sort, so cursors made before 0.6 and arrays from `encodeCursor()` fail with `Invalid cursor`. Start again with `after: null`.
- Writes take conditions, return rows from bulk calls and cap how many rows they change.

  - `update(key, patch, { where })` returns `not_found` for a row that doesn't match, `expect` takes `where` operators, and `updateMany` and `deleteMany` take `returning: true`.
  - `updateMany` and `deleteMany` take `maxAffected`; a write matching more rows fails with `max_affected` and changes nothing. It needs PostgREST 13 (set `postgrestVersion: "12.2"` on older servers), and the rules plugin's `strict()` preset requires it.
  - Every call takes `timeout` and `retry`, `createMany` and `upsertMany` take `defaultToNull`, and mutations take `count: "planned"`.
  - **Breaking:** `deleteMany` and `updateMany` refuse a `where` that filters nothing. Pass `allowAll: true` to `updateMany` to update every row.
  - **Breaking:** an update whose `data` sets no columns returns `invalid_request` instead of `not_found`.
  - **Breaking:** a `*` in `like` and `ilike` patterns is a literal character; use `%`.
- `db.$rpc` is typed per overload, accepts `null` arguments and runs over the Postgres executor. Run `better-supabase gen`, then `better-supabase codemod 0.6`, which lists every `$rpc` call to review.

  - `gen` keeps every overload of a function as a union in `Functions`, and `$rpc` picks the overload by the argument names the call passes. Arguments are typed `T | null`.
  - `postgresExecutor` implements `rpc()`, so requests over a direct connection (an API key, a job) call functions too.
  - `rpcTransport` and the jobs block's `pgmq_public` backend accept a typed `SupabaseClient<Database>` without a cast.
  - **Breaking:** `gen` types function results as nullable: scalars, `setof` scalar elements, a single row and each `returns table` column are `| null`. Set `functions.<name>.notNull` in `better-supabase.config.ts` to `true` or to the columns that are never null.
  - **Breaking:** `$rpc` returns rows of a table and `returns table` records in the configured casing with the configured codecs, like repository reads, typed as the model row. Delete hand-written key mapping around those results, or pass `{ raw: true }` for what PostgREST sent.
- New SaaS blocks, each an SQL module with a subpath: `better-supabase/blocks/api-keys`, `/audit`, `/settings`, `/usage`, `/billing`, `/flags`, `/comments`, `/attachments`, `/data-lifecycle`, `/sso`, `/onboarding`, `/waitlist`, `/announcements` and `/push`. Add one with `better-supabase sql add <name>`; `stripe` is an optional peer that only billing and usage load.

  - SSO serves SCIM 2.0, flags evaluate rollouts in SQL and through an OpenFeature-shaped provider, and `SPEC_PINS` gains `ocsf`, `openfeature` and `scim`. Under the `provider` model, SSO and waitlist roles go through `roleThrough` and need `can_assign` (`SSO_ROLE_FORBIDDEN`, `WAITLIST_ROLE_FORBIDDEN`), and `join_waitlist` always answers `status: "waiting"` to clients.
  - Blocks take `ctx.postgres` from `@supabase/server`, `withBlock(key, create)` puts a block on the context, and every block takes `temporal` and a `problem` option (`ProblemFormat`) for error bodies.
  - New event types cover organization domains and deletion, `billing.*`, comments, attachments, data exports and `waitlist.approved`.
  - **Breaking:** the server's `auth.kind` gains `"apiKey"`, so an exhaustive `switch` over it needs the new case.
- `withBetterSupabase(server, options)` from `better-supabase/server` is one `@supabase/middleware` entry that resolves the caller and contributes the request's database handles, and the server gains session and account helpers. `better-supabase` depends on `@supabase/server` `^1.9.1`.

  - The entry reads a bearer token, then the session cookie, enforces `allow`, `aal` and `scopes`, and contributes `bs`, `db`, `sql`, `tenant` and the keys `withSupabase` writes. `server.context(request)` runs the same entries once per request. `createServer(betterSupabase, { db: { timeout, retry, urlLengthLimit } })` tunes its requests.
  - Session cookies support the `@supabase/ssr` 0.12 `tokens-only` encoding (`encode: "tokens-only"`, doctor BS412 for mismatches), and `cookieScopes` and `clearSessionAtScopes` expire cookies an earlier deploy set.
  - `sessionStatus` and `clearSessionCookies` run the ended-session check in any proxy. `endSessions(sql, userId)` and `suspendAccount(admin, sql, userId, { suspended })` end sessions and ban a user, and `deleteAccount` is exported on its own and returns the database's error, such as `ORGANIZATION_OWNER_REQUIRED`, with the `sql` option.
  - An early refresh that hits a network failure keeps the user until the token expires, and the `otel` plugin records `db.$rpc` calls as spans.
  - **Breaking:** the leaf entry `withBetterSupabase(betterSupabase)` that ran after `withSupabase` is renamed `withBetterDb(betterSupabase)`. Replace it, or drop `withSupabase` and use the new `withBetterSupabase(server)`.
- The settings block has a `platform` scope for product-wide settings, and feature flags can be managed from an admin page.

  - `defineSettings({ platform })` adds `client.platform`; each key names the permission that changes it and who reads it, and only listed keys can be written.
  - `createFlagAdmin({ transport })` lists, saves and deletes flags and sets overrides for staff with `flags.manage`. `tenant_ids_with_flag(key)` is the policy form of `flag_enabled`, `createFlagsProvider` works over the Data API, and `flagContext` reads a `memberships` claim.
- SQL modules call each other through shared helpers and declare the modules they work with, and fewer modules pull in others they only use when present. Run `better-supabase sql sync`.

  - `ModuleContext` gains `record`, `notify`, `enqueue`, `entitlements`, `broadcast`, `can` and `staff`. Each returns a statement or expression that is valid without the other module, so a module installs alone and calls the other one once it is installed.
  - `SqlModule.integrates` lists the optional modules a module works with. `ctx.installed` and `ctx.of` throw for a module it neither requires nor lists, `better-supabase sql list` prints "works with", and the blocks overview has the full matrix.
  - `inbox` no longer requires `jobs` or `streams`: without `jobs` it queues no bot or delivery jobs. `workflows` no longer requires `jobs`, and `ai-cache` no longer requires `tenant`.
  - `webhooks-in` requires `updated-at`, which its trigger uses.
  - `support.ended` records the session's tenant in the audit log and the outbox, like `support.started`.
  - `sql.modules.jobs.schema` is rejected: the jobs module always installs in `better_supabase`, so a schema only pointed the modules that call it at missing functions. Functions of the `access` module are always called in `better_supabase`, whatever `sql.modules.access.schema` sets for its tables.
- SQL modules can be called over the Data API without exposing their schema, and land their extensions and event triggers in migrations. Run `better-supabase sql sync` and create a migration for the new function bodies.

  - `sql.modules.<module>.api` writes `security invoker` entry points for the functions apps call into a schema, and `rpcTransport(supabase, { schema: "api" })` calls them there. Doctor BS312 points at the option.
  - `sql add`, `sync` and `upgrade` write the modules' extensions into a `<stamp>_better_supabase_extensions.sql` migration, and data files repeat extensions and event triggers so pg-delta plans need no hand edits (doctor BS321, BS323).
  - The `ensure-rls` module enables row level security on every new table outside the Supabase-managed schemas.
  - A write that breaks a `jsonb-schemas` schema fails with a `validation` error whose `issues` name the column.
  - `sql.modules.sessions.options.policies` writes a restrictive `bs_session_active` policy on every table, and doctor BS320 reports tables without one.
  - The `grants` module keys functions by signature, takes column privileges such as `"update(title, body)"`, and derives grants from permissive policies with `options.fromPolicies`.
  - Policies check tenant permissions once per statement, module functions pass `supabase db lint`, and module actions follow a renamed sibling key.
  - **Breaking:** `grants` writes the complete privilege set of each entry in `expose`, so a table loses privileges it doesn't list, including `truncate`, `references` and `trigger`, on the next sync.
- `defineBucket` covers what Storage added in supabase-js 2.117, takes several path layouts and reports bad paths as a `Result`.

  - `versioning` and `lifecycle`, applied with `bucket.apply(client)` and checked by doctor BS302. Connected buckets list `versions`, take a `versionId`, and have `removeVersions`, `purgeCache`, `copy` and `move`. `defineVectorBucket` and `defineAnalyticsBucket` declare the new bucket kinds.
  - `path: [current, older]` accepts several templates, and `{...rest}` matches any depth. `defineBuckets({ ... })` registers definitions by id for rows that store a bucket id (`byId`, `resolve`, `connectStored`).
  - Policies from `.sql()` use the name index, `sweep()` removes only objects matching every `within` value, and `replace()` checks `previous` against the tenant.
  - **Breaking:** a bucket definition's `path(values)` returns a `Result<StoragePath>` instead of throwing.
  - **Breaking:** a connected bucket's `publicUrl()` returns a `Result<string>`. `better-supabase codemod 0.6` lists both kinds of call.
- The tenant module keeps references inside one tenant and reads membership roles through a roles table.

  - `sql.modules.tenant.options.sameTenant` entries add a trigger that fails a write referencing another tenant's row with `TENANT_MISMATCH`, also through `match` and `through` columns.
  - `options.roleThrough: { table, id, column, where?, tenant? }` reads role names when memberships store role ids, taken from the provider's `roleSources` under the `provider` model. A `bs_role_scope` trigger refuses roles outside `where` on direct writes (`MEMBERSHIP_ROLE_SCOPE`), and doctor BS324 reports a shared roles table without `where`.
- `better-supabase/testing` checks Next.js navigations for instant renders and database budgets, and its kits check adapters and executors.

  - `expectInstant(page, options)` runs a navigation inside `@next/playwright`'s `instant()` (an optional peer) and checks what shows while the lock holds. `expectDbBudget(response, { maxCalls })` checks the `withDbStats()` header and waits `settleMs` for late responses.
  - `testAdapter`, `testExecutor` and `testPlugin` check custom adapters, executors and plugins against the first-party contracts.
- The usage block (`better-supabase/blocks/usage`) meters fractional quantities against quotas that follow the plan or billing cycle, and entitlements read from any plan catalog.

  - Quantities and limits are `numeric`, `recordMany` and `consumeMany` record several meters all or none, and a `null` limit is unlimited.
  - `options.meters` is a meter catalog in the config or a table, read by `usage.meters()`, `overview(organizationId)` and `current()`. `options.history` keeps each record, read by `history` and `breakdown`. Reading needs `usage.read` and recording `usage.record`.
  - The `billing` period follows `usage_billing_period(tenant)`, and `reportUsageToStripe({ overage: true })` sends only usage above the quota.
  - `entitlements.source` reads features from your plan tables or `"custom"`, with values through `entitlement_value`, and `entitlements.claim` sets the claim's shape (`EntitlementClaimOptions`).
- Vector search supports hybrid ranking, filters and scores. Run `better-supabase sql sync`.

  - `vectorSearch` entries take `type: "halfvec"`, `hybrid`, `boost`, `prefilter`, `predicate` and `order`.
  - `db.$search` takes `filter`, `text` and `score: true`; on a `hybrid` entry `text` alone ranks by full text when the embedding call fails.
  - `options.schema` sets pgvector's schema, which `sql sync` otherwise reads from your `create extension` statement.
- **Breaking:** `createInbox` in `better-supabase/blocks/jobs` is now `createWebhookInbox`, and its types are `WebhookInbox*`; the old names are removed. `better-supabase/blocks/inbox` has a different `createInbox` for the conversation inbox. Run `better-supabase sql upgrade`.

  - Messages record a tenant and dedupe per source and tenant, and `message.checkpoint(fields)` saves progress a retry resumes from.
  - `store(event)` stores an event an SDK already verified, and a source without secrets is store-only.
  - Sources take `maxAttempts`, `process` takes `budgetMs` and renews leases, and bodies over `maxBodyBytes` (1 MiB) get a 413.
- The `webhooks-in` module and `createIncomingWebhooks` in `better-supabase/blocks/webhooks` give each tenant trigger URLs with hashed tokens, and `createSafeFetch` calls URLs that users supply.

  - Endpoints verify Standard Webhooks, HMAC-SHA256 or a shared secret, with size and rate limits, keep secrets in Vault, and hand deliveries to the webhook inbox. `update`, `rotate` and `rotateSecret(id, { grace })` change them, and `options.subjects` attaches them to a record.
  - `verifyWebhook` and incoming endpoints accept Svix's `svix-*` headers.
  - `createSafeFetch(options)` allows public HTTPS URLs only, checks every redirect, drops credentials on a redirect to another origin, and throws `UnsafeUrlError` for a refused URL and `UrlCheckError` for a failed check such as DNS.
- The `workflows` and `workflow-builder` SQL modules record durable workflow runs and store graph workflows per tenant.

  - `better-supabase/blocks/workflows` records the runs of any engine in `workflow_runs` with cron schedules, semaphores and admission control, and `useWorkflowRuns` and `useWorkflowRun` in `/blocks/workflows/react` read them.
  - `better-supabase/workflow-sdk/world` is a Workflow SDK World on Supabase (Node only, `createWorld()`, the `workflow-sdk-world` module), and `better-supabase/workflow-sdk` adds `startFor`, `workflowStarter`, `startOnEvent`, `authorizeHook`, `hookMetadata` and `protectWebHandler`.
  - `better-supabase/blocks/workflow-builder` stores definitions, checked versions, triggers, credentials by `credential_ref`, a step library, node status and alerts, with `useWorkflowBuilder` and `useWorkflowCanvasRun`. `better-supabase/workflow-sdk/builder` adds `compileGraph`, `graphStarter` and `nodeRunReporter`. The new permissions are `workflow.edit` and `workflow.publish`.
  - Only the service role stores a version's compiled form (`createBuilder` writes it through `service`), and `graphStarter` compiles from the graph on every start. Node ids are 1 to 100 letters, digits, `_` or `-`.
  - The World refuses unsigned poll deliveries outside development and test, fails closed on a Vault read error, aborts a delivery after `deliveryTimeout` (300 s), and mirrors a run's tenant only when its actor holds `workflow.run` there.
- `verify_api_key` refuses a personal key while its user is banned in Supabase Auth (`banned_until` in the future) or soft-deleted. Before, only the configured disabled column stopped a personal key, so a banned user's key kept working after their sessions ended.
- The `data-lifecycle` module (version 5) exports and purges more module tables, leaves credentials and audit snapshots out of exports, and the organization purger revokes credential refs before the purge. Run `better-supabase sql sync` to get the new version.

  - Platform role assignments, outbox events, webhook secrets (purged, never exported), inbox tables, flag overrides and SCIM users per user, waitlist redemptions, AI provider keys, connector servers and grants, and workflow credentials join exports and purges.
  - Exports leave out every `credential_ref` column, and audit events lose `old_record`, `new_record` and the impersonation columns.
  - `createOrganizationPurger({ credentials })` revokes the `credential_ref` of each row the purge deletes through the `CredentialProvider` (connector grants for their user), only when the ref carries the organization. The rest come back in `purge.credentials.unrevoked` with a reason (`foreign`, `no_provider` or `not_revocable`), and a failed revoke stops the purge before it deletes anything.
  - The purger now checks that the deletion is due (`organization_credential_refs`) before it cancels billing or clears Storage.

## 0.5.1 (2026-10-04) [#051-2026-10-04]

- Breaking (types): `SessionActor` is a union on `kind`: `oauth-client` (with `chain`), `support` (with `sessionId`, `readOnly` and `reason`) and `impersonation` (with `reason`), and `Impersonator` gains `kind`. An exhaustive `switch` on `session.actor.kind` needs the two new cases.

  Support sessions and impersonated sessions mark their `act` claim: `supportClaims` writes `act.kind: "support"` and `actingAs` writes `act.kind: "impersonation"`. `actorOf` reads them as their own actor kinds instead of OAuth clients, `session.impersonator` follows `actorOf`, so an OAuth client or agent chain is no longer shown as an impersonator, and `session.delegation` and the `scopes` guard apply to `oauth-client` actors only. An `act` with another `kind`, or a support level without `session_id`, makes the session invalid (`reason: 'actor'`). A support token minted by 0.5.0 (`session_id` without `kind`) still counts as a support session until 0.6. `supabaseClaimFixtures` from `better-supabase/testing` holds a support session (writable and read-only), an impersonated session, an OAuth client and an agent chain, each valid against PermDock's `supabase-claims-v1.json`.
- Breaking: `allow: ['anon']` no longer admits users from `signInAnonymously()`. Only `'anonymous'` admits anonymous sign-ins, and `'anon'` means a caller without a session. `['anonymous']` alone now admits anonymous sign-ins and refuses other users; before, it admitted nobody. A route that serves guests next to signed-in users lists `['user', 'anonymous']`, and a public route that also serves guests `['user', 'anonymous', 'anon']`. With PermDock, pair the default `allow` with `rls: { anonymousSignIns: 'deny' }` in `permdock.config.ts`.
- The entitlements module in PermDock mode reads `entitlement_members()` from the manifest's `rls.memberships` entries for its scope, the tables PermDock's `member_<scope>_ids_for` reads, and falls back to the hook's membership sources only for a manifest without `rls.memberships`. Doctor (BS408) warns when `rls.memberships` maps no table to the scope, and notes the fallback.
- The `permdock` access model reads PermDock's manifest itself. `better-supabase sql add` takes the helpers' schema from `rls.schema`, the scope from the root `rls.scopes` entry (the one without `within`) and the id type from that scope's `type`, whatever `entitlements.permdock` says, so a manifest with `rls.schema: "authz"` and a root scope `tenant` gets `authz.permitted_tenant_ids`. It stops instead of guessing when the manifest is missing, has no single root scope, or disagrees with `kits.access.permdock.scope`, `kits.access.permdock.schema` or `kits.access.idType`; 0.5.0 fell back to `permdock.permitted_organization_ids` and `uuid`. Code that renders the kit without the CLI (`renderKit`, `moduleBody`) now sets both `kits.access.permdock.schema` and `kits.access.permdock.scope`. Doctor reports the same problems, and helpers the manifest or the database lacks, as the new BS411 error.
- Under the `permdock` access model, `can_user()` and `member_can()` raise SQLSTATE `0A000` (hint `ACCESS_CALLER_ONLY`) for anyone but the caller instead of returning `null`, because PermDock's helpers read `auth.uid()`. The invitations module no longer re-checks the inviter's permission when an invitation is accepted, and the notifications module no longer filters recipients by their read permission, under that model; in 0.5.0 the re-check passed silently on the `null` answer, and the filter dropped every recipient but the sender. Check both in the app when they matter. Run `better-supabase sql add` to rewrite the installed files.
- The access kit docs, the `canAssign` JSDoc and the `can_assign()` SQL comment recommend PermDock's assignment rule for the `permdock` model: `canAssign: "permdock.permdock_can_assign({role}, {tenant}::text)"`, with the manifest's `rls.schema`. This is the assignment rule the 0.5.0 note "the `permdock` and `custom` access models need an assignment rule" refers to; see [the access kit](https://bettersupabase.com/docs/kits/access#permdock). Without it only the service role assigns roles, since PermDock projects don't install the `tenant` module, and doctor (BS411) warns.
- Under the `permdock` access model, `better-supabase sql add` and doctor (BS411) check every permission key the kit modules check against `permissions.catalog.json`, platform keys for `permdock_has` included. A key must be `rowConditions: false`, because PermDock's helpers check role and scope only; a key with row conditions, without the flag or missing from the catalog stops `sql add`. `kitPermissionKeys(kits, names)` from `better-supabase/sql` lists the keys with their module, action and scope, and `kitFilePaths(names, layout)` lists the files a set of modules writes without rendering them, so `sql list` works while the access settings are incomplete.
- `better-supabase gen` uses `@supabase/postgrest-typegen` 0.4.0. `database.types.ts` gains a `ComputedFields` key on every table and view: the names of its computed fields, or `never`. postgrest-js reads it to leave computed fields out of `select('*')`, and row-typed function arguments no longer include them. Run `better-supabase gen` to regenerate. Supabase CLI 2.119 doesn't write `ComputedFields` yet, so until it does, `supabase gen types` output differs from `gen` by that key.
- The optional `oxfmt` peer accepts any version from 0.66.0 below 1.0, so `pnpm add -D oxfmt` installs the latest. The runtime dependencies are ranges instead of exact versions: `@supabase/postgrest-js ^2.116.0` (the floor of the `@supabase/supabase-js` peer), `@supabase/ssr ^0.12.7`, `@supabase/server ^1.9.0`, `@supabase/middleware ^1.0.0` and `@standard-schema/spec ^1.1.0`, so an app shares one copy of each with its own Supabase packages. `@supabase/postgrest-typegen` stays an exact version.

  The `canAssign` JSDoc no longer says it defaults to true. The `custom` access model requires it. Without it, the `permdock` model lets only owners assign the owner role when the `tenant` module is installed, and lets only the service role assign roles when it isn't. The access kit docs now say the same, and their `custom` example sets `canAssign`, which that model requires.

## 0.5.0 (2026-10-04) [#050-2026-10-04]

- Guards refuse anonymous users (`signInAnonymously()`, the `is_anonymous` claim) with a 403 `ANONYMOUS_USER` unless `allow` lists `'anonymous'` or `'anon'`, `toSession()` adds `anonymous`, and the `realtime-tables` kit module sends anonymous users no change signals. Before 1.0 this minor release is the breaking slot: routes that served guest sessions need `allow: ['user', 'anonymous']`.

  The framework adapters agree with each other. `ctx.apply(response)` adds refreshed session cookies and, after a write, `bs-primary-until`, so Hono, edge and the new oRPC `bs.fetchHandler(handler)` keep read-your-writes like Next.js does. MCP reads the session from the `Authorization` header only, answers an unreachable JWKS with a 503 instead of a scope challenge, sends a missing second factor as a plain 403, takes `advertisedScopes` (with `scopes` as a deprecated alias) and enforces `requiredScopes`. REST resources refuse cross-site form posts that ride on the session cookie (`CROSS_SITE_REQUEST`) and answer malformed keys and bodies with `invalid_input`, whose detail survives in production. Hono's `bs.onError` returns an `HTTPException`'s own response, Next.js route handlers turn unexpected throws into a 500 Problem Details response while `redirect()` and `notFound()` still work, and `contextForSession` requires the token's auth kind to match the session. `createServer` takes `fetch` for its PostgREST and supabase-js clients, the `auth` event carries `reason` and `rawSource`, the `react-server` build exports `hasEntitlement` and `useLiveCount`, and every adapter builds the user actor, impersonator included, from one function.
- The `audit` SQL kit module can adopt an existing audit table (`kits.audit.tables` and `columns`), and `audit()` takes `redact`, `category`, `event_prefix`, `target_type` and `tenant_column`. The new `audit_event` function records events that are not row changes, with an idempotency key. The options `appendOnly`, `readPolicy`, `impersonators`, `restricted`, `eventRoles`, `eventCategory` and `eventSource` add an append-only guard, a tenant read policy, hidden impersonation columns and a separate table for sensitive details. `purge_audit_log` honours an `audit_retention(tenant)` SQL hook, and `purgeAuditLog` from `better-supabase/jobs` takes a per-tenant retention callback. Run `better-supabase sql upgrade` to move from version 1.
- `better-supabase/hono` adds `bs.app()`, a `Hono` app typed with the adapter's `Env` and with `bs.onError` installed, and the type-only `bs.Env` for apps that build their own. After `@supabase/server`'s `withSupabase`, `bs.middleware()` reuses its verification of the bearer token when the stored claims are that token's payload, then runs its own claims, `act` and `userMetadata` checks.

  `clearOnUserChange(queryClient, auth)` in `better-supabase/query` removes the `["bs"]` queries when the signed-in user changes, for apps without React; `BetterSupabaseProvider` uses it.

  A bucket with `tenant` now checks paths on the client too: `upload`, `download`, signing, `remove`, `reserve` and `list` refuse a path in another tenant's segment, or any path when the connection has no tenant, with a `forbidden` error before calling Storage. Pass `{ context }` or `{ tenant }` to `connect()`, or `{ allTenants: true }` for cross-tenant admin work; `deleteAccount` does the latter. `client.path(target)` returns the checked path.

  Jobs record the enqueuing request's actor and tenant: `enqueue(queue, payload, { context })` and `schedule(..., { context })` store them next to the payload, and the handler gets them as `job.context`, ready for `db.$with(job.context)`. A job without a tenant gets the `tenant()` plugin's `onMissing` unless the worker runs with `allTenants: true`.

  `actor()` fills an `impersonatedBy` column (generated from `impersonated_by`, the column the SQL kit's `track_actor` stamps) from the impersonating admin, and clears it on writes without one.

  Event sink sends are tracked on `betterSupabase.events` (`pending`, `settled()`). Next.js routes and actions hand them to `after()`, and edge handlers to `waitUntil`: the Workers `ctx`, or `createEdge(..., { waitUntil })` on Supabase.

  The PermDock guide has oRPC and Hono recipes that refuse a procedure or route without a permission.
- Jobs run without pgmq or pg_cron when you want. `kits.jobs.options.backend: "table"` stores jobs in `better_supabase.job_messages` and claims them with `for update skip locked`, and `kits.jobs.options.scheduler: "drain"` stores schedules with a time zone each. `jobs.drainRoute({ secret, handlers })` is a route for Vercel Cron that enqueues due schedules and drains queues within a time budget, and `drain` takes a `budgetMs`. `schedule` takes `{ timeZone }` and validates the cron expression first. `createJobs` accepts any `QueueBackend` (API version 1), with `sqlQueueBackend`, `pgmqPublicBackend` and the `testQueueBackend` conformance kit. The `jobs` module is now version 2: `better-supabase sql upgrade` drops the old four-argument `schedule_job`.
- The SQL kit's rows and role settings, which a schema diff can't capture, move to `supabase/better-supabase-data`, and the new `better-supabase sql data` writes them into a migration stamped after the newest one. `rate-limit` sets `pgrst.db_pre_request` that way, and doctor BS313 warns when the live database never calls `check_request()`. `sql upgrade` writes next to the located `config.toml`, and each module rejects options it doesn't declare.

  Breaking: `entitlements` no longer defaults `entitlements.customer` to `organizations.stripe_customer_id`. It reads the managed `organizations` module's column when that module is installed; otherwise `sql add` stops until you set it. `tenant_ids_with_entitlement(key)` gives policies a set check.

  Jobs enforce max attempts at claim, retry with full jitter and replay dead letters (`replay_dead_job`, `jobs.replay`). The outbox orders by `(xid, position)` and gives events uuid ids. Outgoing webhooks take a lease token, follow the Svix retry schedule with `Retry-After`, and disable a destination after failing for `disableAfter`. Notifications back off between deliveries and fail them after `max_attempts`. Key indexes lead with the key, webhook policies read `tenant_ids_with()` once per statement, `jsonb-schemas` adds its checks `not valid` and validates them separately, and `purge_rate_limits()`, `purge_webhook_deliveries()` and `purge_notifications()` join the other purges.
- Kits share one set of extension points: typed events, policy callbacks, SQL hooks and fixed attribute names.

  - `betterSupabase.on("kit", handler)` receives `support.*`, `org.*`, `invitation.*`, `notification.*` and `webhook.*` events with a copy of their data. `onKitEvent(betterSupabase, "support.*", handler)` from `better-supabase/events` filters by type or prefix and types the data.
  - `forwardKitEvents()` and `kitCloudEvent()` send kit events to an `EventSink` as CloudEvents, and `traceKitEvents()` from `better-supabase/otel` records them with the `KIT_ATTRIBUTES` names (`better_supabase.org.id`, `better_supabase.support.session_id`, ...).
  - Policy callbacks (`Policy`, `PolicyDecision`) fail closed: only `true` allows, and a throw or rejection denies.
  - `kits.<module>.hooks` sets where a module looks for the app's `before_*` and `after_*` SQL functions, and `kits.<module>.events: false` stops it writing its events to the outbox.
- Breaking: the SQL kit fails closed. `current_tenant_id()` returns a tenant only while the caller is a member, the claim is cleared when a member is removed, and `kits.access.activeTenant` defaults to `'resolver'` (the tenant `ServerOptions.tenant` resolved, then the claim). Disabled organizations deny access, the `permdock` and `custom` access models need an assignment rule, and only an owner can transfer ownership. A member can never raise their own role or assign one above it.

  Platform invitations move to `platform_invitations` under a role ceiling that accept checks again, accept checks that the inviter can still assign the role, and `valid_for` is capped by `maxValidFor`. Support sessions refuse platform targets and writes by default and allow one active session per admin; a token with an `act` claim has no platform permissions.

  Doctor BS312 reports a kit schema listed in `[api] schemas`. `set_actor` keeps `created_by` on updates, `track_realtime` refuses a table without the tenant column, the rate limit and `request_ip()` use the right-most forwarded hop, audit is append-only by default, idempotency keys are scoped to the caller, public buckets get no select policy, members see only public profile columns, and outgoing webhooks follow no redirects, cap the response body and use 32-byte secrets.
- Breaking: the SQL kit follows one naming standard. Run `better-supabase sql upgrade`, then `sql sync` and `sql data`; the [0.4 to 0.5 guide](https://bettersupabase.com/docs/migration/0.4-to-0.5) lists every step. The forward steps rename `memberships.org_id` and `invitations.org_id` to `organization_id`, `better_supabase.audit_log` to `audit_events` (with `occurred_at` and `organization_id`, and a read-only `audit_log` view until 0.6), and `audit_trigger()` to `audit_row_change()`. Doctor reports the old column names in your SQL (BS309), also when a column is unqualified next to its table.

  The modules new in this release use the same names: `webhook_endpoints` with `event_types` and the statuses `pending`, `delivering`, `succeeded`, `retrying`, `dead` and `canceled`; notifications with `type`, `data`, `actor_id` and `user_id`, and `types` instead of `kinds` in `createNotifications`. Permission keys follow `<area>.<verb>`, and managed tables get `updated_at` triggers and indexes on their foreign keys.

  Breaking: `toCloudEvents` and `kitCloudEvent` put the actor in `data.actorId` instead of the `actorid` context attribute, so user ids stay out of broker headers. The outbox relay prefixes event types with `dev.better-supabase`, and `webhooks.sink()` removes the prefix again.
- The new `sessions` SQL module adds `better_supabase.session_active()` for restrictive policies: it is false once the token's session was revoked or expired, or the user is banned or deleted.

  Options that only exist to match an existing schema are accepted in `mode: "adopt"` only: `tokenStorage: "plain"` for invitations, `secretStorage: "column"` and non-`text` `eventIdType` or `runIdType` for webhooks-out, and `kitSource` or `defaultSource` for the outbox. Doctor BS314 warns about each until you remove it. `better-supabase/sql` exports `migrationOptionUses(kits)` to list them. The `assignmentCeiling` and `triggerPrefix` options, the invitations `errorCodes` option and `kits.access.disabled.tenantKey` are removed.
- SQL kit modules now fit existing schemas. `kits.<module>` in the config sets a module's mode (`managed`, `adopt` over your own tables, or `custom` where you write the contract functions), its schema, table and column names, tenant id type and permission keys. Each file's header records the module version and mode, and schema files record them in `better_supabase.kit_modules`. Doctor BS307 checks the functions of custom-mode modules, and BS304 points hand edits at `kits` instead.

  The new `access` module gives policies and kit modules one permission check, `can(scope, id, permission)`, with `tenant_ids_with()`, `is_platform()`, `can_user()`, `can_assign()` and `permission_claims()`. `kits.access.model` picks a role list from the config, role and permission tables with per-tenant overrides, PermDock or your own functions, and `kits.access.disabled` switches off tenants and users with a `disabled_at` column. The `tenant` module (version 2) takes its role names from `kits.access.roles`, adds `memberships.last_used_at` and `org_member_role()`, writes the memberships claim as an array or a map (`options.claimFormat`), and reads the active tenant from `kits.access.activeTenant`: a resolver (the default), the claim or a profile column.

  For resolvers, `createServer` takes `tenant: (request, auth) => id`, and `context()` takes `{ tenant }`. The tenant becomes `context.tenant`, the `better_supabase.tenant` setting over Postgres and the `x-bs-tenant` header (`TENANT_HEADER`) over the Data API. `postgres.asUser`, `executorFor` and `transaction` take `settings` and `readOnly`. Doctor BS308, with `--as <user id>`, warns when the access token hook writes no tenant claim while the claim is the active tenant.

  `better-supabase/config` exports the kit config types (`KitsConfig`, `KitModuleConfig`, `KitMode`, `AccessKitConfig` and `ActiveTenantSource`) for apps that build their `kits` config in a separate module.
- SQL kit modules have versions and an upgrade path, and a codemod rewrites renamed APIs.

  - `better-supabase sql upgrade` reads each module's `@bs-kit` version (a file without one is version 1), writes the forward steps into `supabase/migrations/<timestamp>_better_supabase_kit_upgrade.sql` and rewrites the module files. `--check` exits 1 when a module is behind or a file is stale.
  - Deprecated kit functions keep a wrapper under the old name until they are removed. `upgradePlan()`, `kitDeprecations()` and `moduleVersion()` are exported from `better-supabase/sql`.
  - `track_updated_at()` and `audit()` warn about an existing trigger that does the same work, and drop it with `replace_trigger => true`.
  - Doctor reports deprecated or removed kit symbols in schema files and policies (BS309), a kit trigger next to an equivalent one (BS310), and a module behind its version on disk or in `better_supabase.kit_modules` (BS311). BS304 skips files BS311 reports.
  - `better-supabase codemod <version>` rewrites imports, members, JSX props and call options for renamed APIs (`0.4`, and `0.5` for `createMcp`'s `scopes`), skipping strings and comments, and lists the lines it leaves for review. `--dry-run` prints the diff.
- Add the `notifications` SQL kit module and `better-supabase/notifications`. `notify(jsonb)` is a `security definer` sender that checks `notifications.send`, leaves out the actor and non-members, and applies subject subscriptions and per-channel preferences. `createNotifications` sends with typed data and reads, counts, marks, dismisses and resolves notifications; `deliver()` sends email, push and other channels through `NotificationChannel` implementations with leases and retries. `useNotifications` from `better-supabase/react` keeps a list current over a private Realtime topic. Existing tables work with `mode: 'adopt'`, and the topic and event names are configurable. `OrgsTransport` is now `KitTransport`, exported from both `better-supabase/orgs` and `better-supabase/notifications`.
- Organizations and invitations. The new `organizations` SQL kit module creates, updates and deletes organizations (hard or soft), changes member roles, removes members, transfers ownership and switches the active organization through `kits.access.activeTenant`. A deferred trigger keeps an owner in every organization, and a trigger stops members from raising their own role or assigning one above it. The `invitations` module moves to version 2: `invite_member` returns the invitation with its token, invitations can be resent, revoked, declined and previewed without a session, can carry `prefill` data and can target the platform under the catalog access model, and accepting re-checks that the inviter may still invite. `create_invitation` keeps its 0.4 signature. Both modules check permissions through the access contract, so `invitations` now needs `access`, and both support `mode: 'adopt'` with options for token storage, slug rules, error SQLSTATEs and extra columns. The new `better-supabase/orgs` subpath calls them with `createOrgs`, over Postgres (`sqlTransport`) or the Data API (`rpcTransport`), with `canInvite` and `onInvite` callbacks and `org.*` and `invitation.*` kit events, including the new `org.updated` and `org.deleted`. The `tenant` module now installs on a fresh database under `kits.access.model: 'catalog'`.
- Transactional outbox. The new `outbox` SQL kit module stores events with `emit_event(type, payload, subject, tenant, key, source)` in the writing transaction, deduplicates by key per tenant, adds `track_events(table)` row triggers and keeps events for `outbox_history` until `purge_outbox`. Named consumers read in order with their own cursor and a lease, and a claim never skips an event whose transaction is still open. The `organizations` and `support-sessions` modules write their events to it once it's installed. `createOutbox` in `better-supabase/jobs` emits, registers consumers, relays events as CloudEvents to any `EventSink` and serves `relayRoute` for a cron caller. The module supports `mode: 'adopt'` for an existing events table.

  The `defaultSource` option fills the source of `emit_event` calls that pass none, and `kitSource` sets the source kit modules write (`better-supabase/{module}` by default), so an adopted events table with a check on its source column keeps working. Adopt mode still creates the consumers table, since an existing app has no cursors yet.
- The PermDock integration fails closed in more places. Buckets and topics in `permdock` mode now call PermDock's helpers in `permdock`, PermDock's default `rls.schema`, instead of `public`; set `schema: 'public'` on the policy if your PermDock config writes them there. `gen` and doctor BS214 refuse PermDock bucket policies when `permissions.catalog.json` is missing, and the catalog must be version 1. `sql add entitlements` refuses a PermDock project without a manifest or `rls` block instead of falling back to the tenant module (set `entitlements.permdock: false` for that). BS214 also reports a helper schema other than the manifest's, a scope the manifest doesn't declare and a key checked at another scope than its catalog entry. BS408 checks that PermDock's hook fills `claims.features` from `better_supabase.feature_claims` and that a membership source covers the scope. The new BS409 reports a `claims.tenant` that differs from the manifest's `rls.tenantClaim`, PermDock markers other than v1 and a `claims.scope` that isn't the root scope. Scope id types `integer`, `int8` and other aliases are accepted, and MCP table tools take a permission per operation from the resource's `meta`.
- Plugins compose without holes. `timestamps()`, `actor()` and `softDelete()` refuse caller-supplied values for the columns they fill unless the call passes `{ override: true }`, and generated JSON Schema and OpenAPI documents mark those columns `readOnly`. On tenant tables, an upsert that updates on conflict needs the tenant column in its conflict target, the SQL executor guards the update with `where <tenant> = excluded.<tenant>`, a query whose includes reach a tenant table fails closed without a tenant, and numeric tenant columns compare by their text. An upsert that updates a soft-deleted row restores it.

  `rules()` runs before every other plugin. `noUnboundedFindMany` skips aggregates, `maxLimit` ignores `paginate()`'s look-ahead row, `requireTenantContext` reads the same claim paths as `tenant()`, and `noSensitiveSelect` checks reads only, because writes without a `select` no longer return sensitive columns. Offset `paginate()` orders by the primary key by default. `validation()` decodes codec columns (Temporal values, `bigint`) before validating and encodes them afterwards.

  Mutation events give hooks and listeners a copy of the rows, so they can't change the result (`testPlugin` checks this), and carry `intent` (`softDelete` for a delete that became an update), the affected primary `keys` when they are known, and the `tenant` that `tenant()` resolved. CloudEvents and cache invalidation use them, so soft deletes send `row.softdeleted` events and invalidate their rows. `defineReadSet` warns when query plugins scope tables its generated function reads. The audit kit keys entries by each table's primary key and reads the tenant column from `plugins.tenant.column`, and `gen` refuses a soft-delete column that isn't a timestamp.
- Profiles and image buckets. The new `profiles` SQL kit module creates a profile on sign-up from auth metadata (full, first and last name, avatar), allocates a unique username, mirrors `auth.users.email`, grants updates only on the columns users own and refuses changes to service-owned columns (`PROFILE_COLUMN_READONLY`). It supports `mode: 'adopt'` for an existing table keyed by any column, an `after_profile_sync` hook, extra columns, a read policy for people in the same organizations and `backfill_profiles()`. `better-supabase/storage` adds `avatarBucket()` and `orgLogoBucket()` presets and a bucket policy, `{ access: { read, write } }`, that checks permissions through the SQL kit's access contract.
- Support mode: platform admins can view the app as a user. The new `support-sessions` SQL kit module records sessions in a table (or an adopted one), checks `is_platform('support.start')`, writes `support.started` and `support.ended` to the audit log and can call your access token hook for the target's claims. `createServer` takes `support: supportSessions({ store, authorize, claims, policy, cookie })`, which apps without support mode never bundle; while the `bs-support` cookie names a running session, contexts, sessions, actions and routes run as the target over Postgres, read-only by default, with an `act` claim that carries the session id. Next.js gets `bs.startSupport()`, `bs.stopSupport()` and `supportTag()`, React gets `useSupportSession()`, and `better-supabase/testing` gets `testSupportSessionStore`. The audit module records the session in a new `support_session_id` column; an adopted table maps `supportSession` to `null` when it has none.
- Values from Postgres and the peer libraries no longer break responses. JSON responses from the server adapters and MCP write `bigint` values as decimal strings. A `timestamptz` or `timestamp` holding `infinity` comes back as an `invalid_value` error (status 500, with `column`) instead of throwing, because Temporal has no infinite value. Temporal values from another realm or a second polyfill copy are recognized by their `Symbol.toStringTag`, and Standard Schema issues with symbol path keys keep the error serializable.

  `loadEnv()` reads inline keys from `SUPABASE_JWKS`, as `@supabase/server` does, and verifies with them without fetching `jwksUrl`. The OpenTelemetry plugin writes `db.namespace` as `{database}|{schema}` (`postgres|public` by default, `otel({ database })` to change it), adds `server.address` and `server.port` from `otel({ server })` to spans and metrics, and sets `db.response.status_code` only for SQLSTATE codes. `BetterQueryMeta` builds on the app's `Register['queryMeta']`, and `send_email`'s `email_data` accepts fields Auth adds later.

  Doctor's BS410 reports HTTP auth hooks and checks their `v1,whsec_` secrets, and its custom access token hook event carries `iss` and `amr`. The peer ranges now state what the code needs: `pg >=8.15 <9`, `@tanstack/query-core ^5.62.0`, `@orpc/server >=2.0.0-beta.40 <3`, `hono <5`, `next <17`, `@opentelemetry/api <2`, and `oxfmt 0.66.0`, the version `@supabase/postgrest-typegen` pins; the `gen` notice and docs show how to allow a newer oxfmt.
- Add the `webhooks-out` SQL kit module and outgoing webhooks in `better-supabase/webhooks`. Destinations subscribe to event types (exact, `*` or `prefix.*`), secrets live in Supabase Vault (or a column) and rotate with an overlap, and every delivery is logged with its status, response and duration. `createWebhooks` publishes events idempotently, dispatches to one destination, and `deliver()` or `deliverRoute()` sends due deliveries with leases, retries with backoff, dead letters, redelivery and auto-disable after repeated dead letters. Requests are signed with Standard Webhooks by default or with `hmacSigner` for an existing format, and `publicUrl()` rejects private and local addresses, also on each redirect. `WebhookSigner`, `WebhookTransport` and `WebhookSecretStore` are versioned extension interfaces with conformance kits in `better-supabase/testing`. RLS policies in the `notifications` module now use `better_supabase.can`, which clients may execute, instead of `member_can`.
- The docs and skills follow the Naming page's file layout everywhere. The PermDock page defines `betterSupabase` in `src/lib/supabase/index.ts` and creates `bs` in `server.ts` and `client.ts`, and every Next.js `server.ts` starts with `import "server-only"`. The docs drift check now fails a code block that breaks the layout.
- Doctor BS404 and `doctor --fix-grants` follow pg-delta for PermDock's hook. With `[experimental.pgdelta] enabled = true`, they point to `permdock supabase hook generate --out <schema file>` without `--grants-out`, then `supabase db schema declarative sync`, instead of a grants migration. A hook whose grants are in the declarative schema file that defines it now counts as granted under pg-delta, and the finding for a file that already grants the hook names `supabase db schema declarative sync` before `supabase migration up`.
- Doctor BS408 checks exactly the PermDock helpers the `entitlements` module calls for the chosen scope, and no longer claims PermDock writes `member_<scope>_ids_for` for every scope. It warns when the manifest lacks `member_<scope>_ids` or doesn't let `authenticated` execute it, when it lacks `member_<scope>_ids_for` (add a membership source for the scope in `permdock.config.ts`), and when `supabase_auth_admin` may not execute `member_<scope>_ids_for` (add `supabase.hook.claims: { features: 'better_supabase.feature_claims' }`).
- The PermDock MCP recipe in the docs and the auth skill fails closed: a tool without a PermDock permission in `meta` is hidden (`visible` returns `false`) and refused (`authorize` returns `{ allowed: false }`). The page also shows PermDock's `decide` for putting the denial reason in the refusal.
- The `entitlements` kit module takes its PermDock scope from the manifest. Without `entitlements.permdock.scope`, it now uses the manifest's root scope (the `rls.scopes` entry without `within`) instead of `organization`, so a PermDock project whose root scope is `tenant` works without config. A manifest with no root scope or several stops `sql add entitlements` and doctor BS408 asks for `entitlements.permdock: { scope }`. An explicit `scope` still overrides, `false` still opts out, and a scope the manifest doesn't list is still reported by BS408. `permdockKeyStatus` and the `PermdockCatalog` and `PermdockKeyStatus` types are exported from `better-supabase/sql`.
- In PermDock mode, the `entitlements` kit module renders the tenant argument of `has_entitlement`, `tenant_entitlements` and `tenant_stripe_customer`, and the rows of `stripe_customer_tenants`, with the scope's id type from the manifest's `rls.scopes` (`uuid`, `text` or `bigint`) instead of always `uuid`. A missing or other type stops `sql add entitlements` and doctor BS408 reports it. `better-supabase/sql` exports `KIT_ID_TYPES`, `isKitIdType` and the `KitIdType` type, and `KitPermdock` has a required `idType`.
- The auth skill's PermDock caching recipe works as written. It passes only static tags to `bs.cached()`, then calls `cacheTag(snapshotTag(session.user.id))` and `cacheLife(cacheLifeFor(snapshot))` inside the loader once the snapshot is built, because the user id and the snapshot don't exist before `bs.cached()` returns. It pairs the recipe with `bs.invalidateSession(userId, { tags: [snapshotTag(userId)] })`.
- PermDock's catalog is read fail closed. `defineBucket` and `defineTopic` with `catalog` accept only keys marked `rowConditions: false`: a key whose entry has no boolean `rowConditions`, or that the catalog doesn't list, now throws with a message to regenerate the catalog with a current `permdock catalog`. Doctor BS214 reports those keys as errors, and `better-supabase gen` refuses to write their bucket policies.

## 0.4.0 (2026-10-03) [#040-2026-10-03]

- The CLI ships inside `better-supabase` again. Installing `better-supabase` gives you the `better-supabase` command, so drop `@better-supabase/cli` from your dev dependencies (it was never published) and run `npx better-supabase init` in a new project. The commands and their options are unchanged. The CLI's own dependencies are bundled into the package, so apps install nothing extra; `pg` stays an optional peer that the CLI needs to read your database.

  Import `run`, `registerCommand`, `defineCliCommand` and the introspection helpers from `better-supabase/cli`. `@supabase/config` is an optional peer again, for reading `supabase/config.toml`.

  `VERSION`, `better-supabase --version`, doctor reports and the header of newly written SQL kit files now show the package version instead of `0.0.0`. Existing kit files are not reported as changed, because the comparison ignores that version.
- Names are now the same in every adapter. The definition from `defineSupabase` is `betterSupabase`, and every runtime instance an adapter creates is `bs`. They live in `lib/supabase/index.ts`, `lib/supabase/server.ts` (with `import "server-only"` in Next.js) and `lib/supabase/client.ts`, which is what `better-supabase init` now writes. The old names have no aliases; the compiler points out each one. [Naming](https://bettersupabase.com/docs/concepts/naming) lists the conventions.

  | Before                                                                | After                                                                                         |
  | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
  | `createBrowser`, `BetterBrowser`, `BrowserOptions`, `BrowserAuth`     | `createClient`, `BetterClient`, `ClientOptions`, `ClientAuth`                                 |
  | `<BetterSupabaseProvider browser={browser}>`, `BrowserLike`           | `<BetterSupabaseProvider client={bs}>`, `ClientLike`                                          |
  | `client.sb` and the `sb` option of `testExecutor` and the test plugin | `betterSupabase`                                                                              |
  | `next.server()`                                                       | `bs.context()` (pass a `Request` to read it instead of the incoming one)                      |
  | `next.serverFor(session, { token })`                                  | `bs.contextForSession(session, { token })`                                                    |
  | `BetterEnv` and `bs.handle()` (Hono)                                  | `HonoEnv` and `bs.handler()`                                                                  |
  | `bs.toORPCError()`                                                    | `bs.toOrpcError()`                                                                            |
  | `HandlerOptions` (Edge)                                               | `MiddlewareOptions`, shared by Hono, oRPC and Edge and exported from `better-supabase/server` |
  | `handler` (MCP)                                                       | `endpoint`                                                                                    |
  | `Queries`, `CreateQueriesOptions`, `queries.key`                      | `BetterQueries`, `QueriesOptions`, `queries.$key`                                             |
  | `Postgres`                                                            | `BetterPostgres`                                                                              |
  | `DefineSupabaseOptions`                                               | `SupabaseOptions`                                                                             |
  | `repository.$table`                                                   | `repository.$tableName`                                                                       |

  `createHono`, `createOrpc`, `createEdge` and `createMcp` now keep the claims and profile types from `.claims()` and `.userMetadata()`, so `c.var.auth`, `context.auth`, the edge handler's `auth` and MCP's `ctx.auth` are typed by your schema. `better-supabase/next` also exports the `LiveCountSeed` type that `bs.liveCount()` returns.
- Less work per request in auth and the adapters.

  - The Next.js adapter verifies the token once per render scope: `context()`,
    `session()` and `cached()` share one resolution. `BetterServer` gains
    `contextFromResolution(resolution, request)` for the same pattern elsewhere.
  - A token seen before skips the claims and `userMetadata` schemas as well as
    the signature check, per schema pair.
  - `createServer` fetches the JWKS when it is created, so the first request
    doesn't wait for it. Pass `prefetchJwks: false` to turn it off; it is off
    under `NODE_ENV=test`.
  - Bearer requests parse cookies only when something reads them, and contexts
    read the replica pin cookie only when a read URL is configured.
  - Job workers back off from `pollInterval` to `maxPollInterval` (30 seconds by
    default) while the queue is empty, and keep the leases of every job in a
    claimed batch alive, not only the running one.
  - Webhook verification imports each secret's key once, compares signatures
    with `crypto.subtle.verify`, and checks the headers before reading the body.
  - The MCP server builds its tool list once when no `visible` hook is set.
  - The OpenTelemetry plugin builds span and metric attributes once per table
    and operation.
  - Edge resource routes answer unknown paths before resolving auth, and a
    malformed id is a 400 instead of a 500. CORS header values are built once.
- A faster CLI that reads the database less often.

  - `--help` and each command's `--help` start in about 30 to 50 ms instead of
    130 to 150 ms: the commands, config loading, env validation, prompts and
    the typegen schemas load only when a command runs.
  - `gen` hashes the system catalogs with one query and reuses the snapshot
    cached in `node_modules/.cache/better-supabase` while the schema is
    unchanged. `gen --watch` keeps one connection open and polls that hash, so
    an unchanged schema costs one small query per interval.
  - Introspection runs its queries concurrently over a pool of four
    connections. Connecting gives up after 10 seconds and each introspection
    query after 2 minutes; Ctrl-C ends the connection or Management API request
    and exits with code 130.
  - `doctor` reads the snapshot and runs its live checks and splinter over one
    connection, and reads its input files concurrently.
  - `oxfmt` is now an optional peer. Without it, `gen` writes
    `database.types.ts` unformatted and prints a notice instead of failing.
  - Generated files and doctor reports sort names by code point, so they no
    longer depend on the machine's locale. Run `better-supabase gen` once; the
    order of a few entries in `generated.ts` and the validator files can change.
  - Breaking: `parseSnapshot` from `better-supabase/cli` now returns a promise,
    so the typegen schemas load on first use. Add `await`. `loadSnapshot` takes
    `signal` and `cache` in its source options.
- Fixes four bugs the performance audit found, one of which changes behavior.

  - `findMany` without `orderBy` now orders by the primary key's database names. On a camel-cased table whose key isn't `id` (`customerTags` with `customerId` and `tagId`) it sent `order=customerId.asc`, which PostgREST rejects.
  - `topic.send()` no longer closes a subscription on the same topic. realtime-js reuses the open channel for a topic, and `send()` removed it after sending.
  - `liveQuery` keeps the shared channel when a listener re-joins in the same tick (React StrictMode remounts), instead of subscribing the new listener to a closing channel.
  - A session refresh now times out after `refreshTimeoutMs` (5000 by default, an option of `resolveAuth` and every adapter's `auth`) and counts as a network failure. Before, a hung Auth request blocked every later request carrying the same refresh token. A rejected refresh is reused for ten seconds, like a successful one.

  Breaking: where refreshing is off (Server Components, route handlers, prefetches, MCP), a token in its last 60 seconds is now valid until its `exp`. It used to resolve 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.
- Generated files and types that cost less to check and to bundle.

  - Breaking: `gen` writes the schema metadata to `generated.meta.js` with a
    `generated.meta.d.ts` next to the main module, which imports it. TypeScript
    reads the metadata as `SchemaMeta` instead of checking a large object
    literal, which cuts check time and editor memory on large schemas. Run
    `better-supabase gen` and commit both new files.
  - Breaking: `BetterPostgres` gains `executorFor(claims)`, and `createServer`
    uses it for `ctx.sql` and `actingAs()`. Apps that don't pass `postgres`
    no longer bundle the SQL compiler; the Next.js, Hono, oRPC, edge and MCP
    entries are about 5 KB gzip smaller. A custom `BetterPostgres` implements
    `executorFor` as `postgresExecutor(asUser(claims))`.
  - Reads without `select` or `include` return the row type directly, which
    removes about a quarter of the type instantiations a query costs.
  - The build marks library modules as side-effect free and annotates
    module-level objects as pure, so bundlers drop the parts an app doesn't use.
- Faster request path in the core runtime.

  - `connect()` costs the same for any number of tables: every connection shares
    one prototype of table getters, and repositories are built on first access.
  - For a signed-in user, `ctx.db` (and `dbFor()`) on the server runs on a bare
    PostgREST client, so requests that only query data no longer build the
    Realtime, Storage and Auth clients. `ctx.supabase` and `ctx.db.$client`
    still return the full supabase-js client, built when first read.
    `better-supabase` now depends on `@supabase/postgrest-js` at the version
    supabase-js uses.
  - Default selections, rendered select strings, column lookups, unique keys,
    invalidation targets and the tables a query spec reads are computed once per
    table or spec instead of on every call. Plugin hooks, tenant resolution and
    claim paths are resolved once, and `mutation` and `error` payloads are only
    built when someone listens.
  - Row decoding and paging copy each row once instead of spreading and deleting
    keys in loops.
  - Bucket connections take `{ cacheSignedUrls: true }` to reuse a signed URL
    until shortly before it expires. The cache belongs to the connection.
  - Outside production, the event hub warns once when an event collects more
    than 50 handlers, which usually means a missing unsubscribe.

  `invalidationTargets()` now returns a frozen `readonly string[]`.
- The Agent Skills cover 0.3. A new `better-supabase-auth` skill teaches sessions, typed claims, OAuth clients and agents behind a token (`session.actor`, `session.delegation` and the `scopes` guard option), `checkSession` before irreversible actions, what happens when claims change, and how to run next to PermDock. The `better-supabase` skill adds an upgrade workflow, cursor pagination, `Temporal` values, the CLI's `--json`, `--db-url-stdin` and exit codes, and `member_org_ids()` in tenant policies. The `better-supabase-api` skill adds `scopes`, cursor resources, the MCP `authorize`, `visible` and `allowedHosts` options, the kit's purge functions and the Postgres pool options. The `better-supabase-testing` skill adds delegated-token API tests, `Temporal` in tests and doctor in CI. Run `better-supabase skills install` to update installed skills.

## 0.3.0 (2026-10-02) [#030-2026-10-02]

- Name the OAuth client or agent behind a bearer token. A user session now carries `actor` and `delegation`, read from `client_id`, `scope` and the RFC 8693 `act` chain the way PermDock's `actorOf` and `delegationOf` read them. A malformed `act` chain resolves to `{ kind: 'invalid', reason: 'actor' }` and a 401, so `InvalidReason` gains `'actor'`: an exhaustive `switch` over it needs the new case. The `scopes` guard option on `next.route`, `next.action` and the edge, Hono and oRPC adapters answers 403 with an RFC 6750 `insufficient_scope` challenge when a delegated token lacks a scope; the user's own token is not limited. `forbidden` errors and Problem Details carry the needed `scopes`. `better-supabase/server` now exports `toSession`, `AuthSession`, `ActClaim`, `SessionActor` and `SessionDelegation`. A new monorepo guide shows one runtime package that owns `defineSupabase`, with domain packages typed from its `db`.
- Breaking: the `BetterResultShape` type is now `BetterResultValue`. It is still the type `toBetterResult` returns, with the `status`, `value` and `error` fields of a better-result value; rename the import to upgrade.
- On the Postgres executor, `createMany` and `upsertMany` split an insert that needs more than 65,535 bind parameters into several statements and run them in one transaction, instead of failing. `upsertMany` sends rows sorted by the conflict columns, so concurrent upserts over overlapping rows lock them in the same order instead of deadlocking; its returned rows follow that order.
- `checkSession(sql, auth)` from `better-supabase/auth` and `better-supabase/server` confirms that a user token's session still exists in `auth.sessions`, and returns an `unauthorized` error with code `SESSION_REVOKED` when the user signed out or the session was ended. Call it before actions you can't undo, such as deleting the account. Nothing calls it by default, so other requests still verify tokens without a round trip.
- The CLI runs on citty. `init` and `add` ask for the casing, the integrations and whether to overwrite files when they run in a terminal; `--yes`, CI and piped input skip the questions. Output is colored in a terminal (`NO_COLOR` turns it off), database work shows a spinner on stderr, and `gen --check`, `introspect --check` and `sql sync --check` print a unified diff of each stale file.

  `registerCommand` takes a command from the new `defineCliCommand`, and `list()` splits list options. The 0.2 form, `registerCommand(name, command, help)`, still works and is deprecated. `help()` replaces the `HELP` constant.
- Breaking: the CLI follows one set of conventions for output, errors, prompts and secrets.

  - `--db-url` is removed from `gen`, `introspect`, `doctor` and `seed`, because a connection string holds the password and arguments end up in shell history and the process list. Set `$DATABASE_URL` or `source.dbUrl`, or pipe the URL in with `--db-url-stdin`.
  - `--json` is a global option. It prints one JSON document on stdout: the command's result, or RFC 9457 Problem Details with a `code` when it fails. `doctor --format json` becomes `doctor --json`.
  - `--yes` (`-y`) is a global option. Prompts never run under `--json`, `--yes` or `CI`.
  - Exit codes are 0 for success, 1 for a failure and 2 for a usage, config or environment error. Every error code has a section on [the errors page](https://bettersupabase.com/docs/cli/errors).
  - A mistyped command gets a suggestion: `Did you mean "gen"?`.
  - `better-supabase.config.*` loads through c12 and is checked against a schema; an unknown key or a wrong value stops the run with each problem's path. `DATABASE_URL` and `SUPABASE_API_URL` must be URLs.
  - `run()` no longer reads `process.env`: pass `run(argv, { env: process.env })`. `CliIo.env` and `CliIo.now` are removed, and `CliIo.stdin` is new.
  - New exports: `CliError`, `CLI_ERRORS_URL`, `commandNames`, and the `CliEnv`, `CliProblem` and `CliErrorCode` types. A command can throw `CliError` and return `data` for `--json`.
- The CLI moved to its own package, `@better-supabase/cli`, released at the same version as `better-supabase`. Install it with `pnpm add -D @better-supabase/cli`; the `better-supabase` command and its options are unchanged.

  Breaking for `better-supabase`: the package no longer ships the `better-supabase` bin or the `better-supabase/cli` subpath. Import `run`, `registerCommand` and the introspection helpers from `@better-supabase/cli` instead. The new `better-supabase/sql` subpath exports the SQL kit (`renderKit`, `kitLayout`, `compileReadSets`), and `better-supabase/config` now exports the snapshot types, `DEFAULT_CLAIMS` and `tenantClaimPaths`.

  The library no longer has `@supabase/config` as an optional peer; only the CLI reads `supabase/config.toml`.
- Doctor BS213 warns when `anon` or `authenticated` may insert or update a column that RLS helpers read to decide access, such as `memberships.role` or `contacts.customer_id`, and a policy lets them write the row. It also checks the `decidingColumns` in PermDock's manifest and names PD028. The finding lists the revoke and the grant of the remaining columns. Snapshots now record column-level insert and update grants (`columnGrants`); older snapshots only show table grants.
- Doctor treats `security definer` functions and functions with a `set` option as not inlinable (BS205, BS206), and its advice for them now points to `column in (select helper())`. New checks: `auth.role()` (BS109), update policies without a select policy or `with check` (BS110), needless and unused API grants (BS111), exposed security definer functions that never check the caller (BS112), storage inserts without the upsert policies (BS113), helpers that read `user_metadata` (BS114), zero-argument helpers called without `select` (BS215), foreign keys without an index (BS216, replacing splinter's lint for the same table), tenant foreign keys that can cross tenants (BS217), soft-delete tables without a partial index (BS218), containment filters without GIN (BS219), column types to avoid (BS220) and direct connections in serverless apps (BS221). BS211 also reports `idle_in_transaction_session_timeout`.

  Snapshots now record each index's access method and predicate, and who among `anon` and `authenticated` may execute the functions doctor reads. Both fields are optional in `snapshot-v2.json`, so older snapshots still load.
- The `entitlements` SQL kit module reads memberships from PermDock when `permdock.manifest.json` is present: `has_entitlement` checks `<rls.schema>.member_<scope>_ids()`, `feature_claims` reads `member_<scope>_ids_for(user_id)` from PermDock's hook, and `entitlement_members` reads the manifest's membership tables. `sql add entitlements` then no longer adds the `tenant` module or prints the hook note. The scope defaults to `organization`; set `entitlements.permdock: { scope }` for another of the manifest's scopes, or `entitlements.permdock: false` to keep `better_supabase.memberships`. The new doctor check BS408 warns when the manifest or the database lacks one of the two helpers.
- Cursor pagination no longer skips rows whose sort column is null: the next page follows where Postgres places nulls (last for `asc`, first for `desc`, or the `nulls` option). On the `better-supabase/postgres` executor a cursor over columns that sort the same way and are not nullable compiles to a row comparison such as `(name, id) > ($1, $2)`, which one index range scan can serve.

  `defineListQuery`, `defineResource`, `createOpenApi` and the MCP table tools accept `pagination: "cursor"`. The list then takes `after` instead of `page`, returns `nextCursor` and `hasMore`, keeps `size` capped by `maxPageSize`, and documents the cursor in its OpenAPI parameters and JSON Schema. `paginate` and offset lists work as before.
- The `audit`, `webhook-inbox` and `jobs` SQL kit modules add `purge_audit_log`, `purge_webhooks` and `purge_job_archive`. Each deletes rows older than an interval in batches and returns how many it deleted, for nightly pg_cron jobs. Only `service_role` can execute them. Run `better-supabase sql add` again to update the kit files.
- Security fixes in the SQL kit. Rerun `better-supabase sql add` for the modules you installed to apply them:

  - `create_invitation` no longer lets an admin invite someone as `owner`; only an owner or the service role can. The service-role check reads `auth.jwt() ->> 'role'` instead of the deprecated `auth.role()`, and `invitations.role` gets the same check constraint as `memberships.role`.
  - `accept_invitation` requires the signed-in user's address in `auth.users` to be confirmed, not only an `email` claim. An unconfirmed user gets the hint `INVITATION_EMAIL_UNCONFIRMED`.
  - `anon` can no longer call `has_org_role`, and the pgTAP kit's `tests.create_user` is granted to `authenticated` and `service_role` only.
  - The tenant module adds `member_org_ids(roles)`, a set-returning helper. Write policies as `org_id in (select better_supabase.member_org_ids())`, which Postgres evaluates once per statement instead of once per row.
  - Deduplicated jobs get a partial index on `dedupe_key` in each queue table, so `enqueue_job` no longer scans the queue while it holds the advisory lock.
  - `triggerSql` puts the realtime trigger function in the `better_supabase` schema, which the Data API does not expose, instead of `public`.

  `better-supabase gen` names a composite foreign key that repeats a column on both sides, such as `(customer_id, organization_id)` referencing `(id, organization_id)`, after the remaining column: the relation is `customer`, as it would be for a plain `customer_id` key. Schemas with such keys get new relation names; set `tables.<name>.relations` to keep the old ones.
- `createMcp` takes `allowedHosts`, which rejects requests whose `Host` header names another host (DNS rebinding protection, next to `allowedOrigins`), and `resourceDocumentation`, published as RFC 9728 `resource_documentation` in the protected resource metadata.
- `next.cached()` takes `tags` and `life.stale`. `tags` adds cache tags to the entry next to `bs:session:<user id>`, and `life.stale` caps the stale time `sessionStale` computes, so `next.cached({ tags: [snapshotTag(sub)], life: cacheLifeFor(snapshot) })` caches a PermDock snapshot no longer than the token or the snapshot. `next.invalidateSession(userId, { tags })` also expires the extra tags. PermDock can now drop the "Planned in better-supabase" note in its `guides/next-cache-components.mdx`.
- Doctor and `gen` read PermDock's `permdock.manifest.json` and `permissions.catalog.json` (paths set by `permdock.manifest` and `permdock.catalog` in the config). BS407 recognizes PermDock's hook by the manifest or its `-- permdock:hook v1` marker, treats functions in `supabase.hook.claims` as the hook's own sources, flags a wrapper that writes those claims again, and reports an info finding when `permdock.config.ts` has no manifest. BS405 takes PermDock's budget from the manifest. The new BS214 reports bucket and topic policies that pass a permission with `rowConditions: true` to PermDock's helpers, `gen` refuses to write such bucket policies, and `defineBucket` and `defineTopic` throw for those keys when given the catalog as `catalog`. The MCP recipe narrows `tool.meta` with PermDock's `isPermission` before `can` and `mayUse`.
- Support the Supabase CLI's pg-delta engine and native local stack. When `supabase/config.toml` sets `[experimental.pgdelta] enabled = true`, `better-supabase sql add` and doctor (BS304, BS305, BS404 and `--fix-grants`) name `supabase db schema declarative sync` instead of `supabase db diff`, BS404 asks for the grants in the schema file that defines the hook, and the declarative schema files are read from `declarative_schema_path` in name order, since pg-delta ignores `schema_paths`. `better-supabase env` falls back to `supabase status --env` when `[experimental] stack = true` makes `supabase status -o json` fail.
- `createPostgres` applies Supabase's role timeouts per transaction: `statement_timeout` 8 s for `asUser` and 3 s for `anon`, because `set role` skips the role's own setting. `statementTimeout` takes a number for every role or one value per role (`admin`, `authenticated`, `anon`). New options: `idleInTransactionTimeout`, `connectionTimeout` and `idleTimeout` (both default to 10 s). Each transaction now sets its timeouts, claims and role in one query after `begin`.
- `createPostgres({ pool })` runs on an existing `pg.Pool` or any object with `connect()` and `end()`, typed as the new `PgPool` and `PgPoolClient` exports of `better-supabase/postgres`. `list.parse()` now reads a typed `ListQueryInput` such as `{ page: 2, size: 10 }` instead of treating it as URL search params and falling back to page 1, and a non-string `q` such as `{ q: 5 }` reports `Must be text` instead of being accepted. `contextFromSupabase` throws a `TypeError` for an auth mode it does not know instead of returning it as the request context. The standards page lists the conformance test behind each adopted standard.
- Doctor reads the declarative schemas in `[db.migrations] schema_paths` order, then the files no entry matches, then the migrations newest first, and the built-in `config.toml` parser reads arrays over several lines. `sql add` names the kit files no `schema_paths` entry matches, because `supabase db diff` would skip them. `doctor --fix-grants` prints the grant and revoke SQL BS404 asks for as one block to append to the migration `supabase db diff` wrote. For PermDock's hook, BS404 points to `permdock supabase hook generate --grants-out` instead, and when a `-- permdock:grants v1` migration already grants the function, it says to apply the migrations.
- Breaking: time values in the public API are now `Temporal` values instead of `Date` objects and epoch numbers.

  - The `codecs.timestamptz` option takes `'instant'` instead of `'date'`. `timestamptz` columns decode to `Temporal.Instant`, `timestamp` columns to `Temporal.PlainDateTime`, and both keep microseconds. Rename the option and run `better-supabase gen`.
  - The `now` options of `defineSupabase`, plugin hooks, `toCloudEvents`, `forwardMutations`, `verifyWebhook` and `bucket.sweep` return a `Temporal.Instant`.
  - `Job.enqueuedAt`, `Job.visibleUntil`, `InboxMessage.receivedAt`, `EnqueueOptions.runAt`, the verified webhook `timestamp` and the `signWebhook` `timestamp` are `Temporal.Instant`.
  - `bucket.sweep({ olderThan })` takes a `Temporal.Duration` or a `Temporal.Instant` instead of milliseconds.
  - TypeScript 5.9 is no longer supported, because it has no Temporal lib. Use TypeScript 6 or 7.

  On Node 24, Safari and other runtimes without a native `Temporal`, install `temporal-polyfill` and import `temporal-polyfill/global` once at startup. Without it, the calls that need `Temporal` return a `DbError` that names the import. Auth keeps its `now` option in epoch milliseconds.
- The PermDock docs and the plugins skill reference match current PermDock. With `supabase.hook.claims: { features: 'better_supabase.feature_claims' }` in `permdock.config.ts`, PermDock's hook writes the `features` claim, so `hasEntitlement(session, ...)` works next to PermDock. The MCP recipe answers `visible` with PermDock's `mayUse`, and the Storage and Realtime pages point to the catalog's `rowConditions` and `permdock doctor` PD037 for permissions the helpers don't fully check.
- Two public types no longer rely on DOM-only globals. The `headers` option of `problem()` takes what the global `Headers` constructor accepts, and the JSON Web Keys in `better-supabase/testing` use the `node:crypto` type, so both resolve in a project whose `lib` leaves out `"DOM"`.
- The `better-supabase` skill's schema workflow now asks for RLS, policies, policy indexes and Data API grants on every new table, and points to Supabase's own skills. The CLI asks for an access token scoped to the project when it reads a hosted project.
- `better-supabase gen` uses `@supabase/postgrest-typegen` 0.3.1, which still produces the same `database.types.ts` as `supabase gen types` from Supabase CLI 2.119. Version 0.4.0 adds a `ComputedFields` key that the Supabase CLI does not emit yet, so it waits until the CLI catches up. The optional `@supabase/config` peer now accepts 0.11.

## 0.2.0 (2026-09-30) [#020-2026-09-30]

- `createMcp` takes optional per-tool authorization hooks. `authorize(ctx, tool, args)` runs after the arguments are validated and before `run`, and returns `{ allowed: true }` or `{ allowed: false, reason?, scopes? }`. A refusal is a tool error, and a refusal with `scopes` is a 403 `insufficient_scope` challenge that lists the scopes the call needs. `visible(ctx, tool)` filters `tools/list`, and a hidden tool is called like an unknown one. `defineTool` and `mcp.tool` accept an opaque `meta`, for example a PermDock permission, which both hooks receive and clients never see. New types: `ToolRef` and `ToolDecision`. The PermDock guide has a recipe that checks PermDock permissions through a structural type.
- Align with PermDock's authorization contract, so a project can use better-supabase and PermDock on the same claims. The migration guide at https://bettersupabase.com/docs/migration/0.1-to-0.2 lists every step.

  Breaking changes:

  - The active tenant claim is `tenant_id` instead of `org_id`, in the token and in `app_metadata`. The new `claims` block in `better-supabase.config.ts` (`claims: { tenant, scope, features }`) renames it for the tenant plugin, the SQL kit, storage and realtime at once. `plugins.tenant.claim` is removed.
  - SQL kit: `better_supabase.current_org_id()` is now `current_tenant_id()`. `membership_claims()` moves to the tenant module and returns PermDock's `[{ scope, id, roles }]`. Plan features move out of memberships into `feature_claims()` in the entitlements module, which fills the `features` claim (`{ [tenantId]: string[] }`).
  - `MembershipClaim` has PermDock's shape (`scope`, `id`, `roles`, and optional `within`, `via`, `expiresAt`). `hasEntitlement` and `EntitlementKey` read the `features` claim.
  - `asUser` signs ES256 tokens with the key from `better-supabase keys` (`signing_keys_path` in `supabase/config.toml`), like a hosted project. `{ alg: 'HS256' }` keeps the shared secret and only works against a local stack.
  - `SPEC_PINS.mcp` and `MCP_PROTOCOL_VERSION` are `2026-07-28`. `createMcp` serves stateless requests (`server/discover`, request `_meta`, `Mcp-Method` and `Mcp-Name` headers) and still answers `initialize` for `2025-11-25`, `2025-06-18` and `2025-03-26` clients. A 403 carries an `insufficient_scope` challenge.
  - BS405 measures the whole token against 2 KB and, when the project has a `permdock.config.ts`, `memberships` plus `attrs` against PermDock's 1 KB budget (`doctor.claimsLimit` overrides the budget, or the whole-token limit without PermDock).

  New:

  - `sb.userMetadata(schema)` parses `user_metadata` into a typed `session.profile` for display. Metadata that fails the schema leaves `profile` undefined and logs one warning with the failing paths. No authorization code reads it. `ProfileOf<B>` in `better-supabase/react` types `useSession` from `createHooks`.
  - Buckets take a PermDock policy (`{ permdock: { read, write }, scope }`) and topics a `permdock: { receive, send, scope }` option. The generated policies check PermDock permissions through its `permitted_<scope>_ids()` or `permdock_has()` helpers instead of the tenant claim.
  - `sql add tenant` stops when a `permdock.config.ts` exists, unless you pass `--force`. Doctor BS407 reports a hook that calls `membership_claims` or writes PermDock's claims next to PermDock.
  - `better-supabase/testing` exports `signLocalJwt`, `localSigningKey`, `signTestJwtWithKey` and the `SigningJwk` type.
  - A new docs page, https://bettersupabase.com/docs/auth/permdock, shows both packages in one project.
- Doctor BS405 now measures what PermDock's budget covers. With a `permdock.config.ts`, it warns when `memberships` plus `attrs` from the hook's output pass 1024 bytes (or `doctor.claimsLimit`), measured with `octet_length` as PermDock's hook measures `supabase.hook.budget`. The whole-token check at 2048 bytes is a separate warning, so a normal PermDock token of about 1.5 KB no longer trips it. Without PermDock, `doctor.claimsLimit` still limits the whole token. `memberships_truncated` is reported as before.
- Entitlements are no longer treated as a PermDock conflict, because `features` is not a PermDock claim.

  - Doctor BS407 reports a custom access token hook next to PermDock only when it calls `better_supabase.membership_claims` or writes `roles`, `user_role`, `memberships` or the configured tenant claim itself. It no longer reports `feature_claims`, and it leaves PermDock's generated hook alone.
  - `sql add entitlements` works next to a `permdock.config.ts` without `--force`. It writes `tenant` as a dependency and prints a note. Only `sql add tenant` still needs `--force`.
  - The PermDock and entitlements docs say what works today. `has_entitlement(tenant, key)` and `tenant_entitlements(tenant)` work in SQL and RLS without a claim. `hasEntitlement(session, ...)` needs the `features` claim, which PermDock's hook doesn't write yet, so read entitlements from the database on the server until PermDock adds a hook slot for it.
- The PermDock guide lists every claim PermDock's generated hook writes (`user_role`, `roles`, `memberships`, the tenant claim, `attrs`, `authz_ver` and `memberships_truncated`). The migration guide and the `sb.userMetadata()` TSDoc say that `session.profile` is for display only, and that roles, memberships, the tenant and entitlements never come from it.
- The storage and realtime docs say that the `permdock` policy mode compares ids as text, so a path or topic segment must be the id's canonical lowercase form: an uppercase uuid is denied. An integration test covers it for buckets and topics.
- The docs now name the right PermDock command for the SQL helpers. `permdock_has` and `permitted_<scope>_ids` come from `permdock rls generate`, not from `permdock supabase hook generate`. The PermDock guide gains a setup step that runs both commands, and says that the helpers live in PermDock's `rls.schema`, which must equal the `schema` option on buckets and topics.
- Document that the `permdock` policy mode for buckets and topics only fits permissions whose grants have no row conditions beyond the scope. PermDock's helpers check role and scope, so a permission with row conditions (for example `ownerId = principal.id`) would grant every object in the scope. Leave those to the policies `permdock rls generate` writes. The storage, realtime and PermDock docs pages show a wrong and a right example, and the TSDoc on the `policy` and `permdock` options says the same.
- The PermDock guide says that a non-default `claims.tenant` must also be set as PermDock's `rls.tenantClaim` and passed to `subjectFromSupabase` or `subjectFromSupabaseSession` as `{ tenant }`, so both packages read and write the same tenant claim.
- The docs site answers questions with Ask AI, shows the last-updated date on every page, and has Open Graph images, a sitemap, type tables generated from the source, and links that add the docs MCP server to Cursor and VS Code. The repository now has a root `CHANGELOG.md`, and its fixture schema is declarative, with pgTAP tests for RLS. The published package does not change.

Every release of better-supabase, newest first. `pnpm version-packages` adds a
section from the pending changesets. Releases up to 0.1.0 are listed in
[packages/better-supabase/CHANGELOG.md](packages/better-supabase/CHANGELOG.md).

# Agent mode

> What the CLI does differently when a coding agent runs it, and how an agent hands a decision back to the user.

Source: https://bettersupabase.com/docs/cli/agent-mode

When a coding agent runs the CLI, it switches to agent mode: every command
prints one [JSON result envelope](/docs/cli#exit-codes-and-json-output) on
stdout, never prompts, and stops before destructive work until the user
agrees. A command that needs an answer or a consent exits with code 3 and
says, in `next`, which command to run once the user decides.

```bash
pnpm better-supabase gen --agent yes
```

## Turning it on and off [#turning-it-on-and-off]

`--agent` takes `auto`, `yes` or `no`, and `$BETTER_SUPABASE_AGENT` sets it
when the flag is not passed. `auto` is the default: agent mode turns on when
the environment shows a coding agent.

| Signal                                                             | Agent                                              |
| ------------------------------------------------------------------ | -------------------------------------------------- |
| `AI_AGENT`                                                         | Its value                                          |
| `CURSOR_TRACE_ID`, when stdout is not a terminal                   | `cursor`                                           |
| `CURSOR_AGENT`                                                     | `cursor-cli`                                       |
| `CLAUDECODE` or `CLAUDE_CODE`                                      | `claude`                                           |
| `CODEX_SANDBOX`, `CODEX_CI` or `CODEX_THREAD_ID`                   | `codex`                                            |
| `GEMINI_CLI`                                                       | `gemini`                                           |
| `COPILOT_MODEL`, `COPILOT_ALLOW_ALL` or `COPILOT_GITHUB_TOKEN`     | `github-copilot`                                   |
| `OPENCODE_CLIENT`, `AUGMENT_AGENT`, `ANTIGRAVITY_AGENT`, `REPL_ID` | `opencode`, `augment-cli`, `antigravity`, `replit` |
| `/opt/.devin` exists                                               | `devin`                                            |

Cursor sets `CURSOR_TRACE_ID` in a person's terminal too, so that one counts
only when stdout isn't a terminal. The list follows
[`@vercel/detect-agent`](https://www.npmjs.com/package/@vercel/detect-agent),
and a test keeps the two in step.

In agent mode, `--no-json` prints text again, and `--agent no` turns the whole
mode off. `--help` and `--version` always print text.

## Missing input [#missing-input]

Where a person would get a prompt, an agent gets `action_required` with the
flags the command needs (`missing`), the values each one takes (`choices`)
and the command to run with the answer filled in (`next`). For example, `init`
at a workspace root needs to know which package owns the runtime:

```json
{
  "$schema": "https://unpkg.com/better-supabase/schemas/cli-result-v1.json",
  "status": "action_required",
  "error": {
    "type": "https://bettersupabase.com/docs/cli/errors#input_required",
    "title": "Input required",
    "code": "input_required",
    "exitCode": 3,
    "missing": ["--package"],
    "choices": { "--package": ["apps/web", "."] }
  },
  "next": [
    {
      "command": "better-supabase init --package apps/web --yes",
      "when": "with the value the user picks from choices[\"--package\"]"
    }
  ]
}
```

The agent asks the user to pick, or picks itself when the answer is clear,
and runs the `next` command with that value. Secrets in the command line are
replaced by their `--*-stdin` flag, so `next` never repeats a password.

## Consent [#consent]

Two kinds of work need the user's consent in agent mode: `--force` over files
the CLI didn't write, and `db seed --apply` against a database that isn't on this
machine. The command stops with `consent_required` and a token:

```json
{
  "status": "action_required",
  "error": {
    "code": "consent_required",
    "detail": "init --force overwrites files better-supabase did not write. Ask the user first, then run again with --confirm 7f21dd17503845de.",
    "exitCode": 3,
    "token": "7f21dd17503845de"
  },
  "next": [
    {
      "command": "better-supabase init --force --confirm 7f21dd17503845de",
      "when": "after the user agrees"
    }
  ]
}
```

The agent shows the user what will happen, and runs the `next` command once
they agree. `--yes` doesn't count as consent. The token is a hash of the
command line and the change, so it works for that exact command only: change a
flag or the target database and the command asks again. It is not a secret;
it makes the agent stop and ask, and doesn't stop a caller that means to skip
the question.

`--dry-run` and `--check` never need consent, since they write nothing.
Apart from these stops, agent mode changes how a command reports, never what
it does.

## Teaching an agent [#teaching-an-agent]

The [Agent Skills](/docs/for-ai-agents#agent-skills) tell agents about these
rules. `better-supabase skills install --for cursor` (or `claude`, `codex`
and the rest) copies them into the project.

# codemod

> Rewrite imports, calls and config keys 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. `--check` writes nothing and exits 1 when a file still needs the
codemod, so CI can tell the upgrade is done ([writing files](/docs/cli#writing-files)).

The codemod runs even when the config file fails to load, because the config
it migrates is often the one the new version rejects. It then walks the
directory that holds the discovered config file, or `--cwd` when there is none.

| 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                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `0.9`   | Moves `sql.modules` to the top-level `blocks` key in `better-supabase.config.*` and renames `ModulesConfig`, `ModuleConfig`, `AccessModuleConfig` and `ModuleMode` to their `Block` names; lists `supportSessions(` (denied without `authorize`, second approver), `canApprove` (now `isApprover`), `createWebhooks(` (CloudEvents), `gatewayOptions(`, `publish_webhook_event(` and the old generic problem types and titles, for review; also `token_scope_allows(` and the role helpers (the delegation cap), `workflowStarter(` and `startOnEvent(` (`runAs`), `createWorkflowEditor(`, `createNotifications(`, `createReminders(` and `.fanOut(` (subject readers), agent `act` levels, `create_share_link`, `before_promotion_activate` and `comments` options; pins `options.defaultFormat: "standard"` on an adopted `webhooks-out` block that maps no `columns.endpoints.format`, and lists `add` calls in scripts and workflows (use `add`, which also wires clients)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `0.11`  | Pins `options.defaultFormat: "cloudevents"` on an adopted `webhooks-out` block that maps no `columns.endpoints.format`, which adopt mode requires from 0.11, and lists the edit for review                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `0.15`  | Regroups `better-supabase.config.*` into `database`, `auth`, `gen`, `api` and `tooling` and renames `subjects` to `records`, `appTables` to `tables`, `integrations` to `adapters` and a block's `mode`, `scope`, `entities` and `hooks` to `ownership`, `owner`, `names` and `callbacks` (see [From 0.14 to 0.15](/docs/migration/0.14-to-0.15)); renames the runtime instance `bs` to `bsb` where a file creates, imports or destructures it, and the `bs` key on request contexts (`ctx.bs`, `c.var.bs`, `locals.bs`); renames the imports `SqlModule` to `BlockSql`, `ModuleDefinition` to `BlockSqlDefinition`, the other `Module*` SQL types to `BlockSql*`, `SubjectRef` to `RecordRef`, `entityForm` to `recordForm`, `ShipmentCarrier` to `ShippingProvider` and `QueueBackend` to `QueueProvider`; lists cache tags, query keys, realtime topics and the debug header and route that start with `bs`, for review; rewrites CLI calls in `package.json` scripts and GitHub workflows to the 0.15 commands (`apply` and `adopt` to `add`, `sql sync` and `sql upgrade` to `upgrade`, `introspect` to `gen --save-snapshot`, `seed` to `db seed`, and `env`, `run`, `keys` and `stack` to `dev`); turns a 0.14 preset code in the config's `preset` into the preset name or object it holds, and a 0.14 design file into a design with its preset inside; renames the blocks 0.15 renamed in the config, the imports and the subpaths, and lists `better-supabase/<block>` event sources for review; moves the `plugins` key into the `updated-at`, `created-by` and `trash` blocks (and the tenant column to `auth.organization.column`) and the `better-supabase/plugins/*` imports to the block extensions, `guards()` from `better-supabase/lint` and `validation()` from `better-supabase`; moves imports from `better-supabase/openapi`, `/asyncapi`, `/arazzo` and `/overlay` to `better-supabase/spec`; removes `ownership: "adopt"` from every block and keeps its `tables` and `columns`, since a managed block now runs over the tables your schema already has |

| Command        | Side effects  |
| -------------- | ------------- |
| `codemod 0.4`  | Writes files. |
| `codemod 0.5`  | Writes files. |
| `codemod 0.6`  | Writes files. |
| `codemod 0.9`  | Writes files. |
| `codemod 0.11` | Writes files. |
| `codemod 0.15` | Writes files. |

## 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
`bsb.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 `bsb.context()` on the createNext() instance
```

Run your type checker afterwards; a rename the codemod missed shows up there.
With `--json`, the envelope's `data` is `{ codemod, dryRun, changed, review }`.

SQL module renames are not source rewrites: `better-supabase upgrade`
handles them, and doctor reports SQL that still uses an old name (BS309).

# Shell completion

> Tab completion for bash, zsh and fish, for both better-supabase and bsb.

Source: https://bettersupabase.com/docs/cli/completion

`better-supabase completion <shell>` prints a script that completes commands,
subcommands, options and their values when you press Tab. It registers the
completion for both bins, `better-supabase` and `bsb`.

```bash
better-supabase completion zsh
```

## Install [#install]

Add the line for your shell to its startup file, then open a new shell.

| Shell | File                         | Line                                        |
| ----- | ---------------------------- | ------------------------------------------- |
| bash  | `~/.bashrc`                  | `eval "$(better-supabase completion bash)"` |
| zsh   | `~/.zshrc`, after `compinit` | `eval "$(better-supabase completion zsh)"`  |
| fish  | `~/.config/fish/config.fish` | `better-supabase completion fish \| source` |

The script calls the installed CLI for every completion, so it stays current
when you upgrade better-supabase. When the bin is a project dependency
instead of a global install, run the line from a shell where `better-supabase`
is on the `PATH`, for example through `pnpm exec`.

Side effects: None. The command prints the script and writes nothing.

## What it completes [#what-it-completes]

Commands, subcommands and options come from the same command list that
`--help` prints. Values come from the option itself:

| Where                                      | Values                                      |
| ------------------------------------------ | ------------------------------------------- |
| `--agent` and other fixed choices          | The choices, such as `auto`, `yes` and `no` |
| `gen --only`                               | The generate tasks; after a comma, the rest |
| `doctor --format`, `--only` and `--ignore` | The report formats and the rule codes       |
| `add`, `remove` and `blocks view`          | Block names                                 |
| `init --preset` and `add --preset`         | Preset names                                |
| `skills install --for`                     | The agents the skills install for           |
| Paths, such as `--out-dir` and `--config`  | Files, from the shell                       |

`--name=value` completes the same way as `--name value`.

## How it works [#how-it-works]

Each script runs `better-supabase __complete -- <words>` with the words on the
command line. The hidden `__complete` command prints one candidate per line,
the value and its description separated by a tab, and a `:files` line when the
shell should offer file names. It loads no config and never fails: on any
error it prints nothing.

# 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`, `database.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({
  database: {
    source: { projectRef: "abcdefghijklmnopqrst" },
    schemas: ["public"],
    realtime: { tables: ["customers", "notes"] },
  },
  gen: {
    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 },
    },
    generators: [zod()],
  },
  blocks: {
    "updated-at": {},
    "created-by": {},
    trash: { options: { deletedAt: "archived_at" } },
  },
  auth: { organization: { column: "organization_id" } },
});
```

## Options [#options]

| Option                  | Default                                                                                 | Notes                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ----------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `database.source`       | local stack                                                                             | `dbUrl`, `projectRef` (+ `accessToken`, default `$SUPABASE_ACCESS_TOKEN`), `snapshot` or `lite`. See [gen](/docs/cli/gen#where-the-schema-comes-from)                                                                                                                                                                                                                                                                            |
| `database.schemas`      | `['public']`                                                                            | Tables in other schemas get prefixed keys                                                                                                                                                                                                                                                                                                                                                                                        |
| `gen.casing`            | `'snake'`                                                                               | `'camel'` maps columns in queries; see [casing](/docs/concepts/casing)                                                                                                                                                                                                                                                                                                                                                           |
| `gen.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                                                                                                                 |
| `gen.output`            | `src/lib/supabase/generated.ts`                                                         | `database.types.ts` and `generated.meta.js` are written next to it                                                                                                                                                                                                                                                                                                                                                               |
| `gen.postgrestVersion`  | `'13'`                                                                                  | Written to `__InternalSupabase` in `database.types.ts`                                                                                                                                                                                                                                                                                                                                                                           |
| `gen.codecs`            | JSON types                                                                              | `int8: 'bigint' \| 'string'`, `numeric: 'string'`, `timestamptz: 'instant'` read values exactly; `timestamptz: 'utc'` keeps strings, always in UTC with a `Z`. See [Temporal](/docs/concepts/temporal)                                                                                                                                                                                                                           |
| `gen.relations`         | `{ nullableUnderRls: false }`                                                           | `nullableUnderRls: true` types to-one includes of tables with RLS as `\| null`; see [includes](/docs/repository/includes#row-level-security)                                                                                                                                                                                                                                                                                     |
| `gen.json`              | `{}`                                                                                    | jsonb types, keyed by `table.column` or `schema.table.column` (database names). `schema` adds a [database check](/docs/blocks/sql#enforcing-jsonb-shapes)                                                                                                                                                                                                                                                                        |
| `gen.sensitive`         | `[]`                                                                                    | Columns the [`noSensitiveSelect` guard](/docs/repository/guards) checks (`table.column`)                                                                                                                                                                                                                                                                                                                                         |
| `gen.generators`        | `[]`                                                                                    | `zod()`, `valibot()`, `jsonSchema()`, `standardSchema()` or your own                                                                                                                                                                                                                                                                                                                                                             |
| `auth.organization`     | `{ claim: 'tenant_id', column: 'organization_id' }`                                     | `claim` is the active-organization claim the memberships extension, `current_tenant_id()` and the storage and realtime policies read; `column` is the column the memberships extension filters on                                                                                                                                                                                                                                |
| `auth.claims`           | `{ scope: 'tenant', features: 'features', memberships: 'memberships' }`                 | JWT claim names. `scope` is the `scope` of the module's `memberships` entries; `features` is the plan-features claim. See [Claims](#claims)                                                                                                                                                                                                                                                                                      |
| `database.buckets`      | `{}`                                                                                    | Typed storage buckets; doctor compares them with the database and `config.toml`                                                                                                                                                                                                                                                                                                                                                  |
| `gen.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)                                                                                                                                                                                                                                 |
| `gen.functions`         | `{}`                                                                                    | Function results that are never null, keyed by function name or `schema.name`: `{ notNull: true }` or the `returns table` columns. An entry wins over an `@notNull` tag in the function's comment. See [gen](/docs/cli/gen)                                                                                                                                                                                                      |
| `database.topics`       | `{}`                                                                                    | Realtime topic templates                                                                                                                                                                                                                                                                                                                                                                                                         |
| `database.realtime`     | `{ tables: [], global: [], users: {}, triggers: [] }`                                   | 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. `policies` and `triggers` make `upgrade` write [topic policies and broadcast triggers](/docs/platform/realtime#from-the-config)                                                                                                                                      |
| `auth.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                                                                                                                                                  |
| `auth.authorization`    | none                                                                                    | An [authorization provider](/docs/extending/authorization-providers) the `provider` access model, the entitlements module, bucket and topic policies and doctor delegate to                                                                                                                                                                                                                                                      |
| `database.vectorSearch` | `{}`                                                                                    | Embedding columns (`{ chunks: 'embedding' }`) the [`vector-search` SQL module](/docs/blocks/vector-search) writes `search_<table>` for                                                                                                                                                                                                                                                                                           |
| `records`               | `{}`                                                                                    | The app's [subject registry](/docs/blocks/sql#subject-registry): each subject type's table, id, tenant, title, URL template, permission keys, scope, rank and search columns. Modules that take subjects fall back to it, and `gen` emits a `SubjectType` union                                                                                                                                                                  |
| `tables`                | `{}`                                                                                    | The app's own [tables](/docs/repository/tables): columns, `owner`, keys, rollups and lookups, which `upgrade` writes with RLS and grants                                                                                                                                                                                                                                                                                         |
| `gen.readSets`          | `[]`                                                                                    | Modules exporting [read sets](/docs/repository/read-sets); `gen` compiles them into the `read-sets` SQL module                                                                                                                                                                                                                                                                                                                   |
| `gen.recordTypes`       | `true`                                                                                  | Writes a [record type](/docs/repository/record-bindings#generated-record-types) to `records/<type>.ts` next to `output` for each type in `config.records` that a block binds to; `false` writes none                                                                                                                                                                                                                             |
| `gen.contentMapper`     | `false`                                                                                 | Serves the schema types to TypeScript 7.1 through the [content mapper](/docs/content-mapper): `gen` writes `schema.bsb` and no `database.types.ts`, and `tsconfig.json` lists the mapper in `contentMappers`                                                                                                                                                                                                                     |
| `gen.hooks`             | `"react"` with the `next`, `react` or `expo` adapter, otherwise `false`                 | Writes `hooks.ts` next to `output` with a list and a record [data hook](/docs/frontend/data-hooks) per table for `"react"`, `"vue"`, `"svelte"` or `"solid"`; `false` writes none                                                                                                                                                                                                                                                |
| `preset`                | unset                                                                                   | A [named preset](/docs/presets), a code from Create or its URL. `defineConfig` expands it into `blocks`, with the blocks they require, and its conventions (`casing`, `database.sql.api`, `integrations`); the keys you set win                                                                                                                                                                                                  |
| `blocks`                | `[]`                                                                                    | The [blocks](/docs/blocks/sql) to install and keep in sync, foundation blocks such as `memberships` and `access` included: a list of names, or an object keyed by block name whose values set its ownership, schema, table and column names, id type, permission keys and options ([existing tables](/docs/blocks/sql#existing-tables-managed-and-custom)). `access` also picks the [access model](/docs/repository/permissions) |
| `database.sql`          | `supabase/schemas`, `900_better_supabase`, `supabase/tests`                             | Where generated SQL and pgTAP files go, the `schema` and `api` every block inherits, the [`scope`](/docs/blocks/sql#scope-and-naming) of blocks that set none. `workflow` (`'pg-delta'`, `'migra'` or `'migrations'`, detected from `config.toml` when unset) picks how blocks reach a migration ([Workflows](/docs/blocks/sql#workflows))                                                                                       |
| `database.seed`         | `supabase/seed.ts`, `supabase/seeds/000_better_supabase.sql`                            | Entry module and output of [`seed`](/docs/cli/db#seed)                                                                                                                                                                                                                                                                                                                                                                           |
| `api.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)                                                                                                                                                                                                                                                         |
| `api.openapi`           | `src/lib/openapi.ts`, `openapi.json`                                                    | Deprecated alias of `specs`. Entry module and output of the OpenAPI document; `spec` reads them when `specs` is unset                                                                                                                                                                                                                                                                                                            |
| `tooling.doctor`        | `format: 'text'`                                                                        | `ignore` finding codes, `strict` mode, `claimsLimit`, `policyHelperLimit`, `migrationSafety.since` (the last migration [BS229 to BS235](/docs/cli/doctor#bs229) skip offline), 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`, `env`) and the `--watch` interval, which `spec --watch` reads too                                                                                                                                                                                                                                                                                     |
| `tooling.env`           | `output: '.env.local'`, prefix from the framework                                       | Defaults of [`env`](/docs/cli/dev#env) `--out` and `--prefix` (`output`, `prefix`; `''` for no prefix)                                                                                                                                                                                                                                                                                                                           |
| `adapters`              | `[]`                                                                                    | The integrations `init` set up. [`add`](/docs/cli/init#add) with no argument sets up the ones that have no files yet                                                                                                                                                                                                                                                                                                             |
| `tooling.skills`        | detected agent folders                                                                  | `agents`: the folders [`skills install`](/docs/for-ai-agents) writes to (`cursor`, `claude`, `agents`)                                                                                                                                                                                                                                                                                                                           |
| `tooling.keys`          | `supabase/signing_keys.json`                                                            | `output`: where [`keys`](/docs/cli/dev#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`            |
| `dev env`        | `tooling.env.output`, `tooling.env.prefix`  |
| `doctor`         | `tooling.doctor.only`, `.format`, `.output` |
| `spec`           | `api.specs.manifest`, `gen.watchInterval`   |
| `add`            | `adapters`                                  |
| `skills install` | `tooling.skills.agents`                     |
| `dev keys`       | `tooling.keys.output`                       |

`tooling.doctor.output` applies when the report uses `tooling.doctor.format`, so
`doctor --json` prints to stdout even when the config writes SARIF to a
file. `add` prints 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({
  gen: { casing: "camel" },
  tooling: { doctor: { ignore: ["BS204"] } },
  $ci: {
    gen: { tasks: ["types", "sql", "spec"] },
    tooling: {
      doctor: { format: "sarif", output: "doctor.sarif", strict: true },
    },
  },
  $env: {
    staging: { database: { 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 `tooling.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({
  auth: {
    organization: { claim: "tenant_id" },
    claims: {
      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 `add` and `upgrade` 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, bucket drift, and the diff
engine when `database.sql.workflow` is unset (`[experimental.pgdelta]` and
`[db.migrations] schema_paths`). 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. It types each
block's `options` from the block's manifest, the same source that types
`blocks.<name>.options` in a TypeScript config, so an unknown block, an
unknown option or a value of the wrong kind is an error in both. 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 `database.source.dbUrl` is redacted.
Use it to check which file the CLI found and what an option resolved to.
`config --preset` prints the preset the config names instead, what it
resolves to, and how the config differs from it
([Reading a preset](/docs/cli/presets#reading-a-preset)).

`gen` warns about `tables` and `json` keys that match nothing in the
configured schemas, such as a misspelled table name, instead of ignoring them.

Side effects: None.

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

# db

> Run SQL files and typed seeds against the local stack or another database, and diff the schema into a migration.

Source: https://bettersupabase.com/docs/cli/db

## query [#query]

```bash
pnpm better-supabase db query supabase/tests/seed.sql supabase/tests/more.sql
pnpm better-supabase db query --single-transaction a.sql b.sql
```

`db query` runs each file as one script, in the order you pass them. A file
can hold many statements, so a data migration or a fixture file runs as it
would in `psql -f`, without a `psql` install.

| Flag                   | Effect                                                                            |
| ---------------------- | --------------------------------------------------------------------------------- |
| `--single-transaction` | Wraps every file in one transaction, so a failure keeps none.                     |
| `--stack <name>`       | Uses the database of `supabase/stacks/<name>/config.toml` (default `$BSB_STACK`). |
| `--db-url-stdin`       | Reads the connection string from stdin, so it never reaches argv.                 |

Without `--db-url-stdin`, the command connects through `$DATABASE_URL`,
`$SUPABASE_DB_URL`, or the local stack. Against a database that is not local,
an agent needs the user's consent first. A failing file stops the run, and
the error names it. The JSON envelope lists the files that ran in `data.ran`.

Side effects: Writes to the database.

## diff [#diff]

```bash
pnpm better-supabase db diff -f add_invoices
pnpm better-supabase db diff --in-stack
```

`db diff` turns the declarative schema files into a migration. It runs the
Supabase CLI's command for the project's workflow first: `supabase db schema
declarative sync --no-apply` under pg-delta, `supabase db diff` under migra.
That command starts a shadow database, a second Postgres. When it can't
start one, usually because every System V shared memory segment is in use
(macOS allows 32, and each running stack holds one), `db diff` diffs inside
the stack you already run instead.

The in-stack diff copies the stack's schema into a temporary database in the
same Postgres and replaces the objects the files manage with the files.
pg-delta then diffs the stack against the copy. The temporary database is
dropped afterwards, also when a step fails. It needs `pg_dump` and `psql`
from the stack's Postgres: `db diff` finds them next to the running
postmaster, and `$BSB_PG_BIN` names their directory when it can't. The stack
has to have applied every migration first (`supabase migration up`), or the
diff would repeat them.

| Flag             | Effect                                                                                 |
| ---------------- | -------------------------------------------------------------------------------------- |
| `-f, --file`     | Saves the diff as `supabase/migrations/<timestamp>_<name>.sql` instead of printing it. |
| `--in-stack`     | Diffs inside the running stack without trying the Supabase CLI's shadow database.      |
| `--stack <name>` | Uses the database of `supabase/stacks/<name>/config.toml` (default `$BSB_STACK`).      |

Under pg-delta, `declarative sync` always writes a file, so `db diff` without
`--file` prints the in-stack diff. A migration from the in-stack diff starts
with a comment that asks you to review it: it compares two databases, so
check the order of the statements before you apply it. The JSON envelope
names the engine in `data.engine` and sets `data.fellBack` when the shadow
database failed. `better-supabase dev stack prune` frees the segments that idle
stacks hold.

Side effects: Writes to the database, writes files with --file.

## 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 db seed          # writes supabase/seeds/000_better_supabase.sql
pnpm better-supabase db seed --check  # fails in CI when the SQL is stale
pnpm better-supabase db seed --apply  # also inserts into the local database
```

`--entry <file>` and `--out <file>` override `database.seed.entry` and
`database.seed.output`. `--dry-run` prints the diff and writes nothing; neither it nor
`--check` runs `--apply`. `--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`. `db seed`
  prints the line when it's missing, and it never overwrites a file it
  didn't write unless you pass `--force`.

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.

### Seeds per tenant [#seeds-per-tenant]

A tenant seed builds rows from a tenant, so every organization can start
with the same demo data. Put it in `database.seed.perTenant`
(`supabase/seed.tenant.ts` by default):

```ts title="supabase/seed.tenant.ts"
import { defineTenantSeed } from "better-supabase/testing";
import { betterSupabase } from "../src/lib/supabase/index.ts";

export const seed = defineTenantSeed(betterSupabase, (tenant) => ({
  customers: { demo: { organizationId: tenant.id, name: "Demo customer" } },
}));
```

```bash
pnpm better-supabase db seed --tenant acme  # one organization, by slug or id
pnpm better-supabase db seed --tenant '*'   # every organization
```

`--tenant` looks organizations up in the
[organizations block](/docs/blocks/organizations) and inserts the rows for
all of them in one transaction, so a failure leaves none behind. `--entry`
overrides `database.seed.perTenant`. The database and consent rules are the ones
`--apply` follows: the local stack needs no consent, any other database
does, and `--dry-run` connects to nothing. `tenant.slug` and `tenant.name`
are set when the organization has them. Every insert is
`on conflict do nothing`, so give rows a key when the seed may run twice
for the same tenant; a row whose id comes from a default is inserted again.

To seed each organization as it's created in development, register the
seed once on the server with a service-role client:

```ts title="src/lib/supabase/dev-seed.ts"
import { createPostgres } from "better-supabase/postgres";
import { seedTenantOnCreate } from "better-supabase/testing";
import { seed } from "../../../supabase/seed.tenant.ts";
import { betterSupabase } from "./index.ts";

const postgres = createPostgres({
  connectionString: process.env.SUPABASE_DB_URL,
});

seedTenantOnCreate(betterSupabase, seed, { sql: postgres.admin });
```

It listens for `organization.created` and runs only when `NODE_ENV` is
`development` (pass `enabled` to decide yourself). The seed runs after the
create call has returned, so it can't change its result; a failed seed goes
to `onError`, or to `console.error` by default.

Side effects: Writes files, writes to the database with `--apply`, writes to the database with `--tenant`.

## backfill [#backfill]

```bash
pnpm better-supabase db backfill invoices.organization_id --from projects.organization_id via project_id
pnpm better-supabase db backfill invoices.currency --value "'EUR'" --dry-run
```

`db backfill` fills the null values of a column that a migration added to a
table that already holds rows, the middle step of expand and contract. With
`--from`, it copies each value from the table a foreign key column points at:
`via project_id` names the column of the target table, and the command finds
the key it references (the foreign key, else the primary key of the source
table). With `--value`, it writes one SQL literal, such as `'EUR'`, `0`,
`true` or `'{}'::jsonb`, to every null row.

It updates `--batch-size` rows per statement, each in its own transaction,
and locks only those rows (`for update skip locked`), so the app keeps
writing while it runs. It prints a line per batch. It only touches rows where
the column is null, so a run that stops can start again, and a second run
fills nothing. Rows whose source value is null, or that have no source row,
stay null; the result counts them in `remaining`.

| Flag                 | Effect                                                                             |
| -------------------- | ---------------------------------------------------------------------------------- |
| `--from <table.col>` | Copies the value from this column, joined through `via <column>`.                  |
| `--via <column>`     | The foreign key column of the target table, the same as a trailing `via <column>`. |
| `--value <literal>`  | Writes this SQL literal instead of `--from`.                                       |
| `--batch-size <n>`   | Rows per statement (default 1000).                                                 |
| `--dry-run`          | Counts the rows it would fill and writes nothing.                                  |
| `--stack <name>`     | Uses the database of `supabase/stacks/<name>/config.toml` (default `$BSB_STACK`).  |
| `--db-url-stdin`     | Reads the connection string from stdin, so it never reaches argv.                  |

A table name without a schema means `public`. The command connects like
[`db query`](#query), and against a database that is not local, an agent
needs the user's consent first. The JSON envelope returns `data.filled`,
`data.batches` and `data.remaining`, or `data.pending` with `--dry-run`.

Each update fires the table's triggers, so audit, version and event triggers
record every backfilled row. When nothing is left null, add the not-null check
as `not valid` in a migration, then run
[`doctor --fix`](/docs/cli/doctor#bs236), which validates it. Then
`set not null` needs no scan:

```sql
alter table invoices
  add constraint invoices_currency_not_null check (currency is not null) not valid;
```

A `tables.<name>` entry that enriches a table you already have adds that check
to the scope column itself, so the order there is the other way round: new
rows must name an owner at once, and `db backfill` fills the existing rows
that [doctor reports as BS238](/docs/cli/doctor#bs238). See
[Your own schemas](/docs/blocks/own-schemas#fill-the-owner-with-db-backfill).

Side effects: Writes to the database.

# dev

> Env files, a command with the stack's env, signing keys, and the local stacks.

Source: https://bettersupabase.com/docs/cli/dev

The `dev` commands set up and inspect local development: `dev env` writes
the env files from the running stack, `dev run` runs a command with them,
`dev keys` writes a signing key, and `dev stack` lists and prunes the local
stacks.

## env [#env]

```bash
pnpm better-supabase dev env
```

`dev 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.
Server code reads the plain names. The browser copies get your framework's
prefix (`NEXT_PUBLIC_`, `VITE_`, `EXPO_PUBLIC_`), or the one you pass with
`--prefix`. `--prefix` takes a framework name (`next`, `vite`, `expo`,
`nuxt`, `astro`, `sveltekit`), `none`, or the prefix itself:

| Variable                           | From                                                                                   |
| ---------------------------------- | -------------------------------------------------------------------------------------- |
| `SUPABASE_URL`                     | `API_URL`                                                                              |
| `SUPABASE_PUBLISHABLE_KEY`         | `PUBLISHABLE_KEY` (or the legacy `ANON_KEY`)                                           |
| `<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_JWKS_URL`                | `API_URL` plus `/auth/v1/.well-known/jwks.json`                                        |
| `SUPABASE_DB_URL`                  | `DB_URL`                                                                               |
| `SUPABASE_JWT_SECRET`              | `JWT_SECRET`, for HS256 [test tokens](/docs/testing) (`asUser(..., { alg: 'HS256' })`) |

Only the URL and the publishable key get the prefix. The secret key, the
database URL and the JWT secret never do, because a prefixed variable is
bundled into client code ([BS501](/docs/cli/doctor#bs501)).

Values are never printed unless you pass `--write -`, which prints the
variables on stdout instead of writing a file. The file is readable by
your user only (mode `0600`), even when its contents didn't change. `dev env`
warns when the file isn't in `.gitignore`, and when your Supabase CLI only
prints legacy keys.
`--write <file>` (or its older name `--out`) writes another file than
`.env.local` (`env.output` in the config sets it for every run, and
`env.prefix` the prefix), and
`--status-file <file>` reads saved `supabase status -o json` output instead of running the
Supabase CLI. Both paths must stay inside the project root: `dev env` refuses an
absolute path elsewhere or one that climbs out with `..`, so the secret key
never lands outside the project. `--dry-run` and `--check` name the file
without showing the keys ([writing files](/docs/cli#writing-files)).

```bash
pnpm better-supabase dev env --write .env.supabase.local --prefix next
```

A separate file keeps the stack's values apart from the ones you edit. In
Next.js, `.env.supabase.local` is not one of the files it loads, so load it
with `dev run` below or a dotenv loader. If you write to `.env.local`, Next.js
loads it after `.env.development.local` and before `.env.development` and
`.env`, and a variable from the first file that sets it wins. It skips
`.env.local` under `NODE_ENV=test`.

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`. `dev 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.

The native stack starts one stack per checkout and branch, and each running
Postgres holds a System V shared memory segment. When segments run out,
[`dev stack prune`](#prune) removes the stacks of deleted worktrees and
branches, and [`db diff`](/docs/cli/db#diff) diffs the declarative schema
without a shadow database.

Side effects: Writes files.

## run [#run]

```bash
pnpm better-supabase dev run -- next dev
```

`dev run` reads `supabase status` and starts the command after `--` with the
stack's variables (the ones `dev env` writes) set, so the app always talks to
the stack this worktree is running. It passes the child's output and exit
code through, forwards Ctrl+C (`SIGINT`) and `SIGTERM` to it, and
`--prefix` picks the browser prefix the same way `dev env` does. Nothing is
written to disk.

Each variable is resolved in this order, first one set wins:

1. The running stack, through `dev run`.
2. Your shell's exported variables.
3. The framework's env files (`.env.local` and the rest). Next.js, Vite and
   dotenv never override a variable that is already set, so the first two
   win over every file.

A `SUPABASE_*` variable exported in your shell (left over from another
project or a hosted one) would otherwise point the app at the wrong stack.
`dev run` uses the stack's value and warns, naming the variable. The warning
shows only the host and port of a URL, and a `sha256:` fingerprint of a key,
never the key itself:

```txt
SUPABASE_URL is exported as 127.0.0.1:54321, but the running stack has 127.0.0.1:55021. run uses the stack's value.
```

[`doctor`](/docs/cli/doctor#bs503) reports the same mismatch when you start
the app another way.

Side effects: Runs the command you pass.

## keys [#keys]

```bash
pnpm better-supabase dev 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. `dev keys` refuses to overwrite an existing file
unless you pass `--force`, and `--out <file>` (or `tooling.keys.output` in the
config) writes somewhere else. `--dry-run` writes nothing.

Side effects: Writes files.

## stack [#stack]

The Supabase CLI's stack engine (`[experimental] stack = true`) starts one
local stack per checkout and branch. Stacks outlive the worktrees and branches
they were started for, and every running Postgres holds one System V shared
memory segment. macOS allows 32 segments by default (`kern.sysv.shmmni`), so a
machine with many stacks runs out, and the next shadow database fails with
`could not create shared memory segment: No space left on device`. `dev stack ls`
and `dev stack prune` show which stacks hold segments and remove the ones you no longer
need.

### ls [#ls]

```bash
pnpm better-supabase dev stack ls
```

`dev stack ls` lists every stack from `supabase stack list` with its branch, its
project root and whether it is running. It marks the stack of this checkout,
a stack whose worktree is gone and a stack whose branch is deleted, and
counts the shared memory segments each stack's postmaster holds. The last
line shows how many segments are in use out of the system limit. The JSON
envelope has the stacks in `data.stacks` and the totals in
`data.sharedMemory`; `unattributed` counts segments no managed stack holds.

Side effects: Reads the local stacks.

### prune [#prune]

```bash
pnpm better-supabase dev stack prune --dry-run
pnpm better-supabase dev stack prune --stop-idle
```

`dev stack prune` destroys each stack whose worktree no longer exists or whose
branch was deleted, with `supabase stack destroy`, which also removes its
data. It never touches the stack of this checkout and branch.

| Flag          | Effect                                                                           |
| ------------- | -------------------------------------------------------------------------------- |
| `--stop-idle` | Also stops the running stacks of other branches and checkouts. Their data stays. |
| `--dry-run`   | Prints the stacks it would destroy or stop, and changes nothing.                 |

In agent mode, destroying a stack needs the user's consent first
([Agent mode](/docs/cli/agent-mode)). Stopping a stack frees its segment, and
`supabase start` in that checkout starts it again with its data. Run
`better-supabase doctor` to see the segment use as a finding (BS700).

Side effects: Stops or destroys local stacks.

### One stack per purpose [#one-stack-per-purpose]

`db seed --apply` and [`db query`](/docs/cli/db) connect to the stack in
`supabase/config.toml`. To keep a second stack for tests or a demo, give it
its own `[db] port` in `supabase/stacks/<name>/config.toml` and pass
`--stack <name>` or set `BSB_STACK=<name>`. Without a port there, the
command reads the `DB_URL` from `supabase status --stack <name>`, and fails
with `not_found` when that stack isn't running. A connection string from
`$DATABASE_URL` or `--db-url-stdin` still wins. If two stacks can't run side
by side on your Supabase CLI, use one git worktree per purpose instead; each
worktree gets its own stack.

### Too many stacks on macOS [#too-many-stacks-on-macos]

The native stack engine keeps one stack per checkout and branch, and a stack
keeps running after its shell exits. Each running stack's Postgres holds a
System V shared memory segment, and macOS allows 32 of them on the whole
machine (`sysctl kern.sysv.shmmni`). Once they are used up, the next
`supabase start` fails because its Postgres can't get a segment. `ipcs -m`
lists the segments in use.

Stop the stacks you aren't using. [`dev stack ls`](#ls) shows every
stack on the machine with its branch and whether its worktree still exists,
and `dev stack prune` destroys the stacks of deleted worktrees and merged
branches (`--stop-idle` also stops the idle ones). `supabase stop --stack-id`
with a stack's id stops that stack without deleting its data. `doctor`
reports the segment use as BS700 before it runs out.

# 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 `--fail-on warning`.

| Option                 | Description                                                                                                                                                                                                                                                        |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--format <f>`         | `text` (default), `sarif` or `github`; also `tooling.doctor.format`. The global `--json` prints the JSON report                                                                                                                                                    |
| `--out <file>`         | Write the report to a file; also `tooling.doctor.output`, which applies with `tooling.doctor.format`                                                                                                                                                               |
| `--only <codes>`       | Run only these checks, e.g. `--only BS100,BS304`; also `tooling.doctor.only`                                                                                                                                                                                       |
| `--ignore <codes>`     | Skip checks. Adds to `tooling.doctor.ignore` in the config                                                                                                                                                                                                         |
| `--fail-on <level>`    | Exit 1 on findings at this level: `error` (the default), `warning` (the default with `tooling.doctor.strict`) or `never`                                                                                                                                           |
| `--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                                                                                                                                                          |
| `--fix`                | Run the SQL of the findings that carry a fix against the database, such as validating a not valid check ([BS236](#bs236)), and leave them out of the report. Asks before it writes to a database that is not local                                                 |

Doctor never prints env values, only variable names and line numbers.

Side effects: Reads the database, writes to the database with `--fix`, calls the Supabase Management API with `--project-ref`, downloads from GitHub.

## 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 --fail-on warning
```

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: {
    tooling: {
      doctor: { format: "sarif", output: "doctor.sarif", strict: true },
    },
  },
});
```

`doctor --json` puts the report in the envelope's `data`. The report follows
[`doctor-report-v1.json`](https://unpkg.com/better-supabase/schemas/doctor-report-v1.json),
and a run with errors has the `findings` error code.

## Ignoring checks [#ignoring-checks]

```ts title="better-supabase.config.ts"
export default defineConfig({
  tooling: { doctor: { ignore: ["BS204"], strict: true } },
});
```

`tooling.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}']`.
`tooling.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).
`tooling.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:

### DoctorConfig

| Prop                 | Type     | Description                                                                                                                                                                                               |
| -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ignore?`            | `array`  | Finding codes to skip, e.g. `['BS204']`.                                                                                                                                                                  |
| `only?`              | `array`  | Run only these checks, e.g. `['BS100', 'BS304']`.                                                                                                                                                         |
| `strict?`            | `union`  | Treat warnings as errors.                                                                                                                                                                                 |
| `format?`            | `union`  | Report format: `text` (default), `sarif` (code scanning) or `github` (workflow annotations). `--json` still wins.                                                                                         |
| `output?`            | `string` | Write the report to this file.                                                                                                                                                                            |
| `sources?`           | `array`  | 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). |
| `migrationSafety?`   | `object` | Which migrations the lock checks (BS229 to BS235) read.                                                                                                                                                   |

## 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 `database.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.** Hosted projects grant new
tables to `anon` and `authenticated` unless the project turns default
privileges off (`auto_expose_new_tables = false` locally). 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
`blocks.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 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. On an app table a
block runs over, only your own policies decide whether the table is reported:
the block reads it through its `bsb_` policies and writes it through its own
functions, so a table with only block policies is left alone.

### 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 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 `blocks.grants.options.fromPolicies`, `better-supabase upgrade`
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.

### BS117 [#bs117]

**Warning: policy without a role.** A policy without `to`, or with
`to public`, applies to every role, `anon` included. Postgres evaluates it on
requests it was never written for, and a later edit to its expression can
open the table to signed-out users. Name the roles the policy is for:

```sql
create policy notes_read on public.notes for select to authenticated
  using (organization_id = (select better_supabase.current_tenant_id()));
```

Restrictive policies only narrow access, so doctor skips them.

## Performance [#performance]

### BS204 [#bs204]

**Warning: tenant column without an index.** With the
[memberships extension](/docs/blocks/memberships), every query filters on the tenant column. Add
an index that starts with it. Views are left out, because a query on a view
uses the indexes of its tables.

### 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 `tooling.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())`. A policy whose every call to the
helper already reads `(select helper(...))` with constant arguments, such as
`(select tenant_ids_with('crm.read'))`, doesn't count.

### 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. On one of your tables that a
block runs over, one of your policies next to the block's `bsb_` policy for the
same command is left to [BS347](#bs347): it reports the pair when the block's
condition is wider. When your policy already covers the block's, the block
adds no policy there.

### 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.
Statements run by Supabase's service roles (PostgREST's schema cache query as
`authenticator`, Auth and Storage) are left out, and so are better-supabase's
own catalog reads from `gen` and `doctor`, which start with the
comment `/* better-supabase */`. When only those spilled, there is no finding. 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 `tooling.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 `list()` 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.

Four kinds of column don't count. A single-column primary key doesn't, because
its uniqueness stops a write from taking over another row. A tenant column
doesn't when every policy for the write pins it without an `or`, to
`better_supabase.current_tenant_id()`, to `better_supabase.can('tenant', column, ...)`
or to `column in (select better_supabase.tenant_ids_with(...))` or
`column in (select better_supabase.member_organization_ids())`, so a write can
only name a tenant the caller is a member of or holds the permission in. A
subject's `deletedAt` column in `config.records` doesn't: `can_subject`
filters soft-deleted rows out of a check that the row's own policies already
pass, so clearing the column only shows a row again to someone who could
already write it. A block's soft-delete column that its triggers never let a
write clear, such as `comments.deleted_at`, doesn't either. A column the
provider lists in `decidingColumns` always counts.

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.
A partial index counts when its `where` only skips rows whose key is null
(`where customer_id is not null`), because those rows reference nothing.
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
[memberships extension](/docs/blocks/memberships), 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
`tooling.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
`gen.codecs.int8` to `"bigint"` or `"string"`, or `gen.codecs.numeric` to `"string"`,
in `better-supabase.config.ts` ([codecs](/docs/cli/config#options)), or add BS222
to `tooling.doctor.ignore` when the values stay small.

### BS223 [#bs223]

**Info: function returns an int8 or numeric scalar.** PostgREST sends a
function's scalar `int8` or `numeric` result, or an array of them, as a JSON
number, so a value past 2^53 (about 15 to 17 significant digits) loses
precision before any codec sees it. `db.$rpc` casts the codec columns of
table and record results to text ([function
results](/docs/concepts/casing#function-results)), but a scalar has no column
to cast. Return it as text (`returns text`, `select total::text`) when it can
grow that large, or add BS223 to `tooling.doctor.ignore` when it stays small. Doctor
can't see inside a `json` or `jsonb` result: cast large numbers to text when
you build it.

### BS224 [#bs224]

**Warning: Data API role without a timeout.** Without `statement_timeout`,
one slow request holds a connection until the query finishes. Without
`idle_in_transaction_session_timeout`, a client that leaves a transaction
open keeps its locks and its connection. PostgREST applies the settings of
`anon` and `authenticated` to each request, and the login role's
(`authenticator`) apply when the request role sets none, so doctor reports a
setting only when neither sets it. A value of 0 turns the timeout off and
counts as unset. [BS211](#bs211) lists the current values. Set them in a
migration:

```sql
alter role authenticated set statement_timeout = '8s';
alter role authenticated set idle_in_transaction_session_timeout = '60s';
notify pgrst, 'reload config';
```

Saved snapshots from before role settings were recorded skip it.

### BS225 [#bs225]

**Info: `pg_stat_statements` is not installed.** The extension records the
time and calls of each statement. `--stats` ([BS209](#bs209)), [BS208](#bs208)
and the Supabase query performance report read it, so without it nothing
shows which queries are slow. Doctor checks `pg_extension` on every run
against a database; with `--stats`, BS209 reports it instead. Install it in a
migration:

```sql
create extension if not exists pg_stat_statements with schema extensions;
```

### BS226 [#bs226]

**Warning: prepared statements through the transaction pooler.** The
transaction pooler on port 6543 gives each transaction any server connection,
so a named prepared statement from one transaction doesn't exist in the next
and the query fails with `prepared statement does not exist`. When an env
file or a file in `tooling.doctor.sources` has a `:6543` URL, doctor reports each
source file that imports postgres.js without `prepare: false`, and, when the
sources use Prisma, each `:6543` URL without `pgbouncer=true`:

```ts
const sql = postgres(process.env.DATABASE_URL!, { prepare: false });
```

`pg`, which `better-supabase/postgres` uses, sends unnamed statements and
works through the pooler as it is. This is the reverse of [BS221](#bs221),
which asks serverless apps to use the pooler.

### BS227 [#bs227]

**Info: offset pagination or exact counts.** `pagination: "offset"` on a
resource or list reads and skips every row before the page, so late pages get
slower as the table grows, and rows written between requests shift the pages.
`count: "exact"` counts every matching row on each request. Doctor reports
each line in `tooling.doctor.sources` that sets either. Use `pagination: "cursor"`
unless the UI needs page numbers, keep `[api] max_rows` set so no request
reads a whole table, and use `count: "planned"` or `"estimated"` on tables
that can grow large.

### BS228 [#bs228]

**Warning: app table column drifted from `tables`.** A column of an app
table has a different type or nullability in the database than
[`tables`](/docs/repository/tables) gives it, usually after an edit by
hand or a migration that wasn't regenerated. `gen` types the column from
`tables`, so the app reads values the database doesn't hold. Change
`tables` to match the database, or run `better-supabase upgrade` and
apply the migration it generates. When rows depend on the old shape, follow
the expand-and-contract steps that `apply --preset` prints for a breaking
change. A table or column the database doesn't have yet is left out, since
that is a migration not applied.

### BS229 [#bs229]

**Warning: index built without `concurrently`.** BS229 to BS235 read the
migrations that haven't run yet, statement by statement, and flag the ones
that lock a table that already holds rows. With `--db-url`, doctor checks the
migrations missing from `supabase_migrations.schema_migrations`. Offline, it
checks the migrations after `tooling.doctor.migrationSafety.since`, or only the newest
migration when `since` is unset; `init` and `add --adopt` set `since` to the latest
migration so older files aren't reported. Tables a migration creates are
skipped, since nothing reads them yet.

`create index` blocks every write to the table until the index is built. Use
`create index concurrently`, and put it in a migration of its own, because it
can't run inside a transaction.

### BS230 [#bs230]

**Warning: foreign key or check validated in the same step.** Adding a foreign
key or a check scans the whole table while it holds a lock that blocks writes.
Add the constraint with `not valid`, then run
`alter table ... validate constraint ...` in a later statement, which scans
without blocking writes. A `references` or `check` written inline in
`add column` can't be `not valid`, so BS230 flags it too: add the column,
then the constraint. For a check, [BS236](#bs236) tells you when the rows
pass and `doctor --fix` validates it.

### BS231 [#bs231]

**Warning: column change that rewrites or scans the table.**
`alter column ... type` rewrites every row under an exclusive lock, unless the
types are binary compatible. `set not null` scans the table under the same
lock. For a type change, add a new column, backfill it, and switch reads to
it. For `set not null`, first add `check (column is not null) not valid`,
validate it, and then set not null, which Postgres then accepts without a
scan. BS231 also flags `add column ... not null` without a default, which
fails once the table has rows: add the column nullable, fill it with
[`db backfill`](/docs/cli/db#backfill), and then add the check.

### BS232 [#bs232]

**Warning: column added with a volatile default.** A default like
`gen_random_uuid()`, `random()` or `clock_timestamp()` makes Postgres write
the value into every existing row while it holds an exclusive lock. `now()`
and constants are stored once and don't rewrite. Add the column without a
default, backfill it in batches, and then set the default.

### BS233 [#bs233]

**Warning: no `lock_timeout` before an exclusive lock.** A migration that
alters an existing table waits behind long-running queries for its lock, and
every query that arrives after it waits too. Start the migration with
`set lock_timeout = '5s';` so it fails fast and can be retried instead of
stalling the app. A statement that only runs `validate constraint` takes a
share update exclusive lock, which doesn't block reads or writes, so it
doesn't count.

### BS234 [#bs234]

**Info: drop or rename.** Dropping or renaming a table or a column breaks the
deployed app until it ships the matching code. Rename in steps: add the new
name, write to both, move the reads, and drop the old name in a later release.
`apply --preset` prints these steps for a breaking change.

### BS235 [#bs235]

**Warning: a `better_supabase` function dropped with a new signature.** When
a better-supabase release changes a function's arguments, a schema diff drops
the old function and creates the new one. 0.12 did this for `audit()`, which
went from 9 to 11 arguments. A call with positional arguments, or a grant that
names the old argument list, fails once the migration runs. Doctor flags a
`drop function better_supabase.<name>(...)` in an unapplied migration unless
the same file creates that function again with the same number of arguments.
Read the [upgrade notes](/docs/migration) for the release, call the function
with named arguments, and grant on the new signature in the same migration.

### BS236 [#bs236]

**Info: not valid check ready to validate.** Expand and contract adds a
column nullable, adds `check (column is not null) not valid` so new rows must
fill it, backfills the old rows with [`db backfill`](/docs/cli/db#backfill),
and then validates the check. BS236 reads the checks the database holds as not
valid and tests each one against the rows. When no row fails it, the finding
carries the fix, and `doctor --fix` runs
`alter table ... validate constraint ...` without a statement timeout. That
scans the table but doesn't block reads or writes. After it, `set not null` on
the column needs no scan. When rows still fail, the finding names the
`db backfill` command for a not-null check; for the scope column of an
enriched app table, [BS238](#bs238) reports it instead. BS236 needs a
database, so a saved snapshot skips it.

Postgres checks a not valid constraint on every insert and update, so an
update of a row the backfill hasn't reached yet fails until it fills the
column. Add the check after the backfill when the app still writes those rows.

### BS237 [#bs237]

**Warning: app table keeps permissive policies or grants of its own.** When a
`tables.<name>` entry enriches a table your schema already creates, your own
policies on it stay: the entry names its policies `bsb_<table>_<command>`, so it
never replaces one of yours, even one named `<table>_select`. Postgres ORs permissive policies, so each one widens what
the entry's scope allows. In `tenant` scope the restrictive `bsb_tenant_scope`
policy still keeps every row inside the member's tenants, so BS237 is a hint
(info): check that your policies need no role or permission the scope's
policies ask for. In `user` scope, or without `bsb_tenant_scope`, nothing
narrows them, and a signed-in user reaches every row they allow. Drop your
policies, or recreate them `as restrictive`.

Enriching never revokes your grants either; it only adds the ones the scope
needs. BS237 lists the select, insert, update and delete grants that `anon` and
`authenticated` hold beyond that. While row level security filters the rows
this is a hint (info). It is a warning when the entry restricts columns
(`access` or `encrypted`), because a table-level grant reaches every column:
revoke it in your schema and the entry's column grants take over. BS111 reports
truncate, references and trigger grants.

### BS238 [#bs238]

**Warning: rows of an enriched app table without an owner.** Enriching a table
that already has rows adds the scope column (`organization_id`, or `user_id` in
`user` scope) nullable, with a not valid not-null check. The scope's policies
match nobody for a row without a value, so only the service role reads or
writes it, and updates to it fail the check. BS238 counts those rows and names
the [`db backfill`](/docs/cli/db#backfill) command that fills them; then
`doctor --fix` validates the check ([BS236](#bs236)). BS238 needs a database,
so a saved snapshot skips it. The check is `not valid` on purpose; see
[Your own schemas](/docs/blocks/own-schemas#why-the-check-is-not-valid).

## 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, the trash extension's delete fails with an RLS error. Filter
deleted rows in queries instead; the trash extension 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 `blocks` differs from
the version this release ships. Doctor renders the files with the same layout
as `upgrade` (grants from policies, session policies, audited tables and the
vector schema), so the two agree on what is out of date. Run `better-supabase upgrade`, then
`supabase db schema declarative sync` (`supabase db diff` on migra) to create
a migration. Under `database.sql.workflow: 'migrations'`, doctor compares against the
newest `*_better_supabase_modules.sql` migration instead, and `upgrade`
writes the new one; apply it with `supabase migration up`.

A file whose `@bsb-hash` line no longer matches its SQL was edited by hand.
`upgrade` leaves that file alone. Move the change into your own schema file
or a SQL hook, or pass `--force` to overwrite it. A block file that no
installed block renders any more is reported too, and `upgrade` removes it
unless it was edited by hand.

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
`database.realtime.tables` has no `bsb_realtime` trigger, so
[live queries](/docs/frontend/live-queries) never hear about its changes. Run
`better-supabase 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 `blocks` uses
`ownership: "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 `managed`, which also runs over tables you
already have. `better-supabase blocks
view <module> --sql` lists the signatures.

### BS308 [#bs308]

**Warning: tenant claim the hook does not write.** With the `memberships` module and
`blocks.permissions.activeTenant: 'claim'`, `current_tenant_id()` and the
memberships extension read the active tenant from the `auth.organization.claim` 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 `blocks`
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 a block maps onto the app's own, and those of a module in custom
mode, are the app's names and are never reported.

### BS310 [#bs310]

**Warning: duplicate block trigger.** A table has `bsb_updated_at` and another
trigger whose function looks like `updated_at`, `moddatetime` or `touch`, or
`bsb_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
`@bsb-block` line, or its row in `better_supabase.blocks` 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 `blocks`
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 `blocks.<module>.api` to an
exposed schema such as `api`: `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
`blocks`, 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 add --adopt` 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
`blocks["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 `blocks` sets an option
that only exists to match a schema the app already has: `tokenStorage: "plain"`
for invitations, `secretStorage: "column"` for credentials,
`secretStorage: "column"`, `statuses` or an `eventIdType` or `runIdType`
other than `text` for webhooks-out, a `blockSource` or `defaultSource` for
the outbox, or `values` or `metadataColumns` for the audit log. The config
only accepts them when the block runs over a table the app already has.
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
`blocks`, and a table in `schemas` has no `bsb_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 app's tables that blocks run over; list other tables in
`blocks.audit.options.exempt` as `{ table, reason }` entries, where `table` is
a `schema.table` glob (`*` matches any run of characters) and `reason` says why
its changes need no audit entry. A bare glob string still works, but doctor
reports it and asks for the reason. Doctor only checks tables in `schemas`, and
a partition counts through its parent.

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    audit: {
      options: {
        exempt: [
          { table: "public.*_archive", reason: "read-only copies" },
          { table: "public.sessions", reason: "high volume, no user data" },
        ],
      },
    },
  },
});
```

The same rule runs in pgTAP with
`select tests.every_table_audited('public', array['sessions', '%_archive'])`
(see [testing](/docs/testing#pgtap)).

### BS316 [#bs316]

**Warning: legacy migra diff engine.** The project keeps declarative schemas
in `supabase/schemas` or lists `blocks`, 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
```

BS316 fires when `database.sql.workflow` is unset and the CLI detects migra (see
[Workflows](/docs/blocks/sql#workflows)), or when the workflow is pg-delta and
`config.toml` doesn't turn it on yet. To stay on migra, set
`sql: { workflow: 'migra' }`; that and `workflow: 'migrations'` silence it.

### 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
`blocks`, 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 `blocks.sessions.options.policies` to `true` and
run `better-supabase upgrade` to write the policy on every table, or add it
by hand. Doctor skips the block schemas and the app's tables that blocks run over; list
other tables in `blocks.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 upgrade`: 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
`blocks`, and `better_supabase.audited_tables` on the live database has
a row whose table no longer exists. Since the module installs the
`bsb_audit_forget_dropped` event trigger, dropping a table deletes its row,
but a table dropped before that keeps it. Run `better-supabase upgrade`
and apply the migration `better-supabase add --adopt` 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 `blocks`
creates an event trigger (`bsb_audit_forget_dropped` for `audit`,
`bsb_ensure_rls` for `rls-guard`) 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 upgrade`, then `better-supabase add --adopt`:
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
`blocks.memberships.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 `bsb.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
`tooling.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
`tooling.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`.

### BS330 [#bs330]

**Error: app table without a column a block uses.** A block runs over a
table of the app's, and the live database lacks a column the block's SQL
names, so the block's functions fail until that column exists. Doctor reads
every column the module's SQL names: the ones in `blocks.<name>.columns`
and the defaults for the unmapped ones, leaving out the columns mapped to
`null`. Tables that a block uses only when `blocks.<name>.tables` maps them
(the notifications `deliveryEvents` and `suppressions`, the audit
`restricted` table and the invitations `platformInvitations`) are checked
once they are mapped, and skipped otherwise, as the generated SQL skips them.
Apply the block's migration, which adds the missing column with
`add column if not exists`, or map the logical column to the column that
holds it, or to `null` when the module allows that. A column on a table that
`blocks.<name>.on` names isn't added by the block: map it or add it yourself.
Needs a database.

### BS331 [#bs331]

**Error: subject registry names a missing table or column.** An entry in
`config.records` names a table that the snapshot lacks, or a column the
table lacks: `id`, `tenant`, `title`, `deletedAt`, a `url` placeholder,
`rank` or a `search` column. `subject_title`, `subject_url`, `can_subject`
and every module that falls back to the registry fail when they run. Point
the entry at the existing table or column, or add it. Doctor skips tables in
schemas the snapshot did not read.

### BS332 [#bs332]

**Warning: subject table without RLS.** A table in `config.records` has row
level security disabled. `can_subject` treats a row the caller can read as
visible, so every signed-in user passes the visibility half of the check for
every row. Enable RLS and add the policies that scope the table.

### BS333 [#bs333]

**Warning: `admin()` inside a workflow-editor executor.** A source file that
runs workflow-editor steps (it uses `executeGraph`, `nodeRunReporter` or
`GraphStepCall`) calls `admin()`. A file that only builds the builder's
`service` transport or starts runs with `graphStarter` isn't reported. The steps
then run with the service role's rights, so a workflow keeps running after its
publisher is demoted. Call `builder.versions.runAs(call.version)` in each step
and query with the claims it returns. See
[publisher rights](/docs/blocks/workflow-editor#publisher-rights).

### BS334 [#bs334]

**Info: a policy checks a role name instead of a permission.** An app policy
or a function in `schemas` calls `member_organization_ids()`,
`has_organization_role()` or `organization_member_role()`. Under an
authorization provider these take membership from its `memberIds`, but a role
list still names roles the provider may not have, and a role check skips the
delegation cap. Check a permission with
`better_supabase.tenant_ids_with('<permission>')` or `member_can()` instead.
The modules' own `bs_` policies aren't reported.

### BS335 [#bs335]

**Warning: partitioned table created unpartitioned.** A block's
`options.partitioned` is on, but its table was created before it: the primary
key is `id` alone, so the purge deletes row by row instead of dropping whole
months. The rule checks these tables in `better_supabase`:

| Block                            | Table                          |
| -------------------------------- | ------------------------------ |
| `audit`                          | `audit_events`                 |
| `outbox`                         | `outbox_events`                |
| `usage` (with `options.history`) | `usage_history`                |
| `webhooks`                       | `webhook_deliveries`           |
| `notifications`                  | `notification_delivery_events` |

Postgres can't convert a table in place. Copy the rows into a partitioned
table and swap the names in a maintenance window, or turn the option off.

### BS336 [#bs336]

**Warning: flag past its expiry.** The flags table has an `expires_at` column
(`blocks.flags.options.extraColumns`), and a flag on the live database that
isn't archived has an `expires_at` in the past. A release flag that outlives
its rollout keeps a dead branch in the code. Remove its checks, then archive
the flag (`flags.upsert(key, { archived: true })`), or move `expires_at` when
the flag stays. The finding names the `owner` column when the table has one.
See [owner and expiry](/docs/blocks/flags#owner-and-expiry).

### BS337 [#bs337]

**Warning: broadcast trigger sends whole rows.** A trigger function calls
`realtime.broadcast_changes(..., new, old)` or sends `old_record` and `record`
through `realtime.send()`, and has no `better-supabase: payload row` comment.
Every subscriber of the topic then sees every column, including ones their
column grants hide. 0.8 `triggerSql()` functions and hand-written ones are
reported. Regenerate the trigger with `topic.trigger()`, whose default payload
sends the primary key and the topic values, or pass `payload: "row"` to keep
whole rows on purpose. See
[row changes](/docs/platform/realtime#row-changes).

### BS338 [#bs338]

**Info: `gen.functions.notNull` entry a comment tag could replace.** `gen` reads
`@notNull` and `@notNull(a, b)` from a function's comment, so the promise can
live next to the SQL instead of in `better-supabase.config.ts`. Doctor lists
each `gen.functions.<name>.notNull` entry for a function in the read schemas: one
the function's tag already matches (remove the entry), one with no tag (add
the tag the finding prints, then remove the entry), and one that differs from
the tag, where the config entry wins. See
[nullable function results](/docs/cli/gen).

### BS339 [#bs339]

**Warning: `expose` excludes a column the table doesn't have.** An `expose`
privilege such as `select(*, -token_hash)` grants every declared column but
the excluded ones. When the table has no `token_hash` column, the exclusion
excludes nothing, and a renamed secret column is granted under its new name.
Remove the exclusion, or exclude the column's new name. See
[column privileges](/docs/guides/data-api-grants).

### BS340 [#bs340]

**Info: a tenant foreign key has no same-tenant trigger.** With the `memberships`
block, doctor lists each single-column foreign key between two tables that
both have the tenant column, when the key doesn't include that column and
the table has no `bsb_same_tenant_<column>` trigger. The foreign key checks
that the referenced row exists, not that it belongs to the same tenant. Add
the reference to `blocks.memberships.options.sameTenant`, or set
`sameTenant: { auto: true }`. See
[references within one tenant](/docs/repository/permissions#references-within-one-tenant).

### BS341 [#bs341]

**Warning: a data export includes a column that looks like a secret.** A
data export writes every column of the tables it reads, except the ones in
`omit`. Doctor reports each column of an exported table, including the ones
`autoTables` finds, whose name contains `token`, `secret`, `_hash`,
`password`, `api_key` or `license_key`, when no `omit` and no `omitPattern`
leaves it out. Add the column to the table's `omit`, keep the table out of
exports with `export: false` or `autoTables.exclude`, or set
`blocks.data-lifecycle.options.omitPattern`. See
[data lifecycle](/docs/blocks/data-lifecycle).

### BS342 [#bs342]

**Error: an enum column of the app's that a module writes as text.** A
module writes a text parameter or a computed text value into a column of a
table the app already has, and the snapshot declares that column as an enum;
string literals are fine. Postgres has
no implicit cast from text to an enum, so the module's functions fail when
they run. Change the column to text with a check constraint on its values.
The columns the module casts to their declared type may stay enums or
domains: status columns, the tags `kind` and `subjectType`, and the numbering
`entity`. `upgrade` and
`better-supabase add --adopt` refuse the same columns when the schema files declare
the enum. See
[existing tables](/docs/blocks/sql#over-your-own-tables).

### BS343 [#bs343]

**Error: a partition without row level security.** A table in `schemas` is a
partition of a table that has RLS on, but RLS is off on the partition itself.
Policies belong to the parent, so a query that names the partition (for
example `select * from decision_events_default`) never applies them, and a
role with a grant on it reads every row. The default partition counts too.
Run `alter table <partition> enable row level security`, and enable RLS in
the code that creates new partitions. The partition functions of the SQL
modules (`ensure_partitions`, `defaultPartition`) already do this for the
outbox, usage, webhook delivery, notification and audit tables
(ADR 0038).
In pgTAP, `tests.partitions_have_rls('schema')` checks the same thing.

### BS344 [#bs344]

**Warning: app webhook endpoints without a format.** `webhooks-out` runs
over an endpoints table of the app's, that table maps no `format` column, and
`blocks.webhooks-out.options.defaultFormat` isn't set. Every endpoint then
gets `defaultFormat`, whose default changed from the 0.8 body (`standard`)
to `cloudevents` in 0.9, so receivers built for the old body break with no
change to the config. The message says whether the table has no `format`
column or has one the config doesn't map. Set `defaultFormat` to the body
your receivers expect, or map `columns.endpoints.format`. `upgrade` refuses
the same config; doctor reports it for configs it loads without that check.
See [connectors](/docs/blocks/webhooks-out#connectors).

### BS345 [#bs345]

**Warning: attachments subject on block-wide keys with stricter RLS.** An
`attachments` subject has no `read` or `upload` key of its own, so its files
are checked with the block-wide `attachments.read` and `attachments.upload`
keys. The subject table's own policies check more than tenant membership: a
permission key of their own, an owner or `auth.uid()` comparison, or another
condition. Any member of the tenant then reads and uploads the files of rows
they can't open. Doctor counts `current_tenant_id()`, `tenant_ids_with`,
`member_<tenant>_ids` (and an authorization provider's own `*_ids_with` and
`*member_*ids` helpers), `can('tenant', ...)` and `member_can` with the
block's key as tenant membership, and a soft-delete `is null` as neutral. A
policy with an `or` branch that needs only membership is wider, not
stricter, and a table with no insert or update policy for clients doesn't
count against `upload`. A subject with its own read key (`permission`, or
`permissions.read` in the registry) passes for `read`, since the block checks
that key on top of `attachments.read`.
Set `subjects.<type>.permissions.upload` in the registry, or
`blocks.attachments.on.<type>.permissions` for `read`, to the keys the table
checks. See
[permissions per subject](/docs/blocks/attachments#permissions-per-subject).

### BS346 [#bs346]

**Warning: block on names an unknown record type.** A block entry's `on`
lists a type, or has a key, that `config.records` doesn't register. The
block's SQL refuses such a type when `gen` renders the migration, so doctor
reports it first. Register the type in `config.records`, or remove it from
`on`. See [record types](/docs/blocks#record-types).

### BS347 [#bs347]

**Warning: block policy widens an app table's access.** A block that runs
over one of your tables adds its own permissive policies there, named
`bsb_<table>_<command>` (such as `bsb_teams_select`), so they never replace
one of yours. Postgres ORs permissive policies, so when you have policies for
that command and none has the same condition, or `true`, for the same roles,
the block's policy can let a user reach rows yours keep closed. A block
condition that is yours with more `and` operands (such as the block's
`authz_actor_may_write()` check) is narrower, so it isn't reported either,
and so is the block's condition when it is one of the `or` operands of yours. A
command you have no policy for is the block's to grant, so doctor doesn't
report it. Doctor compares the conditions as text, so it also reports a
condition that means the same thing written differently. If the block's
condition is what you mean, repeat
it in your policy. Otherwise narrow the permission key the block checks, or
drop the `bsb_` policy in a schema file that runs after the block's. Your
policy and the block's for the same command don't also count as an overlap
under [BS207](#bs207). When one of your policies covers the block's in this
sense, `upgrade` leaves the block's policy out of its file and says so in a
comment, so Postgres doesn't evaluate two policies for the same rows.

### BS348 [#bs348]

**Info: app table keeps its own updated_at trigger.** A block that runs over
one of your tables found a trigger of yours that keeps `updated_at` current
(one that calls `set_updated_at`, `moddatetime` or a function that sets
`new.updated_at`), so it adds no `bsb_updated_at` trigger there. Keep your
trigger firing on update. If you drop it, run `better-supabase upgrade` to add
the block's.

## 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 dev keys`](/docs/cli/dev#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
```

Under `database.sql.workflow: 'migrations'` there is no schema file, so the finding
says to put the block in a new migration and apply it:

```bash
supabase migration new auth_hook_grants
better-supabase doctor --fix-grants >> supabase/migrations/<timestamp>_auth_hook_grants.sql
supabase migration up
```

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 gen --save-snapshot` 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 `tooling.doctor.claimsLimit` without a hook budget |
| The provider's hook budget | `octet_length` of each of `tokenHook.budget.claims` | `tokenHook.budget.bytes`, or `tooling.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 `auth.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 `blocks`, 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
  `auth.entitlements.memberships: "tenant"` to use the tenant module's memberships;
- the provider's hook doesn't fill `auth.claims.features` from
  `better_supabase.feature_claims` (`tokenHook.registeredClaims`), so
  `hasEntitlement()` would never see the plan features. With
  `auth.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 `auth.organization.claim` names another
claim, because the memberships extension, the guards and the module's RLS would then
read a claim the hook never writes. It notes when `auth.claims.scope` is neither
`tenant` nor the provider's `tenantScope`, notes when the provider sets
`suspension` or `roleSources` while `blocks.permissions.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 `blocks.permissions.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 `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 `blocks.permissions.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 `blocks.<module>.permissions`.

It warns when neither `blocks.permissions.functions.canAssign` nor the
provider's `canAssign` is set, because only the service role then assigns
roles. See [the access block](/docs/repository/permissions) 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` | 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    |

`blocks.permissions.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 `tooling.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).

### BS413 [#bs413]

**Warning: support sessions are not tied to the provider's read-only check.**
Support sessions are installed, `blocks.permissions.model` is `provider`, and
`authorization.functions.actorMayWrite` is not set. Writes still refuse a
read-only `act` through the built-in check. Set `actorMayWrite` to the
provider's read-only helper so that helper is the only check.

### BS414 [#bs414]

**Warning: the access contract answers from built-in roles next to a
provider.** The config sets `authorization`, but `blocks.permissions.model`
names another model, so `better_supabase.can()` and
`better_supabase.tenant_ids_with()` answer from the built-in roles. Doctor
reports each policy and each function in `schemas` that calls them. Remove
`model` to use the provider (the default with `authorization`), or call the
provider's own function in that policy.

### BS415 [#bs415]

**Warning: the provider sets the deprecated `canApprove` template.**
`authorization.functions.isApprover` replaces it, with `{kind}` (`'ai-tool'`
for tool calls) and `{subject}` (the tool name) instead of `{tool}`. 0.9 still
reads `canApprove` when `isApprover` is unset.

### BS416 [#bs416]

**Warning: `isApprover` has no branch for support sessions.** Under the
provider access model, the `impersonation` module asks
`authorization.functions.isApprover` who may approve a support request, with
`{kind}` set to `'support-session'` and `{subject}` the request id. A
template written only for `'ai-tool'` answers false, so no admin can approve
and every start that needs a second admin stays pending. Doctor reads the
template and the live definition of each function it calls. Add a
`'support-session'` branch, or set `blocks["impersonation"].options.approval`
to `"none"`.

### BS417 [#bs417]

**Warning: the provider declares teams but sets no `teamIdsOf` template.**
Under the provider access model, `team_ids_of()` and the team sharing in
`saved-views` and `collections` call `authorization.functions.teamIdsOf` for
the caller's teams. Without it, `team_ids_of()` reads the teams block's own
`team_members`, which a provider that keeps its own teams never fills, and
`saved-views` and `collections` without the teams block share with no team.
Doctor warns when the provider declares a `team` (or `teams`) scope, one of
those blocks is installed, and the template is unset. Set `teamIdsOf` to a
set-returning call with `{user}`, such as `provider.team_ids_of({user})`.

### BS418 [#bs418]

**Warning: code uses an API that 0.15 renamed or removed.** Doctor reads the
app sources in `doctor.sources` for imports of a renamed block subpath
(`better-supabase/blocks/work-items`), a built-in plugin subpath
(`better-supabase/plugins/soft-delete`), a renderer subpath that
`better-supabase/spec` replaced (`better-supabase/openapi`, `/asyncapi`,
`/arazzo` and `/overlay`), an export named after a renamed
block (`workItemsBlock`), and the record feature, leases maintenance and pass
credit APIs that 0.15 removed. Run `better-supabase codemod 0.15` for the
renames, and use the replacement the finding names for the removed APIs. The
[0.14 to 0.15 guide](/docs/migration/0.14-to-0.15) lists each one.

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

### BS503 [#bs503]

**Warning: exported Supabase variable differs from the running stack.** A
`SUPABASE_*` variable exported in your shell, such as a `SUPABASE_URL` left
over from another worktree, has another value than the local stack. The app
then talks to the wrong stack, because frameworks never override a variable
that is already set. The finding names the variable and both values, redacted:
the host and port for a URL, and a SHA-256 fingerprint for a key. Unset the
variable, or start the app with `better-supabase dev run -- <cmd>`, which sets the
stack's values over the shell's. Doctor only checks this when the shell
exports a `SUPABASE_*` variable and the local stack is running.

### BS504 [#bs504]

**Error: env key a block needs is missing.** An installed block declares an env
key, such as `CRON_SECRET` for the outbox relay or `STRIPE_WEBHOOK_SECRET` for
billing, and no env file lists it and the shell doesn't set it. The route or
worker that reads it then fails at runtime. `better-supabase add` and `init`
append the keys to `.env.example`; set them in `.env.local` and in every
deployed environment. Optional keys, like `AI_GATEWAY_API_KEY` (the OIDC token
works on Vercel), are never reported.

### BS505 [#bs505]

**Error: required provider is not wired.** A block that can't run without an
implementation is installed: enrichment needs an `EnrichmentProvider` and
mail-sync needs its `MailProvider`s. No app source passes the instance one with
`.with(...)` or `.using(...)`. `add` writes a commented stub for it in
`providers.ts`, next to `blocks.generated.ts`. Fill in the stub, then spread
`...providers` into `defineBlocks(blockConfig, { ...blocks, ...providers })`.
Comments don't count, so the finding stays until the stub is real code.

## 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.7.0` against `>=0.6.0 <0.7`. 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.

### BS602 [#bs602]

**Error: content mapper not set up.** `gen.contentMapper` is on, so `gen`
writes `schema.bsb` instead of the type files, but `tsc` can't load the
[content mapper](/docs/content-mapper) that serves its types. The finding
names each missing piece: `tsconfig.json` has no `contentMappers` entry for
`better-supabase`, the project resolves TypeScript older than 7.1, or the
`typecheck` script in `package.json` runs `tsc` without `--runExternalCode`.
Doctor skips the script check when there is no `typecheck` script.
`better-supabase init --content-mapper` adds the entry and the flag. To go
back to the type files, turn `gen.contentMapper` off and run `gen`.

## Local stacks [#local-stacks]

These checks read the machine's System V shared memory with `ipcs`, so they
report on every stack the Supabase CLI runs, not only this project's. They
skip on Windows.

### BS700 [#bs700]

**Warning: shared memory segments almost used up.** At least 80% of the
System V shared memory segment limit is in use. Every running Postgres keeps
one small segment as an interlock, even with `shared_memory_type = mmap`, and
macOS allows 32 (`kern.sysv.shmmni`). When they run out, the next stack or
shadow database fails with `could not create shared memory segment: No space
left on device`, which is how `supabase db diff` and `declarative sync` fail
on a machine with many worktrees. The finding names the stacks that hold
segments. Run [`stack prune --stop-idle`](/docs/cli/dev#prune) to destroy
the stacks of deleted worktrees and branches and stop the others, or raise
the limit. [`db diff`](/docs/cli/db#diff) needs no shadow database.

### BS701 [#bs701]

**Warning: local stack uses System V shared memory.** A stack holds more than
the one small interlock segment, so its Postgres sets `shared_memory_type` or
`dynamic_shared_memory_type` to `sysv`. Its shared buffers and parallel query
memory then take segments of their own and fill the limit faster. The
defaults on macOS and Linux are `mmap` and `posix`; remove the override from
the stack's Postgres settings.

# 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` the [result envelope](/docs/cli#exit-codes-and-json-output)
on stdout holds a [Problem Details](https://www.rfc-editor.org/rfc/rfc9457)
document under `error`, whose `type` links to the code's section on this
page:

```json
{
  "$schema": "https://unpkg.com/better-supabase/schemas/cli-result-v1.json",
  "status": "error",
  "error": {
    "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"
  }
}
```

The exit code tells a script what to do next:

| Exit code | Meaning                                                                                     |
| --------- | ------------------------------------------------------------------------------------------- |
| 0         | The command succeeded.                                                                      |
| 1         | The command ran and failed, or found something (`--check` drift, doctor errors).            |
| 2         | The command line, the config or the environment needs a change; a rerun fails the same way. |
| 3         | The user has to act first: give an input, consent to a change, or log in.                   |
| 65        | `gen --metadata` rejected its GeneratorMetadata document.                                   |
| 130       | The run was interrupted (Ctrl-C) or a prompt was cancelled.                                 |
| 143       | The process received SIGTERM.                                                               |

On SIGINT or SIGTERM the run gets 2 seconds to clean up, then the process
exits with 130 or 143. A second signal exits at once.

## `usage` [#usage]

The arguments don't fit the command in a way no more specific code covers,
such as an extra positional or a group named without a subcommand. For a
group, or no command at all under `--json`, `choices["<command>"]` lists the
subcommands. 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.

## `unknown_flag` [#unknown_flag]

The command does not take the option. `flag` names it, and when a declared
option is close enough to be a typo, `suggestion` names that one. Run the
command with `--help` for its options. Exit code 2.

## `invalid_value` [#invalid_value]

An option or environment variable has a value the CLI does not accept, such
as `--format xml`, `--agent=maybe` or a `--cwd` that is not a directory.
`flag` names the option when there is one, and the message lists the values
it takes. Exit code 2.

## `command_moved` [#command_moved]

The command was renamed. `suggestion` holds the new form, which you can run
as is. Exit code 2.

## `flag_renamed` [#flag_renamed]

The flag was renamed. `suggestion` holds the command with the new flag,
which you can run as is, and `flag` names the new flag. Exit code 2.

## `missing_value` [#missing_value]

The command needs a value it didn't get, such as a connection string on
stdin for `--db-url-stdin` or a directory for `--cwd`. A stdin flag reports it at once when stdin is a
terminal instead of waiting for input. `flag` names the variable or option
that supplies it. Exit code 2.

## `outside_root` [#outside_root]

A path option points outside the project root, for example
`--out ../.env.local`. Run the command from the project that owns the file.
`flag` names the option. 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
`gen.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.

## `not_found` [#not_found]

A file or module the command reads doesn't exist, such as a snapshot or an
entry named in the config. Exit code 1.

## `invalid_input` [#invalid_input]

A file the command reads exists but holds the wrong thing: a snapshot of an
older version, a status file without `supabase status` JSON, or an entry
that exports no topic or read set. Exit code 1.

## `dependency_missing` [#dependency_missing]

The command needs an optional package the project hasn't installed, such as
`pg` to read a database. `suggestion` holds the install command. Exit
code 1.

## `database_unreachable` [#database_unreachable]

The CLI couldn't connect to the database. The message has the redacted URL
and the driver's error. For a local URL that refused the connection,
`suggestion` is `supabase start`. Exit code 1.

## `supabase_cli_missing` [#supabase_cli_missing]

The command runs the Supabase CLI and found none: not `$SUPABASE_BIN`, not
`node_modules/.bin/supabase`, and not `supabase` on the `PATH`. The CLI never
downloads one. Install it with `pnpm add -D supabase`. Exit code 1.

## `supabase_cli_failed` [#supabase_cli_failed]

The Supabase CLI ran and failed, or printed output the command can't read.
Check that the local stack runs (`supabase start`). Exit code 1.

## `network_error` [#network_error]

An HTTP request got no response: the host was unreachable, the connection
dropped, or the request took longer than 30 seconds. The message names the
host. Check the network and run the command again. Exit code 1.

## `request_failed` [#request_failed]

An HTTP request failed, such as a Management API query or the download of
the pinned `splinter.sql`. The message has the status and the start of the
response. Exit code 1.

## `auth_required` [#auth_required]

The command reads a hosted project and has no access token, or a request was
refused with 401 or 403. Set `SUPABASE_ACCESS_TOKEN` (or `database.source.accessToken`
in the config) to a token that can read the project. `flag` names the
variable when the token is missing. Exit code 3.

## `integrity_failed` [#integrity_failed]

A downloaded file didn't match its pinned hash, so the CLI refused to run
it. Exit code 1.

## `generator_failed` [#generator_failed]

A generator threw, or wrote a file outside the project root or a file that
another generator writes. The message names the generator. Exit code 1.

## `check_failed` [#check_failed]

`--check` found files that are out of date. Run the command without
`--check` to write them. Exit code 1.

## `file_conflict` [#file_conflict]

A file the command generates already exists without the better-supabase
header, so the app wrote it by hand. The command leaves it alone and lists it
as Skipped. Move your code elsewhere, or pass `--force` to overwrite it. Exit
code 1.

## `findings` [#findings]

A check such as `doctor` or `spec validate` found problems at or above the
`--fail-on` level. The report lists them. Exit code 1.

## `input_required` [#input_required]

The command needs an answer it would prompt for, and it can't prompt (in CI,
under `--json` or for an agent), or a required positional such as
`blocks view <block>` is missing. `missing` lists the flags and positionals
it needs, `choices` the values each one takes when there is a fixed set, and `next`
holds the command to run with the answer filled in. Exit code 3. See
[agent mode](/docs/cli/agent-mode#missing-input).

## `consent_required` [#consent_required]

The command would make a destructive change, and needs the user's explicit
consent. Agent mode asks for it before `--force` overwrites files the CLI
didn't write and before `db seed --apply` writes to a database that isn't on
this machine. Rerun it with the `--confirm <token>` that `next` shows (the
token is also in `token`, and covers only that command line); `--yes`
doesn't replace it. Exit code 3. See [agent mode](/docs/cli/agent-mode#consent).

## `cancelled` [#cancelled]

The person cancelled a prompt with Ctrl-C or Escape. Nothing after the
prompt ran. Exit code 130.

## `failed` [#failed]

The command ran and reported a failure. `exitCode` is the command's own exit
code, usually 1.

## `internal` [#internal]

Something the CLI didn't expect went wrong. `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.

## Codes from other CLIs [#codes-from-other-clis]

The exit codes, the envelope and the code names on this page are a contract
other CLIs can share. A CLI that adopts it keeps each code's name, title and
exit code, and adds codes of its own only under new names, such as
`dirty_worktree` (exit 3) for a rewrite that finds uncommitted changes, or
`write_failed` (exit 1). better-supabase never reuses one of those names for
another meaning, so a script can match a code without knowing which CLI
printed it.

# gen

> Generate database types, typed models, relation metadata and validators.

Source: https://bettersupabase.com/docs/cli/gen

```bash
better-supabase gen [--only <list>] [--check] [--watch] [--snapshot <file>] [--db-url-stdin | --project-ref <ref>]
better-supabase gen --metadata <path|-> [--emit <file> | --out-dir <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.

Side effects: Writes files, reads the database, calls the Supabase Management API with `--project-ref`.

## Tasks [#tasks]

| Task    | Runs by default when                                   | Does what                                                                       |
| ------- | ------------------------------------------------------ | ------------------------------------------------------------------------------- |
| `types` | always                                                 | Writes the files this page describes                                            |
| `sql`   | `blocks` lists a module, or `realtime.policies` is set | [`upgrade`](/docs/blocks/sql): rewrites the SQL module files                    |
| `spec`  | the `api.specs.entry` file exists                      | [`spec emit`](/docs/cli/spec), with `api.specs.failOn` and `api.specs.manifest` |
| `seed`  | the `database.seed.entry` file exists                  | [`seed`](/docs/cli/db#seed): renders the fixtures to SQL                        |
| `env`   | never; list it to run it                               | [`env`](/docs/cli/dev#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 `--only` for one run:

```ts title="better-supabase.config.ts"
export default defineConfig({
  gen: { tasks: ["types", "sql", "spec"], watchInterval: 1000 },
});
```

```bash
better-supabase gen --only 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), 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. with `blocks` configured, `blocks.generated.ts` next to the main module:
   each installed block's schema, API schema and the options its SQL and
   TypeScript halves share, with the module defaults filled in, for
   [`defineBlocks(blockConfig, ...)`](/docs/blocks/create-blocks#the-generated-config),
   and `blocks`, each instance's manifest under its key, for
   [`betterSupabase.blocks()`](/docs/blocks/create-blocks#block-instances-on-db).
   It needs no database, only the config;
5. with blocks that have a record binding installed, a
   [record type](/docs/repository/record-bindings#generated-record-types) per type in
   `config.records`, in `records/<type>.ts` next to the main module, unless
   `gen.recordTypes` is `false`;
6. with `gen.hooks` set, `hooks.ts` next to the main module: a list hook and a
   record hook per table (`useCustomers`, `useCustomer`) for the
   [data hooks](/docs/frontend/data-hooks) of that framework;
7. one file per configured generator (`zod()`, `valibot()`, `jsonSchema()`,
   `standardSchema()`);
8. 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. With `--json`, each
run prints one envelope on its own line
([NDJSON](/docs/cli#exit-codes-and-json-output)). Ctrl-C ends the
connection, including a query in flight. Connecting gives up after 10 seconds,
and each introspection query after 2 minutes.

## Content mapper [#content-mapper]

With `gen.contentMapper: true`, `gen` writes `schema.bsb` and a
`generated.ts` that re-exports its types, and no `database.types.ts`;
TypeScript 7.1 gets the types from the
[content mapper](/docs/content-mapper). The `types` task also writes the
mapper's cache, and when the types change, a stamp file in
`node_modules/.cache/better-supabase` that the editor watches, so `--watch`
reloads the types in the editor too. `--check` checks the mapper-mode files.

## Options [#options]

| Option                 | Effect                                                                                                                                                           |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--only <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.                                                        |
| `--dry-run`            | Prints a diff of each file that would change, and writes nothing.                                                                                                |
| `--force`              | Overwrites a generated file that lost its `Generated by better-supabase` header.                                                                                 |
| `--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 <dir>`      | With `--metadata`, writes every generated file into this directory instead of printing one.                                                                      |

With `--json`, the envelope's `data` is `{ "tables", "written" }` for `gen`
and `{ "upToDate", "stale" }` for `gen --check`. When several tasks run,
`data` is `{ "tasks": { "<task>": { "code", "data" } } }` instead, with each
task's result 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. `database.source.snapshot` in the config, when none of those flags is passed;
3. `database.source.dbUrl` or `database.source.projectRef` in the config;
4. a [Supabase Lite](/docs/platform/lite) project's `[db] driver` in
   `supabase/config.toml` (turn this off with `database.source.lite: false`);
5. `$DATABASE_URL`;
6. `$SUPABASE_DB_URL`, the name the Supabase CLI and `better-supabase dev env`
   use;
7. the local stack, on the `[db] port` from `supabase/config.toml`. When
   `config.toml` sets no port, as on the CLI's stack engine
   (`[experimental] stack = true`), the `DB_URL` from `supabase status`. When
   neither answers, the command fails with `not_found` and names both, instead
   of guessing port 54322, which could be another project's stack.

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]

`database.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({
  database: { 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.** `get` 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. The `column`
  and `sequenceColumn` of each [numbering](/docs/blocks/numbering)
  entity with a `table` are optional on insert without being listed, unless
  the block is in `custom` mode.

- **Generated columns.** A generated column (`generated always as (...)
stored`) or an identity column `always` is in `Row` and typed `?: never` in
  `Insert` and `Update`. `WritableColumnOf<"table">` is the union of the
  columns the database lets you write, and `GeneratedColumnOf<"table">` is the
  rest. The generic forms, `WritableColumns<Models, T>` and
  `GeneratedColumns<Models, T>`, come from `better-supabase`. Use them to
  check a column list against the table, for example the columns an offline
  client uploads (see [PowerSync](/docs/frontend/powersync#uploaded-columns)).

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

  You can also keep the promise next to the SQL. `gen` reads an `@notNull`
  tag from the function's comment, and `@notNull(a, b)` names the columns:

  ```sql
  comment on function public.customer_note_counts(uuid[])
    is 'Notes per customer. @notNull(customer_id, note_count)';
  ```

  A config entry for the same function wins over its tag, and a tag that names
  a column the function doesn't return fails `gen` the same way. Doctor's
  [BS338](/docs/cli/doctor#bs338) lists the config entries a tag could
  replace.

  Postgres has no syntax for `not null` on a `returns table` column, and the
  catalog records none, so `gen` can't infer it from the function body. Two
  things do carry it: a function that `returns setof` a table gets that
  table's column nullability, and a column whose type is a domain declared
  `not null` is typed without `| null`:

  ```sql
  create domain public.customer_ref as uuid not null;

  create function public.customer_note_counts(customer_ids uuid[])
  returns table (customer_id public.customer_ref, note_count bigint)
  ...
  ```

  When a `returns table` function still has a nullable column and neither a
  tag nor a config entry covers it, `gen` prints a notice that names the
  function. `notNull: []` in the config keeps every column nullable and
  silences the notice.

- **Nullable arguments.** Every argument type includes `| null`, because
  Postgres passes `null` to any argument. This only widens what you may pass
  to `$rpc`; it never changes a result type. A `strict` function returns
  `null` for a `null` argument, which is why its result stays `| null`.

## 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`.

## Schemas for functions and blocks [#schemas-for-functions-and-blocks]

The `zod()`, `valibot()`, `jsonSchema()` and `standardSchema()` generators
also write a schema for each database function's arguments and result, and
for the inputs of every installed entity block, next to the table schemas:

| Export                               | From                                                   |
| ------------------------------------ | ------------------------------------------------------ |
| `<function>Args`, `<function>Result` | each function: `listTasksArgs`, `customerStatsResult`  |
| `<entity>Input`, `<entity>Change`    | a block entity's create and update inputs: `taskInput` |
| `<entity>ListFilter`                 | a block entity's list options: `taskListFilter`        |

Block schemas carry the names the config gives the entities, so with
`work_item: "task"` the file has `taskInput` with a `taskId` field. An
overloaded function gets no schema, because one schema can't pick a
signature. Use them as action inputs or in forms.

### Form metadata [#form-metadata]

`fieldMeta: true` adds a `fieldMeta` export: for each writable table and
each input schema, a label, a `required` flag, the enum `options` and the
bounds of every field.

```ts title="better-supabase.config.ts"
generators: [zod({ fieldMeta: true })],
```

```ts title="src/lib/supabase/generated.zod.ts"
export const fieldMeta = {
  customers: {
    name: {
      label: "Trading name",
      required: true,
      minLength: 1,
      maxLength: 200,
    },
    status: {
      label: "Status",
      required: false,
      options: ["active", "churned"],
    },
  },
} as const;
```

The label is the column's comment when it is short, one line of at most 60
characters; otherwise it comes from the column name.

## Search params for nuqs [#search-params-for-nuqs]

`nuqs()` from `better-supabase/config` writes `<output>.nuqs.ts`, with
search-param parsers for a list of each table: a filter for every column
with a closed set of values, `orderBy` over the sortable columns,
`direction`, `cursor` and `limit`. Each set comes with a cache for Server
Components, a loader and a serializer for links. The file imports
`nuqs/server`, an optional peer the app installs.

```ts title="better-supabase.config.ts"
import { defineConfig, nuqs } from "better-supabase/config";

export default defineConfig({
  gen: { generators: [nuqs({ tables: ["tasks", "customers"], limit: 50 })] },
});
```

A Server Component reads the params through the cache, and a Client
Component writes them with nuqs' own hooks:

```tsx title="app/tasks/page.tsx"
import { tasksSearchParamsCache } from "@/lib/supabase/generated.nuqs";

export default async function TasksPage({ searchParams }: PageProps<"/tasks">) {
  const { state, orderBy, direction, limit } =
    await tasksSearchParamsCache.parse(searchParams);
  // ...
}
```

```tsx title="app/tasks/task-filters.tsx"
"use client";

import { useQueryStates } from "nuqs";

import { tasksSearchParams } from "@/lib/supabase/generated.nuqs";

export function TaskFilters() {
  const [params, setParams] = useQueryStates(tasksSearchParams);
  // ...
}
```

| Option   | What it does                                                                        |
| -------- | ----------------------------------------------------------------------------------- |
| `tables` | The table keys to write parsers for; defaults to every table, block tables included |
| `limit`  | The default page size; defaults to 25                                               |
| `output` | The file path; defaults to `<output>.nuqs.ts`                                       |

Block tables appear under their final names, such as `tasksSearchParams`.
`load<Name>` parses a URL, a `Request` or `searchParams`, and
`serialize<Name>` writes the params into a link.

## 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 extension](/docs/repository/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({
  gen: {
    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",
});
```

## Saving a snapshot [#saving-a-snapshot]

```bash
better-supabase gen --save-snapshot
better-supabase gen --save-snapshot --check
better-supabase gen --snapshot supabase/snapshot.json
```

`gen --save-snapshot` reads your database the way `gen` does and writes what
it found to `supabase/snapshot.json` instead of generating code. Commit the
file, set `database.source.snapshot` in the config, and CI and contributors
can run `gen` and `doctor` without a database.

| Option          | Effect                                                                                       |
| --------------- | -------------------------------------------------------------------------------------------- |
| `--out <file>`  | Where to write. Defaults to `supabase/snapshot.json`, or `supabase/generator-metadata.json`. |
| `--kind <kind>` | `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.                        |
| `--dry-run`     | Prints a diff of the file and writes nothing.                                                |

`--snapshot <file>` reads a saved snapshot instead of the database, to
convert it with `--kind`, and `--db-url-stdin` and `--project-ref` work as
they do for `gen`. With `--json`, the envelope's `data` is
`{ "tables", "files" }`, plus `upToDate` for `--check`. It always reads the
full schema, without the [introspection cache](#introspection-cache).

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/repository/lint#unbounded-read) lint rule reads.

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. The snapshot leaves out
`rpcFunctions`, which postgrest-typegen derives from `functions`; the
generator resolves it again from the stable, ordered functions. A
`GeneratorMetadata` document passed to `gen --metadata` is treated the same
way, so `rpcFunctions` never names a schema `gen` doesn't generate.

## 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 <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)                            | Installs blocks, applies a preset (`--preset`), maps blocks onto existing tables (`--adopt`) or adds integration glue |
| [`remove`](/docs/cli/init#remove)                      | Takes blocks out of the config and `lib/blocks.ts` and deletes their module files                                     |
| [`upgrade`](/docs/cli/init#upgrade)                    | Writes the upgrade steps and the module files again after a library or config change                                  |
| [`blocks`](/docs/cli/presets#blocks)                   | Lists, shows and searches the blocks you can add, the installed ones, and a preset's                                  |
| [`gen`](/docs/cli/gen)                                 | Generates types, metadata and validators, or saves a schema snapshot with `--save-snapshot`                           |
| [`dev`](/docs/cli/dev)                                 | Writes env files, runs a command with the stack's keys, creates signing keys, and lists or prunes stacks              |
| [`db`](/docs/cli/db)                                   | Runs SQL files, renders typed seeds and diffs the schema into a migration                                             |
| [`spec`](/docs/cli/spec)                               | Writes, checks and validates the OpenAPI, AsyncAPI and Arazzo documents of a `defineApi` module                       |
| [`config`](/docs/cli/config#print-the-resolved-config) | Prints the resolved config, with every default filled in, or how it differs from its preset                           |
| [`codemod`](/docs/cli/codemod)                         | Rewrites imports, calls, config keys and CLI 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                        |
| [`mcp`](/docs/cli/mcp)                                 | Serves the CLI to an agent over MCP on stdio                                                                          |
| [`skills`](/docs/for-ai-agents)                        | Installs the Agent Skills that ship with the package                                                                  |
| [`completion`](/docs/cli/completion)                   | Prints the shell completion script for bash, zsh or fish                                                              |

The package also installs the short bin `bsb`, so `pnpm bsb gen` runs the same
CLI as `pnpm better-supabase gen`.

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 take `--dry-run` and
`--check` ([writing files](#writing-files)).
In a terminal, output is colored (pass `--no-color` or set `NO_COLOR` to turn
it off), `init` and `add` ask what they need, and database work shows a
spinner on stderr. Outside a terminal the spinner is one plain line, such as
`Reading the schema...`.
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 result envelope on stdout, with the data, Problem Details for an error, and the next commands. Never prompts.                                                                                                                                                                             |
| `--agent <mode>`        | Agent mode: `auto` (the default) turns it on when a coding agent runs the CLI, `yes` and `no` decide. Defaults to `$BETTER_SUPABASE_AGENT`. In agent mode, `--json` is on unless you pass `--no-json`, prompts are off, and destructive work waits for consent ([agent mode](/docs/cli/agent-mode)). |
| `--confirm <token>`     | Runs destructive work that a `consent_required` result named, once the user agreed. The token covers that command line only.                                                                                                                                                                         |
| `--yes`, `-y`           | Never prompts; uses the flags and the defaults.                                                                                                                                                                                                                                                      |
| `--color`, `--no-color` | Prints no ANSI colors on either stream, like `NO_COLOR`. `--color` is the default; it colors a terminal only, never a pipe.                                                                                                                                                                          |
| `--debug`               | Prints, on stderr, the stack trace of an error, each child command the CLI runs with its exit code, and how long the run and each step took.                                                                                                                                                         |
| `--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), in agent mode, and
under `--json` or `--yes`, commands use their flags and defaults instead of
asking.

## Writing files [#writing-files]

Every command that writes files prints one line per file:

```txt
Created src/lib/supabase/generated.ts
Updated supabase/schemas/900_better_supabase_audit.sql (audit)
Unchanged src/lib/supabase/database.types.ts
Skipped src/proxy.ts (exists; pass --force to overwrite)
Removed src/lib/supabase/old.ts
```

| Flag        | Effect                                                                                                    |
| ----------- | --------------------------------------------------------------------------------------------------------- |
| `--dry-run` | Prints a unified diff of each file that would change, then the lines (`Would create`), and writes nothing |
| `--check`   | Writes nothing, and exits 1 with the diffs when a file would change (`check_failed`)                      |
| `--force`   | Overwrites files the CLI would skip                                                                       |

The CLI writes a command's files together: it writes each one next to its
target first, then renames them all, so a failed run leaves no file half
written. It refuses a path outside the project root (`outside_root`). Files
that hold secrets, such as `.env.local` and the signing keys, are named in a
diff but never shown.

Two kinds of file are skipped. A file the CLI creates once, like
`lib/supabase/index.ts`, belongs to the app after that, so `init` and `add`
skip it when it exists. A file the CLI regenerates carries a
`Generated by better-supabase` header; when the file on disk has no header,
the app wrote it, so the command skips it and fails with `file_conflict`.
`--force` overwrites both. Under `--json`, `data.files` lists every file
with its outcome (`create`, `update`, `unchanged`, `skip` or `remove`).

## 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), 2 when the command line, the config or
the environment needs a change, and 3 when a person has to act first: give
a missing value (`input_required`) or agree to destructive work
(`consent_required`). Every error has a code, listed on the
[errors page](/docs/cli/errors).

With `--json`, stdout holds exactly one JSON document, the result envelope
described by
[`cli-result-v1.json`](https://unpkg.com/better-supabase/schemas/cli-result-v1.json):

```json
{
  "$schema": "https://unpkg.com/better-supabase/schemas/cli-result-v1.json",
  "status": "error",
  "data": { "upToDate": false, "stale": ["src/lib/supabase/generated.ts"] },
  "error": {
    "type": "https://bettersupabase.com/docs/cli/errors#check_failed",
    "title": "Out of date",
    "detail": "Out of date: src/lib/supabase/generated.ts. Run `better-supabase gen`.",
    "code": "check_failed",
    "exitCode": 1
  }
}
```

| Field    | Holds                                                                                                                   |
| -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `status` | `success` for exit code 0, `action_required` for exit code 3, and `error` otherwise                                     |
| `data`   | The command's result: `gen` gives `{ "tables", "written" }`, `doctor` its report, `blocks list --installed` the modules |
| `error`  | A [Problem Details](https://www.rfc-editor.org/rfc/rfc9457) document with a stable `code` ([errors](/docs/cli/errors))  |
| `next`   | The commands to run next, each as `{ "command", "when" }`                                                               |

A failed check keeps its `data`, so a script can read what is stale and the
error together. Progress and the text output go to stderr, so
`better-supabase gen --check --json | jq .data.stale` stays parseable. In a
terminal the next commands print after the output, under `Next:`.

`--help --json` prints the command tree instead, described by
[`cli-commands-v1.json`](https://unpkg.com/better-supabase/schemas/cli-commands-v1.json),
so an agent can learn the CLI without parsing help text. The document has the
CLI's `name`, `version` and global `options`, and one entry per command with
its `path`, `description`, `docs` link, `examples`, `effects` (the files,
database and network access a run may have), `arguments` and `options`. After
a command name it lists that command, or a group's subcommands:

```sh
better-supabase gen --help --json | jq '.commands[0].effects'
```

Stdout only ever carries the command's output: its text, its envelope, or
the one file `gen --metadata` prints. Prompts, spinners, warnings and
`--debug` lines go to stderr, and each stream is colored only when it is a
terminal.

`gen --watch` and `spec emit --watch` run until stopped. With `--json` they
print one envelope per run on its own line (NDJSON), so a script can read
the stream line by line:

```sh
better-supabase gen --watch --json | while read -r line; do
  echo "$line" | jq -r .status
done
```

The same failure in a row prints once, and the watch retries every interval.

## Environment [#environment]

| Variable                | Used by                                                                                              |
| ----------------------- | ---------------------------------------------------------------------------------------------------- |
| `DATABASE_URL`          | `gen`, `doctor` and `db 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 `database.source.projectRef`                                                     |
| `SUPABASE_API_URL`      | Another Management API host                                                                          |
| `SUPABASE_BIN`          | The Supabase CLI `dev env` runs, instead of `node_modules/.bin/supabase` or `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                                           |
| `BETTER_SUPABASE_AGENT` | Agent mode when `--agent` is not passed: `auto`, `yes` or `no`                                       |

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 dev 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. Pass `io` to
receive the output as it is written; `io.color` and `io.stderrColor` color
each stream, and `io.spinner` shows progress. `help()` returns the
usage text, and `help(command)` the text for one command.

Add your own commands with `defineCliCommand` and pass them to `createCli`,
which returns a CLI with them next to the built-in ones. 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`, `log`, `paint` and `emit`). Throw a
`CliError` to report a coded error, and return `data` to give `--json` a
document. Write messages with `log.info`, `log.warn` and `log.debug`, and
wrap slow work in `log.progress(message, task)`: all of it goes to stderr, so
stdout keeps only the result. `paint` colors text for stdout and is plain
under `--no-color`. A command that runs until stopped prints each result
with `emit` and returns `{ code: 0, streamed: true }`. Options listed in
`lists` may repeat or take commas, and `list()` splits them. Set `runsWithInvalidConfig: true` on a
command that repairs the config: when the config file fails to load, it runs
with the default config rooted at that file's directory instead of reporting
`config_invalid`.

`defineCliCommand` marks each command with `apiVersion: 1`, the command
contract this release supports. `createCli` refuses a command without it, a
name a built-in command already has, and the names kept free for account
commands (`login`, `logout`, `whoami`, `link`, `projects`, `deploy` and
`cloud`).

```ts title="scripts/cli.ts"
import { createCli, defineCliCommand, list } from "better-supabase/cli";

const cli = createCli({
  commands: {
    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 cli.run(process.argv.slice(2), {
  cwd: process.cwd(),
  env: process.env,
});
process.exitCode = code;
```

# init and add

> Scaffold the config, the data layer and framework glue for your project, start from a preset, and add blocks.

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 snake|camel`,
  `snake` by default, as in a config without `gen.casing`) and the
  [conventions](#conventions) that differ from the defaults. It lists the integrations it set up under `adapters`, and
  carries commented `gen` and `tooling` 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)).

`--workflow` picks how the SQL modules reach the database (see
[Workflows](/docs/blocks/sql#workflows)):

```bash
pnpm better-supabase init --workflow migrations
```

| `--workflow`         | What `init` does                                                                                 |
| -------------------- | ------------------------------------------------------------------------------------------------ |
| `pg-delta` (default) | Turns pg-delta on in `supabase/config.toml` and writes no `database.sql.workflow`                |
| `migra`              | Writes `sql: { workflow: 'migra' }` and leaves `config.toml` alone, for declarative schemas 1.0  |
| `migrations`         | Writes `sql: { workflow: 'migrations' }`: `add` and `upgrade` write the modules into a migration |

It then prints the next steps: the install command for your package manager,
`supabase start`, `better-supabase dev env` and `better-supabase gen`. Files that
already exist are never overwritten unless you pass `--force` or confirm the
prompt. `--dry-run` shows what would be written, and `--check` exits 1 when a
file is missing or differs ([writing files](/docs/cli#writing-files)). The names and the layout are described in [Naming](/docs/concepts/naming).

In a terminal, `init` asks for the conventions (unless you pass a convention
flag), the integrations, with the detected ones checked (unless you pass
`--integration`), 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`                  | `client`               |
| `expo`                  | `expo` (and `client`)  |
| `@tanstack/react-query` | `react` (and `client`) |

Add more with `--integration 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`.

Side effects: Writes files.

## Conventions [#conventions]

The convention flags set what [Create](https://app.bettersupabase.com/create)
sets in its Conventions step, with the same names and defaults:

| Flag            | Config key                              | Values                                                             |
| --------------- | --------------------------------------- | ------------------------------------------------------------------ |
| `--casing`      | `casing`                                | `snake` (default), `camel`                                         |
| `--scope`       | `database.sql.owner`                    | `tenant` (default), `user`, `none`                                 |
| `--tenant-name` | `blocks.memberships.names.organization` | `organization` (default), `workspace`, `team`, or a lowercase name |
| `--access`      | `blocks.permissions.model`              | `roles` (default), `catalog`, `provider`, `custom`                 |
| `--id-type`     | `blocks.permissions.idType`             | `uuid` (default), `text`, `bigint`, `integer`                      |
| `--api`         | `database.sql.api`                      | `none` (default), `api`                                            |

Without `--preset`, `init` asks for each one in a terminal, in Create's
order: Casing, Row ownership, Tenant name (for tenant scope only), Auth and
permissions, ID type and API schema. Once you pass a convention flag, it
asks for none of them and takes the defaults for the rest. It writes only
the values that differ from the defaults: `sql` for the scope, tenant name
and API schema, and `blocks.permissions` for the access model and id type, which
installs the access block. With `--access provider` it also writes a comment
that points you at the `authorization` key.

With `--preset`, each flag overrides that convention of the preset, and
`init` writes the preset as an object with the fields that differ from the
defaults. Its `tenantName` takes the names Create offers (`organization`,
`workspace`, `team`), so for another name leave `--tenant-name` out and set
`blocks.memberships.names.organization` in the config. `add --preset` takes
the same flags.

```bash
pnpm better-supabase init --scope user --api api
pnpm better-supabase init --preset saas --tenant-name workspace --access catalog
```

## Starting from a preset [#starting-from-a-preset]

```bash
npx better-supabase init --preset saas
npx better-supabase init --preset 1pWEF3d578B1iGt16
```

`--preset` takes a [named preset](/docs/presets), a code from
[Create](https://app.bettersupabase.com/create), its whole URL or the path of
a [design file](#a-design-from-create). `init` then
also:

- writes `preset` into `better-supabase.config.ts`, which `defineConfig`
  expands into `blocks` and the preset's conventions; the
  [convention flags](#conventions) override them
- adds the preset's integrations and its framework's integration to the
  ones it found
- writes the module files of every block the preset installs, as
  `better-supabase upgrade` does
- writes `lib/blocks.ts`, which wires the blocks that have a TypeScript
  client with `defineBlocks`
- writes the [wiring](#the-wiring-and-the-checklist) the preset's blocks
  need: their env keys in `.env.example` and their provider stubs in
  `providers.ts`
- prints a numbered checklist with the `npx shadcn@latest add
@better-supabase/...` command for the [registry glue](/docs/registry) that
  fits the preset's framework and platforms

A preset that can't install, such as one whose blocks need tenants under
scope `user`, stops `init` with the reason. In a terminal without
`--preset`, a convention flag or `--integration`, `init` first asks for a preset; None
keeps the steps above out. To apply a preset to a project that already has
a config, use [`add --preset`](/docs/cli/presets#applying-a-preset).

### A design from Create [#a-design-from-create]

When you change the preset or add tables or extra columns on the Create
canvas, its URL carries the design as `?design=`, with the preset inside it.
Quote the URL so the shell keeps it whole:

```bash
npx better-supabase init --preset "https://app.bettersupabase.com/create?design=0..."
```

`init` decodes the design locally, with no network call, and writes its
preset into `preset`, its [app tables](/docs/repository/tables) into `tables` and its extra columns into
each block's `options.extraColumns`. The canvas positions
stay in the URL. A design that doesn't decode, or that adds columns to a
block the preset doesn't install, stops `init` with the reason.

A design is capped at 6000 characters so the URL stays usable. When Create
says a design is too large to share, download the design file instead and
pass its path to `--preset`:

```bash
npx better-supabase init --preset ./better-supabase.design.json
```

The file holds the design as JSON with its preset, with no length limit,
and `init` reads it like a URL: the same checks, the same config keys and the
same module files. A path ending in `.json` is read as a design file, relative
to `--cwd`. [`add --preset`](/docs/cli/presets#applying-a-preset) takes the same file. The format
is on [Designs and the resolver](/docs/presets/designs#design-files).

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

## Content mapper [#content-mapper]

```bash
pnpm better-supabase init --content-mapper
```

`--content-mapper` sets `gen.contentMapper: true`, adds the better-supabase
entry to `contentMappers` in `tsconfig.json` and adds `--runExternalCode` to
the `typecheck` script, so TypeScript 7.1 serves the schema types from the
[content mapper](/docs/content-mapper) instead of `database.types.ts`. A
`tsconfig.json` that holds comments or is missing, and a `package.json`
without a `typecheck` script that runs `tsc`, are left alone; the next steps
print the change to make instead.

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

On [Create](https://app.bettersupabase.com/create), turn on Monorepo and enter
the package path (`apps/web` by default): the install and `add` commands then
end in `--package <dir>`. A Create URL carries it as `&package=`, so `init`
and `add --preset` take it from a URL you pass to `--preset`. The migrations stay in the root `supabase/` folder. See
[monorepos](/docs/guides/monorepo) for how to split the runtime from domain
packages.

## add [#add]

```bash
pnpm better-supabase add comments tags
pnpm better-supabase add --integration hono,mcp
```

`add` with block names is how you install blocks. It lists them in `blocks`
of the config and writes their module files and the modules they require,
rendered next to the blocks you already have, so a block sees what is
installed. `--dry-run` prints what it would write, `--check` exits 1 when a
file is out of date, `--tests-dir <dir>` writes the pgTAP files somewhere
other than `database.sql.testsDir`, and `--allow-hook-overlap` installs a
block whose hooks overlap another's ([SQL modules](/docs/blocks/sql)). It
also wires the blocks that have a TypeScript client into `lib/blocks.ts`:
an import and an entry in the object passed to `defineBlocks` (or
`createBlocks`). When the file is missing or calls neither with an object
literal, `add` prints the lines to add instead. Last, it prints the
`npx shadcn@latest add @better-supabase/...` command for the registry glue
of those blocks that fits the frameworks in the config or `package.json`,
and the command that turns the files into a migration.
`better-supabase blocks list` shows the names. `--force` overwrites files
that exist.

A new block can change the SQL of blocks you already have: organizations
renders its audit triggers once audit is installed, and entitlements reads
plan quotas once billing is. `add` renders those installed blocks again, writes
only the files whose output changed, and prints `Re-rendered <block>: it
integrates with <new block>` for each.

### The wiring and the checklist [#the-wiring-and-the-checklist]

Each block's manifest declares what the app has to provide for it: env keys,
providers, routes and workers. `add` and `init` write what they can and print
the rest as a numbered checklist:

- **Env keys.** Missing keys go to the end of `.env.example`, each under its
  description, so `CRON_SECRET=` appears once for the outbox and the queue.
  Set them in `.env.local` and in every deployed environment.
- **Providers.** A block that takes an implementation (enrichment's
  `EnrichmentProvider`, mail-sync's `MailProvider`s, the shipping carriers,
  the order payments and tax) gets a commented stub in `providers.ts`, next to
  `blocks.generated.ts`. Fill it in and spread it into `defineBlocks`:
  `defineBlocks(blockConfig, { ...blocks, ...providers })`.
- **Routes.** The outbox relay, the queue drain, the Stripe webhook and the AI
  chat route are registry items; the checklist names the `npx shadcn@latest add`
  command for your framework.
- **Workers.** The checklist names each route a cron calls, with its schedule
  and the `Authorization: Bearer $CRON_SECRET` header.
- **Block timers.** The SLA, sequences, AI schedules and workflows blocks run
  timers on the queue; the checklist lists them and names
  `jobs.ensureSchedules(blockSchedules(blocks), { prefix: BLOCK_SCHEDULE_PREFIX })`
  and the `blockTickHandler` drain ([Block timers](/docs/blocks/queue#block-timers)).
- **Actions.** The checklist lists the action kinds the installed blocks ship,
  such as `tasks.from_conversation`, to register in the rules consumer's
  `createActionRegistry` so their default automations run.

[Doctor](/docs/cli/doctor) fails on what is left: BS504 on a required env key
no env file lists, BS505 on a required provider that is still a comment.

`add --preset <name|code|url>` applies a preset to the config you have
(`--only`, `--package` and `--allow-breaking`), and `add --adopt <block>
--tables <schema.table>` maps a block onto tables you already have. Both read
the database through `--snapshot`, `--db-url-stdin` or `--project-ref` when
they need it, and [Presets and blocks](/docs/cli/presets) describes them.

`--integration` adds glue for integrations instead. `add` without an argument
or `--integration` sets up every integration in the config's
`adapters` list whose files are missing, which is how a fresh checkout
or a new package in a workspace gets its glue. When `--integration` names
integrations the config doesn't list yet, `add` prints the `adapters`
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 `bsb` and adds the middleware and error handler                                                              |
| `orpc`      | `router.ts`, which creates `bsb` and adds the auth middleware                                                                           |
| `edge`      | `supabase/functions/api` (`server.ts` creates `bsb`) plus `supabase/functions/_shared/supabase.ts`                                      |
| `expo`      | `lib/supabase/expo.ts` (`bsb` for API routes), `lib/supabase/client.native.ts` (the session in secure storage) and `app/+middleware.ts` |
| `mcp`       | `supabase/functions/mcp`, an MCP server as an Edge Function (`server.ts` creates `bsb`)                                                 |

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

Side effects: Writes files, reads the database with `--adopt`, calls the Supabase Management API with `--project-ref`.

## remove [#remove]

```bash
pnpm better-supabase remove comments --dry-run
pnpm better-supabase remove comments tags
```

`remove` takes blocks out of `blocks` in the config and out of
`lib/blocks.ts`, and deletes the module files only they wrote. It refuses a
block another installed block requires; remove both together. It writes no
migration: [`db diff -f remove_comments`](/docs/cli/db#diff) writes the one
that drops their tables, so you can read the `drop` statements before they
run. `--dry-run` prints what it would change, and `--check` exits 1 while
the files still differ. A module file edited by hand stays, and the command
exits 1 naming it; `--force` deletes it anyway.

Side effects: Writes files.

## upgrade [#upgrade]

```bash
pnpm better-supabase upgrade
pnpm better-supabase upgrade --check
```

`upgrade` writes the module files of every block in the config again, with
the current library and config. When an installed block's version in the
database files is older, it first writes the upgrade steps between the two
versions into a migration stamped before the next schema migration, then
the files. Run it after you bump `better-supabase` or change a block's
options. `--check` exits 1 when a file differs and is the CI check,
`--dry-run` prints the diff, and `--tests-dir <dir>` writes the pgTAP files
somewhere other than `database.sql.testsDir`.

Each block file carries a `-- @bsb-hash sha256:...` line over its SQL. When
the SQL no longer matches the hash, the file was edited by hand: `upgrade`
leaves it alone and exits 1 naming it. Move the change into your own schema
file or a SQL hook, or pass `--force` to overwrite it. `upgrade` also renames
files from earlier releases (2-digit positions such as
`900_better_supabase_04_memberships.sql` become `..._004_...`) and removes
block files that no installed block writes, unless they were edited by hand.
Over a table your own schema already creates, a block adds only the columns it
lacks; see [Your own schemas](/docs/blocks/own-schemas).

Side effects: Writes files.

# MCP server

> Serve the CLI to a coding agent as an MCP server on stdio, read-only by default, with file writes behind the user's consent.

Source: https://bettersupabase.com/docs/cli/mcp

`better-supabase mcp` runs an MCP server on stdio for the project in the
current directory. Each tool runs one CLI command in process with `--json`
and returns its [result envelope](/docs/cli#exit-codes-and-json-output) as
`structuredContent`, so an agent reads the same `status`, `data`, `error`
and `next` it would get from the shell.

```bash
pnpm better-supabase mcp
```

Side effects: Reads the database, writes files with --allow-writes.

This server is for development. To give an agent access to your app's data
as the signed-in user, serve [`createMcp`](/docs/frameworks/mcp) from the
app instead.

## Add it to an agent [#add-it-to-an-agent]

In Cursor, add the server to `.cursor/mcp.json` in the project:

```json title=".cursor/mcp.json"
{
  "mcpServers": {
    "better-supabase": {
      "command": "pnpm",
      "args": ["exec", "better-supabase", "mcp"]
    }
  }
}
```

In Claude Code, add it from the project directory:

```bash
claude mcp add better-supabase -- pnpm exec better-supabase mcp
```

The server runs every command in the directory the agent started it in, with
the environment the agent gave it, and the config file and `--env` it found
at start.

## Tools [#tools]

The read-only tools run by default:

| Tool                 | Command                           |
| -------------------- | --------------------------------- |
| `doctor`             | `doctor`                          |
| `blocks_list`        | `blocks list`, with `category`    |
| `blocks_view`        | `blocks view <block>`             |
| `blocks_search`      | `blocks search <text>`            |
| `config`             | `config`                          |
| `config_preset`      | `config --preset`                 |
| `blocks_installed`   | `blocks list --installed`         |
| `gen_check`          | `gen --check`                     |
| `add_preset_dry_run` | `add --preset <preset> --dry-run` |

`--allow-writes` adds the tools that write files: `gen`, `upgrade`,
`add_preset` (`add --preset`, with `preset` and `allow_breaking`), `add`
(with `blocks`) and `remove` (with `blocks`).

## Consent for writes [#consent-for-writes]

A write tool never runs on its first call. It returns a `consent_required`
error, the same one [agent mode](/docs/cli/agent-mode) uses, with a `token`
and a `detail` to show the user. Once the user agrees, the agent calls the
tool again with the same arguments and `confirm` set to the token. The token
covers only those arguments, so a call with other blocks or another preset
asks again.

```json
{
  "status": "action_required",
  "error": {
    "code": "consent_required",
    "detail": "add writes files in /app. Ask the user first, then call add again with confirm \"3f9c0a1b2d4e5f60\".",
    "token": "3f9c0a1b2d4e5f60"
  }
}
```

The commands themselves run with agent mode off, so this one consent covers
the call. No tool forces an overwrite or writes to the database.

## Protocol [#protocol]

The server answers `initialize` for clients on the 2025 revisions and
`server/discover` for 2026-07-28 clients, then `tools/list` and
`tools/call`, one JSON-RPC message per line. The CLI prints its own
messages on stderr, so stdout carries only protocol messages.

# Presets and blocks

> Apply a preset to an existing project, map blocks onto tables it already has, read a preset or design, and find the blocks to add.

Source: https://bettersupabase.com/docs/cli/presets

[`add --preset`](/docs/cli/init#add) applies a [preset](/docs/presets) to
the project you have, `add --adopt` maps a block onto tables you already
have, and `blocks` reads the block catalog. To start a new project from a
preset, use [`init --preset`](/docs/cli/init#starting-from-a-preset).

## Applying a preset [#applying-a-preset]

```bash
pnpm better-supabase add --preset crm
pnpm better-supabase add --preset 1pWEF3d578B1iGt16 --dry-run
pnpm better-supabase add --preset crm --only blocks
```

`add --preset` edits the config file you have. By default it sets `preset` to the
named preset or the code, replacing the one the config has, so
`defineConfig` expands it into `blocks` and the conventions while your own
keys keep winning. It prints the diff of the config file, then loads the
config again and writes the module files of every block it now holds, as
`better-supabase upgrade` does. `--dry-run` prints the diff and writes
nothing, and `--check` exits 1 with the diff when the config doesn't hold the
preset yet.

| Value of `--only` | What `add --preset` changes                                                                  |
| ----------------- | -------------------------------------------------------------------------------------------- |
| unset             | `preset`: the preset's blocks and its conventions                                            |
| `blocks`          | `blocks`: the preset's blocks and what they require, listed by name; `preset` stays as it is |
| `conventions`     | `preset`: a code with the preset's conventions and no blocks                                 |

With a Create URL that holds a design (`&design=`), `add --preset` also merges the
design into the config: each table in `tables`, replacing a table of the
same name and keeping the others, and each block's extra columns over the
`options.extraColumns` it already has. Quote the URL. `--only conventions`
leaves the design out. A design too large for a URL comes as a design file
from Create; pass its path instead of the URL:

```bash
pnpm better-supabase add --preset ./better-supabase.design.json
```

See [init](/docs/cli/init#a-design-from-create) for what a design holds.

The [convention flags](/docs/cli/init#conventions) (`--casing`, `--scope`,
`--tenant-name`, `--access`, `--id-type` and `--api`) override that
convention of the preset, and `add --preset` writes the changed code. `--only
blocks` takes none of them.

In a workspace, `--package <dir>` edits the config of that package instead
of the one at `--cwd`; a Create URL with `&package=` sets it too. A URL with
`&authz=` makes `add --preset` print a note: the authorization library you picked on
Create isn't part of the preset, so set it up from Get code.

```bash
pnpm better-supabase add --preset crm --scope user --api api
```

### Breaking changes [#breaking-changes]

Before it writes a design, `add --preset` compares its tables with `tables`.
Adding a table, an optional column, an index, a comment or a subject title
is safe, and so is a looser rule or a new `onDelete`. These break rows
already in the database or app code that reads them:

- renaming a table or column, dropping a column, or changing its type or
  the table's id type;
- making a column required without a default;
- a new or tighter rule that existing rows may fail: `unique`, a removed
  option, a raised `min` or lowered `max`, a checked format (`email`,
  `url`, `rating`, `percent`), a new reference target or a new unique set;
- a new `scope`, or removing `timestamps`, `rank` or `softDelete`, which
  drops the columns they add.

`add --preset` lists each breaking change with the expand-and-contract steps that
make it safe, writes nothing and exits 1:

```txt
Breaking changes to app tables:
  Changes notes.body from jsonb to text.
    1. Add a text column next to body.
    2. Fill it from body with a cast in a migration, and write both columns.
    3. Move reads to the new column, drop body in a later migration and rename the new one if you need the name.
```

Pass `--allow-breaking` to write the design anyway, for example on a project
without data. `--dry-run` prints the list and the diff without it. A table
or column with the same definition under a new name counts as a rename.
With `--allow-breaking`, `add --preset` prints the `alter table ... rename`
statements to run in a migration before `upgrade`, so the rows move to the
new name (`data.renames` under `--json`).

A table in `tables` that the design doesn't name is kept, and `add --preset`
reports it (`data.kept`). Remove it from `tables` yourself to drop it.
`diffAppTables` and `diffDesigns` from `better-supabase/preset` return the
same changes, and Create shows them on the Changes tab of Get code.
[Doctor's BS228](/docs/cli/doctor#bs228) reports a column whose type or
nullability in the database has drifted from `tables`.

### Adopting existing tables [#adopting-existing-tables]

```bash
pnpm better-supabase add --preset crm --only blocks --adopt --dry-run
```

With `--adopt`, `add --preset` reads the tables in the database and looks for each
block's tables under their default names (`teams` and `team_members` for
`teams`). For every block it finds there, it writes the `tables` and
`columns` of `blocks.<block>`, so the block
[runs over your tables](/docs/blocks/sql#over-your-own-tables): `tables`
names the existing tables, `columns` the columns that differ from the
defaults, and an optional column the table lacks maps to `null`. It writes
no `ownership`. The proposed mapping of each block is printed before the
diff, so `--dry-run` shows both. Blocks with no existing table are left as
they are, and so is a block in `custom` ownership.

When a block has a table under a default name that the block can't run
over, for example because a value it writes doesn't fit a column,
`add --preset` writes nothing and prints why. Change the column, map the
block with `better-supabase add --adopt`, or leave the block out of the
preset. A required column the table lacks is a warning, because the block's
migration adds it.
`--adopt` reads the tables where `gen` does: the config's `source`,
`$DATABASE_URL`, `$SUPABASE_DB_URL` or the local stack, or `--snapshot`,
`--db-url-stdin` and `--project-ref`.

A preset that can't install stops `add --preset` with the reason. When the config
isn't an object literal `add --preset` can follow, it changes nothing and prints the
`preset` entry to set yourself. Without a config file, `add --preset` points to
`init --preset`.

## Adopting tables [#adopting-tables]

```bash
pnpm better-supabase add --adopt teams --tables public.squads --dry-run
pnpm better-supabase add --adopt audit --tables public.audit_logs
pnpm better-supabase add --adopt tags --tables app.labels,app.label_links --json
```

`add --adopt <block>` maps one block onto tables the app already has and writes the
proposed `tables` and `columns` of `blocks.<block>`; it writes no
`ownership`, because a managed block
[runs over every table your schema already has](/docs/blocks/sql#over-your-own-tables).
Each table in `--tables`
(`table` or `schema.table`, comma-separated or repeated) goes to the block's
logical table whose columns it matches best; the block's other tables are
looked up by their default names, and the ones the app doesn't have are left
for the block to create. Each logical column then takes the column
of the same name, or a likely rename: `organization_id` for `tenant`,
`position` for `rank`, `recipient_user_id` for `user`. Those guesses are
marked in the output, so check them before you keep the entry. When the
tenant column is a `bigint`, `text` or `integer`, the entry sets `idType`
too.

The output lists every logical table and column with what it maps to, then
a report like doctor's: errors for what can't map (none of the block's
tables found, a value the module refuses) and warnings for a required column
the table lacks, which the block's migration adds, and for the
migration-only options the entry keeps. With an error `add --adopt` exits
1 and writes nothing. Otherwise it prints the diff of the config and writes
it; `--dry-run` writes nothing, and `--check` exits 1 when the config doesn't
hold the mapping yet. Run `better-supabase upgrade` afterwards to
write the block's module over your tables; [Your own schemas](/docs/blocks/own-schemas)
describes what it writes there. `--json` prints the entry,
the mapping and the report as data. The tables are read like `gen`
reads them, with `--snapshot`, `--db-url-stdin` and `--project-ref`.

A block that creates all its tables can't be mapped; the error lists the
blocks that can run over yours.

Without a block, `add --adopt` writes the data migration instead: the rows
and settings of the installed blocks that a schema diff skips
([SQL modules](/docs/blocks/sql)).

## Reading a preset [#reading-a-preset]

```bash
pnpm better-supabase blocks list --preset crm
pnpm better-supabase blocks list --preset "https://app.bettersupabase.com/create?design=0..."
pnpm better-supabase config --preset
```

`blocks list --preset` takes a name, a design code or a `/create` URL and
prints the preset's name (or `custom`), its conventions, every block it
installs with the reason it is there, and anything that can't install, which
exits 1. A 0.14 preset code stops with the reason and names
`better-supabase codemod 0.15`.

`config --preset` reads the `preset` of the config and prints the same, then
how the config differs from it: the blocks it adds (`+`), a casing that
differs, and the preset blocks it gives settings of its own (`~`).

Neither command changes anything.

## blocks [#blocks]

```bash
pnpm better-supabase blocks list
pnpm better-supabase blocks list --category crm
pnpm better-supabase blocks view comments
pnpm better-supabase blocks search invoice
```

`blocks list` prints every block by category, and `--category` keeps one
(`identity`, `tenancy`, `collaboration`, `messaging`, `growth`, `crm`,
`work`, `people`, `commerce`, `scheduling`, `content`, `ai`, `automation`,
`developer`, `admin` or `database`). `blocks view` prints one
block: what it requires, what it works with, its modes, its client
subpath, the `better-supabase add` command and its registry glue.
`blocks search` matches the name, the title and the description. Each one
reads the same catalog as [/blocks](https://bettersupabase.com/blocks) and
`--json` prints it as data.

```bash
pnpm better-supabase blocks list --installed
pnpm better-supabase blocks view audit --sql
```

`blocks list --installed` lists the blocks in your config with the version
each one has in the database files and whether `upgrade` has steps for it.
`--tests-dir <dir>` reads the pgTAP files from another folder than
`database.sql.testsDir`. `blocks view --sql` prints the SQL `add` would write
for the block with your config, and writes nothing. `blocks list --preset`
reads a preset, as above.

| Command         | Side effects |
| --------------- | ------------ |
| `blocks list`   | None.        |
| `blocks view`   | None.        |
| `blocks search` | None.        |

# 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 emit --check   # exit 1 when a file is out of date
pnpm better-supabase spec validate       # also check against the official schemas
```

| Subcommand     | What it does                                                                                                   |
| -------------- | -------------------------------------------------------------------------------------------------------------- |
| `emit`         | Renders each output and writes the files that changed                                                          |
| `emit --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.

| Command         | Side effects  |
| --------------- | ------------- |
| `spec emit`     | Writes files. |
| `spec validate` | None.         |

## The entry module [#the-entry-module]

`spec` imports `api.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({
  api: {
    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`   | `api.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`, `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, `api.openapi.entry` becomes `api.specs.entry` and `api.openapi.output` becomes
the output of the one OpenAPI document. A module that exports a
`createOpenApi` document instead of a `defineApi` definition works too, so
`spec emit openapi` replaces the old `openapi emit` command.

## 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 `api.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 `api.specs.failOn`                |
| `--manifest <file>`        | Also write the diagnostics manifest to this file. Defaults to `api.specs.manifest`                             |
| `--cache`, `--no-cache`    | Reuse renders whose inputs did not change (the default), or render every output again                          |
| `--check`                  | With `emit`, compare each output with the file on disk and write nothing; exit 1 when one is stale or missing  |
| `--dry-run`                | With `emit`, print a diff of each output that would change and write nothing                                   |
| `--watch`                  | With `emit`, render again whenever a watched file changes                                                      |
| `--interval <ms>`          | Milliseconds between checks in `--watch`. Defaults to `gen.watchInterval`, then `2000`                         |

`--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                                             |
| `asyncapi-topic-undocumented` | warning  | A topic in `realtime.policies.from` has no channel in the AsyncAPI document              |

## Topics from `realtime.policies` [#topics-from-realtimepolicies]

When the config sets `realtime.policies` (see
[writing the policies to a file](/docs/platform/realtime#writing-the-policies-to-a-file)),
`spec` loads its `from` modules with the same
loader `upgrade` uses, for every AsyncAPI output. Each `defineTopic` export
whose template no channel address matches gets an
`asyncapi-topic-undocumented` warning naming the export, so a topic you add
for its policies can't go missing from the document. Pass the module to
`defineApi` with `events: { topics: topicsFrom(topics) }` to document it
(see [broadcast topics](/docs/specs/asyncapi#broadcast-topics)). A module
that fails to load, or exports no topic, exits with code 1.

`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`), an
overlay file or a `realtime.policies.from` module changed, and runs again
when one did. A changed config file is loaded again first; a config that
fails to load is reported once, and the watch waits until it loads. 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. With
`--json`, each run prints one envelope on its own line
([NDJSON](/docs/cli#exit-codes-and-json-output)).

## 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` puts it in the envelope's `data`. 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: `api.openapi.html` for `openapi.json`. The page loads
the document from `./openapi.json` and takes its title from `info.title`.
`emit --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 emit --check --fail-on warning
pnpm better-supabase spec validate --manifest spec-manifest.json
```

`emit --check` exits 1 when a file is stale or missing, or when a finding fails
`--fail-on`. Usage errors exit 2.

# 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` 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-editor) 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({
  auth: { authorization: authorizationProvider() },
  blocks: { permissions: {}, organizations: {} },
});
```

With `authorization` set, `blocks.permissions.model` defaults to
`"provider"`, so `can()`, `tenant_ids_with()` and every module check answer
from the provider's templates.

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

A package that audits the tables it creates, or the app's tables, calls
`better_supabase.audit()` like the app does. Each call replaces the table's
settings, so pass the app's `ignore` and `redact` from `audit_settings(table)`
instead of calling it with defaults. See the
[audit log](/docs/blocks/sql#audit-log).

## What reads it [#what-reads-it]

| Feature                                                                                    | What it takes from the provider                                                   |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `blocks.permissions.model: "provider"`                                                     | `can()` and the modules' checks call `functions`, at `tenantScope`                |
| `auth.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`               |
| `on.<type>.scope` in attachments and comments                                              | `idsWith` at that scope, for grants on a single record                            |
| [API keys](/docs/blocks/api-keys) with `claim`                                             | nothing at runtime; the claim names follow the provider's functions               |
| `better-supabase add memberships`                                                          | 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`          |

`auth.entitlements.memberships` and `blocks.permissions.model` both default to
`"provider"` when `authorization` is set. Set another access model to keep
the built-in roles; doctor BS414 then reports each policy and function that
calls `can()` or `tenant_ids_with()`, because those answer from roles the
provider never sees.

The config key covers SQL. Server code reads the same decisions from
[runtime hooks](#runtime-hooks), passed to `createServer`.

## 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 };
  operations?: Record<string, AuthorizationOperation>;
  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.

### Nested tenancy [#nested-tenancy]

The built-in tenancy has one level: a row belongs to an organization, and
`tenant_ids_with()` returns organization ids. An app that splits each
organization into environments (or projects, or workspaces) models the extra
level as provider scopes. Each scope names its `parent`, and `tenantScope`
picks the level the modules treat as the tenant:

```ts
scopes: [
  { name: "organization", idType: "uuid" },
  { name: "environment", idType: "uuid", parent: "organization" },
],
tenantScope: "environment",
functions: {
  idsWith: "authz.{scope}_ids_with({permission})",
  isPlatform: "authz.is_platform({permission})",
  memberIds: "authz.member_{scope}_ids()",
},
```

With `tenantScope: "environment"`, the modules' `organization_id` columns
hold environment ids, and `can()` asks the provider for the environments
where the caller holds a permission. The provider decides how a role on an
organization reaches its environments, so `authz.environment_ids_with()`
returns the environments of every organization where the caller holds the
key, plus those granted one by one. Keep `tenantScope: "organization"` when
the modules work per organization and only your own tables have an
environment column; your policies then call the provider's environment
function directly.

### 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                                                           |
| `teamIdsOf`      | `{user}`                                                    | a set of the team ids `{user}` belongs to                                                  |
| `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}`                                 |
| `isApprover`     | `{kind}`, `{subject}`, `{tenant}`, `{requester}`, `{scope}` | `boolean`: the caller may approve request `{subject}` of kind `{kind}`                     |
| `canApprove`     | `{tool}`, `{tenant}`, `{scope}`                             | deprecated: `isApprover` with `{kind}` `'ai-tool'`                                         |
| `holdersOf`      | `{permission}`, `{tenant}`, `{scope}`                       | a set of user ids that hold `{permission}` on `{tenant}`                                   |
| `actorMayWrite`  | none                                                        | `boolean`: the caller's session may write                                                  |
| `permitted`      | `{permission}`, `{id}`, `{tenant}`, `{resource}`, `{scope}` | `boolean`: the caller holds `{permission}` on instance `{id}` of `{resource}`              |
| `portalSubjects` | `{action}`                                                  | a set of `(tenant, subject_type, subject_id)` the caller reaches as a guest for `{action}` |
| `portalParties`  | none                                                        | a set of `(tenant, party_type, party_id)` the caller acts for as a guest                   |

`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 `permissions` 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` call `idsWithFor`; and
`usage` reads memberships through `memberIds`; `approvals` checks that a
delegate is a member through `memberIdsFor`. Doctor (BS411) warns for each
installed module whose template the provider doesn't set.

`teamIdsOf` is for a provider that keeps its own teams. `team_ids_of()` in
the `teams` block returns its set instead of reading `team_members`, and
`saved-views` and `collections` share with those teams even without the
`teams` block. Doctor (BS417) warns when the provider declares a `team`
scope and leaves it unset.

`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`, `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 `auth.organization.claim`                                          |
| `ownedClaims`      | BS407 reports another hook that writes them; `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                                                                |

### Approvals [#approvals]

`isApprover` is the one eligibility check for every request that waits for
someone else: AI tool calls in the [ai-chat block](/docs/blocks/ai-chat)
(`{kind}` `'ai-tool'`, `{subject}` the tool's name) and support sessions
(`{kind}` `'support-session'`, `{subject}` the request id). `{tenant}` is
required. `{requester}` is optional: the uuid of the user who asked (the
approval's `requested_by`, the support admin, the chat's owner). A provider
that resolves out-of-office covers inside `isApprover` reads it so a cover
never lets the requester's own delegator approve. Under the `provider` model,
`decide_ai_tool_approval` lets the service role and any caller `isApprover`
allows decide the call. `approvals.distinctApprover: true` keeps the
requester from deciding their own request, for four-eyes approval. Without
`isApprover`, the chat's owner decides a tool call.

`canApprove` still works in 0.9 and is read when `isApprover` is unset;
`better-supabase codemod 0.9` points at each use. Doctor BS415 warns while a
provider sets it.

`holdersOf` is the audience for `notifications.send({ permission })`: the
user ids that hold that key on the tenant. A provider sets it from its
manifest when the tenant scope lists `permitted_<scope>_users`, for example
`permdock.permitted_organization_users({permission}, {tenant})`. Grant
execute on that function to the role that owns `send_notification`, and
never to `authenticated`. Without the template, a notification that names
`permission` fails with `NOTIFICATION_PERMISSION_AUDIENCE_UNAVAILABLE`.

`actorMayWrite` replaces the built-in read of the `act` claim. A provider
sets it to `permdock.permdock_actor_may_write()` when the manifest lists
that helper. Write functions and write policies call the template and not
the built-in as well. Doctor BS413 warns when support sessions are installed
under the provider model and this template is missing.

```ts
functions: {
  // ...
  isApprover: "authz.is_approver({kind}, {subject}, {tenant}::text, {requester})",
},
approvals: { distinctApprover: true },
```

#### Out-of-office covers [#out-of-office-covers]

The approvals block's `delegate(id, userId)` hands one approver's place on
one step of one request to someone else. A standing cover ("Ana approves for
Ben until Friday") is a different thing, and it belongs to the provider when
the provider has one: its `isApprover` answers true for the cover as well as
for Ben, so every module that waits for an approval (the approvals block, AI
tool calls, support sessions) sees it without a configuration of its own.
Configure covers in one place only. An app without such a provider calls
`delegate` per request instead.

PermDock keeps covers in its `approval_delegations` table and resolves them
in `permdock_is_approver`, one hop deep, never for the requester's own
delegator. Its [better-supabase adapter](https://permdock.com/docs/adapters/better-supabase#approval-delegation)
passes `{requester}` for that check.

### Delegated tokens [#delegated-tokens]

`delegation.scopes` maps an OAuth scope to the permission keys a delegated
token (an OAuth client or an agent) may use. The access functions narrow
every check to those keys, and `blocks.permissions.scopes` is merged over it.
See [Access](/docs/repository/permissions#delegated-tokens).

```ts
delegation: {
  scopes: { "invoices:read": ["invoices.read"], "invoices:write": ["invoices.read", "invoices.update"] },
},
```

### Share links as guests [#share-links-as-guests]

The [share-links block](/docs/blocks/share-links#guest-pages) resolves a
token in `security definer` functions. A provider can instead turn the row
`resolve_share_link` returns into a link principal and exchange it for a
short-lived anonymous session, so a guest page reads the subject under the
provider's row-level security. PermDock's `subjectFromShareLink` does this:
it builds a link capability from the row, caps its lifetime at the row's
`expiresAt`, and denies when the row is missing, revoked or expired.

### Guests and portals [#guests-and-portals]

`portalSubjects` and `portalParties` let a provider's external users (a
client, a contractor) into comments, attachments, invoices and bookings
without making them members. `{action}` is `'read'`, `'comment'` or
`'upload'`, and every column is read as text:

```ts
functions: {
  portalSubjects: "authz.guest_records({action})",
  portalParties: "authz.guest_parties()",
},
```

`access` wraps them in `better_supabase.portal_subjects(action)` and
`portal_parties()`, which policies read through `ctx.scope.guest` and
`ctx.scope.guestParty`. With the [guests block](/docs/blocks/guests)
installed, its own grants are added to the provider's sets. Without either,
the checks render `false` (ADR 0070).

### Operations [#operations]

`operations` maps an operation name to the permission it needs, so one table
names the permission for every workflow-editor node, MCP tool, AI tool and
inbox command. A module looks up `<prefix><name>` and falls back to its own
key, after the `blocks.<module>.permissions` rename:

| Prefix           | Operations            |
| ---------------- | --------------------- |
| `workflow.node.` | workflow-editor nodes |
| `mcp.tool.`      | MCP tools             |
| `ai.tool.`       | AI tools              |
| `inbox.command.` | inbox commands        |

```ts
operations: {
  "mcp.tool.search_invoices": { permission: "invoices.read" },
  "ai.tool.refund": {
    permission: "invoices.refund",
    meta: { risk: "high", effects: ["write", "external"] },
  },
},
```

An MCP tool's `operation`, an agent's `toolOperations` and a
workflow-editor step's `operation` name an entry in place of the prefixed
one. Under the access module, `better_supabase.operation_permission(op)`
answers the key of any operation in SQL: the installed modules' operations
(`<module>.<action>`, such as `audit.view`) after renames, with this table
over them, and `null` for a name nobody declared.

Each `permission` must be in `permissions` when the provider lists them; the
conformance block checks it. `meta.risk` and `meta.effects` describe the
operation for approval prompts and tool listings.

## 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 upgrade` 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 `upgrade`:

```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}`.

## Runtime hooks [#runtime-hooks]

The config key is plain data for SQL. `AuthorizationHooks` is the runtime
half: functions that decide the same things in server code, so one provider
can own automations, support sessions, delivery, identity links, chat
actions, approvals and delegated tokens end to end. Build it with `defineAuthorizationHooks`
from `better-supabase/server` and pass it to `createServer`:

```ts title="src/server.ts"
import { createServer, defineAuthorizationHooks } from "better-supabase/server";
import { betterSupabase } from "./supabase";

const authorization = defineAuthorizationHooks({
  apiVersion: 1,
  name: "your-provider",
  support: { authorize: async (input) => policy.supportStart(input) },
  delivery: { subscribers: policy.subscribers, audience: policy.audience },
  approverOf: (approver, request) => policy.mayApprove(approver, request),
});

export const bsb = createServer(betterSupabase, { authorization });
```

Every hook is optional. A slot passed to a block directly wins over the hook.

| Hook                             | Decides                                                                                   | Owner page                                              |
| -------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `automation.mayPublish`          | whether the publisher may publish a version whose nodes need the stored permissions       | [Workflow builder](/docs/blocks/workflow-editor)        |
| `automation.runAs`               | the claims each step of a published version runs with; `null` fails the step              | [Workflow builder](/docs/blocks/workflow-editor)        |
| `support.authorize`              | a support session start: `true` still needs a second approver, `{ approvedBy }` names one | [Support sessions](/docs/auth/impersonation)            |
| `delivery.subscribers`           | which webhook endpoints, outbox consumers and realtime members may read an event          | [Outgoing webhooks](/docs/blocks/webhooks-out)          |
| `delivery.audience`              | which notification recipients may read a notification                                     | [Notifications](/docs/blocks/notifications)             |
| `identities.principalByIdentity` | the user or contact a verified email address or chat user links to                        | [Identities](/docs/blocks/identities)                   |
| `chat.onAction`                  | a button a linked chat user pressed, such as an approval                                  | [Chat SDK](/docs/chat-sdk)                              |
| `approverOf`                     | whether a user may approve a request; the server side of `isApprover`                     | [Approvals](#approvals)                                 |
| `tickets.subject`                | the context an opaque run ticket stands for, for jobs and workflow steps                  | [Jobs](/docs/blocks/queue)                              |
| `delegation.permissions`         | the permission keys a delegated token's scopes grant, as the cap `forContext` runs with   | [Access](/docs/repository/permissions#delegated-tokens) |

Without `delivery.subscribers` or `delivery.audience`, delivery still fails
closed on records: webhooks, notifications and reminders fall back to
`subjectReaders`, which keeps a subscriber only when
`can_subject_for(user, type, id, 'read')` is true for an event about a
registered subject, and realtime fan-out refuses such an event without a
policy.

Each hook fails closed. A throw or rejection refuses, `null` from `runAs`,
`principalByIdentity` or `tickets.subject` refuses, and no hook falls back to
the service role. better-supabase refuses a requester as their own approver
before it calls `approverOf`.

`createServer` throws when the hooks target another `apiVersion`, with a
message naming both versions.

### Test the hooks [#test-the-hooks]

`testAuthorizationHooks` from `better-supabase/testing` runs the hooks a
provider sets against cases every provider must refuse: subscriber and
audience policies return a subset of their input, an unlinked identity is
`null`, a requester never approves their own request, a forged ticket is
`null`, an unknown publisher may not publish and gets no claims, an
unlinked chat author is refused, an admin with no claims cannot start a
support session, an admin is never named as their own support approver,
and a delegated token with no scopes maps to no permissions.

```ts title="tests/provider.test.ts"
import { testAuthorizationHooks } from "better-supabase/testing";
import { it } from "vitest";

it("fails closed", () => testAuthorizationHooks(authorizationHooks()));
```

## Version cache [#version-cache]

A provider that caches decisions per user can key them on an authorization
version: a number the database bumps whenever a user's roles or grants
change. An access token hook copies it into the token as `authz_ver`, and
`session.authzVersion` (on `AuthSession` and `SessionSnapshot`) reads it, or
`null` without the claim. `issuedAt` reads `iat` the same way.

The provider's library owns the cache. better-supabase types it
structurally as `AuthzVersionCache`, which only needs `evict(userId)`.
Create it once at module scope, never per request or per cache scope, and
hand it to the server:

```ts title="src/lib/supabase/server.ts"
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";
import { versions } from "./authz";

export const bsb = createNext(betterSupabase, { versions });
```

Every authorizer call then gets `{ versions, memo }` as its runtime
(`AuthorizerRuntime`): `versions` is the shared cache, and `memo` is a
`RequestMemo` for reads that several checks in one scope share. Create a
memo with `requestMemo()` from `better-supabase/server`; nothing request-specific lives in module state.
`bsb.invalidateSession(userId)` calls `versions.evict(userId)` next to
expiring the user's cache entries.

To evict on other instances too, have the database broadcast each change on
a private Realtime channel, and subscribe from the module that creates the
cache:

```sql
perform realtime.send(
  jsonb_build_object('user_id', new.user_id, 'version', new.version),
  'changed',
  'authz-versions',
  true
);
```

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

export const versions = createVersionCache();

subscribeAuthzVersions({
  client: serviceClient,
  topic: "authz-versions",
  event: "changed",
  userIdKey: "user_id",
  versions,
  onError: (error) => console.warn(error),
});
```

The subscriber joins with the service-role client, so the channel needs no
`realtime.messages` policy and no user can listen. Each message evicts one
user; a malformed message goes to `onError`. A second call with the same
`versions` returns the first subscription; its `ready` promise settles once
the channel joined, and `close()` leaves it. Start it from that module, not from `instrumentation.ts`, so the
cache and its subscription live and die together.

## 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 and its verified token `claims`, 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, plus `claims`: a read-only copy of the verified access-token
claims, including what your token hook wrote (memberships, roles, the tenant
claim, `attrs` and `act`). A caller without a session is
`{ type: "anon", id: "anonymous" }`, with no properties and no claims.
Adapters read memberships and roles from `subject.properties.claims`, never
from `resource.properties` or `context`, which can carry caller input.
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> }`                       |

### Load the resource [#load-the-resource]

A route or action that acts on one row can load that row first, so the
authorizer decides on its owner or team instead of on what the client sent.
Pass `resource` next to `permission`. It runs after the session and
`requireOrganization` checks, and gets the validated input and the request's
context (`db`, `session`, `tenant`). Its return value replaces the default
resource:

```ts title="app/projects/actions.ts"
import * as v from "valibot";
import { bsb } from "@/lib/supabase/server";

export const renameProject = bsb.action(
  {
    input: v.object({ id: v.string(), name: v.string() }),
    permission: "projects.update",
    resource: async ({ id }, { db }) => {
      const project = await db.projects
        .get(id, { select: ["id", "ownerId", "teamId"] })
        .orThrow();

      return {
        type: "project",
        id: project.id,
        properties: { ownerId: project.ownerId, teamId: project.teamId },
      };
    },
  },
  ({ id, name }, { db }) => db.projects.update(id, { name }),
);
```

The authorizer then grants `projects.update` to the owner, or to a member of
the team, by comparing `resource.properties` with the subject's claims. A
loader that throws refuses the caller: a `DbException` (such as the
`not_found` from `.orThrow()`) keeps its error, and any other throw refuses
with `forbidden` and `code: "RESOURCE_UNAVAILABLE"`. Without a `permission`,
the loader doesn't run.

## 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 bsb = 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 (a role, `claims` or
`memberships` placed in the resource properties or the context), 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           | `blocks.<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         |
| Add a block of your own              | `defineBlock({ ... })`                                | 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"
blocks: {
  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 of your own, 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).

The option names the block's main table; tables the block fills on its own
(lines, deliveries, history rows) take no extra columns. A function that
gains a `fields` argument for them gets it only when `extraColumns` is set,
and each build drops the signature it doesn't create, so turning the option
on or off leaves one function that a call matches.

| Block            | Columns from                                              | `fields` types                                                                     |
| ---------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| organizations    | `options.attributes`, `options.extraColumns`              | `mine()`, `create`, `update`                                                       |
| invitations      | `blocks.invitations.options.extraColumns`                 | `invitationFields`: `invite`, `updateInvitation`, `myInvitations`                  |
| profiles         | `options.extraColumns`, or any column you adopt           | `mine()`, `updateMine`                                                             |
| notifications    | the `data` of each type; `options.extraColumns` on events | `send`, `get`, `list`, `page`                                                      |
| deals            | `options.extraColumns` on `deals`                         | every deal it returns, `create`, `update`                                          |
| drive            | `options.extraColumns` on `drive_nodes`                   | every node it returns, `createFolder`, `upload`                                    |
| employees        | `options.extraColumns` on `employees`                     | every employee it returns, `create`, `update`                                      |
| engagements      | `options.extraColumns` on `engagements`                   | every engagement it returns, `log`, `update`                                       |
| flags            | `options.extraColumns` on `flags`                         | `list`, `save`                                                                     |
| forms            | `options.extraColumns` on `forms`                         | `create`, `update`, `get`, `list`                                                  |
| hiring           | `options.extraColumns` on `jobs`                          | `saveJob`                                                                          |
| inventory        | `options.extraColumns` on `inventory_locations`           | `saveLocation`                                                                     |
| invoices         | `options.extraColumns` on `invoices`                      | every invoice it returns, `create`                                                 |
| orders           | `options.extraColumns` on orders                          | `checkout`, `getOrder`, `capture`, `invoice`                                       |
| parties          | `options.extraColumns` on companies                       | `saveCompany`, `mergeCompanies`, `listCompanies`                                   |
| pipelines        | `options.extraColumns` on pipelines                       | `save`, `list`                                                                     |
| promotions       | `options.extraColumns` on promotions                      | `savePromotion`                                                                    |
| push             | `options.extraColumns` on devices                         | `register`, `tokensFor`                                                            |
| quotes           | `options.extraColumns` on quotes                          | `create`, `update`, `send`, `decide`, `revise`, `fromDeal`                         |
| relations        | `options.extraColumns` on relations                       | `link`, `of`, `edges`                                                              |
| reminders        | `options.extraColumns` on reminders                       | `save`                                                                             |
| saved-views      | `options.extraColumns` on views                           | `save`, `duplicate`, `list`                                                        |
| service-accounts | `options.extraColumns` on principals                      | `create`, `list`                                                                   |
| access-requests  | `options.extraColumns` on `access_requests`               | writes: `request`; reads: every read                                               |
| agents           | `options.extraColumns` on `agents`                        | writes: `create`, `update`; reads: every agent read                                |
| ai-chat          | `options.extraColumns` on `ai_chats`                      | writes: `chats.create`, `chats.update`; reads: `chats.get`, `chats.list`           |
| ai-files         | `options.extraColumns` on `ai_files`                      | writes: `files.upload`, `files.store`; reads: the file reads                       |
| ai-schedules     | `options.extraColumns` on `ai_scheduled_tasks`            | writes: `create`, `update`; reads: `create`, `update`, `pause`, `resume`, `list`   |
| announcements    | `options.extraColumns` on `announcements`                 | writes: `publish`, `update`; reads: `listActive`, `list`                           |
| api-keys         | `options.extraColumns` on `api_keys`                      | writes: `create` (`rotate` copies them); reads: `list`, `verify`                   |
| approvals        | `options.extraColumns` on `approval_requests`             | writes: `request`; reads: every request read                                       |
| attachments      | `options.extraColumns` on `attachments`                   | writes: `upload`, `put`; reads: every attachment read                              |
| bookings         | `options.extraColumns` on `bookings`                      | writes: the customer object of `hold` and `public.hold`; reads: every booking read |
| calendar         | `options.extraColumns` on `calendar_events`               | writes: `save` and the occurrence edits; reads: `events`                           |
| catalog          | `options.extraColumns` on `products`                      | writes: `createProduct`, `updateProduct`; reads: the saved product                 |
| collections      | `options.extraColumns` on `subject_collections`           | writes: `create`, `rename`; reads: `create`, `rename`, `list`                      |

Deals, employees, invoices and the blocks from access-requests to
collections take the columns next to their own fields
(`create(id, { name, region })` writes `region`); the others take them
under `fields` (`saveJob(id, { title, fields: { level: 3 } })`). Every
block returns them under `fields`, and the blocks from access-requests to
collections merge the parsed values into the row they return. Except
organizations, profiles, employees, drive and attachments, a block copies
the columns right after its insert, so give a `not null` column a default.

The other blocks with a managed table that apps extend take the same pair:
`options.extraColumns` on the table below and `fields` on the creator. A
read returns the columns under `fields`, keyed by database name, and a
write takes them as `fields` and copies only the columns it names, so the
others keep their default.

| Block           | Table                  | `fields` on writes                 |
| --------------- | ---------------------- | ---------------------------------- |
| comments        | `comments`             | `create`, `edit`                   |
| connectors      | `connector_servers`    | `servers.create`, `servers.update` |
| share-links     | `share_links`          | `create`                           |
| signatures      | `signature_requests`   | `request`                          |
| sla             | `sla_policies`         | `savePolicy`                       |
| tags            | `tags`                 | `create`, `update`                 |
| targets         | `targets`              | `save`                             |
| teams           | `teams`                | `create`, `update`                 |
| templates       | `templates`            | `save`                             |
| time-tracking   | `time_entries`         | `start`, `log`, `update`           |
| trash           | `trash_items`          | `trash`                            |
| versions        | `versions`             | `snapshot`                         |
| waitlist        | `waitlist_entries`     | `join`, for a new address only     |
| webhook-inbox   | `webhook_inbox`        | `store`                            |
| tasks           | `work_items`           | `create`, `update` by column name  |
| workflow-editor | `workflow_definitions` | `definitions.save`                 |

sso, state-machine, usage, webhooks and workflows take `hooks` but no
`fields`: their rows mirror another system (Supabase Auth, the IdP, an
engine's run registry), are written by triggers on the app's own table, or
are a metering log whose per-event data goes in `metadata`.

A few blocks keep no app columns: `numbering` (counters the module
owns), `onboarding` (progress rows of the steps `defineChecklist` declares),
`outbox` (an event log whose payload carries the app's data), `reactions`,
`search` (an index rebuilt from each subject's own row) and `settings`
(values typed by `defineSettings`).

## 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 like the blocks, and
blocks that consume one through a port (ai-schedules sending notifications) go
through its hooks too:

```ts
const blocks = createBlocks(
  {
    transport,
    hooks: { notifications: { send: { before: checkQuietHours } } },
  },
  { organizations: organizationsBlock, notifications: notificationsBlock },
);
```

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.

## Your own blocks [#your-own-blocks]

Every block is described by one manifest, built with `defineBlock` and
exported from the block's subpath (`commentsBlock` from
`better-supabase/blocks/comments`). A block of your own uses the same
function, so `createBlocks` and `defineBlocks` take it next to the built-in
ones:

```ts title="src/lib/invoicing.ts"
import { defineBlock, defineEvents, event } from "better-supabase/blocks";

export const invoicingEvents = defineEvents({
  "invoice.sent": event<{ readonly invoiceId: string }>(),
});

export const invoicingBlock = defineBlock({
  name: "invoicing",
  events: invoicingEvents,
  consumes: { notifier: "optional" },
  create: createInvoicing,
  category: "commerce",
});
```

| Field      | What it is                                                                                                          |
| ---------- | ------------------------------------------------------------------------------------------------------------------- |
| `name`     | The block's name, kebab-case                                                                                        |
| `sql`      | The SQL modules it installs, the keys of `blocks` in the config                                                     |
| `options`  | A `blockOptions` schema per SQL module, which types `blocks.<module>.options` and `schemas/config-v1.json`          |
| `events`   | Its own events, from `defineEvents`                                                                                 |
| `consumes` | The ports it needs, each `optional` or `required`                                                                   |
| `provides` | The ports it builds from its client, such as `notifier: (client) => client`                                         |
| `create`   | Its creator, `create<Name>(options)`                                                                                |
| `client`   | The name of its headless controller, when it has one                                                                |
| `category` | Where it sits in the catalog: `identity`, `tenancy`, `work`, `commerce`, `developer` and so on (16 in all)          |
| `recipes`  | The [UI recipes](/docs/registry/ui-recipes) that use it                                                             |
| `record`   | The part of a record page it owns, from `defineRecordBinding` ([Record bindings](/docs/repository/record-bindings)) |

Four more fields declare what the app has to provide, so `add` and `init` can
write it and print the rest ([the wiring](/docs/cli/init#the-wiring-and-the-checklist)):

| Field       | What it is                                                                                                |
| ----------- | --------------------------------------------------------------------------------------------------------- |
| `env`       | The env keys it reads, each `{ key, description, required? }`; `add` appends them to `.env.example`       |
| `providers` | The implementations it takes, each with its `option`, `type`, `from`, `required` and a stub `example`     |
| `routes`    | The registry items with its routes, without the framework suffix (`outbox-relay` for `outbox-relay-next`) |
| `workers`   | The crons or processes it needs, with the `path` and `schedule` a cron calls                              |

```ts
export const invoicingBlock = defineBlock({
  name: "invoicing",
  create: createInvoicing,
  env: [
    {
      key: "INVOICING_API_KEY",
      description: "Server-only: the invoicing service key.",
    },
  ],
  providers: [
    {
      option: "tax",
      type: "TaxProvider",
      from: "better-supabase/commerce",
      required: false,
      description: "Computes tax on invoice lines.",
      example: "fixedRates({ rates })",
    },
  ],
});
```

Doctor reads the same fields for the installed blocks: BS504 fails on a
required env key no env file lists, and BS505 on a required provider no source
passes.

The ports are `notifier`, `files`, `attachments`, `workflows`, `mailer` and `sink`
([the table](/docs/blocks/create-blocks#ports) says who provides each).
A block reaches another block only through a port it consumes, and never
builds another block's client.

Every built-in block follows one contract, which
`tests/blocks/conformance.test.ts` checks over the manifests: the creator is
`create<Name>(options)` and takes `BlockOptions`, it accepts `hooks`, every
managed table takes `fields` and `extraColumns`, every call goes through
`blockCall`, the block has its own event map, and it builds no other block's
client. The test lists the blocks that don't meet a rule yet; the list only
shrinks.

# 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 `delete({ where })` 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                                                                                                                                                                                                                                                                                                                                                                    |
| `testQueueProvider(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, and `expire` (when present) leaves a running session alone                                                                                                                                                                                                                                          |
| `testIncomingDestination(destination, { tenant, source? })`                   | API v1, a name, a repeated endpoint and message id reported as a duplicate with the first id, and another message id or endpoint accepted as new                                                                                                                                                                                                                                                                                                              |
| `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                                                                                                                                                                                                                                                                                                                                        |
| `testPaymentProvider(provider, { amount?, authorize?, webhook? })`            | API v1, a name, a zero amount and a plain number refused, a created payment with its amount, reference and nothing captured or refunded, the same payment for a repeated idempotency key, `not_found` for an unknown id, `get` and `cancel` when present, and with `authorize` a full capture, a partial refund and an over-refund refused; with `webhook` a valid delivery parsed and a tampered one refused                                                 |
| `testTaxProvider(provider, { address, currency?, taxCategory?, expectTax? })` | API v1, a name, a mixed-currency cart and an address without a country refused, quote lines in cart order with shipping last and a total that adds up, an inclusive line, zero tax for an exempt customer, a commit under its reference, and a full and partial reversal with negative amounts, refusing more than the sale and an unknown line                                                                                                               |
| `testShippingProvider(carrier, { from, to, service, currency?, webhook? })`   | API v1, a name, a request without packages or with a bad country refused, a rate for the service in the asked currency, an unknown service refused, a label with a tracking number, a cost and a known format, the same label for a repeated idempotency key, tracking events oldest first, `not_found` for an unknown tracking number, a voided label, and with `webhook` a signed delivery read into updates with tracking numbers and a forged one refused |
| `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, `bsb-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, claims or memberships, 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`                                                                                                                                                 |
| `testControllerBinding(name, { mount, settle?, disposes? })`                  | a framework binding over a block controller renders the initial snapshot and starts the work once, renders each change, passes the controller's actions through, and stops the work (and disposes the controller unless `disposes: false`) on unmount                                                                                                                                                                                                         |
| `testRecordBinding(name, binding, { client, subject, subjects? })`            | API v1, a non-empty read permission, non-empty event patterns, a `load` that settles for `subject`, and a `count` keyed by `subjectKey` with a whole number per record                                                                                                                                                                                                                                                                                        |

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.

## Controller bindings [#controller-bindings]

`testControllerBinding` mounts the binding over a probe controller, the way
a component would. `mount` returns what the component renders (`read`), the
actions it got and an `unmount`; `settle` lets the framework apply pending
updates. The React, Vue, Svelte and Solid bindings of the blocks run it; a
binding for another framework runs it the same way.

```ts title="preact-binding.test.ts"
import { testControllerBinding } from "better-supabase/testing";
import { it } from "vitest";

it("conforms", () =>
  testControllerBinding("preact", {
    mount: (create) => {
      const view = renderHook(() => useController(create));
      return {
        read: () => view.result.current.state,
        actions: view.result.current.actions,
        unmount: view.unmount,
      };
    },
  }));
```

# 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 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 bsb = 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.

### Your own secrets table [#your-own-secrets-table]

An app that already keeps credentials in a table of its own can keep that
table: map it in `tables.secrets`, and the three functions read and write its rows
instead of Vault names, and keep their signatures, so `vaultCredentials()`
works unchanged.

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    credentials: {
      tables: { secrets: "public.integration_secrets" },
      columns: {
        secrets: { tenant: "organization_id", scope: "user_id", secret: null },
      },
    },
  },
});
```

The module then skips its `create table`, adds the columns it needs that
your table lacks, and writes the three functions, its policies, indexes and
the Vault cleanup trigger over it. `provider`
and `name` are required, and so is `vaultId` (or `secret` with
`secretStorage: "column"`).

| Column      | Default      | Holds                                                         |
| ----------- | ------------ | ------------------------------------------------------------- |
| `provider`  | `provider`   | The provider, such as `openai`                                |
| `name`      | `name`       | The credential's name                                         |
| `tenant`    | `tenant`     | The tenant of a tenant ref, or null; map to `null` to drop it |
| `scope`     | `scope`      | The user id of a per-user secret, or null                     |
| `vaultId`   | `vault_id`   | The Vault secret's id, with `secretStorage: "vault"`          |
| `secret`    | `secret`     | The secret itself, with `secretStorage: "column"`             |
| `updatedAt` | `updated_at` | Set on every write                                            |

A call's name maps to a row like this:

- A `tenant/<tenant>/<name>` name, which a tenant ref stores, reads the row
  with that tenant when the table maps `tenant`.
- A name that ends in `@<user id>`, which a `scope: "user"` ref stores, reads
  the row with that user in `scope` when the table maps `scope`. A name then
  ends at its last `@`.
- `vaultCredentials()` always calls with the provider `vault`, so a `vault`
  call names another provider's row as `<provider>:<name>`. The ref
  `{ "provider": "vault", "secret": "openai:api-key" }` reads the row whose
  provider is `openai` and whose name is `api-key`.

`options.secretStorage` is `"vault"` by default: each row's `vaultId` points
to a Vault secret, `credential_set` creates or updates it, and a trigger
deletes the Vault secret when its row is deleted or its `vaultId` changes.
`secretStorage: "column"` keeps the secret in the `secret` column, for a
table that stores it there today; doctor warns about it (BS314) until the
secrets move into Vault. Doctor also reports a mapped column the live table
lacks (BS330).

## 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 bsb = 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 memberships extension 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. Each
block declares its own events in its manifest, and exports them next to it:
`messagingEvents` from `better-supabase/blocks/messaging`, `impersonationEvents`
from `better-supabase/server`, `credentialsEvents` from
`better-supabase/credentials`. `onBlockEvent` from `better-supabase/events`
listens to one type or to every type with a prefix, and types the data for the
match from the events you pass:

```ts
import { onBlockEvent } from "better-supabase/events";
import { impersonationEvents } from "better-supabase/server";

onBlockEvent(betterSupabase, impersonationEvents, "support.*", (event) => {
  // event.type is "support.started" | "support.ended" | "support.denied"
  auditTrail.write({ type: event.type, admin: event.data.adminId });
});
```

[`defineBlocks`](/docs/blocks/create-blocks#typed-events-with-defineblocks)
merges the events of every block you use, so `app.on(betterSupabase,
"message.*", handler)` types them without listing each block's events. The
union comes from the manifests you pass, never from `declare module`.

### App events [#app-events]

`defineEvents` declares your own events with their data, for
`defineBlocks(blockConfig, blocks, appEvents)` and `onBlockEvent`. `emitBlockEvent` sends
one, typed by the same map:

```ts
import {
  defineEvents,
  emitBlockEvent,
  event,
  type EventMapOf,
} from "better-supabase/events";

export const appEvents = defineEvents({
  "invoice.sent": event<{ readonly invoiceId: string }>(),
});

emitBlockEvent<EventMapOf<typeof appEvents>>(
  betterSupabase.events,
  "invoice.sent",
  { invoiceId },
  { subject: `invoices/${invoiceId}`, tenant: organizationId },
);
```

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
`impersonation/<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`                                                                                                                                                                                                                                                                     |
| `legal_hold.placed`, `legal_hold.released`                                                                                                                                                                                                                                                                                 | `holdId`, `organizationId`, `kind`, `reason`                                                                                                                                                                                                                                                                 |
| `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)                                                                                                                                                                                                                                                                        |
| `signing_key.created`, `signing_key.retired`                                                                                                                                                                                                                                                                               | `ownerType`, `ownerId`, `kid`, `alg` (never the private key)                                                                                                                                                                                                                                                 |
| `ssf_stream.created`, `ssf_stream.status_changed`, `ssf_stream.deleted`                                                                                                                                                                                                                                                    | `streamId`, `receiver`, `tenant`; `method` on created, `status` and `reason` on status_changed                                                                                                                                                                                                               |
| `oauth_client.registered`, `oauth_client.deleted`                                                                                                                                                                                                                                                                          | `clientId` (never the secret)                                                                                                                                                                                                                                                                                |
| `oauth_grant.created`, `oauth_grant.revoked`                                                                                                                                                                                                                                                                               | `grantId`, `userId`, `clientId`                                                                                                                                                                                                                                                                              |
| `hostname.added`, `hostname.verified`, `hostname.primary_set`, `hostname.removed`                                                                                                                                                                                                                                          | `organizationId`, `hostnameId`, `hostname`                                                                                                                                                                                                                                                                   |
| `review.submitted`, `review.published`, `review.rejected`, `review.replied`, `review.deleted`                                                                                                                                                                                                                              | `organizationId`, `id`, `subjectType`, `subjectId`, `authorId`, `rating`, `status`, `verified`                                                                                                                                                                                                               |
| `moderation_report.created`                                                                                                                                                                                                                                                                                                | `organizationId`, `id`, `itemId`, `subjectType`, `subjectId`, `reason`                                                                                                                                                                                                                                       |
| `moderation_item.decided`                                                                                                                                                                                                                                                                                                  | `organizationId`, `id`, `subjectType`, `subjectId`, `authorId`, `action`, `status`, `previousStatus`, `automatic`                                                                                                                                                                                            |
| `moderation_ban.created`, `moderation_ban.lifted`                                                                                                                                                                                                                                                                          | `organizationId`, `id`, `userId`, `expiresAt`                                                                                                                                                                                                                                                                |
| `enrollment_program.saved`, `enrollment_program.deleted`                                                                                                                                                                                                                                                                   | `organizationId`, `id`, `key`, `version`                                                                                                                                                                                                                                                                     |
| `enrollment.created`, `enrollment.step_completed`, `enrollment.completed`, `enrollment.cancelled`                                                                                                                                                                                                                          | `organizationId`, `id`, `program`, `subjectType`, `subjectId`, `status`, `step`, `completedSteps`, `totalSteps`                                                                                                                                                                                              |
| `course.saved`, `course.deleted`                                                                                                                                                                                                                                                                                           | `organizationId`, `id`, `key`, `status`                                                                                                                                                                                                                                                                      |
| `course.enrolled`, `course.unenrolled`, `course.completed`                                                                                                                                                                                                                                                                 | `organizationId`, `id`, `userId`, `enrollmentId`                                                                                                                                                                                                                                                             |
| `course_lesson.completed`                                                                                                                                                                                                                                                                                                  | `organizationId`, `id`, `courseId`, `userId`, `enrollmentId`                                                                                                                                                                                                                                                 |
| `course_certificate.issued`, `course_certificate.revoked`                                                                                                                                                                                                                                                                  | `organizationId`, `id`, `courseId`, `userId`, `enrollmentId`                                                                                                                                                                                                                                                 |
| `leave.requested`, `leave.approved`, `leave.cancelled`                                                                                                                                                                                                                                                                     | `organizationId`, `id`, `userId`, `typeId`, `startsOn`, `endsOn`, `amount`, `approvalId`                                                                                                                                                                                                                     |
| `leave.adjusted`                                                                                                                                                                                                                                                                                                           | `organizationId`, `id`, `userId`, `typeId`, `kind`, `amount`, `period`                                                                                                                                                                                                                                       |
| `rule.saved`, `rule.enabled`, `rule.disabled`, `rule.removed`                                                                                                                                                                                                                                                              | `organizationId`, `ruleId`, `name`, `subjectType`, `trigger`, `version`                                                                                                                                                                                                                                      |
| `rule.matched`                                                                                                                                                                                                                                                                                                             | `organizationId`, `ruleId`, `version`, `subjectType`, `subjectId`, `trigger`                                                                                                                                                                                                                                 |
| `marketplace_installation.upserted`, `marketplace_installation.removed`                                                                                                                                                                                                                                                    | `installationId`, `configurationId`, `teamId`                                                                                                                                                                                                                                                                |
| `marketplace_invoice.state_changed`                                                                                                                                                                                                                                                                                        | `installationId`, `invoiceId`, `externalId`, `state`                                                                                                                                                                                                                                                         |
| `notification.created`                                                                                                                                                                                                                                                                                                     | `notificationId`, `organizationId`, `type`, `recipientIds`                                                                                                                                                                                                                                                   |
| `notification.delivered`, `notification.failed`, `notification.opened`, `notification.clicked`, `notification.bounced`, `notification.complained`, `notification.delayed`                                                                                                                                                  | `notificationId`, `organizationId`, `type`, `channel`, `subjectType`, `subjectId`, `deliveryId`; `notification.failed` adds `error`, the provider events `provider` and `providerEventId`                                                                                                                    |
| `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.account_linked`, `billing.checkout_completed`, `billing.subscription_created`, `billing.subscription_updated`, `billing.subscription_deleted`, `billing.seats_synced`                                                                                                                  | `organizationId`, `customerId`, `accountId`, `subscriptionId`, `status`, `quantity`, `previousQuantity`, `stripeEventId`                                                                                                                                                                                     |
| `conversation.created`, `conversation.archived`, `conversation.member_added`, `conversation.member_removed`                                                                                                                                                                                                                | `conversationId`, `organizationId`, `subjectType`, `subjectId`, `kind`, `recordType`, `recordId`, `userId`                                                                                                                                                                                                   |
| `message.sent`, `message.edited`, `message.deleted`, `message.pinned`, `message.resolved`                                                                                                                                                                                                                                  | `messageId`, `conversationId`, `organizationId`, `subjectType`, `subjectId`, `authorId`, `threadId`, `mentionIds`, `recordMentions` (`message.sent`: the records the message mentions, `{ type, id }`), `pinned`, `resolved`; a comment adds `conversationKind` (`subject`), `recordType` and `recordId`     |
| `channel.created`, `channel.updated`                                                                                                                                                                                                                                                                                       | `channelId`, `organizationId`, `subjectType`, `subjectId`, `name`, `visibility`, `recordType`, `recordId`                                                                                                                                                                                                    |
| `attachment.uploaded`, `attachment.scanned`, `attachment.replaced`, `attachment.restored`                                                                                                                                                                                                                                  | `attachmentId`, `organizationId`, `subjectType`, `subjectId`, `uploadedBy`, `mimeType`, `size`, `status`, `version`                                                                                                                                                                                          |
| `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_conversation.unsnoozed`, `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`, `subjectType`, `subjectId`                                                                                                                                                                                               |
| `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`                                                                                                                                                                                                                                                                                  |
| `number.assigned`                                                                                                                                                                                                                                                                                                          | `organizationId`, `entity`, `period`, `number`                                                                                                                                                                                                                                                               |
| `contract.created`, `contract.updated`, `contract.status_changed`, `contract.renewed`, `obligation.saved`, `obligation.status_changed`                                                                                                                                                                                     | `organizationId`, `contractId`, `title`, `status`, `previousStatus`, `endsOn`, `renewedCount`, `obligationId`                                                                                                                                                                                                |
| `control.saved`, `control.evidence_added`, `evidence_task.missed`, `policy.published`, `policy.acknowledged`                                                                                                                                                                                                               | `organizationId`, `controlId`, `frameworkId`, `code`, `evidenceId`, `evidenceTaskId`, `assigneeId`, `dueOn`, `policyId`, `versionId`, `number`, `userId`                                                                                                                                                     |
| `incident.opened`, `incident.updated`, `incident.resolved`, `postmortem.published`, `status_component.changed`                                                                                                                                                                                                             | `organizationId`, `incidentId`, `title`, `severity`, `status`, `public`, `updateId`, `postmortemId`, `statusComponentId`, `previousStatus`                                                                                                                                                                   |
| `team.created`, `team.updated`, `team.archived`, `team.deleted`, `team_member.added`, `team_member.removed`                                                                                                                                                                                                                | `organizationId`, `id`, `userId`, `name`, `role`                                                                                                                                                                                                                                                             |
| `tag.created`, `tag.updated`, `tag.deleted`, `subject.tagged`, `subject.untagged`                                                                                                                                                                                                                                          | `organizationId`, `id`, `kind`, `name`, `subjectType`, `subjectId`                                                                                                                                                                                                                                           |
| `status.changed`, `status.paused`, `status.resumed`, `status.flagged`                                                                                                                                                                                                                                                      | `organizationId`, `subjectType`, `subjectId`, `from`, `to`, `reason`; `status.paused` adds `until` and `reason`, `status.flagged` `flag` and `enabled`                                                                                                                                                       |
| `custom_field.saved`, `custom_field.deleted`, `field_value.set`                                                                                                                                                                                                                                                            | `organizationId`, `id`, `entity`, `subjectType`, `subjectId`                                                                                                                                                                                                                                                 |
| `field_value.viewed` (audit log only, with `auditReads`)                                                                                                                                                                                                                                                                   | `organizationId`, `subjectType`, `subjectId`, `ids`                                                                                                                                                                                                                                                          |
| `field_key.dropped`                                                                                                                                                                                                                                                                                                        | `owner` (`tenant:<id>`, `user:<id>` or `app`, never key material)                                                                                                                                                                                                                                            |
| `share_link.created`, `share_link.revoked`, `subject.viewed`                                                                                                                                                                                                                                                               | `organizationId`, `id`, `subjectType`, `subjectId`, `role`                                                                                                                                                                                                                                                   |
| `reminder.sent`                                                                                                                                                                                                                                                                                                            | `organizationId`, `id`, `subjectType`, `subjectId`, `userId`                                                                                                                                                                                                                                                 |
| `import.completed`, `import.failed`                                                                                                                                                                                                                                                                                        | `organizationId`, `id`, `kind`, `count`, `status`, `error`                                                                                                                                                                                                                                                   |
| `saved_view.saved`, `saved_view.deleted`                                                                                                                                                                                                                                                                                   | `organizationId`, `id`, `kind`, `name`                                                                                                                                                                                                                                                                       |
| `subject.linked`, `subject.unlinked`                                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `kind`, `subjectType`, `subjectId`, `targetType`, `targetId`                                                                                                                                                                                                                         |
| `bulk_run.completed`                                                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `kind`, `status`, `count`, `failed`                                                                                                                                                                                                                                                  |
| `calendar.created`, `calendar_event.created`, `calendar_event.updated`, `calendar_event.deleted`                                                                                                                                                                                                                           | `organizationId`, `id`, `name`, `calendarId`                                                                                                                                                                                                                                                                 |
| `booking.reserved`, `booking.confirmed`, `booking.cancelled`, `booking.expired`                                                                                                                                                                                                                                            | `organizationId`, `id`, `resourceId`, `serviceId`                                                                                                                                                                                                                                                            |
| `booking.rescheduled`                                                                                                                                                                                                                                                                                                      | `organizationId`, `id`, `resourceId`, `serviceId`                                                                                                                                                                                                                                                            |
| `version.created`, `version.restored`, `version.deleted`                                                                                                                                                                                                                                                                   | `organizationId`, `subjectType`, `subjectId`, `id`, `number`, `label`, `source`, `restoredFrom`                                                                                                                                                                                                              |
| `subject.trashed`, `subject.restored`, `subject.purged`                                                                                                                                                                                                                                                                    | `organizationId`, `subjectType`, `subjectId`, `id`, `batchId`                                                                                                                                                                                                                                                |
| `reaction.added`, `reaction.removed`                                                                                                                                                                                                                                                                                       | `organizationId`, `subjectType`, `subjectId`, `emoji`, `actor`, `kind`, `value`                                                                                                                                                                                                                              |
| `approval.requested`, `approval.approved`, `approval.rejected`, `approval.cancelled`, `approval.expired`, `approval.delegated`                                                                                                                                                                                             | `organizationId`, `subjectType`, `subjectId`, `id`, `action`, `status`, `step`                                                                                                                                                                                                                               |
| `access.requested`, `access.request_approved`, `access.ended`                                                                                                                                                                                                                                                              | `organizationId`, `userId`, `role`, `id`, `approvalId`, `expiresAt`, `reason` (for `access.ended`)                                                                                                                                                                                                           |
| `document.created`                                                                                                                                                                                                                                                                                                         | `organizationId`, `subjectType`, `subjectId`, `id`, `name`                                                                                                                                                                                                                                                   |
| `document.suggested`                                                                                                                                                                                                                                                                                                       | `organizationId`, `subjectType`, `subjectId`, `id`, `name`, `suggestionId`, `authorId`, `status`                                                                                                                                                                                                             |
| `document.suggestion_accepted`                                                                                                                                                                                                                                                                                             | `organizationId`, `subjectType`, `subjectId`, `id`, `name`, `suggestionId`, `authorId`, `status`                                                                                                                                                                                                             |
| `document.suggestion_rejected`                                                                                                                                                                                                                                                                                             | `organizationId`, `subjectType`, `subjectId`, `id`, `name`, `suggestionId`, `authorId`, `status`                                                                                                                                                                                                             |
| `form.created`, `form_version.published`                                                                                                                                                                                                                                                                                   | `organizationId`, `id`, `key` (for `form.created`), `formId` and `number` (for `form_version.published`)                                                                                                                                                                                                     |
| `form_submission.submitted`, `form_submission.reviewed`, `form_submission.deleted`                                                                                                                                                                                                                                         | `organizationId`, `subjectType`, `subjectId`, `id`, `formId`, `versionId` and `source` (for `submitted`), `status` (for `reviewed`)                                                                                                                                                                          |
| `signature.requested`, `signature.signed`, `signature.completed`, `signature.declined`, `signature.cancelled`                                                                                                                                                                                                              | `organizationId`, `subjectType`, `subjectId`, `id` (the request), `versionId` (for `requested` and `completed`), `signatureId`, `signer` and `method` (for `signed`), `signer` (for `declined`)                                                                                                              |
| `template.published`                                                                                                                                                                                                                                                                                                       | `organizationId` (null for a system template), `id` (the template), `versionId`, `version`, `type`                                                                                                                                                                                                           |
| `template.applied`                                                                                                                                                                                                                                                                                                         | `organizationId`, `subjectType`, `subjectId` (the record it created), `id` (the application), `templateId`, `versionId`                                                                                                                                                                                      |
| `time_entry.started`, `time_entry.recorded`, `time_entry.updated`, `time_entry.deleted`                                                                                                                                                                                                                                    | `organizationId`, `subjectType`, `subjectId`, `id`, `userId`, `startedAt`, and `seconds`, `billable` and `rate` (except for `started` and `deleted`)                                                                                                                                                         |
| `timesheet.submitted`, `timesheet.approved`, `timesheet.reopened`                                                                                                                                                                                                                                                          | `organizationId`, `id`, `userId`, `week`, `approvalId`                                                                                                                                                                                                                                                       |
| `service_principal.created`, `service_principal.role_changed`, `service_principal.disabled`                                                                                                                                                                                                                                | `organizationId`, `id`, and `name` and `role` (`role` only for `role_changed`, neither for `disabled`)                                                                                                                                                                                                       |
| `product.created`, `product.updated`, `product.status_changed`, `product.deleted`                                                                                                                                                                                                                                          | `organizationId`, `id`, `slug`, `kind`, `from`, `to`                                                                                                                                                                                                                                                         |
| `variant.created`, `variant.updated`, `variant.deleted`                                                                                                                                                                                                                                                                    | `organizationId`, `id`, `productId`, `sku`                                                                                                                                                                                                                                                                   |
| `catalog.price_changed`                                                                                                                                                                                                                                                                                                    | `organizationId`, `id`, `variantId`, `priceListId`, `currency`, `amount`, `previousAmount` (amounts as text, in minor units)                                                                                                                                                                                 |
| `storefront.published`, `storefront.unpublished`                                                                                                                                                                                                                                                                           | `organizationId`                                                                                                                                                                                                                                                                                             |
| `pipeline.created`, `pipeline.updated`, `pipeline.archived`                                                                                                                                                                                                                                                                | `organizationId`, `pipelineId`, `subjectType`, `key`                                                                                                                                                                                                                                                         |
| `pipeline_stage.saved`, `pipeline_stage.archived`                                                                                                                                                                                                                                                                          | `organizationId`, `pipelineId`, `stageId`, `key`, `category`                                                                                                                                                                                                                                                 |
| `pipeline.stage_changed`                                                                                                                                                                                                                                                                                                   | `organizationId`, `subjectType`, `subjectId`, `pipelineId`, `fromStageId`, `toStageId`, `category`, `outcome`                                                                                                                                                                                                |
| `company.created`, `company.updated`, `company.archived`, `company.merged`                                                                                                                                                                                                                                                 | `organizationId`, `companyId`, `name`, `mergedId`                                                                                                                                                                                                                                                            |
| `company.person_linked`, `company.person_unlinked`                                                                                                                                                                                                                                                                         | `organizationId`, `companyId`, `personId`, `role`                                                                                                                                                                                                                                                            |
| `person.created`, `person.updated`, `person.archived`, `person.merged`                                                                                                                                                                                                                                                     | `organizationId`, `personId`, `name`, `mergedId`                                                                                                                                                                                                                                                             |
| `engagement.logged`, `engagement.updated`, `engagement.completed`, `engagement.deleted`                                                                                                                                                                                                                                    | `organizationId`, `engagementId`, `type`, `subjectType`, `subjectId`, `subjects`                                                                                                                                                                                                                             |
| `engagement.recorded`, `engagement.transcribed`, `engagement.summarized`                                                                                                                                                                                                                                                   | `organizationId`, `engagementId`, `type`, `subjectType`, `subjectId`, `subjects`, `recordingId` (recorded, transcribed)                                                                                                                                                                                      |
| `mail_account.connected`, `mail_account.disconnected`                                                                                                                                                                                                                                                                      | `organizationId`, `accountId`, `userId`, `provider`, `address`                                                                                                                                                                                                                                               |
| `mail_message.received`, `mail_message.sent`                                                                                                                                                                                                                                                                               | `organizationId`, `accountId`, `userId`, `threadId`, `messageId`, `direction`, `fromAddress`, `subjects`                                                                                                                                                                                                     |
| `mail_message.opened`, `mail_message.clicked`                                                                                                                                                                                                                                                                              | `organizationId`, `accountId`, `userId`, `threadId`, `messageId`, `templateVersionId`, `url`, `subjects`                                                                                                                                                                                                     |
| `segment.saved`, `segment.removed`                                                                                                                                                                                                                                                                                         | `organizationId`, `segmentId`, `subjectType`, `name`                                                                                                                                                                                                                                                         |
| `segment.entered`, `segment.left`                                                                                                                                                                                                                                                                                          | `organizationId`, `segmentId`, `subjectType`, `subjectId`                                                                                                                                                                                                                                                    |
| `subject.assigned`                                                                                                                                                                                                                                                                                                         | `organizationId`, `queueId`, `subjectType`, `subjectId`, `userId`, `strategy`                                                                                                                                                                                                                                |
| `party.enriched`                                                                                                                                                                                                                                                                                                           | `organizationId`, `subjectType`, `subjectId`, `provider`, `status`, `filled`                                                                                                                                                                                                                                 |
| `sequence.saved`, `sequence.deleted`                                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `key`, `status`                                                                                                                                                                                                                                                                      |
| `sequence_enrollment.created`, `sequence_enrollment.step_completed`, `sequence_enrollment.paused`, `sequence_enrollment.resumed`, `sequence_enrollment.exited`, `sequence_enrollment.completed`                                                                                                                            | `organizationId`, `id`, `sequenceId`, `subjectType`, `subjectId`, `status`, `step`, `reason`                                                                                                                                                                                                                 |
| `post_board.saved`                                                                                                                                                                                                                                                                                                         | `organizationId`, `id`, `key`, `kind`                                                                                                                                                                                                                                                                        |
| `post.created`, `post.updated`, `post.status_changed`, `post.moderated`, `post.merged`, `post.deleted`                                                                                                                                                                                                                     | `organizationId`, `id`, `boardId`, `authorId`, `status`, `moderation`, `mergedIntoId`                                                                                                                                                                                                                        |
| `content_entry.created`, `content_entry.updated`, `content_entry.publish_requested`, `content_entry.scheduled`, `content_entry.unscheduled`, `content_entry.published`, `content_entry.archived`                                                                                                                           | `organizationId`, `id`, `type`, `locale`, `slug`, `status`, `translationGroupId`                                                                                                                                                                                                                             |
| `forecast.snapshotted`                                                                                                                                                                                                                                                                                                     | `organizationId`, `snapshotId`, `pipelineId`, `since`, `until`, `currency`, `dealCount`                                                                                                                                                                                                                      |
| `collection.created`, `collection.archived`, `collection.entry_added`, `collection.entry_removed`                                                                                                                                                                                                                          | `organizationId`, `collectionId`, `subjectType`, `subjectId` on entry events, `name` and `scope` on `collection.created`                                                                                                                                                                                     |
| `saved_item.reminded`                                                                                                                                                                                                                                                                                                      | `organizationId`, `savedItemId`, `userId`, `subjectType`, `subjectId`, `remindAt`, `note` when set                                                                                                                                                                                                           |
| `deal.created`, `deal.updated`, `deal.reopened`, `deal.archived`, `deal.deleted`                                                                                                                                                                                                                                           | `organizationId`, `dealId`, `subjectType`, `subjectId`, `name`, `fields`                                                                                                                                                                                                                                     |
| `deal.closed`                                                                                                                                                                                                                                                                                                              | `organizationId`, `dealId`, `subjectType`, `subjectId`, `name`, `outcome`, `amount`, `currency`, `reason`                                                                                                                                                                                                    |
| `work_item.created`, `work_item.updated`, `work_item.assigned`, `work_item.reopened`, `work_item.archived`, `work_item.deleted`                                                                                                                                                                                            | `organizationId`, `workItemId`, `subjectType`, `subjectId`, `title`, `fields`, `assigneeIds`                                                                                                                                                                                                                 |
| `work_item.closed`                                                                                                                                                                                                                                                                                                         | `organizationId`, `workItemId`, `subjectType`, `subjectId`, `title`, `state`, `closeReason`                                                                                                                                                                                                                  |
| `quote.created`, `quote.updated`, `quote.sent`, `quote.viewed`, `quote.expired`                                                                                                                                                                                                                                            | `organizationId`, `quoteId`, `subjectType`, `subjectId`, `identifier`                                                                                                                                                                                                                                        |
| `quote.accepted`, `quote.rejected`, `quote.changes_requested`, `quote.revised`                                                                                                                                                                                                                                             | the same, with `total`, `currency`, `byName`, `note` or `revisionId`                                                                                                                                                                                                                                         |
| `invoice.created`, `invoice.issued`, `invoice.paid`, `invoice.voided`, `invoice.uncollected`                                                                                                                                                                                                                               | `organizationId`, `invoiceId`, `subjectType`, `subjectId`, `identifier`, `total`, `currency`, `dueOn`, `reason`, `balance`                                                                                                                                                                                   |
| `invoice.payment_recorded`, `invoice.credited`                                                                                                                                                                                                                                                                             | the same, with `paymentId`, `creditNoteId`, `amount` and `balance`                                                                                                                                                                                                                                           |
| `sla.warned`, `sla.breached`, `sla.completed`                                                                                                                                                                                                                                                                              | `organizationId`, `clockId`, `subjectType`, `subjectId`, `metric`, `dueAt`, `late`                                                                                                                                                                                                                           |
| `job.opened`, `job.closed`                                                                                                                                                                                                                                                                                                 | `organizationId`, `jobId`, `title`                                                                                                                                                                                                                                                                           |
| `application.created`, `application.hired`, `application.rejected`, `application.retracted`                                                                                                                                                                                                                                | `organizationId`, `applicationId`, `subjectType`, `subjectId`, `jobId`, `candidateType`, `candidateId`, `reason`                                                                                                                                                                                             |
| `scorecard.submitted`, `interview.scheduled`                                                                                                                                                                                                                                                                               | the same, with `reviewerId`, `recommendation`, `interviewId` or `startsAt`                                                                                                                                                                                                                                   |
| `employee.created`, `employee.updated`, `employee.offboarded`, `employee.manager_changed`                                                                                                                                                                                                                                  | `organizationId`, `employeeId`, `subjectType`, `subjectId`, `name`, `userId`, `managerId`, `status`, `fields`, `endDate`, `reason`, `fromManagerId`, `toManagerId`                                                                                                                                           |
| `ledger_account.opened`, `ledger_account.closed`                                                                                                                                                                                                                                                                           | `organizationId`, `id`, `kind`, `code`, `unit`, `holderType`, `holderId`                                                                                                                                                                                                                                     |
| `ledger_transaction.posted`, `ledger_transaction.reversed`                                                                                                                                                                                                                                                                 | `organizationId`, `id`, `kind`, `referenceType`, `referenceId`, `reverses`, `entries`                                                                                                                                                                                                                        |
| `ledger_hold.placed`, `ledger_hold.captured`, `ledger_hold.released`                                                                                                                                                                                                                                                       | `organizationId`, `id`, `accountId`, `amount`, `status`, `transactionId`                                                                                                                                                                                                                                     |
| `asset_location.created`, `asset_location.updated`, `asset_location.archived`                                                                                                                                                                                                                                              | `organizationId`, `id`, `kind`, `name`, `parentId`                                                                                                                                                                                                                                                           |
| `asset.created`, `asset.updated`, `asset.assigned`, `asset.released`                                                                                                                                                                                                                                                       | `organizationId`, `id`, `kind`, `name`, `status`, `locationId`, `assigneeType`, `assigneeId`                                                                                                                                                                                                                 |
| `score_model.saved`, `score_model.deleted`                                                                                                                                                                                                                                                                                 | `organizationId`, `id`, `key`, `subjectType`, `version`                                                                                                                                                                                                                                                      |
| `score.changed`                                                                                                                                                                                                                                                                                                            | `organizationId`, `modelId`, `modelKey`, `subjectType`, `subjectId`, `score`, `previousScore`, `band`, `previousBand`                                                                                                                                                                                        |
| `guest.invited`, `guest.accepted`, `guest.revoked`                                                                                                                                                                                                                                                                         | `organizationId`, `id`, `email`, `partyType`, `partyId` (invited), `userId` (accepted)                                                                                                                                                                                                                       |
| `guest_grant.created`, `guest_grant.revoked`                                                                                                                                                                                                                                                                               | `organizationId`, `id`, `guestId`, `subjectType`, `subjectId`, `actions`                                                                                                                                                                                                                                     |
| `pass.issued`, `pass.revoked`                                                                                                                                                                                                                                                                                              | `organizationId`, `id`, `kind`, `holderUserId`, `bookingId` (issued)                                                                                                                                                                                                                                         |
| `pass.admitted`, `pass.rejected`                                                                                                                                                                                                                                                                                           | `organizationId`, `id`, `checkInId`, `resourceId`, `bookingId`, `reason` (rejected)                                                                                                                                                                                                                          |
| `pass.credit_used`, `pass.credit_refunded`                                                                                                                                                                                                                                                                                 | `organizationId`, `id`, `entryId`, `bookingId`                                                                                                                                                                                                                                                               |
| `time.invoiced`                                                                                                                                                                                                                                                                                                            | `organizationId`, `invoiceId`, `entryIds`, `currency`, `total`                                                                                                                                                                                                                                               |
| `lease.created`, `lease.activated`, `lease.renewed`, `lease.terminated`                                                                                                                                                                                                                                                    | `organizationId`, `id`, `unitId`, `partyType`, `partyId`, `status`, `startsOn`, `endsOn`, `rent`, `currency`                                                                                                                                                                                                 |
| `lease.charged`                                                                                                                                                                                                                                                                                                            | `organizationId`, `id`, `leaseId`, `periodStart`, `periodEnd`, `amount`, `currency`, `transactionId`                                                                                                                                                                                                         |
| `lease.paid`                                                                                                                                                                                                                                                                                                               | `organizationId`, `id`, `transactionId`, `amount`, `currency`                                                                                                                                                                                                                                                |
| `expense_report.created`, `expense_report.cancelled`                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `userId`, `status`, `total`, `currency`                                                                                                                                                                                                                                              |
| `expense_report.submitted`                                                                                                                                                                                                                                                                                                 | the same, and `approvalId`                                                                                                                                                                                                                                                                                   |
| `expense_report.approved`, `expense_report.reimbursed`                                                                                                                                                                                                                                                                     | the same, and `transactionId`                                                                                                                                                                                                                                                                                |
| `donation.received`, `donation.failed`, `donation.refunded`                                                                                                                                                                                                                                                                | `organizationId`, `id`, `donorType`, `donorId`, `fundId`, `pledgeId`, `amount`, `currency`, `status`                                                                                                                                                                                                         |
| `pledge.created`, `pledge.updated`                                                                                                                                                                                                                                                                                         | `organizationId`, `id`, `donorType`, `donorId`, `fundId`, `amount`, `currency`, `frequency`, `status`                                                                                                                                                                                                        |
| `donation_receipt.issued`, `donation_receipt.voided`                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `number`, `kind`, `donorType`, `donorId`, `amount`, `currency`                                                                                                                                                                                                                       |
| `work_order.created`                                                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `status`, `technicianId`, `subjectType`, `subjectId`                                                                                                                                                                                                                                 |
| `work_order.updated`                                                                                                                                                                                                                                                                                                       | `organizationId`, `id`                                                                                                                                                                                                                                                                                       |
| `work_order.scheduled`                                                                                                                                                                                                                                                                                                     | `organizationId`, `id`, `technicianId`, `startsAt`, `endsAt`                                                                                                                                                                                                                                                 |
| `work_order.unscheduled`                                                                                                                                                                                                                                                                                                   | `organizationId`, `id`, `technicianId` (the one it was taken from)                                                                                                                                                                                                                                           |
| `work_order.status_changed`                                                                                                                                                                                                                                                                                                | `organizationId`, `id`, `from`, `to`, `technicianId`                                                                                                                                                                                                                                                         |
| `work_order.part_used`                                                                                                                                                                                                                                                                                                     | `organizationId`, `id` (the work order), `partId`, `assetId`, `variantId`, `quantity`                                                                                                                                                                                                                        |
| `dispatch_route.planned`                                                                                                                                                                                                                                                                                                   | `organizationId`, `id`, `technicianId`, `routeDate`, `stops` (the count)                                                                                                                                                                                                                                     |
| `shipment.created`                                                                                                                                                                                                                                                                                                         | `organizationId`, `id`, `reference`, `subjectType`, `subjectId`                                                                                                                                                                                                                                              |
| `shipment.labeled`                                                                                                                                                                                                                                                                                                         | `organizationId`, `id`, `carrier`, `service`, `trackingNumber`                                                                                                                                                                                                                                               |
| `shipment.status_changed`                                                                                                                                                                                                                                                                                                  | `organizationId`, `id`, `from`, `to`, `trackingNumber`                                                                                                                                                                                                                                                       |
| `shipment.cancelled`                                                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `trackingNumber`                                                                                                                                                                                                                                                                     |
| `stock.adjusted`                                                                                                                                                                                                                                                                                                           | `organizationId`, `id`, `variantId`, `locationId`, `quantity`, `reason`, `available`                                                                                                                                                                                                                         |
| `stock.reorder_point_reached`                                                                                                                                                                                                                                                                                              | `organizationId`, `subjectType`, `subjectId`, `variantId`, `locationId`, `available`, `reorderPoint`                                                                                                                                                                                                         |
| `reservation.created`, `reservation.committed`, `reservation.released`                                                                                                                                                                                                                                                     | `organizationId`, `id`, `variantId`, `locationId`, `quantity`, `reason`                                                                                                                                                                                                                                      |
| `transfer.shipped`, `transfer.received`, `transfer.cancelled`                                                                                                                                                                                                                                                              | `organizationId`, `id`, `from`, `to`, `status`                                                                                                                                                                                                                                                               |
| `purchase_order.placed`, `purchase_order.received`, `purchase_order.cancelled`                                                                                                                                                                                                                                             | `organizationId`, `id`, `locationId`, `status`, `supplierType`, `supplierId`                                                                                                                                                                                                                                 |
| `drive.node_created`, `drive.node_renamed`, `drive.node_moved`, `drive.node_shared`, `drive.node_unshared`, `drive.node_purged`                                                                                                                                                                                            | `organizationId`, `subjectType`, `subjectId`, `kind`, `name`, `previousName`, `parentId`, `previousParentId`, `principalType`, `principalId`, `role`, `objects` (for `drive.node_purged`)                                                                                                                    |
| `order.placed`, `order.paid`, `order.cancelled`, `order.fulfilled`, `order.completed`, `order.refunded`                                                                                                                                                                                                                    | `organizationId`, `subjectType` (`order`), `subjectId`, `id`, `number`, `customerId`; `order.placed` adds `currency`, the totals and `units`, `order.paid` `currency`, `total`, `units` and `invoiceNumber`, `order.cancelled` `reason`, `order.refunded` `currency`, `amount`, `refundedTotal` and `status` |
| `order.prepared`, `order.served`                                                                                                                                                                                                                                                                                           | `organizationId`, `subjectType` (`order`), `subjectId`, `id`, `number`, `customerId`                                                                                                                                                                                                                         |
| `fulfillment.shipped`, `fulfillment.delivered`, `return.requested`, `return.received`                                                                                                                                                                                                                                      | `organizationId`, `subjectType` (`order`), `subjectId`, `id`, `orderId`, `number`; shipments add `carrier` and `trackingNumber`, returns `quantity` and `reason`                                                                                                                                             |
| `promotion.redeemed`                                                                                                                                                                                                                                                                                                       | `organizationId`, `id`, `promotionId`, `code`, `orderId`, `currency`, `amount`                                                                                                                                                                                                                               |

Every event about one subject (`subject.tagged`,
`subject.untagged`, `status.*`, `field_value.set`,
`share_link.created`, `subject.viewed`, `reminder.sent`, `subject.linked`,
`subject.unlinked`, `version.*`, the trash events, `reaction.*`,
`approval.*`, `drive.*`, `form_submission.*`, `signature.*`,
`template.applied`, `segment.entered`, `segment.left`, `subject.assigned`, `party.enriched`,
the `sequence_enrollment.*` events, `stock.reorder_point_reached`, the `order.*`, `fulfillment.*` and
`return.*` events, and every `notification.*` event except
`notification.created`) carries the same three keys, typed as `SubjectEventData`
from `better-supabase/events` (`CollaborationEventData` adds the version,
trash, reaction and approval keys):
`organizationId`, `subjectType` and `subjectId`. A sink that handles one of
them, such as the [watcher sink](/docs/blocks/notifications#watchers),
handles them all. `status.changed` also names the previous status in `from`
and the `reason` passed to `transition()`, or `null`.

`attachment.uploaded`, `.replaced` and `.restored`, `time_entry.*` and
`workflow_run.completed`, `.failed` and `.cancelled` carry `subjectType` and
`subjectId` only when the row belongs to a record: a file or run without a
subject has `null` there.
They are not part of `SubjectEventType<M>`, the types in an event map `M`
whose keys are never `null`, so a sink that wants them checks `subjectType`
first.

`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
`blocks.<module>.callbacks`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    organizations: {
      callbacks: {
        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; `blocks.<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 })`                                               |
| [`QueueProvider`](#queuebackend)                        | `sqlQueueProvider(sql)`, `pgmqPublicBackend(client)`        | `createQueue({ backend, queues })`                                                 |
| [`SupportSessionStore`](#supportsessionstore)           | `sqlSupportStore(postgres)`                                 | `createServer(betterSupabase, { support: supportSessions({ store, authorize }) })` |
| [`NotificationChannel`](#notificationchannel)           | none (bring your email or push provider)                    | `createNotifications({ channels })`                                                |
| [`DeliveryTransport`](#deliverytransport)               | `resendTransport(options)`                                  | `emailChannel({ transport })`                                                      |
| [`WebhookSigner`](#webhooksigner)                       | `standardWebhooks()`, `hmacSigner(options)`                 | `createWebhooks({ signer })`                                                       |
| [`WebhookTransport`](#webhooktransport)                 | `fetchTransport({ allowUrl })`                              | `createWebhooks({ http })`                                                         |
| [`WebhookSecretStore`](#webhooksecretstore)             | `sqlSecretStore(transport)`                                 | `createWebhooks({ secrets })`                                                      |
| [`IncomingDestination`](#incomingdestination)           | `inboxDestination(sql)`, `jobsDestination(jobs, options)`   | `createIncomingWebhooks({ sql, destination })`                                     |
| [`StreamStore`](#streamstore)                           | `postgresStreamStore(options)`, `redisStreamStore(options)` | `teeToStore(store, id, stream)`, `resumeFromStore(store, id)`                      |
| [`CredentialProvider`](#credentialprovider)             | `vaultCredentials(options)`, `vercelConnectCredentials()`   | `createServer(betterSupabase, { credentials })`                                    |
| [`PaymentProvider`](#paymentprovider)                   | `stripePayments(options)`                                   | the commerce blocks                                                                |
| [`TaxProvider`](#taxprovider)                           | `fixedRates(options)`, `stripeTax(options)`                 | the commerce blocks                                                                |
| [`ShippingProvider`](#shipmentcarrier)                  | `flatRateCarrier(options)`                                  | `createShipments(client, { carriers })`                                            |
| [`AuthorizationProvider`](#authorizationprovider)       | none (built by your authorization system)                   | `authorization` in the config                                                      |
| [`AuthorizationHooks`](#authorizationhooks)             | none (built by your authorization system)                   | `createServer(betterSupabase, { authorization })`                                  |
| [`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)                                      | `createAiSchedules({ run })`                                                       |
| [`GraphCompiler`](#graphcompiler)                       | none (your engine's compiled form)                          | `createWorkflowEditor({ compile })`                                                |
| [`BuilderStarter`](#builderstarter)                     | none (starts a run on your engine)                          | `createWorkflowEditor({ 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`.

## QueueProvider [#queueprovider]

Stores and leases jobs for [`createQueue`](/docs/blocks/queue). 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. `expire()` is optional
too: it ends every running session past its expiry with `endedBy: "expired"`
and returns them, for `support.expire()`. `withTemporal(namespace)` is
optional as well: `createServer` calls it with the definition's `temporal`,
and it returns the store using that namespace for its time values unless the
store was given one itself. `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`.

## IncomingDestination [#incomingdestination]

Where [incoming webhooks](/docs/blocks/webhooks-in#sending-deliveries-to-a-queue)
put a verified delivery. It has `apiVersion: 1`, a `name` and
`deliver(delivery)`, which gets the endpoint's id, tenant and metadata, the
source, the message id, the type, the payload and the kept headers. It
returns `{ id, duplicate }`: a repeated endpoint id and message id must
return `duplicate: true`, and `receive` answers it with `200`. Throw to
answer `500` so the sender retries.

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

## PaymentProvider [#paymentprovider]

Takes payments for orders in [commerce](/docs/concepts/commerce#payments). It
has `apiVersion: 1`, a `name`, `createPayment`, `capture`, `refund` and
`parseWebhook`, and optionally `cancel` and `get`. Amounts are `Money` in
bigint minor units. Every method returns a `Result`: `invalid_input` for an
amount the provider can't charge or a refund above what was captured,
`not_found` for an unknown payment, `unauthorized` for a bad webhook
signature and `raised` with the hint `PAYMENT_DECLINED` for a decline. A
repeated `idempotencyKey` returns the first call's result.
`testPaymentProvider` from `better-supabase/testing` is its conformance kit.

## TaxProvider [#taxprovider]

Computes and records sales tax in [commerce](/docs/concepts/commerce#tax). It
has `apiVersion: 1`, a `name`, `quote(cart)`, `commit({ quote, reference })`
and `reverse({ transaction, reference, lines })`. A quote has one line per
cart line in cart order, then shipping, and a total that is their sum. A
reversal has negative amounts and can't exceed the sale. `testTaxProvider`
is its conformance kit.

## ShippingProvider [#shippingprovider]

Buys labels and reads tracking for the [shipments block](/docs/blocks/shipments).
It has `apiVersion: 1`, a `name`, `rates(request)`, `createLabel(request,
{ idempotencyKey })`, `track(trackingNumber)` and `cancel(trackingNumber)`,
and an optional `parseWebhook(input)` that verifies a signed delivery and
returns its tracking updates. Tracking events come oldest first, and each has
an id that the block records once. `testShippingProvider` is its conformance
kit.

## 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 `permissions` module                |
| `functions.isApprover`        | `decide_ai_tool_approval` in `ai-chat` and `approve_support_request` in `impersonation` |
| `approvals.distinctApprover`  | `decide_ai_tool_approval`: the chat's owner can't decide its own tool calls             |
| `functions.permitted`         | realtime topics that declare a `permission` on the instance in their name               |
| `operations`                  | workflow-editor nodes, MCP tools, AI tools and inbox commands                           |
| `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 `upgrade`    |

## AuthorizationHooks [#authorizationhooks]

The runtime half of an authorization provider, with `apiVersion: 1`: hooks
for automations, support sessions, delivery, identity links, chat actions,
approvals and run tickets, passed as `authorization` to `createServer`. Each
hook is optional and fails closed.
[Runtime hooks](/docs/extending/authorization-providers#runtime-hooks) lists
them. `testAuthorizationHooks` from `better-supabase/testing` is its
conformance block.

## DeliveryTransport [#deliverytransport]

Sends one notification over a channel, with `apiVersion: 1`, the `channel`
(`email`) and a `provider` name stored on the delivery. `send` gets the
delivery id as its `idempotencyKey`. `resendTransport` is the
shipped email transport; [Notifications](/docs/blocks/notifications) has the
Postmark, SES and SMTP recipes. `testDeliveryTransport` from
`better-supabase/testing` is its conformance block.

## 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 `createAiSchedules` 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. The memberships extension 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
(`guards()` does), and `pre` for one whose rewrite others should see
(the trash extension 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. The trash extension 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 (the memberships extension 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 block extensions describe themselves:
[updated-at](/docs/blocks/updated-at), [trash](/docs/blocks/trash#in-the-repository)
and [memberships](/docs/blocks/memberships).

## 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 the updated-at, trash, memberships and
  created-by extensions 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);

const customers = defineRepository(base, "customers", (repo) => ({
  active: () =>
    repo.list({ where: { status: "active" }, orderBy: { name: "asc" } }),
  byKvk: (kvk: string) => repo.get({ where: { kvk } }, { optional: true }),
}));

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 and block
extension (organization 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 `list`
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.list({ 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.
- **Type tests for `$rpc`** pin the typing of database function calls: one
  named argument object, required and optional arguments, the picked
  overload's return type, and the `returns` and `raw` options. A change to
  how `$rpc` infers arguments or results is a breaking change.

## 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`). In 0.9, BS117 (policies
without a role), BS224 (Data API roles without a statement or idle
transaction timeout), BS225 (`pg_stat_statements` missing), BS226 (prepared
statements through the transaction pooler), BS227 (offset pagination and
exact counts) and BS228 (an app table column drifted from `tables`)
arrived the same way, and BS216 started accepting partial indexes that only
skip null keys.

## 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/repository/permissions) (`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 `@bsb-block` line and in
   `better_supabase.blocks`. A change the schema diff can't follow (a
   renamed column, a backfill) raises the version and ships a forward step
   for `better-supabase 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 `blocks.<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 only when a block runs over a table the
app already has, 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/lint` (lint rule ids and their options, and the `guards()`
extension with its guard names and presets). 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.6.0 <0.7`, and the CLI is tested against 0.6.0; `gen`
  prints a notice when another release is installed.
  `database.types.ts` matches `supabase gen types` for that version, apart
  from the `__InternalSupabase` key that `supabase gen types --local` leaves
  out. 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 --for 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 `--for`. Reinstall after an upgrade, and
`--check` tells you when they are stale, and `--dry-run` shows the diff. `--source <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
```

| Command          | Side effects  |
| ---------------- | ------------- |
| `skills list`    | None.         |
| `skills install` | Writes files. |

## shadcn registry [#shadcn-registry]

The glue items (block wiring, server actions, hooks, route handlers and Expo
config) are a shadcn registry under the `@better-supabase` namespace, at
`https://bettersupabase.com/r/{name}.json`. Add it to `components.json` and the
[shadcn MCP server](https://ui.shadcn.com/docs/mcp) lists, searches and
installs them next to your other registries:

```json title="components.json"
{
  "registries": {
    "@better-supabase": "https://bettersupabase.com/r/{name}.json"
  }
}
```

There are no UI items: an agent builds the interface from the app's shadcn
components and a block's hook, following the
[UI recipes](/docs/registry/ui-recipes). The `better-supabase` skill carries
the same recipes in `references/ui-recipes.md`. See [Registry](/docs/registry)
for every item.

## 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">`.
- The block and recipe pages work the same way:
  [`/blocks/notifications.md`](/blocks/notifications.md) has the install
  command, the config entry, the events and the related blocks, and
  [`/blocks.md`](/blocks.md) and [`/recipes.md`](/recipes.md) list them all.
  `llms.txt` links both lists.
- 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.
- `--json` prints one result envelope on stdout
  ([`cli-result-v1.json`](https://unpkg.com/better-supabase/schemas/cli-result-v1.json)):
  the command's `data`, Problem Details with a stable `code` under `error`
  ([errors](/docs/cli/errors)), and the `next` commands to run. The files the CLI writes
  for other tools (the snapshot, the doctor report, the config) have a
  `$schema` URL. `--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)).

# Reference

> The CLI, extension points, standards and the changelog.

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

- [CLI](/docs/cli)
- [Config](/docs/cli/config)
- [Extending](/docs/extending/plugins)
- [Standards](/docs/standards)
- [Supabase packages](/docs/supabase-packages)
- [For AI agents](/docs/for-ai-agents)
- [Roadmap](/docs/roadmap)
- [Changelog](/docs/changelog)

# 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](/docs/changelog)
records what shipped.

## Shipped in 0.8 [#shipped-in-08]

The [0.8.0 changelog](/docs/changelog#080-2026-10-12)
has the details.

- `audience` and `issuer` pinning in `better-supabase/server`
  ([token audience](/docs/auth/server#token-audience)).
- The `standardSchema()` generator in `better-supabase/config`.
- TanStack DB collections with Realtime sync in
  `better-supabase/tanstack-db/supabase` (replaced by `tanstackDbEngine` in
  0.15).
- Typed Edge Function calls with `defineFunction()` and `functions()`.
- Supabase MCP server interop with `supabaseMcp()` and `supabaseMcpHandler()`.
- Supabase Lite in `better-supabase/server`, `better-supabase/testing` and the
  CLI.

## Now [#now]

Shipping in 0.9.

- **Exact money on the wire** (the deals, quotes, invoices, pipelines and
  employees blocks, and `db.$rpc`): amounts are `Money` values, every `int8`
  crosses PostgREST as text and decodes strictly, and doctor BS223 flags
  functions that return a bare `bigint` or `numeric`. See
  [ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md).
- **Courses and scored forms** (`better-supabase/blocks/courses` and
  `better-supabase/blocks/forms`): courses of modules and lessons on
  enrollments, quizzes graded in SQL, certificates, and NPS rollups per form.
  See [Courses](/docs/blocks/courses).
- **Posts** (`better-supabase/blocks/posts`): forum and feedback boards on
  the messaging core, with votes, roadmap statuses, merged duplicates,
  replies and moderation. See [Posts](/docs/blocks/posts).
- **Vertical presets** (`better-supabase/preset`): nineteen presets in a
  Verticals group, from property management on leases to community and
  product feedback on posts, each `saas` plus the blocks of one kind of
  business app. See [Presets](/docs/presets#vertical-presets).

## Next [#next]

Parity gaps with what Supabase ships today.

- **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`.

## Planned: collaboration and SaaS parity [#planned-collaboration-and-saas-parity]

This track adds one messaging core with a team chat block on it, moves
comments and the inbox onto that core, and then closes the feature gaps
against CRMs (Salesforce, HubSpot, Attio, Apollo), Google Workspace and
thirty other SaaS verticals. Each phase ships as its own pull request, and
the breaking phases ship with a data migration, an upgrade guide and a
changeset that lists every old name next to its replacement.
[ADR 0062](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0062-messaging-core-and-breaking-moves.md)
records the messaging core and
[ADR 0063](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0063-activity-block.md)
records the activity block.

### Feature map [#feature-map]

The features that show up in almost every SaaS vertical, and where
better-supabase stands on each:

| Feature                                              | Today                                                                                          | Planned                                                              |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Records, custom fields, app tables                   | app tables, custom-fields, parties                                                             | nothing new                                                          |
| Views (grid, board, calendar, timeline)              | saved-views and the `record-*` recipes                                                         | list loaders with section columns                                    |
| Comments                                             | threads, mentions, reactions                                                                   | attachments, resolve, edit history, pins, assign-to, unread, anchors |
| Team chat (channels, DMs, threads, unread, typing)   | the `messaging` and `channels` blocks                                                          | nothing new                                                          |
| Tasks                                                | assignees, subtasks, dependencies                                                              | checklists, recurrence, watchers, attachments on create and update   |
| Files                                                | scanning, the drive                                                                            | versions, previews through image variants, an upload controller      |
| Follow, notify, unread state                         | notifications                                                                                  | per-thread read markers                                              |
| Activity timeline                                    | engagements and the comments feed                                                              | its own `timeline` block and `loadTimeline`                          |
| Presence                                             | documents only                                                                                 | a shared helper used by channels, comments and documents             |
| Record mentions (smart chips)                        | users only                                                                                     | `@[Title](subject:type/id)` checked with the subject's permission    |
| Email sync, sequences, tracking, enrichment, scoring | the `mail-sync`, `segments`, `assignment`, `enrichment`, `sequences` and `lead-scoring` blocks | nothing new                                                          |
| Forecast snapshots, field history                    | the `forecasts` and `field-history` blocks                                                     | nothing new                                                          |
| Calendar extras and appointment schedules            | missing                                                                                        | working hours, out of office, focus time, free/busy, schedules       |
| Inbox snooze and scheduled send                      | missing                                                                                        | both, on the messaging core                                          |
| Document suggestion mode and named versions          | missing                                                                                        | both, with anchored comments                                         |
| Search, templates, automations, approvals, audit, AI | present                                                                                        | nothing new                                                          |

Twenty more verticals (property management, field service, clinics,
courses, events, legal, expenses, contracts, customer success, communities,
product feedback, call intelligence, logistics, restaurants, fitness,
nonprofits, IT service management, compliance, construction and insurance)
need the same few primitives: a ledger, assets and locations, votes and
scores, moderation, enrollments, guest and portal access, check-in passes,
merge and anchors on files. Those become shared blocks first, and the
verticals become thin blocks and presets on top of them.

### Phases [#phases]

1. **Table and entity renames in `upgrade`:** renaming a block's tables
   or entities on an installed project emits `alter table ... rename` and
   function swaps, plus the column moves a module declares in
   `NAMES.migrations`.
2. **Entities for the collaboration blocks:** comments, attachments,
   reactions, engagements, documents, calendar and reminders declare
   renameable entities and `schemas`, and the hand-written subject cases go.
3. **One `RecordRef` (breaking):** every subject block takes the subject as
   its first argument, reads the tenant from the block environment and
   returns comment and file authors. `createBlockClient` replaces the
   per-block `connect*` wrappers.
4. **Permissions and subjects on the client:** `my_permissions`,
   `permissionFlags`, `can`, `usePermission` and `Can`; `subject_titles`
   and `resolveSubjects`; URLs for every subject.
5. **Sections:** `defineSection` in block manifests, `loadSections`,
   `record_summary`, tenant-scoped cache tags and `createRecordController`.
6. **Generated features:** `gen` writes one binding per subject with a
   section per installed block. UI stays recipes only.
7. **Lists, workspace, flows and forms:** `defineRecordList`, `my_work`,
   generated navigation and commands, `defineFlow` and `recordForm`.
8. **The messaging core and the `channels` block:** conversations,
   messages, edits, members, thread reads, follows, mentions, typing and
   scheduled send, with attachments and reactions on messages, and a `chat`
   preset.
9. **Comments on the core (breaking):** comments become subject
   conversations with resolve, edit history, pins, anchors and attachments,
   and the activity feed moves to its own `timeline` block.
10. **Tasks and files:** attachment versions and previews, checklists,
    recurrence and watchers on work items.
11. **The inbox on the core (breaking):** inbox conversations and messages
    move onto the core, inbound files become scanned attachments, and the
    `crm` preset gains deals, quotes and targets.
12. **Shared collaboration features:** presence, record mentions, save for
    later and AI thread summaries.
13. **CRM gaps:** mail sync, email templates and tracking, sequences,
    segments, assignment and territories, enrichment, lead scoring,
    forecast snapshots and field history.
14. **Google Workspace parity:** calendar availability, appointment
    schedules, inbox snooze and scheduled send, document suggestion mode and
    drive labels and approvals.
15. **Shared primitives (breaking where they absorb existing tables):**
    ledger, assets and locations, votes and scores, moderation, enrollments,
    guest and portal access, merge, file anchors, passes and billable time.
16. **Community and feedback:** the `posts` block, and the `community` and
    `feedback` presets on it. Shipped; see [Posts](/docs/blocks/posts) and
    [Presets](/docs/presets#vertical-presets).

## Planned: block instances and one data contract [#planned-block-instances-and-one-data-contract]

Picking blocks should build a working product. This track gives every
concept one name, lets you install a block more than once under its own
names, attaches blocks to your record types by default, and puts app tables
and block tables behind the same data verbs and generated hooks. It removes
the layers that duplicate each other: built-in plugins, sections, the
per-framework block folders, preset codes, the extra UI and API paths and the
split automation stack. Every phase is breaking, ships as its own pull
request, and comes with a codemod or a doctor lint. The terms are on the
[Naming](/docs/concepts/naming#glossary) page, and
[ADR 0093](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0093-block-instances-and-one-data-contract.md)
records the decision.

1. **Terminology:** module becomes block, organization replaces tenant in
   every name you see, and `bs` becomes `bsb` everywhere.
2. **Config groups** (`database`, `auth`, `records`, `tables`, `blocks`,
   `adapters`, `gen`, `api`, `tooling`) and one organization resolver.
3. **The CLI** with one command per job, and a new `remove`.
4. **Designs replace preset codes** in Create URLs and the CLI.
5. **Block renames** to plain defaults: `tasks`, `contacts`, `activities`,
   `queue`, `automations`, `memberships`.
6. **Every block renameable,** and every primary noun a record type.
7. **Built-in plugins become block extensions.**
8. **One data contract** for app tables and block tables.
9. **Instances:** `block`, `on` and `uses` in config, SQL per instance,
   consumers that register, attach by default and one client per instance.
10. **The client and generated hooks,** with no transport or organization in
    app code.
11. **Per-framework block folders removed.**
12. **Record bindings** replace sections and record features.
13. **One UI path** through registry items.
14. **One API layer** and one spec entry, with supported and community
    adapters.
15. **One automation model** on the outbox and the queue.
16. **Wired by default:** env, routes, workers and providers from the
    manifests.
17. **Presets as products,** with `cms`, `erp` and `pim`.
18. **Gaps between blocks** people pick together.
19. **Local-first engines** behind the same hooks.

## Later [#later]

- **A machine-readable capability file** for better-supabase itself, in the
  shape of the `supabase/sdk` capability matrix. Upstream: `supabase/sdk`.
- **ElectricSQL and Zero engines**: data engines over their sync engines, in
  the shape of `powersyncEngine` and `sqliteEngine`.
  Upstream: `electric-sql/electric`, `rocicorp/mono`.

The [blocks overview](/docs/blocks) lists the blocks that ship today.

# 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. `createArazzo` from `better-supabase/spec` 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/spec";
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: {} },
  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

`createAsyncApi` from `better-supabase/spec` 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/spec";
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 (`bsb:t:<schema>.<table>`, with a
  `memberships` 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, with the verified token claims in `properties.claims`: `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
the memberships extension 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, and aborts a request after `timeoutMs`
(10 seconds by default). 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. `bsb.route` and `bsb.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 shadcn registry), the test
validates the output against a vendored copy of it.

| Standard                                                                                                                                                                                                 | Posture          | Where                                                                                                                                              | Tests                                                      |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [Standard Schema v1](https://standardschema.dev)                                                                                                                                                         | Adopted          | validation extension, `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, form answer schemas, config, doctor report and metadata `$schema` URLs                                                    | `json-schema-2020-12.test.ts`, `json-schema-forms.test.ts` |
| [OpenAPI 3.0, 3.1 and 3.2](https://spec.openapis.org/oas/v3.2.0)                                                                                                                                         | Adopted (pinned) | [`/spec`](/docs/standards/openapi): `createOpenApi`, `openapiFormat` and `better-supabase spec emit openapi`; 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 [`/spec`](/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) | [`/spec`](/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) | [`/spec`](/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 dev 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`                                     |
| [OTLP/HTTP logs, JSON encoding](https://opentelemetry.io/docs/specs/otlp/)                                                                                                                               | Adopted (pinned) | `otlpLogs()` in the [outgoing webhooks block](/docs/blocks/webhooks-out#connectors)                                                                | `otlp.test.ts`                                             |
| [W3C Server Timing (Working Draft)](https://www.w3.org/TR/2026/WD-server-timing-20260407/)                                                                                                               | Adopted (pinned) | `bsb.proxy({ serverTiming: true })` emits `bsb-proxy` and `bsb-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/queue#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/queue#idempotency-keys)                                                                                                     | `idempotency-key.test.ts`                                  |
| [pgTAP](https://pgtap.org)                                                                                                                                                                               | Adopted          | [`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`                         |
| [shadcn registry schema (shadcn 4.21.4)](https://ui.shadcn.com/docs/registry)                                                                                                                            | Adopted (pinned) | the `@better-supabase` glue items at `https://bettersupabase.com/r/{name}.json` ([registry](/docs/registry))                                       | `shadcn-registry.test.ts`                                  |
| [TypeScript 7.1 content mappers (protocol 1)](https://github.com/microsoft/typescript-go/pull/4712)                                                                                                      | Adopted (pinned) | the schema types served by `dist/mapper/bin.js` when `gen.contentMapper` is on ([content mapper](/docs/content-mapper))                            | `typescript-content-mapper.test.ts`, `tests/types/ts-7.1`  |
| [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) | [`/spec`](/docs/standards/asyncapi): Realtime topics, table broadcasts and events                                                                  | `asyncapi.test.ts`                                         |
| [RFC 5545 recurrence](https://www.rfc-editor.org/rfc/rfc5545)                                                                                                                                            | Adopted (pinned) | `expandRule()` in the [reminders block](/docs/blocks/reminders); `parseIcs()` and `toIcs()` in the [calendar block](/docs/blocks/calendar)         | `rfc5545.test.ts`                                          |
| [RFC 4180 CSV](https://www.rfc-editor.org/rfc/rfc4180)                                                                                                                                                   | Adopted (pinned) | `parseCsv()` and `toCsv()`                                                                                                                         | `rfc4180.test.ts`                                          |
| [ISO 4217 currency codes](https://www.iso.org/iso-4217-currency-codes.html)                                                                                                                              | Adopted (pinned) | `money()` and `minorUnits()` in the [commerce module](/docs/concepts/commerce)                                                                     | `iso4217.test.ts`                                          |
| [RFC 6902 JSON Patch](https://www.rfc-editor.org/rfc/rfc6902)                                                                                                                                            | Adopted (pinned) | `createPatch()` and `applyPatch()` in the [versions block](/docs/blocks/versions)                                                                  | `rfc6902.test.ts`                                          |
| [RFC 9396 Rich Authorization Requests](https://www.rfc-editor.org/rfc/rfc9396)                                                                                                                           | Adopted (pinned) | `authorization_details` on delegated tokens, kept by `forContext` and job envelopes                                                                | `rfc9396.test.ts`                                          |
| [JWS and JWK (RFC 7515, 7517, 7638, 8037, RFC 9864)](https://www.rfc-editor.org/rfc/rfc9864)                                                                                                             | Adopted (pinned) | `signer().signJwt()`, `jwks()` and `verifyCompact()` in the [signing keys block](/docs/blocks/signing-keys), and Standard Webhooks `v1a`           | `rfc9864.test.ts`                                          |
| [OpenID Shared Signals (SSF 1.0, CAEP 1.0, RISC 1.0, RFC 8417, 8935, 8936)](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)                                                            | Adopted (pinned) | `mintSet()`, `verifySet()`, the transmitter and receiver in the [shared signals block](/docs/blocks/security-events), and the `set` webhook format | `security-events.test.ts`                                  |
| [RFC 9068 JWT access tokens](https://www.rfc-editor.org/rfc/rfc9068)                                                                                                                                     | Adopted (pinned) | `accessTokenResolver()` in [`better-supabase/server`](/docs/auth/access-tokens) checks `typ`, `iss`, `aud`, `exp`, `iat` and the required claims   | `rfc9068.test.ts`                                          |
| [OAuth 2.1 storage (RFC 7591, 7636, 8628, 9700)](https://www.rfc-editor.org/rfc/rfc9700)                                                                                                                 | Adopted (pinned) | client registration, S256 PKCE, device codes and code reuse revocation in the [oauth-server block](/docs/blocks/oauth-server)                      | `oauth-server.test.ts`                                     |
| [RateLimit header fields (draft-ietf-httpapi-ratelimit-headers-11)](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-11)                                                       | Adopted (pinned) | `rateLimitHeader()`, `rateLimitPolicyHeader()` and `rateLimit()` on the Hono and oRPC adapters                                                     | `ratelimit-headers.test.ts`                                |

## Pins [#pins]

```ts
import { SPEC_PINS } from "better-supabase";

SPEC_PINS.otelSemconv; // OpenTelemetry semantic conventions version
SPEC_PINS.otlp; // OTLP/HTTP JSON version the otlpLogs() webhook encoder writes
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
SPEC_PINS.rfc5545; // RFC 5545 recurrence rules and iCalendar files (reminders, calendar)
SPEC_PINS.rfc4180; // RFC 4180 CSV that parseCsv reads and toCsv writes
SPEC_PINS.iso4217; // ISO 4217 List One publication the commerce block's minor units follow
SPEC_PINS.rfc6902; // RFC 6902 JSON Patch that the versions block writes and applies
SPEC_PINS.rfc9396; // RFC 9396 authorization_details that delegated tokens and job envelopes carry
SPEC_PINS.rfc9864; // RFC 9864 fully specified JOSE algorithms the signing-keys block can write
SPEC_PINS.rfc8417; // RFC 8417 Security Event Tokens that the security-events block mints and verifies
SPEC_PINS.rfc8935; // RFC 8935 push delivery: the set webhook format and the receiver push endpoint
SPEC_PINS.rfc8936; // RFC 8936 poll delivery that the transmitter serves and the receiver runs
SPEC_PINS.ssf; // OpenID Shared Signals Framework the transmitter metadata and stream API follow
SPEC_PINS.caep; // OpenID CAEP event types in CAEP_EVENTS
SPEC_PINS.risc; // OpenID RISC event types in RISC_EVENTS
SPEC_PINS.rfc9068; // RFC 9068 JWT access tokens that accessTokenResolver verifies
SPEC_PINS.rfc7591; // RFC 7591 client registration metadata the oauth-server block stores
SPEC_PINS.rfc7636; // RFC 7636 PKCE; the oauth-server block accepts only S256
SPEC_PINS.rfc8628; // RFC 8628 device authorization that the oauth-server block's device codes follow
SPEC_PINS.rfc9700; // RFC 9700 OAuth security BCP behind code reuse and redirect URI rules
SPEC_PINS.ratelimitHeaders; // draft-ietf-httpapi-ratelimit-headers revision behind RateLimit and RateLimit-Policy
SPEC_PINS.shadcnRegistry; // shadcn release whose registry.json and registry-item.json schemas the registry items follow
SPEC_PINS.typescriptContentMapper; // TypeScript 7.1 content mapper protocol version the schema mapper speaks
```

# 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/spec` 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 emit --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, after (or page), size and facet parameters are used as they are.
  Otherwise `after`/`size`, or `page`/`size` with `pagination: 'offset'` on
  the resource (and on a table without a primary key), 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.
When the executor runs them together (the direct Postgres executor does, in
one transaction), the round trip gets one more span, `BATCH SELECT <table>`,
with `db.operation.batch.size` set to the number of reads. A batch over
several tables is named `BATCH SELECT`, and a batch of mixed operations is
named `BATCH`. It is marked failed when any read in it fails.

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/spec`
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/spec";

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, defineApi, loadJsonPath } 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
      .list({ where: { userId: user_id }, limit: 1 })
      .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
[memberships extension](/docs/blocks/memberships) 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.

A secret that starts with `whpk_` is an Ed25519 public key: `verifyWebhook`
then checks the `v1a` signatures instead of the `v1` ones. Senders give you
that key, and `webhookPublicKey(jwk)` turns a public JWK into it. See
[Ed25519 signatures](/docs/blocks/webhooks-out#ed25519-signatures).

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, sqlTransport } from "better-supabase/blocks/queue";
import { stripeInboxVerify } from "better-supabase/blocks/webhooks";

const inbox = createWebhookInbox({
  transport: sqlTransport(postgres.admin),
  source: "stripe",
  verify: stripeInboxVerify(process.env.STRIPE_WEBHOOK_SECRET!),
});
```

Stripe signs Connect events (snapshot events for a connected account, with
`account` set) and the thin event notifications of event destinations (`object:
"v2.core.event"`, with `related_object` and, for a connected account,
`context`) with the same header, so `verifyStripeWebhook` and
`stripeInboxVerify` take all of them and store the payload as it arrived. A
thin notification has no `data.object`: fetch the full event with
`stripe.v2.core.events.retrieve(id)` when you process it.

`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.15`) | [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 dev env` runs `supabase status` to read the local URLs and
  keys. It uses `$SUPABASE_BIN` when set, then the project's
  `node_modules/.bin/supabase`, then `supabase` on the `PATH`. It never
  downloads the CLI; without one it fails with
  [`supabase_cli_missing`](/docs/cli/errors#supabase_cli_missing).
- `better-supabase init` prints the `supabase` commands to run next
  (`supabase start`, `supabase db reset`).

See [Local development](/docs/cli/dev) for the commands and their output.