# Events

> Observe queries, mutations, errors, auth and refreshes with betterSupabase.on.

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

```ts
const off = betterSupabase.on("mutation", ({ table, kind, rows, context }) => {
  metrics.increment(`db.${table}.${kind}`, rows.length);
});

off();
```

`betterSupabase.on` returns a function that unsubscribes. Handlers are observers: they
can't change a result, and an error they throw goes to the
[`Logger`](/docs/extending/interfaces#logger) instead of the caller.

Subscribe once, at module scope or when a long-lived service starts. A
handler registered per request or per component render is never removed
unless you call `off()`, so the hub grows with every call. Outside
production, the logger warns once when one event has more than 50 handlers.

| Event      | Payload                                                                                                                                 |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `query`    | `table`, `operation`, `ok`, `durationMs`, `rows`, `truncated` (an unbounded read hit [`maxRows`](/docs/repository/pagination#row-caps)) |
| `mutation` | `table`, `kind` (`insert`, `upsert`, `update`, `delete`), `intent`, a copy of the app-cased `rows`, `keys`, `tenant`, `context`         |
| `error`    | `table`, the `DbError`                                                                                                                  |
| `auth`     | `source` (`bearer`, `cookie`, `none`), `ok`, `userId`, `rawSource` (a custom resolver's name), `reason` (why there is no user)          |
| `refresh`  | `ok`, `shared` (joined an in-flight refresh), `durationMs`                                                                              |
| `block`    | `type` (`support.started`, `organization.created`, ...), a copy of `data`, `subject`, `tenant`, `actorId`, `time`, `context`            |

`mutation` rows are a copy of what the database returned, so a listener
cannot change the caller's result. `delete(id)` returns the primary key, so
row-level cache tags and `row.deleted` CloudEvents work for deletes too.

`intent` is what the caller asked for: a soft delete runs as an `update`
(`kind`) with `intent: "softDelete"`. `keys` holds the primary keys of the
changed rows when they are known, from the returned rows or from a `where` on
the primary key, so writes that return nothing (soft deletes,
`returning: false`) still invalidate the right rows. `tenant` is
`context.tenant` or the tenant the `tenant()` plugin resolved from the claims.

Listeners are per runtime: register them once, next to `defineSupabase`, not
per request. Cache invalidation (`betterSupabase.cache`), CloudEvents
(`forwardMutations`) and OpenTelemetry metrics are built on these events.

## Diagnostics [#diagnostics]

`diagnostics: true` writes one debug record to the
[`Logger`](/docs/extending/interfaces#logger) for every `query`, `error`,
`refresh` and `auth` event, following the Supabase SDK
[diagnostic logging capability](https://github.com/supabase/sdk/blob/capability-matrix/v1.14.0/packages/capability-matrix/specs/client/observability/diagnostic_logging.md):

```ts
const betterSupabase = defineSupabase(schema, {
  diagnostics: process.env.NODE_ENV !== "production",
  logger: pino(),
});
// debug: "select customers ok in 14ms" { table, operation, ok, durationMs, rows, truncated }
// debug: "conflict error on contacts" { table, kind, status, code }
```

The records carry names, outcomes, timings and counts only. They never
contain tokens, keys, user ids, row values, filters, query strings, headers
or database error messages (a constraint message can quote the conflicting
value). The option is off by default, and with it off no handler is
registered. A logger that throws is caught like any other handler, so it
never changes a result.

## Block events [#block-events]

The blocks and the SQL modules report what they did as `block` events.
`onBlockEvent` from `better-supabase/events` listens to one type or to every
type with a prefix, and types the data for the match:

```ts
import { onBlockEvent } from "better-supabase/events";

onBlockEvent(betterSupabase, "support.*", (event) => {
  // event.type is "support.started" | "support.ended" | "support.denied"
  auditTrail.write({ type: event.type, admin: event.data.adminId });
});
```

Every type is `<entity>.<past_verb>` in snake case. A payload with a tenant
carries `organizationId`, and the subject is `<collection>/<id>` with a
kebab-case plural collection, such as `organizations/<id>` or
`support-sessions/<id>`. Each SQL module declares the events it writes, with
their subject and payload keys, and rendering fails when a module writes
anything else. Events from writers that a job, a webhook or an engine may
call again carry an idempotency key, so the outbox keeps one row.

| Type                                                                                                                                                                                                                                                                                                                       | Data                                                                                                                                |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `support.started`, `support.ended`, `support.denied`                                                                                                                                                                                                                                                                       | `sessionId`, `adminId`, `targetUserId`, `reason`, `readOnly`, `expiresAt`, `organizationId`, `denial`, `endedBy`                    |
| `organization.created`, `organization.updated`, `organization.deleted`, `organization.member_added`, `organization.member_removed`, `organization.member_left`, `organization.member_suspended`, `organization.member_resumed`, `organization.role_changed`, `organization.ownership_transferred`, `organization.switched` | `organizationId`, `userId`, `role`, `previousRole`                                                                                  |
| `organization.deletion_requested`, `organization.deletion_cancelled`, `organization.purged`                                                                                                                                                                                                                                | `organizationId`, `userId`, `purgeAfter`                                                                                            |
| `organization.domain_added`, `organization.domain_updated`, `organization.domain_verified`, `organization.domain_removed`                                                                                                                                                                                                  | `organizationId`, `domainId`, `domain`, `userId`                                                                                    |
| `sso_provider.registered`, `sso_provider.unregistered`                                                                                                                                                                                                                                                                     | `organizationId`, `providerId`, `domains`, `userId`                                                                                 |
| `scim_user.saved`, `scim_user.deleted`, `scim_group.saved`, `scim_group.deleted`                                                                                                                                                                                                                                           | `organizationId`, `scimUserId` or `scimGroupId`                                                                                     |
| `invitation.created`, `invitation.resent`, `invitation.updated`, `invitation.accepted`, `invitation.declined`, `invitation.revoked`                                                                                                                                                                                        | `invitationId`, `organizationId`, `email`, `role` (never the token)                                                                 |
| `waitlist.approved`, `waitlist.rejected`                                                                                                                                                                                                                                                                                   | `entryId`, `email`                                                                                                                  |
| `invite_code.created`, `invite_code.revoked`                                                                                                                                                                                                                                                                               | `codeId`, `organizationId`, `prefix`, `role` (never the code)                                                                       |
| `api_key.created`, `api_key.revoked`, `api_key.rotated`                                                                                                                                                                                                                                                                    | `keyId`, `organizationId`, `userId`, `name`, `publicId`, `previousKeyId` (never the secret)                                         |
| `organization_setting.updated`, `organization_setting.reset`, `platform_setting.updated`, `platform_setting.reset`                                                                                                                                                                                                         | `organizationId`, `key`                                                                                                             |
| `flag.saved`, `flag.deleted`, `flag.override_set`                                                                                                                                                                                                                                                                          | `key`, `variant`, `organizationId`, `userId`                                                                                        |
| `announcement.saved`, `announcement.deleted`                                                                                                                                                                                                                                                                               | `announcementId`                                                                                                                    |
| `credential.set`, `credential.deleted`                                                                                                                                                                                                                                                                                     | `provider`, `name` (never the secret)                                                                                               |
| `notification.created`, `notification.delivered`, `notification.failed`                                                                                                                                                                                                                                                    | `notificationId`, `organizationId`, `type`, `recipientIds`, `channel`, `error`                                                      |
| `webhook.delivered`, `webhook.failed`, `webhook.disabled`, `webhook.secret_rotated`                                                                                                                                                                                                                                        | `endpointId`, `organizationId`, `deliveryId`, `eventType`, `status`, `attempt`, `error`, `failingSince`                             |
| `incoming_webhook.created`, `incoming_webhook.updated`, `incoming_webhook.enabled_set`, `incoming_webhook.token_rotated`, `incoming_webhook.secret_rotated`, `incoming_webhook.deleted`                                                                                                                                    | `organizationId`, `endpointId`, `name`, `verify`, `enabled`, `secretRotated`                                                        |
| `billing.customer_linked`, `billing.checkout_completed`, `billing.subscription_created`, `billing.subscription_updated`, `billing.subscription_deleted`, `billing.seats_synced`                                                                                                                                            | `organizationId`, `customerId`, `subscriptionId`, `status`, `quantity`, `previousQuantity`, `stripeEventId`                         |
| `comment.created`, `comment.mentioned`, `comment.deleted`                                                                                                                                                                                                                                                                  | `commentId`, `organizationId`, `subjectType`, `subjectId`, `authorId`, `parentId`, `mentionIds`                                     |
| `attachment.uploaded`, `attachment.scanned`                                                                                                                                                                                                                                                                                | `attachmentId`, `organizationId`, `subjectType`, `subjectId`, `uploadedBy`, `mimeType`, `size`, `status`                            |
| `object.uploaded`                                                                                                                                                                                                                                                                                                          | `bucket`, `path`                                                                                                                    |
| `data_export.requested`, `data_export.completed`, `data_export.failed`                                                                                                                                                                                                                                                     | `exportId`, `subject`, `organizationId`, `userId`, `requestedBy`, `files`, `expiresAt`, `error`                                     |
| `push.device_registered`, `push.device_unregistered`                                                                                                                                                                                                                                                                       | `deviceId`, `userId`, `platform`, `provider`                                                                                        |
| `inbox_conversation.opened`, `inbox_conversation.assigned`, `inbox_conversation.resolved`, `inbox_conversation.reopened`, `inbox_message.received`                                                                                                                                                                         | `conversationId`, `organizationId`, `inboxId`, `contactId`, `assigneeId`, `previousAssigneeId`, `status`, `messageId`               |
| `workflow_run.completed`, `workflow_run.failed`, `workflow_run.cancelled`                                                                                                                                                                                                                                                  | `runId`, `organizationId`, `engine`, `externalId`, `definition`, `status`, `error`                                                  |
| `workflow.published`, `workflow_alert.triggered`                                                                                                                                                                                                                                                                           | `organizationId`, `definitionId`, `versionId`, `version`, `alertId`, `definition`, `onEvent`, `channel`, `runId`, `status`, `error` |
| `connector.saved`, `connector.deleted`, `connector.fingerprint_decided`, `connector_grant.created`, `connector_grant.revoked`                                                                                                                                                                                              | `organizationId`, `serverId`, `name`, `fingerprint`, `approved`, `grantId`, `userId`                                                |
| `agent.saved`, `agent.published`, `agent.deleted`, `agent.installed`, `agent.uninstalled`                                                                                                                                                                                                                                  | `organizationId`, `agentId`, `slug`, `visibility`, `userId`                                                                         |
| `ai_provider_key.saved`, `ai_provider_key.deleted`                                                                                                                                                                                                                                                                         | `organizationId`, `keyId`, `provider`, `name` (never the key)                                                                       |
| `ai_chat.shared`, `ai_chat.share_revoked`, `ai_chat_message.completed`                                                                                                                                                                                                                                                     | `chatId`, `organizationId`, `ownerId`, `shareId`, `leafId`, `messageId`, `model`, `status`                                          |
| `ai_tool_policy.set`, `ai_tool_approval.decided`                                                                                                                                                                                                                                                                           | `organizationId`, `tool`, `policy`, `chatId`, `approvalId`, `decision`                                                              |
| `audit.revealed`                                                                                                                                                                                                                                                                                                           | `organizationId`, `entries`                                                                                                         |

`BLOCK_EVENT_RENAMES` from `better-supabase/events` maps the 0.5 names to the
current ones (`BLOCK_EVENT_RENAMES[type] ?? type`), for consumers that still
receive both.

Block events follow the same rules as the others: the data is a copy, and a
handler can't change what the block does. To decide something, pass the module's
policy callback instead (below).

`forwardBlockEvents(betterSupabase, sink, { source, types })` sends them to an
[`EventSink`](/docs/extending/interfaces#eventsink) as CloudEvents
(`dev.better-supabase.organization.created`, the tenant as `partitionkey`, the actor as
`data.actorId`), and `traceBlockEvents(betterSupabase)` from `better-supabase/otel`
records them on the active span. These events are in-process: when one must
not be lost, install the `outbox` module, and the SQL modules write the same
event in the transaction that caused it.

`dataschema` sets the CloudEvents `dataschema` attribute, the URI (or `$id`)
of the JSON Schema the event's `data` conforms to. It is accepted by
`forwardBlockEvents`, `blockCloudEvent`, `forwardMutations` and
`toCloudEvents`. A string applies to every event; a function receives
`{ type, table }` (`type` with the configured prefix, `table` set for row
events) and returns a URI, or `undefined` to leave the attribute out.
Without the option, events carry no `dataschema`:

```ts
forwardBlockEvents(betterSupabase, sink, {
  source: "/crm",
  dataschema: ({ type }) => `https://crm.example.com/schemas/${type}.json`,
});
```

## Policy callbacks [#policy-callbacks]

Where a block has a decision the app may want to make (who may start a support
session, who may invite, whether to deliver a notification, which webhook
URLs are allowed), it takes a policy callback such as `authorize`,
`canInvite`, `shouldDeliver` or `allowUrl`. Every policy follows the same
rules:

* It can be sync or async.
* Only `true` allows. `false`, any other value, a throw or a rejection
  denies, so a bug in a policy fails closed.
* Without the callback, the module's documented default applies.
* A denial emits the module's `*.denied` or `*.failed` event with the reason.

## Attribute names [#attribute-names]

Spans and events use fixed attribute names (`BLOCK_ATTRIBUTES` from
`better-supabase/events`), so dashboards don't depend on a block's internals:

| Attribute                             | Value                         |
| ------------------------------------- | ----------------------------- |
| `better_supabase.block.event`         | the event type                |
| `better_supabase.tenant`              | the tenant id                 |
| `better_supabase.actor.id`            | the user who caused it        |
| `better_supabase.support.session_id`  | the support session           |
| `better_supabase.organization.id`     | the organization              |
| `better_supabase.invitation.id`       | the invitation                |
| `better_supabase.notification.kind`   | the notification kind         |
| `better_supabase.webhook.endpoint_id` | the outgoing webhook endpoint |

## SQL hooks [#sql-hooks]

SQL modules call optional app functions around their work, for example
`after_organization_create(organization uuid, user_id uuid)`. A hook runs in the same
transaction when a function with that name and argument list exists in
`public`, and is skipped otherwise. Raise an exception in a `before_*` hook
to refuse the operation. Point a module at another schema or function with
`sql.modules.<module>.hooks`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      organizations: {
        hooks: {
          schema: "app",
          functions: { after_organization_create: "private.seed_organization" },
        },
      },
    },
  },
});
```

Hooks are named `before_<entity>_<action>` and `after_<entity>_<action>`;
`notification_audience` and `after_notify` keep the names they shipped with.
Each module's page lists its hooks and their arguments, and
[Extending blocks](/docs/extending/blocks#hooks-in-sql) lists them for
organizations, profiles and notifications. With the `outbox`
module installed, modules also write their events with `emit_event` in the
same transaction; `sql.modules.<module>.events: false` turns that off for one
module.