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

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

The module needs `tenant` 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.

```ts
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 [#rules]

```ts
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](/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 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]

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

```ts
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](/docs/blocks/approvals) for the action in `config.action`, which must be a policy action your approvals options name for the subject type.

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

```tsx title="components/rule-editor.tsx"
"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](/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/rules` for your own editor. `createRuleBuilderController` is the same state without React. In Server Components, call `rules.get(id)` instead.

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

```vue title="Component.vue"
<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 [#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.

```svelte title="Component.svelte"
<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 [#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.

```tsx title="Component.tsx"
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 [#configure]

```ts title="better-supabase.config.ts"
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 [#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 [#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).