{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table-actions",
  "title": "Data Table Row Actions",
  "description": "Row, bulk, and filter-scoped actions declared next to their Drizzle handlers and rendered from JSON: the list endpoint advertises what can be done to each row, the UI renders buttons, one POST applies it in a transaction.",
  "dependencies": [
    "@tanstack/react-query@^5.101.4",
    "drizzle-orm@^0.45.2",
    "zod@^4.3.6",
    "lucide-react@^0.469.0",
    "sonner@^1.7.2"
  ],
  "registryDependencies": [
    "https://data-table.openstatus.dev/r/data-table.json",
    "https://data-table.openstatus.dev/r/data-table-drizzle.json",
    "https://data-table.openstatus.dev/r/data-table-query.json",
    "button",
    "dropdown-menu",
    "alert-dialog"
  ],
  "files": [
    {
      "path": "src/lib/actions/index.ts",
      "content": "export {\n  defineActions,\n  type ActionDefinitionBase,\n  type DefinedActions,\n  type DefineActionsOptions,\n} from \"./define-actions\";\nexport {\n  ROW_ACTIONS_KEY,\n  type ActionDescriptor,\n  type ActionError,\n  type ActionErrorCode,\n  type ActionRequest,\n  type ActionResponse,\n  type ActionScope,\n  type ActionVariant,\n  type WithRowActions,\n} from \"./types\";\nexport {\n  isSafeActionHref,\n  sanitizeActionDescriptors,\n  validateActionDescriptor,\n  type ActionRejection,\n  type ActionValidationOptions,\n} from \"./validate\";\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/actions/define-actions.ts",
      "content": "import type { Filters } from \"@/lib/filters\";\nimport {\n  ROW_ACTIONS_KEY,\n  type ActionDescriptor,\n  type ActionScope,\n  type ActionVariant,\n  type WithRowActions,\n} from \"./types\";\n\n/**\n * The declarative half of an action — everything except how it executes.\n *\n * `createActionHandler` (Drizzle) adds the `handler`; this module never looks\n * at it, which is what keeps `annotate` and `descriptors` importable anywhere.\n */\nexport type ActionDefinitionBase<\n  TRow = Record<string, unknown>,\n  TValues = Record<string, unknown>,\n> = {\n  label: string;\n  /** Defaults to `[\"row\", \"bulk\"]`. */\n  scope?: ActionScope[];\n  variant?: ActionVariant;\n  /**\n   * Confirmation copy. `{count}` is replaced with the affected row count and\n   * `{one|other}` picks a form by it: `\"Delete {count} {log|logs}?\"`.\n   */\n  confirm?: string;\n  /**\n   * Availability, as filter values — `{ status: [\"dead\"] }` — in exactly the\n   * shape the list endpoint reads from its search params.\n   *\n   * One declaration, two engines: `filters.matches` evaluates it per row to\n   * compute `_actions`, and the SQL engine compiles it into the handler's WHERE\n   * guard. Keys must be filterable columns; anything else throws at\n   * construction rather than silently matching every row.\n   *\n   * Typed by the `Filters` the actions are defined against: from a table\n   * schema definition, `TValues` is `FilterValues<typeof definition>`, so a\n   * key that is not a filterable column or a value the column cannot filter\n   * on (`{ level: [\"fatal\"] }` against `col.enum(LEVELS)`) is a compile\n   * error before it is a runtime one.\n   */\n  when?: Partial<TValues>;\n  /**\n   * JS-only escape hatch for availability the filter semantics cannot express\n   * (a computed field, a non-filterable column). It only shapes `_actions`;\n   * there is no SQL counterpart, so the handler must guard itself.\n   */\n  available?: (row: TRow) => boolean;\n};\n\nexport type DefineActionsOptions = {\n  /** `href` is `${basePath}/${id}`. */\n  basePath: string;\n  /** Published on `bulk` descriptors as `maxIds`. */\n  maxIds?: number;\n};\n\nexport type DefinedActions<TDef extends ActionDefinitionBase<never>> = {\n  /** The declarations, with defaults applied. Keyed by id. */\n  definitions: ReadonlyMap<string, TDef & { scope: ActionScope[] }>;\n  /** Public metadata for the wire. Same order as declared. */\n  descriptors: ActionDescriptor[];\n  /** Stamp every row with the ids it currently qualifies for. */\n  annotate<TRow>(rows: readonly TRow[]): WithRowActions<TRow>[];\n  /** The ids one row qualifies for. */\n  actionsFor(row: unknown): string[];\n};\n\nconst DEFAULT_SCOPE: ActionScope[] = [\"row\", \"bulk\"];\n\n/**\n * An id is a URL segment and an audit-log key. Keep it boring. The leading\n * letter is not taste: `Object.entries` hoists integer-like keys (`\"1\"`,\n * `\"42\"`) to the front in numeric order, which would silently reorder\n * `descriptors` and the menus built from them.\n */\nconst ID_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * The pick-list. Adding a field to `ActionDescriptor` fails to compile here\n * until it is either projected or deliberately excluded — the failure mode\n * this guards against is `{ ...definition }` leaking a handler, a secret, or a\n * `when` clause the server never meant to publish.\n */\nconst PUBLIC_KEYS = {\n  id: true,\n  label: true,\n  scope: true,\n  variant: true,\n  confirm: true,\n  href: true,\n  maxIds: true,\n} as const satisfies Record<keyof ActionDescriptor, true>;\n\ntype _PublicKeysExhaustive = [\n  Exclude<keyof ActionDescriptor, keyof typeof PUBLIC_KEYS>,\n] extends [never]\n  ? true\n  : never;\nconst _publicKeysExhaustive: _PublicKeysExhaustive = true;\nvoid _publicKeysExhaustive;\n\nfunction toDescriptor(\n  id: string,\n  definition: ActionDefinitionBase<never> & { scope: ActionScope[] },\n  options: DefineActionsOptions,\n): ActionDescriptor {\n  const descriptor: ActionDescriptor = {\n    id,\n    label: definition.label,\n    scope: [...definition.scope],\n    href: `${options.basePath}/${id}`,\n  };\n  // Optional keys are only present when set, so the JSON stays canonical.\n  if (definition.variant !== undefined) descriptor.variant = definition.variant;\n  if (definition.confirm !== undefined) descriptor.confirm = definition.confirm;\n  // Only a bulk request can carry more than one id, so only there does the\n  // limit mean anything to the client.\n  if (options.maxIds !== undefined && definition.scope.includes(\"bulk\")) {\n    descriptor.maxIds = options.maxIds;\n  }\n  return descriptor;\n}\n\n/**\n * Validate a `when` clause against the declared filter semantics.\n *\n * `filters.plan` silently drops unknown keys and inactive values, which is\n * right for search params and wrong for an availability guard: a typo would\n * turn \"only dead rows\" into \"every row\". So every key must be a spec, and\n * every value must plan to an op.\n */\nfunction assertWhen(\n  id: string,\n  when: Record<string, unknown>,\n  filters: Filters,\n): void {\n  for (const key of Object.keys(when)) {\n    if (!filters.spec(key)) {\n      throw new Error(\n        `[defineActions] Action \"${id}\": when.${JSON.stringify(key)} is not a filterable column. ` +\n          `Filterable: ${filters.specs.map((spec) => spec.key).join(\", \") || \"(none)\"}`,\n      );\n    }\n    const ops = filters.plan({ [key]: when[key] });\n    if (ops.length === 0) {\n      throw new Error(\n        `[defineActions] Action \"${id}\": when.${JSON.stringify(key)} is not an active filter value ` +\n          `(got ${JSON.stringify(when[key])}). An empty guard would match every row.`,\n      );\n    }\n  }\n}\n\n/**\n * Turn action declarations into the two things the rest of the system needs:\n * public descriptors for the wire, and a per-row availability stamp.\n *\n * Generic over the definition type so the Drizzle handler can carry its\n * `handler` through without this module depending on Drizzle, and over the\n * filter values so `when` is checked against the columns `filters` declares.\n */\nexport function defineActions<\n  TDef extends ActionDefinitionBase<never, TValues>,\n  TValues extends Record<string, unknown> = Record<string, unknown>,\n>(\n  filters: Filters<TValues>,\n  actions: Record<string, TDef>,\n  options: DefineActionsOptions,\n): DefinedActions<TDef> {\n  const basePath = options.basePath.replace(/\\/+$/, \"\");\n  if (basePath.length === 0) {\n    throw new Error(`[defineActions] basePath must be a non-empty path`);\n  }\n  if (\n    options.maxIds !== undefined &&\n    (!Number.isInteger(options.maxIds) || options.maxIds < 1)\n  ) {\n    throw new Error(\n      `[defineActions] maxIds must be a positive integer, got ${String(options.maxIds)}`,\n    );\n  }\n  const resolved: DefineActionsOptions = { ...options, basePath };\n\n  const definitions = new Map<string, TDef & { scope: ActionScope[] }>();\n  const descriptors: ActionDescriptor[] = [];\n\n  for (const [id, definition] of Object.entries(actions)) {\n    if (!ID_PATTERN.test(id)) {\n      throw new Error(\n        `[defineActions] Action id ${JSON.stringify(id)} must match ${ID_PATTERN} — it becomes a URL segment.`,\n      );\n    }\n    if (!definition.label) {\n      throw new Error(`[defineActions] Action \"${id}\" needs a label`);\n    }\n\n    const scope = Array.from(new Set(definition.scope ?? DEFAULT_SCOPE));\n    if (scope.length === 0) {\n      throw new Error(`[defineActions] Action \"${id}\" declares an empty scope`);\n    }\n\n    if (definition.when) assertWhen(id, definition.when, filters);\n\n    const withDefaults = { ...definition, scope };\n    definitions.set(id, withDefaults);\n    descriptors.push(toDescriptor(id, withDefaults, resolved));\n  }\n\n  const actionsFor = (row: unknown): string[] => {\n    const ids: string[] = [];\n    for (const [id, definition] of definitions) {\n      if (definition.when && !filters.matches(definition.when, row)) continue;\n      if (definition.available && !definition.available(row as never)) continue;\n      ids.push(id);\n    }\n    return ids;\n  };\n\n  return {\n    definitions,\n    descriptors,\n    actionsFor,\n    annotate: (rows) =>\n      rows.map((row) => ({ ...row, [ROW_ACTIONS_KEY]: actionsFor(row) })),\n  };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/actions.ts",
      "content": "import {\n  defineActions,\n  type ActionDefinitionBase,\n  type ActionDescriptor,\n  type ActionErrorCode,\n  type ActionRequest,\n  type ActionResponse,\n  type WithRowActions,\n} from \"@/lib/actions\";\nimport type { Filters } from \"@/lib/filters\";\nimport { and, count, inArray, sql, type SQL } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\nimport { z } from \"zod\";\nimport { buildWhereConditions } from \"./filters\";\nimport type { ColumnMapping, DrizzleDB } from \"./types\";\n\n/**\n * What a handler receives.\n *\n * `where` is the authority: the requested set (ids, or a filter) intersected\n * with the action's `when` guard. A handler that uses it cannot touch a row\n * the action does not apply to, no matter what the client sent.\n */\nexport type ActionContext = {\n  where: SQL;\n  /** Present for `scope: \"ids\"`. */\n  ids?: string[];\n  /**\n   * Present for `scope: \"filter\"`. Pruned to keys the filter semantics know;\n   * the values are exactly what the client sent — never `filters.coerce`d, so\n   * a slider range is not clamped to its declared bounds. See `execute`.\n   */\n  filter?: Record<string, unknown>;\n  /** Whoever the route authenticated. Never read from the request body. */\n  actor?: string;\n  /** Client-generated; at-least-once, see `ActionRequest`. */\n  cmdId: string;\n};\n\nexport type DrizzleActionDefinition<\n  TRow = Record<string, unknown>,\n  TValues = Record<string, unknown>,\n> = ActionDefinitionBase<TRow, TValues> & {\n  /**\n   * Runs inside one transaction and returns how many rows it applied to.\n   *\n   * Actions enqueue; they don't execute. A replay flips `status` to\n   * `pending` and lets the worker do the work — that is why one short\n   * transaction is enough and why nothing here does I/O.\n   *\n   * ```ts\n   * handler: async (ctx, tx) => {\n   *   const rows = await tx\n   *     .update(outbox)\n   *     .set({ status: \"pending\", attempt: 0 })\n   *     .where(ctx.where)\n   *     .returning({ id: outbox.id });\n   *   return rows.length;\n   * }\n   * ```\n   */\n  handler: (ctx: ActionContext, tx: DrizzleDB) => Promise<number>;\n};\n\nexport type ActionAuditEvent = {\n  action: string;\n  actor?: string;\n  cmdId: string;\n  scope: ActionRequest[\"scope\"];\n  ids?: string[];\n  filter?: Record<string, unknown>;\n  applied: number;\n  at: Date;\n};\n\nexport type ActionHandlerConfig<\n  TRow = Record<string, unknown>,\n  TValues = Record<string, unknown>,\n> = {\n  db: DrizzleDB;\n  table: PgTable;\n  /**\n   * The same `defineFilters(...)` the list handler uses. Built from the table\n   * schema definition, it also types every action's `when` guard.\n   */\n  filters: Filters<TValues>;\n  columnMapping: ColumnMapping;\n  /** The schema key that identifies a row. Must be in `columnMapping`. */\n  idColumn: string;\n  actions: Record<string, DrizzleActionDefinition<TRow, TValues>>;\n  /** `href` is `${basePath}/${id}` — the route that calls `execute`. */\n  basePath: string;\n  /**\n   * Upper bound on `ids.length`; larger requests are `invalid_request`. Also\n   * published on `bulk` descriptors so the client can stop short of it.\n   */\n  maxIds?: number;\n  /**\n   * The shape of one id, when the column is stricter than \"non-empty string\"\n   * — `z.uuid()` for a `uuid` column. Without it a malformed id reaches\n   * Postgres, whose cast error surfaces as a 500 rather than a 400.\n   *\n   * Parsed asynchronously, so an async refinement is allowed here.\n   */\n  idSchema?: z.ZodType<string>;\n  /**\n   * Called once per applied command, after the transaction committed. If it\n   * throws, the mutation has already happened — the error propagates so the\n   * route can decide, but nothing is rolled back.\n   */\n  audit?: (event: ActionAuditEvent) => void | Promise<void>;\n};\n\nconst STATUS_BY_CODE: Record<ActionErrorCode, number> = {\n  unknown_action: 404,\n  scope_not_allowed: 400,\n  invalid_request: 400,\n  count_mismatch: 409,\n  forbidden: 403,\n  failed: 500,\n};\n\n/** A typed failure the route maps to an HTTP response. */\nexport class ActionHandlerError extends Error {\n  readonly code: ActionErrorCode;\n  readonly status: number;\n  /** Present on `count_mismatch`. */\n  readonly actual?: number;\n\n  constructor(code: ActionErrorCode, message: string, actual?: number) {\n    super(message);\n    this.name = \"ActionHandlerError\";\n    this.code = code;\n    this.status = STATUS_BY_CODE[code];\n    if (actual !== undefined) this.actual = actual;\n  }\n\n  /** The JSON body for the wire. */\n  toJSON(): { error: ActionErrorCode; actual?: number } {\n    return this.actual === undefined\n      ? { error: this.code }\n      : { error: this.code, actual: this.actual };\n  }\n}\n\nfunction createRequestSchema(idSchema: z.ZodType<string>) {\n  return z.discriminatedUnion(\"scope\", [\n    z.object({\n      scope: z.literal(\"ids\"),\n      ids: z.array(idSchema).min(1),\n      cmd_id: z.string().min(1),\n    }),\n    z.object({\n      scope: z.literal(\"filter\"),\n      filter: z.record(z.string(), z.unknown()),\n      expected_count: z.number().int().nonnegative().optional(),\n      cmd_id: z.string().min(1),\n    }),\n  ]);\n}\n\n/** Postgres SQLSTATE for \"could not serialize access\". */\nconst SERIALIZATION_FAILURE = \"40001\";\n/** How often a serialization failure is retried before it propagates. */\nconst MAX_ATTEMPTS = 3;\n\n/**\n * Drivers report SQLSTATE as `code`, and Drizzle wraps the driver error in a\n * `DrizzleQueryError` whose `cause` is the original — so walk the chain.\n */\nexport function isSerializationFailure(error: unknown): boolean {\n  let current: unknown = error;\n  for (\n    let depth = 0;\n    depth < 5 && typeof current === \"object\" && current;\n    depth++\n  ) {\n    if ((current as { code?: unknown }).code === SERIALIZATION_FAILURE) {\n      return true;\n    }\n    current = (current as { cause?: unknown }).cause;\n  }\n  return false;\n}\n\nexport type ActionHandler<TRow = Record<string, unknown>> = {\n  /** Public metadata for `meta.actions`. */\n  descriptors: ActionDescriptor[];\n  /** Stamp fetched rows with `_actions`. */\n  annotate<T extends TRow>(rows: readonly T[]): WithRowActions<T>[];\n  /**\n   * Run one action. Throws `ActionHandlerError` for every contract violation;\n   * anything else that escapes is the handler's own failure, already rolled\n   * back.\n   */\n  execute(\n    actionId: string,\n    body: unknown,\n    options?: { actor?: string },\n  ): Promise<ActionResponse>;\n};\n\n/**\n * The write-side sibling of `createDrizzleHandler`.\n *\n * Shares its `filters` and `columnMapping`, so an action's `when` guard and a\n * filter-scoped request compile through the very same `buildWhereConditions`\n * the list endpoint uses — the set the user saw is the set the action hits.\n */\nexport function createActionHandler<\n  TRow = Record<string, unknown>,\n  TValues extends Record<string, unknown> = Record<string, unknown>,\n>(config: ActionHandlerConfig<TRow, TValues>): ActionHandler<TRow> {\n  const {\n    db,\n    table,\n    filters,\n    columnMapping,\n    idColumn,\n    basePath,\n    maxIds = 1000,\n    idSchema = z.string().min(1),\n    audit,\n  } = config;\n  const requestSchema = createRequestSchema(idSchema);\n\n  const idCol = columnMapping[idColumn];\n  if (!idCol) {\n    throw new Error(\n      `[createActionHandler] idColumn \"${idColumn}\" not found in columnMapping`,\n    );\n  }\n\n  // Mirror `createDrizzleHandler`: `buildWhereConditions` skips a key that is\n  // missing from the mapping. For a `when` guard that would mean \"every row\";\n  // for a filter-scoped request, \"more rows than the user saw\". Fail here.\n  const unmapped = filters.specs\n    .map((spec) => spec.key)\n    .filter((key) => !columnMapping[key]);\n  if (unmapped.length > 0) {\n    throw new Error(\n      `[createActionHandler] These filterable columns are missing from columnMapping:\\n` +\n        unmapped.map((key) => `  - ${key}`).join(\"\\n\"),\n    );\n  }\n\n  const defined = defineActions<\n    DrizzleActionDefinition<TRow, TValues>,\n    TValues\n  >(filters, config.actions, { basePath, maxIds });\n\n  // `when` guards never change after construction, so compile them once.\n  const guards = new Map<string, SQL[]>();\n  for (const [id, definition] of defined.definitions) {\n    guards.set(\n      id,\n      definition.when\n        ? buildWhereConditions(filters, definition.when, columnMapping)\n        : [],\n    );\n  }\n\n  async function execute(\n    actionId: string,\n    body: unknown,\n    options: { actor?: string } = {},\n  ): Promise<ActionResponse> {\n    const definition = defined.definitions.get(actionId);\n    if (!definition) {\n      throw new ActionHandlerError(\"unknown_action\", `No action \"${actionId}\"`);\n    }\n\n    // Async so a caller's `idSchema` may carry an async refinement; a sync\n    // schema parses just the same through it.\n    const parsed = await requestSchema.safeParseAsync(body);\n    if (!parsed.success) {\n      throw new ActionHandlerError(\n        \"invalid_request\",\n        `Invalid request body: ${parsed.error.issues.map((issue) => `${issue.path.join(\".\") || \"(root)\"}: ${issue.message}`).join(\"; \")}`,\n      );\n    }\n    const request = parsed.data;\n\n    const guard = guards.get(actionId) ?? [];\n    let where: SQL;\n    /** The set the client counted — the filter alone, before the guard. */\n    let shown: SQL | undefined;\n    let ids: string[] | undefined;\n    let filter: Record<string, unknown> | undefined;\n\n    if (request.scope === \"ids\") {\n      const allowed =\n        definition.scope.includes(\"row\") || definition.scope.includes(\"bulk\");\n      if (!allowed) {\n        throw new ActionHandlerError(\n          \"scope_not_allowed\",\n          `Action \"${actionId}\" does not accept ids (scope: ${definition.scope.join(\", \")})`,\n        );\n      }\n      if (!definition.scope.includes(\"bulk\") && request.ids.length > 1) {\n        throw new ActionHandlerError(\n          \"scope_not_allowed\",\n          `Action \"${actionId}\" accepts one id at a time (scope: ${definition.scope.join(\", \")})`,\n        );\n      }\n      if (request.ids.length > maxIds) {\n        throw new ActionHandlerError(\n          \"invalid_request\",\n          `Too many ids (${request.ids.length} > ${maxIds}); use scope \"filter\"`,\n        );\n      }\n      ids = Array.from(new Set(request.ids));\n      where = and(inArray(idCol, ids), ...guard)!;\n    } else {\n      if (!definition.scope.includes(\"filter\")) {\n        throw new ActionHandlerError(\n          \"scope_not_allowed\",\n          `Action \"${actionId}\" does not accept a filter (scope: ${definition.scope.join(\", \")})`,\n        );\n      }\n      // The same interpretation as the list endpoint: `filters.plan` (inside\n      // `buildWhereConditions`) drops unknown keys and inactive values and\n      // never clamps. `filters.coerce` would clamp a slider to its declared\n      // bounds — and a range the user dragged past those bounds must hit the\n      // rows they saw, not a narrower set. Unknown keys are dropped from the\n      // recorded filter as well.\n      filter = Object.fromEntries(\n        Object.entries(request.filter).filter(\n          ([key]) => filters.spec(key) !== undefined,\n        ),\n      );\n      const visible = buildWhereConditions(filters, filter, columnMapping);\n      // An empty filter is \"every row\". Legal — `expected_count` is the\n      // client's safety net — but the handler still gets a real SQL.\n      shown = and(...visible) ?? sql`true`;\n      where = and(...visible, ...guard) ?? sql`true`;\n    }\n\n    const ctx: ActionContext = {\n      where,\n      cmdId: request.cmd_id,\n      ...(ids ? { ids } : {}),\n      ...(filter ? { filter } : {}),\n      ...(options.actor !== undefined ? { actor: options.actor } : {}),\n    };\n    const expectedCount =\n      request.scope === \"filter\" ? request.expected_count : undefined;\n\n    const run = () =>\n      db.transaction(\n        async (tx) => {\n          if (expectedCount !== undefined && shown !== undefined) {\n            // Drift is measured on the set the client was shown, not on the\n            // guarded set: a guard makes `applied` smaller by design, and that\n            // is not \"the set changed\".\n            const [row] = await tx\n              .select({ n: count() })\n              .from(table)\n              .where(shown);\n            const actual = Number(row?.n ?? 0);\n            if (actual !== expectedCount) {\n              throw new ActionHandlerError(\n                \"count_mismatch\",\n                `Expected ${expectedCount} rows, found ${actual}`,\n                actual,\n              );\n            }\n          }\n          const result = await definition.handler(ctx, tx);\n          if (!Number.isInteger(result) || result < 0) {\n            throw new Error(\n              `[createActionHandler] Action \"${actionId}\" handler must return the applied row count, got ${String(result)}`,\n            );\n          }\n          return result;\n        },\n        // The count is only a promise if the mutation sees the same rows.\n        // Under READ COMMITTED each statement takes its own snapshot, so a row\n        // committed in between is counted by neither and updated anyway.\n        // REPEATABLE READ pins one snapshot for the whole transaction: a\n        // concurrent insert is invisible to both statements, and a concurrent\n        // change to a counted row fails with 40001 instead of being applied\n        // over — which is retried below against a fresh snapshot, where the\n        // recount catches it as `count_mismatch`.\n        expectedCount !== undefined\n          ? { isolationLevel: \"repeatable read\" }\n          : undefined,\n      );\n\n    let applied: number;\n    for (let attempt = 1; ; attempt++) {\n      try {\n        applied = await run();\n        break;\n      } catch (error) {\n        if (attempt >= MAX_ATTEMPTS || !isSerializationFailure(error)) {\n          throw error;\n        }\n      }\n    }\n\n    if (audit) {\n      await audit({\n        action: actionId,\n        cmdId: request.cmd_id,\n        scope: request.scope,\n        applied,\n        at: new Date(),\n        ...(ids ? { ids } : {}),\n        ...(filter ? { filter } : {}),\n        ...(options.actor !== undefined ? { actor: options.actor } : {}),\n      });\n    }\n\n    return { applied };\n  }\n\n  return {\n    descriptors: defined.descriptors,\n    annotate: (rows) => defined.annotate(rows),\n    execute,\n  };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/components/data-table/data-table-actions/index.ts",
      "content": "export { DataTableActionsBar } from \"./bar\";\nexport { createActionsColumn, DataTableActionsCell } from \"./column\";\nexport {\n  DataTableActionsConfirmDialog,\n  describeCommand,\n  type ConfirmingCommandView,\n} from \"./confirm-dialog\";\nexport {\n  DataTableActionsProvider,\n  useDataTableActions,\n  type ActionRequestInput,\n  type AppliedEvent,\n  type DataTableActionsContextValue,\n  type DataTableActionsProviderProps,\n  type TriggerMeta,\n} from \"./provider\";\nexport {\n  ActionRequestError,\n  actionsForScope,\n  interpolate,\n  newCommandId,\n  partitionRows,\n  postAction,\n  rowActionsOf,\n  rowScopedActions,\n} from \"./utils\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-actions/provider.tsx",
      "content": "\"use client\";\n\nimport type {\n  ActionDescriptor,\n  ActionRequest,\n  ActionResponse,\n} from \"@/lib/actions/types\";\nimport { sanitizeActionDescriptors } from \"@/lib/actions/validate\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport {\n  DataTableActionsConfirmDialog,\n  type ConfirmingCommandView,\n} from \"./confirm-dialog\";\nimport {\n  ActionRequestError,\n  newCommandId,\n  postAction,\n  rowActionsOf,\n} from \"./utils\";\n\ntype DistributiveOmit<T, K extends keyof T> = T extends unknown\n  ? Omit<T, K>\n  : never;\n\n/** A request minus the `cmd_id` the provider stamps on at send time. */\nexport type ActionRequestInput = DistributiveOmit<ActionRequest, \"cmd_id\">;\n\nexport type TriggerMeta = {\n  /** How many rows the user was shown. */\n  count: number;\n  /** Selected rows the action does not apply to. */\n  skipped?: number;\n  /** Runs after a 2xx — e.g. clear the selection the action consumed. */\n  onApplied?: () => void;\n};\n\nexport type AppliedEvent = {\n  action: ActionDescriptor;\n  request: ActionRequest;\n  response: ActionResponse;\n};\n\nexport type DataTableActionsContextValue<TData = unknown> = {\n  actions: ActionDescriptor[];\n  getRowId: (row: TData) => string;\n  getRowActions: (row: TData) => string[];\n  /**\n   * A human name for the row, for the actions trigger's accessible label.\n   * `undefined` when the host did not supply one — the trigger then falls\n   * back to static text rather than reading out the internal row id.\n   */\n  getRowLabel?: (row: TData) => string;\n  /**\n   * Start an action. Opens the confirmation if the descriptor asks for one,\n   * otherwise sends immediately. A `count_mismatch` reopens the dialog with\n   * the server's number and offers to apply anyway.\n   */\n  trigger: (\n    action: ActionDescriptor,\n    request: ActionRequestInput,\n    meta: TriggerMeta,\n  ) => void;\n  isPending: boolean;\n};\n\nconst DataTableActionsContext =\n  React.createContext<DataTableActionsContextValue<never> | null>(null);\n\nexport function useDataTableActions<TData = unknown>() {\n  const context = React.useContext(DataTableActionsContext);\n  if (!context) {\n    throw new Error(\n      \"useDataTableActions must be used within a DataTableActionsProvider\",\n    );\n  }\n  return context as unknown as DataTableActionsContextValue<TData>;\n}\n\n/** What the dialog shows, plus what to send once the user says yes. */\ntype ConfirmingCommand = ConfirmingCommandView & {\n  request: ActionRequestInput;\n  onApplied?: () => void;\n};\n\nexport type DataTableActionsProviderProps<TData> = {\n  /** From `meta.actions`. `undefined` while loading renders no buttons. */\n  actions?: ActionDescriptor[];\n  getRowId: (row: TData) => string;\n  /** Defaults to the server's `_actions` stamp. */\n  getRowActions?: (row: TData) => string[];\n  /**\n   * Names the row for screen readers (\"Actions for <label>\"). Pass something\n   * the user can recognize — a title, an email — not the id the wire uses.\n   * Omitted, every trigger reads \"Row actions\".\n   */\n  getRowLabel?: (row: TData) => string;\n  /**\n   * Origins the actions endpoint may live on, beyond the page's own.\n   *\n   * Descriptors are server-authored and choose the URL this provider POSTs row\n   * ids to. Relative hrefs need nothing here; an absolute one is only sent if\n   * its origin is the page's own or listed here. Anything else is dropped\n   * before it can be rendered — see `sanitizeActionDescriptors`.\n   */\n  allowedActionOrigins?: readonly string[];\n  /**\n   * The first element of the table's query key. Every page under it is\n   * invalidated after a successful action, so rows that no longer match\n   * leave the view.\n   */\n  queryKeyPrefix?: string;\n  onApplied?: (event: AppliedEvent) => void;\n  /** Injected in tests. */\n  fetcher?: typeof fetch;\n  children: React.ReactNode;\n};\n\nexport function DataTableActionsProvider<TData>({\n  actions,\n  getRowId,\n  getRowActions,\n  getRowLabel,\n  allowedActionOrigins,\n  queryKeyPrefix,\n  onApplied,\n  fetcher,\n  children,\n}: DataTableActionsProviderProps<TData>) {\n  const queryClient = useQueryClient();\n\n  // The descriptors are validated once, here, rather than at click time: a\n  // button whose `href` we would refuse to POST to should never be drawn.\n  const safeActions = React.useMemo(() => {\n    const { actions: kept, rejected } = sanitizeActionDescriptors(actions, {\n      allowedOrigins: allowedActionOrigins,\n    });\n    if (rejected.length > 0) {\n      console.warn(\n        \"[data-table-actions] dropped unsafe or malformed descriptors:\",\n        rejected.map(({ id, reason }) => `${id} (${reason})`).join(\", \"),\n      );\n    }\n    return kept;\n  }, [actions, allowedActionOrigins]);\n  // Awaiting the user's answer; distinct from `isPending` (request on the wire).\n  const [confirming, setConfirming] = React.useState<ConfirmingCommand | null>(\n    null,\n  );\n  // The `cmd_id` the dialog last submitted. Only that request may close the\n  // dialog: an unconfirmed action settling must not tear down a confirmation\n  // the user opened while it was still in flight.\n  const submittedFromDialog = React.useRef<string | null>(null);\n\n  const mutation = useMutation({\n    mutationFn: (variables: {\n      action: ActionDescriptor;\n      request: ActionRequest;\n      meta: TriggerMeta;\n    }) => postAction(variables.action.href, variables.request, fetcher),\n    onSuccess: (response, { action, request, meta }) => {\n      if (request.scope === \"filter\") {\n        // The client counted the filter-only set; the server applied within\n        // the action's guard. A smaller number is the guard at work, not a\n        // partial failure.\n        toast.success(\n          `${action.label}: applied to ${response.applied} of ${meta.count} matching`,\n        );\n      } else if (response.applied < request.ids.length) {\n        // Only eligible ids were sent, so a shortfall means rows changed\n        // between fetch and click.\n        toast.warning(\n          `${action.label}: applied to ${response.applied} of ${request.ids.length}`,\n        );\n      } else {\n        toast.success(`${action.label}: applied to ${response.applied}`);\n      }\n      if (queryKeyPrefix) {\n        void queryClient.invalidateQueries({ queryKey: [queryKeyPrefix] });\n      }\n      // The confirmation stays open, with its buttons disabled, until the\n      // request it submitted settles — so the user sees the outcome land. Any\n      // other action settling leaves it alone.\n      if (submittedFromDialog.current === request.cmd_id) {\n        submittedFromDialog.current = null;\n        setConfirming(null);\n      }\n      meta.onApplied?.();\n      onApplied?.({ action, request, response });\n    },\n    onError: (error, { action, request, meta }) => {\n      if (\n        error instanceof ActionRequestError &&\n        error.code === \"count_mismatch\" &&\n        request.scope === \"filter\"\n      ) {\n        const { cmd_id: _cmdId, ...input } = request;\n        // A fresh dialog, nothing submitted from it yet.\n        submittedFromDialog.current = null;\n        setConfirming({\n          action,\n          request: input,\n          count: meta.count,\n          skipped: meta.skipped ?? 0,\n          scope: \"filter\",\n          actual: error.actual ?? 0,\n          onApplied: meta.onApplied,\n        });\n        return;\n      }\n      if (submittedFromDialog.current === request.cmd_id) {\n        submittedFromDialog.current = null;\n        setConfirming(null);\n      }\n      toast.error(`${action.label} failed: ${error.message}`);\n    },\n  });\n\n  // `mutate` is referentially stable; the result object is not, and depending\n  // on it would rebuild `trigger` — and the context value — on every render.\n  const { mutate, isPending } = mutation;\n\n  const send = React.useCallback(\n    (\n      action: ActionDescriptor,\n      request: ActionRequestInput,\n      meta: TriggerMeta,\n      options?: { fromDialog?: boolean },\n    ) => {\n      const cmdId = newCommandId();\n      if (options?.fromDialog) submittedFromDialog.current = cmdId;\n      mutate({\n        action,\n        request: { ...request, cmd_id: cmdId } as ActionRequest,\n        meta,\n      });\n    },\n    [mutate],\n  );\n\n  const trigger = React.useCallback<\n    DataTableActionsContextValue<TData>[\"trigger\"]\n  >(\n    (action, request, meta) => {\n      if (action.confirm) {\n        setConfirming({\n          action,\n          request,\n          count: meta.count,\n          skipped: meta.skipped ?? 0,\n          scope: request.scope,\n          onApplied: meta.onApplied,\n        });\n        return;\n      }\n      send(action, request, meta);\n    },\n    [send],\n  );\n\n  const confirm = React.useCallback(() => {\n    if (!confirming) return;\n    const { action, request, count, skipped, actual, onApplied } = confirming;\n    if (actual !== undefined && request.scope === \"filter\") {\n      // The user accepted the server's number: drop the optimistic check.\n      const { expected_count: _expected, ...rest } = request;\n      send(\n        action,\n        rest,\n        { count: actual, skipped, onApplied },\n        {\n          fromDialog: true,\n        },\n      );\n      return;\n    }\n    send(action, request, { count, skipped, onApplied }, { fromDialog: true });\n  }, [confirming, send]);\n\n  const cancel = React.useCallback(() => {\n    submittedFromDialog.current = null;\n    setConfirming(null);\n  }, []);\n\n  const value = React.useMemo<DataTableActionsContextValue<TData>>(\n    () => ({\n      actions: safeActions,\n      getRowId,\n      getRowActions: getRowActions ?? rowActionsOf,\n      getRowLabel,\n      trigger,\n      isPending,\n    }),\n    [safeActions, getRowId, getRowActions, getRowLabel, trigger, isPending],\n  );\n\n  return (\n    <DataTableActionsContext.Provider\n      value={value as unknown as DataTableActionsContextValue<never>}\n    >\n      {children}\n      <DataTableActionsConfirmDialog\n        command={confirming}\n        inFlight={isPending}\n        onConfirm={confirm}\n        onCancel={cancel}\n      />\n    </DataTableActionsContext.Provider>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-actions/confirm-dialog.tsx",
      "content": "\"use client\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport type { ActionDescriptor } from \"@/lib/actions/types\";\nimport { LoaderCircle } from \"lucide-react\";\nimport * as React from \"react\";\nimport { interpolate } from \"./utils\";\n\n/** The command awaiting the user's answer, as the dialog sees it. */\nexport type ConfirmingCommandView = {\n  action: ActionDescriptor;\n  /** How many rows the user was shown. */\n  count: number;\n  /** Selected rows the action does not apply to. */\n  skipped: number;\n  /** `filter` scope applies to every matching row, not a selection. */\n  scope: \"ids\" | \"filter\";\n  /** Set when the server answered `count_mismatch`. */\n  actual?: number;\n};\n\nexport function describeCommand(command: ConfirmingCommandView): {\n  title: string;\n  description: string | null;\n} {\n  const { action, count, skipped, scope, actual } = command;\n\n  if (actual !== undefined) {\n    return {\n      title: `${action.label}: the matching set changed`,\n      description: `You were shown ${count} ${plural(count, \"row\")}; the server now counts ${actual}. Apply to ${actual} ${plural(actual, \"row\")} anyway?`,\n    };\n  }\n\n  const title = interpolate(\n    action.confirm ?? `${action.label} {count} ${plural(count, \"row\")}?`,\n    { count },\n  );\n  const parts: string[] = [];\n  if (scope === \"filter\") {\n    parts.push(\"Applies to every row matching the current filters.\");\n  }\n  if (skipped > 0) {\n    parts.push(\n      `${skipped} selected ${plural(skipped, \"row\")} ${skipped === 1 ? \"does\" : \"do\"} not qualify and will be skipped.`,\n    );\n  }\n  return { title, description: parts.length > 0 ? parts.join(\" \") : null };\n}\n\nfunction plural(n: number, noun: string): string {\n  return n === 1 ? noun : `${noun}s`;\n}\n\nexport function DataTableActionsConfirmDialog({\n  command,\n  inFlight = false,\n  onConfirm,\n  onCancel,\n}: {\n  /** Open while non-null. */\n  command: ConfirmingCommandView | null;\n  /**\n   * The confirmed request is on the wire. The dialog stays open with both\n   * buttons disabled until it settles — the owner closes it by clearing\n   * `command`, or swaps in a `count_mismatch` view.\n   */\n  inFlight?: boolean;\n  onConfirm: () => void;\n  onCancel: () => void;\n}) {\n  // Keep the last command on screen while the dialog animates out: `command`\n  // is cleared before the exit transition ends, and an empty title flickers.\n  const [view, setView] = React.useState(command);\n  if (command !== null && command !== view) setView(command);\n\n  const copy = view ? describeCommand(view) : null;\n  const destructive = view?.action.variant === \"destructive\";\n\n  return (\n    <AlertDialog\n      open={command !== null}\n      onOpenChange={(open) => {\n        // Escape and outside clicks don't abandon a request already sent.\n        if (!open && !inFlight) onCancel();\n      }}\n    >\n      <AlertDialogContent size=\"sm\">\n        <AlertDialogHeader>\n          <AlertDialogTitle>{copy?.title}</AlertDialogTitle>\n          {copy?.description ? (\n            <AlertDialogDescription>{copy.description}</AlertDialogDescription>\n          ) : null}\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel disabled={inFlight}>Cancel</AlertDialogCancel>\n          <AlertDialogAction\n            variant={destructive ? \"destructive\" : \"default\"}\n            disabled={inFlight}\n            aria-busy={inFlight || undefined}\n            onClick={(event) => {\n              // Radix closes on click; the owner closes once the request lands.\n              event.preventDefault();\n              onConfirm();\n            }}\n          >\n            {inFlight ? (\n              <LoaderCircle aria-hidden className=\"size-4 animate-spin\" />\n            ) : null}\n            {view?.actual !== undefined\n              ? \"Apply anyway\"\n              : (view?.action.label ?? \"Confirm\")}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-actions/bar.tsx",
      "content": "\"use client\";\n\nimport { DataTableContext } from \"@/components/data-table/data-table-provider\";\nimport { Button } from \"@/components/ui/button\";\nimport * as React from \"react\";\nimport { useDataTableActions } from \"./provider\";\nimport { actionsForScope, partitionRows } from \"./utils\";\n\n/**\n * Bulk buttons for a selection. Drop it into `DataTableFloatingBar`:\n *\n * ```tsx\n * <DataTableFloatingBar>\n *   {({ rows }) => <DataTableActionsBar rows={rows} />}\n * </DataTableFloatingBar>\n * ```\n *\n * Structurally typed on `{ original }` so it accepts TanStack rows without\n * depending on the table.\n */\nexport function DataTableActionsBar<TData>({\n  rows,\n}: {\n  rows: readonly { original: TData }[];\n}) {\n  const { actions, getRowId, getRowActions, trigger, isPending } =\n    useDataTableActions<TData>();\n  // Optional on purpose: inside `DataTableFloatingBar` the table is there and\n  // the selection is cleared once the action landed — the rows it named may\n  // have left the view, and a bar saying \"3 selected\" over nothing is wrong.\n  const table = React.useContext(DataTableContext)?.table;\n  const bulk = actionsForScope(actions, \"bulk\");\n  if (bulk.length === 0) return null;\n\n  return (\n    <>\n      {bulk.map((action) => {\n        const { eligible, skipped } = partitionRows(\n          rows,\n          action.id,\n          getRowActions,\n        );\n        // The server publishes its `maxIds`; past it the request is refused\n        // as `invalid_request`, so refuse it here with a reason instead.\n        const overLimit =\n          action.maxIds !== undefined && eligible.length > action.maxIds;\n        // Why the button is dead, in the order the user hits them. A disabled\n        // button swallows pointer events, so the tooltip lives on the wrapper\n        // — on the button it would never show.\n        const reason = overLimit\n          ? `${action.label} applies to at most ${action.maxIds?.toLocaleString()} rows at a time`\n          : eligible.length === 0\n            ? `Select a row this action applies to`\n            : isPending\n              ? \"Another action is still running\"\n              : undefined;\n        return (\n          <span\n            key={action.id}\n            className=\"inline-flex\"\n            title={reason}\n            data-reason={reason}\n          >\n            <Button\n              size=\"sm\"\n              variant={\n                action.variant === \"destructive\" ? \"destructive\" : \"outline\"\n              }\n              disabled={reason !== undefined}\n              data-action={action.id}\n              data-eligible={eligible.length}\n              data-skipped={skipped}\n              data-over-limit={overLimit ? \"\" : undefined}\n              onClick={() =>\n                trigger(\n                  action,\n                  {\n                    scope: \"ids\",\n                    ids: eligible.map((row) => getRowId(row.original)),\n                  },\n                  {\n                    count: eligible.length,\n                    skipped,\n                    onApplied: () => table?.resetRowSelection(),\n                  },\n                )\n              }\n            >\n              {action.label}\n              <span className=\"tabular-nums opacity-70\">{eligible.length}</span>\n            </Button>\n          </span>\n        );\n      })}\n    </>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-actions/column.tsx",
      "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport type { DataTableFeatures } from \"@/lib/table/features\";\nimport type { ColumnDef, RowData } from \"@tanstack/react-table\";\nimport { MoreHorizontal } from \"lucide-react\";\nimport { useDataTableActions } from \"./provider\";\nimport { rowScopedActions } from \"./utils\";\n\n/** The cell: a menu of the row-scoped actions this row is stamped with. */\nexport function DataTableActionsCell<TData>({\n  row,\n}: {\n  row: { original: TData };\n}) {\n  const { actions, getRowId, getRowActions, getRowLabel, trigger, isPending } =\n    useDataTableActions<TData>();\n  const available = rowScopedActions(actions, getRowActions(row.original));\n  if (available.length === 0) return null;\n  const id = getRowId(row.original);\n  // Never the row id: it is the internal key the wire uses, which for a\n  // composite or opaque key reads as noise. Hosts name the row themselves.\n  const label = getRowLabel?.(row.original);\n\n  return (\n    <div\n      className=\"flex items-center justify-center\"\n      // The row itself opens the sheet on click and on Enter. Both bubble up\n      // from the trigger — and, through React's tree, from the portalled menu\n      // items — so both stop here. Only the keys that open the row are\n      // stopped; everything else keeps bubbling to the row, the table, and\n      // any app-level shortcut listening above them.\n      onClick={(event) => event.stopPropagation()}\n      onKeyDown={(event) => {\n        if (event.key === \"Enter\" || event.key === \" \") {\n          event.stopPropagation();\n        }\n      }}\n    >\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"icon-xs\"\n            className=\"size-5\"\n            aria-label={label ? `Actions for ${label}` : \"Row actions\"}\n          >\n            <MoreHorizontal />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\">\n          {available.map((action) => (\n            <DropdownMenuItem\n              key={action.id}\n              // One action at a time, as in the bulk bar: a second trigger\n              // while one is on the wire races the first for the shared\n              // confirmation dialog.\n              disabled={isPending}\n              variant={\n                action.variant === \"destructive\" ? \"destructive\" : \"default\"\n              }\n              onSelect={() =>\n                trigger(action, { scope: \"ids\", ids: [id] }, { count: 1 })\n              }\n            >\n              {action.label}\n            </DropdownMenuItem>\n          ))}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n\n/**\n * A column that renders `DataTableActionsCell`. Append it to the generated\n * columns; it needs a `DataTableActionsProvider` above the table. The column\n * is hideable — pair it with a `columnVisibility` default of `{ [id]: false }`\n * to keep it hidden until enabled from the view options.\n */\nexport function createActionsColumn<TData extends RowData>(\n  options: { id?: string; size?: number } = {},\n): ColumnDef<DataTableFeatures, TData> {\n  const { id = \"actions\", size = 40 } = options;\n  return {\n    id,\n    header: () => <span className=\"sr-only\">Actions</span>,\n    cell: ({ row }) => <DataTableActionsCell row={row} />,\n    enableSorting: false,\n    enableHiding: true,\n    enableResizing: false,\n    size,\n    minSize: size,\n    maxSize: size,\n    meta: { label: \"Actions\", kind: \"actions\" },\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-actions/utils.ts",
      "content": "import {\n  ROW_ACTIONS_KEY,\n  type ActionDescriptor,\n  type ActionErrorCode,\n  type ActionRequest,\n  type ActionResponse,\n  type ActionScope,\n} from \"@/lib/actions/types\";\n\n/**\n * Replace `{name}` placeholders. Unknown names are left as-is.\n *\n * `{one|other}` picks a form by `vars.count` — `\"Delete {count} {log|logs}?\"`\n * reads \"Delete 1 log?\" and \"Delete 3 logs?\". Descriptors travel as JSON, so\n * this is the only way copy can pluralise without a function.\n *\n * Whitespace around the forms is formatting, not copy: `{log | logs}` is the\n * same marker written with room to breathe, so the picked form is trimmed\n * rather than leaking a stray space into the sentence.\n */\nexport function interpolate(\n  template: string,\n  vars: Record<string, string | number>,\n): string {\n  return template.replace(\n    /\\{(\\w+)\\}|\\{([^{}|]*)\\|([^{}|]*)\\}/g,\n    (match, name: string | undefined, one: string, other: string) => {\n      if (name !== undefined) {\n        return Object.hasOwn(vars, name) ? String(vars[name]) : match;\n      }\n      return Number(vars.count) === 1 ? one.trim() : other.trim();\n    },\n  );\n}\n\n/** Read the server's `_actions` stamp off a row. Missing means \"none\". */\nexport function rowActionsOf(row: unknown): string[] {\n  if (typeof row !== \"object\" || row === null) return [];\n  const value = (row as Record<string, unknown>)[ROW_ACTIONS_KEY];\n  return Array.isArray(value) ? value.filter((v) => typeof v === \"string\") : [];\n}\n\nexport function actionsForScope(\n  actions: readonly ActionDescriptor[],\n  scope: ActionScope,\n): ActionDescriptor[] {\n  return actions.filter((action) => action.scope.includes(scope));\n}\n\n/** Row-scoped actions the row is stamped with, in descriptor order. */\nexport function rowScopedActions(\n  actions: readonly ActionDescriptor[],\n  rowActionIds: readonly string[],\n): ActionDescriptor[] {\n  return actionsForScope(actions, \"row\").filter((action) =>\n    rowActionIds.includes(action.id),\n  );\n}\n\n/**\n * Split a selection into the rows an action applies to and the rest.\n *\n * A bulk button stays enabled while *any* selected row qualifies — an\n * intersection would make bulk useless on a mixed list — and the request\n * carries only the eligible ids, with the skipped count surfaced in the\n * confirmation.\n */\nexport function partitionRows<TRow extends { original: unknown }>(\n  rows: readonly TRow[],\n  actionId: string,\n  getRowActions: (row: TRow[\"original\"]) => string[],\n): { eligible: TRow[]; skipped: number } {\n  const eligible = rows.filter((row) =>\n    getRowActions(row.original).includes(actionId),\n  );\n  return { eligible, skipped: rows.length - eligible.length };\n}\n\nexport function newCommandId(): string {\n  const c = globalThis.crypto;\n  if (c && typeof c.randomUUID === \"function\") return `cmd_${c.randomUUID()}`;\n  return `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/** The server said no. `code` mirrors `ActionError.error`. */\nexport class ActionRequestError extends Error {\n  readonly code: ActionErrorCode;\n  readonly status: number;\n  /** Present on `count_mismatch`. */\n  readonly actual?: number;\n\n  constructor(code: ActionErrorCode, status: number, actual?: number) {\n    super(code.replace(/_/g, \" \"));\n    this.name = \"ActionRequestError\";\n    this.code = code;\n    this.status = status;\n    if (actual !== undefined) this.actual = actual;\n  }\n}\n\nconst KNOWN_CODES: ReadonlySet<string> = new Set<ActionErrorCode>([\n  \"unknown_action\",\n  \"scope_not_allowed\",\n  \"invalid_request\",\n  \"count_mismatch\",\n  \"forbidden\",\n  \"failed\",\n]);\n\n/** One POST. Throws `ActionRequestError` for any non-2xx. */\nexport async function postAction(\n  href: string,\n  request: ActionRequest,\n  fetcher: typeof fetch = fetch,\n): Promise<ActionResponse> {\n  const response = await fetcher(href, {\n    method: \"POST\",\n    headers: { \"content-type\": \"application/json\" },\n    body: JSON.stringify(request),\n  });\n\n  let json: unknown = null;\n  try {\n    json = await response.json();\n  } catch {\n    // A non-JSON body is handled by the status check below.\n  }\n\n  if (!response.ok) {\n    const body = (json ?? {}) as { error?: unknown; actual?: unknown };\n    const code =\n      typeof body.error === \"string\" && KNOWN_CODES.has(body.error)\n        ? (body.error as ActionErrorCode)\n        : \"failed\";\n    const actual = typeof body.actual === \"number\" ? body.actual : undefined;\n    throw new ActionRequestError(code, response.status, actual);\n  }\n\n  // A 2xx without a numeric `applied` is not \"applied to 0\" — it is a route\n  // that does not speak the contract (204, an HTML page from a proxy, …).\n  const body = (json ?? {}) as { applied?: unknown };\n  if (typeof body.applied !== \"number\") {\n    throw new ActionRequestError(\"failed\", response.status);\n  }\n  return { applied: body.applied };\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "https://data-table.openstatus.dev/docs/actions",
  "type": "registry:block"
}
