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.
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 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.
better-supabase add rulesThe module needs tenant and access.
Fields
defineRuleFields declares what rules on a subject type may test: a field id, its filter type 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.
import {
createRulesAdmin,
defineRuleFields,
} from "better-supabase/blocks/rules";
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 createRulesAdmin({ transport: serviceTransport })
.syncFields(expenseFields)
.orThrow();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 save would report, so an editor can show them before it saves.
Rules
import { createRules } from "better-supabase/blocks/rules";
const rules = createRules({ transport });
const rule = await rules
.save(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();save 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).
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
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:
const matches = await createRulesAdmin({ 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 are references, never code: the database stores { kind, config } and your app runs them. createRuleDispatcher maps each kind to a handler that returns a Result, and dispatch runs the actions of every match in order. A failed action doesn't stop the others, a handler that throws becomes an unexpected outcome, and a kind without a handler is an invalid_request outcome with the hint RULE_ACTION_UNKNOWN.
import { createApprovals } from "better-supabase/blocks/approvals";
import {
approvalsAction,
createRuleDispatcher,
} from "better-supabase/blocks/rules";
const dispatcher = createRuleDispatcher({
handlers: {
"approvals.request": approvalsAction(createApprovals({ transport })),
},
});
const outcomes = await dispatcher
.dispatch(matches, {
organizationId,
subjectType: "expense",
subjectId: expense.id,
payload: { amount: expense.amount },
})
.orThrow();approvalsAction starts an approval request for the action in config.action, which must be a policy action your approvals options name for the subject type.
Editor
useRuleBuilder from better-supabase/blocks/rules/react 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 save. Pass rule: null to start a new rule from initial (an empty and group by default).
"use client";
import { useRuleBuilder } from "better-supabase/blocks/rules/react";
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 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 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/rules for your own editor. createRuleBuilderController is the same state without React. In Server Components, call rules.get(id) instead.
Vue
better-supabase/blocks/rules/vue exports useRuleBuilder. It takes the options of createRuleBuilderController with a client from createRules; auth and ports come from app.use(betterSupabase(bs)) 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.
<script setup lang="ts">
import { useSupabase } from "better-supabase/vue";
import { useRuleBuilder } from "better-supabase/blocks/rules/vue";
import { createRules, rpcTransport } from "better-supabase/blocks/rules";
const { organizationId } = defineProps<{ organizationId: string }>();
const supabase = useSupabase();
const { state, actions } = useRuleBuilder({
client: createRules({ 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
better-supabase/blocks/rules/svelte exports useRuleBuilder, with the same options; auth and ports come from setBetterSupabase(bs) unless you pass them. The hook 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.
<script lang="ts">
import { getBetterSupabase } from "better-supabase/svelte";
import { useRuleBuilder } from "better-supabase/blocks/rules/svelte";
import { createRules, rpcTransport } from "better-supabase/blocks/rules";
let { organizationId }: { organizationId: string } = $props();
const { supabase } = getBetterSupabase();
const builder = useRuleBuilder({
client: createRules({ 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
better-supabase/blocks/rules/solid exports useRuleBuilder, with the same options; auth and ports come from <BetterSupabaseProvider client={bs}> 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.
import { useSupabase } from "better-supabase/solid";
import { useRuleBuilder } from "better-supabase/blocks/rules/solid";
import { createRules, rpcTransport } from "better-supabase/blocks/rules";
export function Component(props: { organizationId: string }) {
const supabase = useSupabase();
const { state, actions } = useRuleBuilder({
client: createRules({ 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
blocks: {
rules: {
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 |
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 |
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.
The block takes hooks around its methods; see extending blocks.
Last updated on
Leave
Leave types in days or hours, requests approved through the approvals module, and a balance ledger per member and year with grants, accruals, adjustments and a year-end carryover.
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.