# Row Actions

> Declare actions next to their Drizzle handlers; the table renders row menus and a bulk bar from server metadata

Source: https://data-table.openstatus.dev/docs/actions · Docs index: https://data-table.openstatus.dev/llms.txt · Full docs: https://data-table.openstatus.dev/llms-full.txt

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

```bash
npx shadcn@latest add https://data-table.openstatus.dev/r/data-table-actions.json
```

Requires the [Drizzle](/docs/drizzle-orm) block: `createActionHandler` shares
its `filters` and `columnMapping`.

## The contract

The list response gains two things:

```json
{
  "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:

```http
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:

```http
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:

- **`_actions` is a hint; the WHERE is the authority.** The handler's
  `ctx.where` is the requested ids (or filter) intersected with the action's
  `when` guard. A stale client cannot make it touch a row the action does not
  apply to; `applied` simply comes back lower.
- **Actions enqueue, they don't execute.** A replay sets `status = pending`
  and 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_id` reaches the handler and the
  audit hook; if an action is not idempotent, that is where to dedupe.

## Server

### Declare the actions

```ts
// 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

```ts
// 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

```ts
// 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

```tsx
// 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](https://sonner.emilkowal.ski) 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](/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).
