Events
Observe queries, mutations, errors, auth and refreshes with betterSupabase.on.
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 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) |
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: true writes one debug record to the
Logger for every query, error,
refresh and auth event, following the Supabase SDK
diagnostic logging capability:
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
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:
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 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:
forwardBlockEvents(betterSupabase, sink, {
source: "/crm",
dataschema: ({ type }) => `https://crm.example.com/schemas/${type}.json`,
});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
trueallows.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
*.deniedor*.failedevent with the reason.
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 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:
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 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.
Last updated on
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.
Conformance blocks
Prove a custom executor, cache adapter, sink, auth resolver, framework adapter, generator or plugin meets its contract.