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

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

```ts
import { createBulkActions } from "better-supabase/blocks/bulk-actions";
```

## 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/blocks/app-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-actions": {
    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]

```ts
const bulk = createBulkActions({ 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 }]
```

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