<!--
SPDX-FileCopyrightText: 2025 Torkild G. Kjevik
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>

SPDX-License-Identifier: MIT
-->

# RPC Action Options

This guide covers all configuration options available for `rpc_action` declarations, including load restrictions, query controls, identity lookups, and more.

## Overview

Each `rpc_action` accepts two required arguments and optional configuration:

```elixir
rpc_action :function_name, :ash_action_name, options
```

| Argument | Description |
|----------|-------------|
| First | Name of the generated TypeScript function |
| Second | Name of the Ash action to execute |
| Options | Keyword list of configuration options |

## Load Restrictions

Control which relationships and calculations clients can request using `allowed_loads` and `denied_loads`.

Load restrictions shape the API surface of an action — they are **not** an authorization mechanism. Access control is handled by your Ash policies (including field policies), which apply to every load regardless of these options. Use load restrictions to keep an endpoint's loadable surface tight, most commonly to stop clients from requesting expensive loads (heavy aggregates, deep relationship trees) on actions that don't need them.

Both lists are validated at compile time: every entry must resolve to a loadable public field (relationship, calculation, aggregate, or embedded-resource attribute), with nested keywords checked against the relationship's destination resource. Typos fail compilation instead of silently never matching.

### allowed_loads (Whitelist)

Only allow loading specific fields:

```elixir
typescript_rpc do
  resource MyApp.Todo do
    # Only user and tags can be loaded
    rpc_action :list_todos, :read, allowed_loads: [:user, :tags]
  end
end
```

```typescript
// Allowed
const result = await listTodos({
  fields: ["id", "title", { user: ["name"], tags: ["name"] }]
});

// Error: "comments" not in allowed_loads
const result = await listTodos({
  fields: ["id", "title", { comments: ["text"] }]
});
```

### denied_loads (Blacklist)

Block specific fields while allowing all others:

```elixir
typescript_rpc do
  resource MyApp.Todo do
    # Everything except the expensive aggregates can be loaded
    rpc_action :list_todos, :read, denied_loads: [:activity_stats, :full_history]
  end
end
```

```typescript
// Allowed (user is not denied)
const result = await listTodos({
  fields: ["id", "title", { user: ["name"] }]
});

// Error: "activity_stats" is denied
const result = await listTodos({
  fields: ["id", "title", { activity_stats: ["total_events"] }]
});
```

### Nested Load Restrictions

Restrict nested relationships using keyword list syntax:

```elixir
typescript_rpc do
  resource MyApp.Todo do
    # Allow user, but only allow loading user's public_profile
    rpc_action :list_todos, :read,
      allowed_loads: [
        :tags,
        user: [:public_profile]
      ]
  end
end
```

```typescript
// Allowed
const result = await listTodos({
  fields: [
    "id",
    { user: ["name", { public_profile: ["bio"] }] }
  ]
});

// Error: user.private_settings not allowed
const result = await listTodos({
  fields: [
    "id",
    { user: ["name", { private_settings: ["data"] }] }
  ]
});
```

### TypeScript Type Generation

Load restrictions affect the generated TypeScript types. With `allowed_loads`, only the allowed fields appear in the field selection types:

```elixir
rpc_action :list_todos, :read, allowed_loads: [:user]
```

```typescript
// Generated type only includes "user" as a loadable field
// "comments", "tags", etc. are not available in autocomplete
const result = await listTodos({
  fields: ["id", "title", { user: ["name"] }]  // Only user is available
});
```

### Error Responses

When a client requests a restricted field:

```typescript
// With allowed_loads: [:user]
const result = await listTodos({
  fields: ["id", { comments: ["text"] }]  // "comments" not allowed
});

// Returns:
// {
//   success: false,
//   errors: [{
//     type: "load_not_allowed",
//     message: "Field 'comments' is not in the allowed loads list",
//     fields: ["comments"]
//   }]
// }
```

```typescript
// With denied_loads: [:activity_stats]
const result = await listTodos({
  fields: ["id", { activity_stats: ["total_events"] }]
});

// Returns:
// {
//   success: false,
//   errors: [{
//     type: "load_denied",
//     message: "Field 'activity_stats' is denied",
//     fields: ["activity_stats"]
//   }]
// }
```

### When to Use Each

| Option | Use When |
|--------|----------|
| `allowed_loads` | An action only needs a small, known set of loadable fields |
| `denied_loads` | Most fields are fine and you only need to block a few expensive ones |

**Best practice**: Use `allowed_loads` when you want a lean endpoint with an explicit loadable surface. Use `denied_loads` when you only need to keep a few expensive loads off an action. In both cases, rely on Ash policies — not load restrictions — for access control.

## Query Controls

### enable_filter?

Disable client-side filtering:

```elixir
typescript_rpc do
  resource MyApp.Todo do
    # Standard action with filtering
    rpc_action :list_todos, :read

    # Server controls filtering via action arguments
    rpc_action :list_recent_todos, :list_recent, enable_filter?: false
  end
end
```

When `enable_filter?: false`:
- The `filter` parameter is **not included** in TypeScript types
- Filter types for this action are **not generated**
- Any filter sent by client is **silently ignored**

```typescript
// With enable_filter?: false
const result = await listRecentTodos({
  fields: ["id", "title"],
  input: { daysBack: 7 }  // Use action arguments for filtering
  // filter: { ... }      // Not available in types
});
```

### enable_sort?

Disable client-side sorting:

```elixir
typescript_rpc do
  resource MyApp.Todo do
    # Standard action with sorting
    rpc_action :list_todos, :read

    # Server controls ordering
    rpc_action :list_ranked_todos, :read, enable_sort?: false
  end
end
```

