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.
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 queue bulk,
which broadcasts its progress on a Realtime topic.
better-supabase sql add bulkimport { createBulk } from "better-supabase/blocks/bulk";Actions
Each block that is installed adds its action:
| Action | Block | args |
|---|---|---|
tags.set | Tags | { add?: tagIds, remove?: tagIds } |
stateMachine.transition | State machine | { to, reason? } |
relations.link | Relations | { kind, targetType, targetId, data? } |
trash.trash | 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:
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
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:
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
start stores the run in bulk_runs and enqueues a job on the queue
bulk. Process it with the handler:
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
A bulk call returns no undo token, because the inverse of a row depends on
what the row held before. With the audit block on the
subject table, audit.revert writes the old values back.
With ReUI
- Data Grid: row selection feeds
rows, and the selection bar runsapplywith an action per button. - A toast subscribes to the run's topic and shows
positionoftotal, then lists the failed rows fromget.
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.
Last updated on
Relations
Typed links between subjects (blocks, parent, relates or your own kinds) with inverse names, cycle checks, one-target kinds, ancestors, descendants, rollups and undo tokens.
Undo
Apply the undo tokens that block methods return, on the server with createUndo and in the browser with a useUndoStack for a toast's Undo button.