Data ops
Select all, including the rows you never loaded
Ticking rows sends their keys; “select all” sends the filter, and your database does the rest in one statement. Either way the grid gets a job back at once — and there are things that job cannot tell you yet.
You filter a table down to the 40,000 orders marked cancelled and want them gone. The grid has loaded a hundred of them. Ticking a hundred checkboxes four hundred times is not a plan, and neither is a browser tab holding 40,000 keys in memory to send them back.
So there are two ways to select, and they are not the same operation underneath.
Two selections
Tick rows and the selection is those rows: their primary keys are what travel to the server. Tick a few and a banner offers to select all rows matching the current filter. Take it, and no keys travel at all — the request carries the filter you have on screen, and your database is asked to act on whatever matches it at that moment.
That is the whole trick behind acting on rows you never loaded. The grid does not need them; the WHERE clause does. With no filter active the banner says all rows are selected, and it means the table — every row, including the ones the grid has not fetched.
Why it comes back later
Either kind of selection gets the same answer: 202 Accepted and a job. The rows are not gone when the dialog closes. The job is queued, and a message is on its way to the processor that talks to your database. The grid tells you work is happening in the background and stays usable.
For a delete, ticked rows disappear optimistically, so the grid looks right immediately; if the request fails to start, they come back. A filtered selection greys out the rows you can see instead, because the grid cannot know the full set it just asked about.
When the processor finishes, a completion event reaches every open grid on that view. Yours reloads, and a toast says how many rows succeeded and how many failed — or, for a partial result, both. The job record keeps the same counts, and the per-row errors, for as long as it exists.
What the processor actually does
The two selections take two different paths through the processor, and the difference is worth knowing.
Keys are worked through in batches of a thousand, each batch its own transaction. A batch that fails is rolled back and every row in it is recorded as a failure with the database's own message and SQL state; the other batches carry on. A ticked selection can therefore half-succeed, and the toast will say so.
A filter becomes a single statement — DELETE FROM … WHERE … or UPDATE … SET … WHERE … — in a single transaction. It is all or nothing: either every matching row changes, or the job fails and none did. This is the branch that scales to millions of rows, because the database does what databases do and nobody serialises a key list.
Each statement gets sixty seconds. Both branches run directly against your database — the processor opens a connection to it and runs the statement there. Nothing is copied out to do the work.
Edit is the same job with a SET clause
Bulk edit picks one column from the action bar and one value, and applies it to every selected row through exactly the machinery above. Before the job is created, the column is checked against your role's write permissions for that view: a column you cannot edit one cell at a time, you cannot edit ten thousand at a time either.
Delete asks you to type DELETE first. Edit does not ask twice.
Who can see the job
Only the person who started it. A job's status endpoint answers 403 to anyone else, and the job list for a workspace is filtered to your own — a colleague cannot watch, or find, your running delete.
Not the API's bulk endpoints
The generated REST API has bulk endpoints of its own. Those are a different thing: synchronous, at most a hundred items per call, a per-item result in the response. They are for a program that already has the records in hand. The jobs described here are for a person looking at a filter.
What it doesn't do (yet)
- There is no progress, only done. The notification says to track progress in the Activity panel, but nothing ever reports a partial count: the job is queued and then it is completed or failed. A delete of half a million rows shows nothing in between. The job record has room for it — processed rows, a percentage — and the processor never fills it in.
- Two filter operators are misread. The grid can filter on starts with and ends with. The bulk processor does not know those operators and treats them as equals, so a "select all matching" delete or edit on such a filter acts on the rows whose value is exactly the fragment — usually none. The job reports success with zero rows, which is the quiet kind of wrong. Every other operator is translated as written.
- On PostgreSQL, "select all matching" only works for filters on text columns. The filter's value reaches the database as text, and PostgreSQL will not compare a number, date or boolean column to text:
operator does not exist: integer > character varying. The job fails and nothing changes — it fails closed — but it means that filtering by a date or an amount and choosing select-all ends in a failed job today. Filter by a text column, or tick the rows. - Composite primary keys and ticked rows do not mix. A ticked selection on a table whose key spans two columns sends each key as an object; the processor cannot bind an object as a parameter, so every row in the batch is recorded as a failure — the database complains about a missing
hstoreextension, which is its way of saying it was handed a map. Nothing is changed, which is the right failure, but there is no way to bulk delete or edit ticked rows on such a table. The filter path is unaffected. - No cancel. A job has a cancelled state in its vocabulary and nothing that can put it there. Once queued, it runs.
- No undo. The optimistic removal in the grid is reversed only if the request fails to start. A completed delete is a completed delete; export first if that matters.
- One column per edit. The action bar offers a single column and a single value. The request format accepts several updates at once; the interface does not.
- Archive is a placeholder. It sits in the action bar's menu, greyed out. There is no archive.
The bulk operations guide covers selection and the action bar. The export is a snapshot, not the exit is this same job machinery producing a file instead of a change, and a webhook you fire by hand is it producing an HTTP request.
Verified 4 Sep 2026: All green against a real PostgreSQL in Testcontainers. BulkActionServiceTest (23) and CompositePkBulkActionTest (4) cover delete and update by keys and by filter, batching, a failing batch recorded per row, non-existent and UUID keys. BulkActionJobSecurityTest covers who can see a job; BulkActionHandlerTest that completion marks the job and broadcasts. The limitations were not taken from the code alone — a throwaway diagnostic ran each one: a numeric, a boolean and a date filter each failed with 'operator does not exist … character varying' and deleted nothing; starts-with and ends-with reported success and zero rows; object-shaped composite keys failed every row in the batch. The grid behaviour — banner, typed confirmation, optimistic removal, toasts — was read from the components; the bulk-operations e2e spec only proves the action bar appears..