When `enable_sort?: false`:
- The `sort` parameter is **not included** in TypeScript types
- Any sort sent by client is **silently ignored**

```typescript
// With enable_sort?: false
const result = await listRankedTodos({
  fields: ["id", "title", "rank"]
  // sort: "-rank"  // Not available in types
});
```

### Combining Controls

```elixir
# Fully server-controlled action
rpc_action :list_curated_todos, :read,
  enable_filter?: false,
  enable_sort?: false,
  allowed_loads: [:user]
```

## Get Actions

### get?

Constrain a read action to return a single record:

```elixir
typescript_rpc do
  resource MyApp.User do
    rpc_action :get_current_user, :read, get?: true
  end
end
```

Uses `Ash.read_one` instead of `Ash.read`, returning a single record or error.

### get_by

Look up a single record by specific fields:

```elixir
typescript_rpc do
  resource MyApp.User do
    rpc_action :get_user_by_email, :read, get_by: [:email]
  end
end
```

```typescript
const result = await getUserByEmail({
  getBy: { email: "user@example.com" },
  fields: ["id", "name", "email"]
});
```

### not_found_error?

Control behavior when a get action finds no record:

```elixir
typescript_rpc do
  resource MyApp.User do
    # Returns error when not found (default)
    rpc_action :get_user, :read, get_by: [:id]

    # Returns null when not found
    rpc_action :find_user, :read, get_by: [:email], not_found_error?: false
  end
end
```

```typescript
// With not_found_error?: false
const result = await findUser({
  getBy: { email: "maybe@example.com" },
  fields: ["id", "name"]
});

if (result.success) {
  if (result.data) {
    console.log("Found:", result.data.name);
  } else {
    console.log("User not found");  // No error, just null
  }
}
```

## Identity Lookups

Control how records are located for update and destroy actions.

### Primary Key (Default)

```elixir
rpc_action :update_user, :update
# Equivalent to: identities: [:_primary_key]
```

```typescript
await updateUser({
  identity: "550e8400-e29b-41d4-a716-446655440000",
  input: { name: "New Name" },
  fields: ["id", "name"]
});
```

### Named Identity

First define the identity on your resource:

```elixir
defmodule MyApp.User do
  use Ash.Resource

  identities do
    identity :unique_email, [:email]
  end
end
```

Then configure the RPC action:

```elixir
rpc_action :update_user_by_email, :update, identities: [:unique_email]
```

```typescript
await updateUserByEmail({
  identity: { email: "user@example.com" },
  input: { name: "New Name" },
  fields: ["id", "name"]
});
```

### Multiple Identities

Allow either primary key or named identity:

```elixir
rpc_action :update_user, :update, identities: [:_primary_key, :unique_email]
```

```typescript
// By primary key
await updateUser({
  identity: "550e8400-e29b-41d4-a716-446655440000",
  input: { name: "Via PK" },
  fields: ["id"]
});

// By email
await updateUser({
  identity: { email: "user@example.com" },
  input: { name: "Via Email" },
  fields: ["id"]
});
```

### Actor-Scoped Actions

For actions that operate on the current actor:

```elixir
# Action filters to current user
defmodule MyApp.User do
  actions do
    update :update_me do
      change relate_actor(:id)
    end
  end
end

# No identity needed
rpc_action :update_me, :update_me, identities: []
```

```typescript
// No identity parameter - operates on authenticated user
await updateMe({
  input: { name: "My New Name" },
  fields: ["id", "name"]
});
```

### Composite Identities

Identities spanning multiple fields:

```elixir
identities do
  identity :by_tenant_user, [:tenant_id, :user_id]
end

rpc_action :update_subscription, :update, identities: [:by_tenant_user]
```

```typescript
await updateSubscription({
  identity: {
    tenantId: "tenant-uuid",
    userId: "user-uuid"
  },
  input: { status: "active" },
  fields: ["id", "status"]
});
```

## Metadata Fields

Expose action metadata to clients:

```elixir
rpc_action :list_todos, :read, show_metadata: [:total_count, :has_more]
```

See [Action Metadata](action-metadata.md) for details.

## Quick Reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `allowed_loads` | `list(atom \| keyword)` | `nil` | Whitelist of loadable fields |
| `denied_loads` | `list(atom \| keyword)` | `nil` | Blacklist of loadable fields |
| `enable_filter?` | `boolean` | `true` | Enable client-side filtering |
| `enable_sort?` | `boolean` | `true` | Enable client-side sorting |
| `get?` | `boolean` | `false` | Return single record |
| `get_by` | `list(atom)` | `nil` | Fields for single-record lookup |
| `not_found_error?` | `boolean` | `true` | Error vs null on not found |
| `identities` | `list(atom)` | `[:_primary_key]` | Allowed identity lookups |
| `show_metadata` | `list(atom) \| false \| nil` | `nil` | Metadata fields to expose |
| `metadata_field_names` | `keyword` | `nil` | Metadata field name mappings |
| `namespace` | `atom \| string` | `nil` | Organize action into namespace |
| `description` | `string` | `nil` | Custom JSDoc description |
| `deprecated` | `boolean \| string` | `nil` | Mark as deprecated |
| `see` | `list(atom)` | `nil` | Related actions for @see tags |

## Next Steps

- [Querying Data](../guides/querying-data.md) - Filtering, sorting, pagination
- [CRUD Operations](../guides/crud-operations.md) - Update and destroy patterns
- [Action Metadata](action-metadata.md) - Exposing metadata to clients
- [Field Selection](../guides/field-selection.md) - Field selection patterns
- [Developer Experience](developer-experience.md) - Namespaces, JSDoc, and manifest generation
