Row Actions
The data-table-actions block adds a write path to a server-side table: row
menus and a bulk bar for selections, both rendered from metadata the list
endpoint publishes. Actions are declared once, next to their Drizzle handler,
and the UI never learns what a "replay" is.
Installation
npx shadcn@latest add https://data-table.openstatus.dev/r/data-table-actions.jsonRequires the Drizzle block: createActionHandler shares
its filters and columnMapping.
The contract
The list response gains two things:
{
"data": [
{
"uuid": "msg_1",
"level": "error",
"_actions": ["acknowledge", "delete"]
}
],
"meta": {
"actions": [
{
"id": "acknowledge",
"label": "Acknowledge",
"scope": ["row", "bulk", "filter"],
"href": "/drizzle/api/actions/acknowledge"
},
{
"id": "delete",
"label": "Delete",
"scope": ["row", "bulk"],
"variant": "destructive",
"confirm": "Delete {count} {log|logs}?",
"href": "/drizzle/api/actions/delete"
}
]
}
}A click is one request:
POST /drizzle/api/actions/acknowledge
{ "scope": "ids", "ids": ["msg_1", "msg_7"], "cmd_id": "cmd_…" }
→ { "applied": 2 }or, for every row matching the current filters:
POST /drizzle/api/actions/acknowledge
{ "scope": "filter", "filter": { "level": ["error"] }, "expected_count": 40, "cmd_id": "cmd_…" }
→ { "applied": 40 } // or 409 { "error": "count_mismatch", "actual": 38 }Errors are { "error": code } with unknown_action (404), scope_not_allowed
(400), invalid_request (400), count_mismatch (409, with actual),
forbidden (403) and failed (500).
Three rules make the contract hold:
_actionsis a hint; the WHERE is the authority. The handler'sctx.whereis the requested ids (or filter) intersected with the action'swhenguard. A stale client cannot make it touch a row the action does not apply to;appliedsimply comes back lower.- Actions enqueue, they don't execute. A replay sets
status = pendingand lets the worker do the work. That is why one short transaction is enough and why a handler does no I/O. - At-least-once. There is no ledger.
cmd_idreaches the handler and the audit hook; if an action is not idempotent, that is where to dedupe.
Server
Declare the actions
// app/drizzle/api/actions.ts
import { createActionHandler } from "@/lib/drizzle/actions";
import { defineFilters } from "@/lib/filters";
export const actionHandler = createActionHandler({
db,
table: logs,
filters: defineFilters(tableSchema.definition), // same as createDrizzleHandler
columnMapping, // same as createDrizzleHandler — with the id column added
idColumn: "uuid", // a schema key in columnMapping
basePath: "/drizzle/api/actions", // href = `${basePath}/${id}`
actions: {
acknowledge: {
label: "Acknowledge",
scope: ["row", "bulk", "filter"], // default: ["row", "bulk"]
when: { level: ["error"] },
handler: async (ctx, tx) => {
const rows = await tx
.update(logs)
.set({ level: "warning" })
.where(ctx.where)
.returning({ uuid: logs.uuid });
return rows.length;
},
},
delete: {
label: "Delete",
variant: "destructive",
confirm: "Delete {count} {log|logs}?",
handler: async (ctx, tx) =>
(await tx.delete(logs).where(ctx.where).returning()).length,
},
},
audit: (event) => auditLog.insert(event), // after commit
});idColumn is the row identity every request is keyed by. It is usually not
filtered or sorted, so it may not be in the columnMapping you wrote for the
list handler yet — add it (uuid: logs.uuid); the mapping doubles as the
projection, so the rows the client gets carry it. Like createDrizzleHandler,
construction throws if any filterable column is missing from the mapping — a
when guard on an unmapped column would otherwise silently match every row.
when is filter values, in exactly the shape the list endpoint reads from
its search params. One declaration, two engines: filters.matches evaluates it
per row to compute _actions, and the SQL engine compiles it into the handler's
WHERE guard. Keys must be filterable columns — a typo throws at construction
instead of silently matching every row, and when filters was built from
tableSchema.definition it does not get that far: when is typed as
FilterValues<typeof tableSchema.definition>, so an unknown key, a
.notFilterable() column, or a value the column cannot filter on
({ level: ["fatal"] } against col.enum(LEVELS)) is a compile error.
defineFilters(schemaJson) and defineFilters(specs) know nothing about the
columns at compile time, so there when stays untyped and only the runtime
check applies. For availability the filter semantics
cannot express, available: (row) => boolean is a JS-only escape hatch; it
shapes _actions but has no SQL counterpart, so the handler must guard itself.
The handler runs inside db.transaction and returns the applied row count
(.returning() makes that driver-agnostic). Throwing rolls everything back.
What the client sees is built by an explicit pick-list — id, label,
scope, variant, confirm, href — never by spreading the definition, so
a handler, a when clause, or a stray secret cannot leak into meta.actions.
Advertise them from the list route
// app/drizzle/api/route.ts
const result = await handler.execute(search);
const data = actionHandler.annotate(result.data); // adds `_actions` per row
return Response.json(
SuperJSON.stringify({
data,
meta: {
totalRowCount: result.totalRowCount,
filterRowCount: result.filterRowCount,
chartData,
facets: result.facets,
actions: actionHandler.descriptors,
},
prevCursor: result.prevCursor,
nextCursor: result.nextCursor,
}),
);Accept commands
// app/drizzle/api/actions/[id]/route.ts
import { ActionHandlerError } from "@/lib/drizzle/actions";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = await auth(); // your auth — the actor is never read from the body
try {
const result = await actionHandler.execute(id, await req.json(), {
actor: session.email,
});
return Response.json(result);
} catch (error) {
if (error instanceof ActionHandlerError) {
return Response.json(error.toJSON(), { status: error.status });
}
return Response.json({ error: "failed" }, { status: 500 });
}
}Per-action authorization belongs here too: check the session's role before
calling execute. Hiding a button is not authorization.
Client
// client.tsx
import {
createActionsColumn,
DataTableActionsBar,
DataTableActionsProvider,
} from "@/components/data-table/data-table-actions";
import { DataTableFloatingBar } from "@/components/data-table/data-table-floating-bar";
const baseColumns = generateColumns<ColumnSchema>(tableSchema.definition);
// The actions column ships hidden by default (`columnVisibility: { actions:
// false }`) and is enabled from the view options. Append it only once the
// server advertises actions. Module constants keep the table's options stable.
const columnsWithActions = [
...baseColumns,
createActionsColumn<ColumnSchema>(),
];
function ClientInner() {
const { data } = useInfiniteQuery(dataOptions(search));
const actions = data?.pages.at(-1)?.meta.actions;
const columns = actions?.length ? columnsWithActions : baseColumns;
return (
<DataTableActionsProvider<ColumnSchema>
actions={actions}
getRowId={(row) => row.uuid}
getRowLabel={(row) => row.pathname} // names the row for screen readers
queryKeyPrefix="drizzle" // invalidated after every applied action
>
<DataTableInfinite
columns={columns}
floatingBarSlot={
<DataTableFloatingBar<ColumnSchema>>
{({ rows }) => <DataTableActionsBar rows={rows} />}
</DataTableFloatingBar>
}
// …
/>
</DataTableActionsProvider>
);
}Two surfaces, both reading the same provider:
| Component | Scope | Where | Sends |
|---|---|---|---|
createActionsColumn() | row | a ⋯ menu cell, per row | { scope: "ids", ids: [rowId] } |
DataTableActionsBar | bulk | inside DataTableFloatingBar | only the eligible selected ids |
Outcomes are reported with sonner toasts, so
mount a <Toaster /> once in your layout (npx shadcn@latest add sonner) —
without it a failed or partial action gives no visible feedback.
A bulk button stays enabled while any selected row qualifies; the request
carries only the eligible ids and the confirmation says how many were skipped.
The handler's maxIds (default 1000) is published on every bulk descriptor,
and a selection past it disables the button with the limit as its tooltip
rather than earning an invalid_request — past that many rows, reach for the
filter scope below. confirm on a descriptor opens an alert dialog with {count} interpolated
and {one|other} picking a form by it ("Delete {count} {log|logs}?"), and stays
open with its buttons disabled until the request settles;
variant: "destructive" styles both the button and the dialog's action. After
a success the provider toasts the result, clears the selection, and invalidates
[queryKeyPrefix], so rows that no longer match leave the view.
The filter scope has no shipped surface. It is part of the wire contract and
the handler implements it, but a trigger that promises "all 40 matching" is
only honest when the host can name that number, so the block leaves it to you:
call trigger from useDataTableActions with { scope: "filter", filter, expected_count }, taking expected_count from the server's own
filterRowCount and only while the table is not fetching — a stale count
describes the previous query. The server counts and mutates under REPEATABLE READ, so the set it counted is the set it applies to, and answers 409
count_mismatch with the actual number if it drifted; the provider reopens the
confirmation with that number and offers to apply anyway, which resends without
expected_count. It then applies within the action's when guard, so "applied
to 12 of 40 matching" is the guard at work, not a partial failure.
Ids are validated as non-empty strings by default. When the id column is
stricter, say so with idSchema — z.uuid() for a uuid column — so a
malformed id is a 400 invalid_request, not a cast error surfacing as a 500.
Multi-row selection needs col.select() in the schema, as for any floating
bar.
Demo
The /drizzle demo declares acknowledge (error → warning) and
delete. In development they are on by default against your own database.
The public site runs against a shared database, so there writes are opt-in:
set ALLOW_DEMO_ACTIONS=1 and the list endpoint starts advertising actions;
otherwise POST /drizzle/api/actions/* answers 403 and the UI renders no
buttons (ALLOW_DEMO_ACTIONS=0 forces that locally too).
