# 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

`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/jobs) queue `bulk`,
which broadcasts its progress on a Realtime topic.

```bash
better-supabase sql add bulk
```

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

## 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 (`{}`)                             |

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"
sql: {
  modules: {
    bulk: {
      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 = createBulk({ 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
`createBulk` 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).