documentation/topics/action-metadata.md

<!--
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
-->

# Action Metadata Support

AshTypescript provides full support for [Ash action metadata](https://hexdocs.pm/ash/dsl-ash-resource.html#actions-read-metadata). Action metadata allows you to expose additional computed information alongside action results, such as processing times, cache status, API versions, or any other contextual information.

## Configuring Metadata Exposure

Control which metadata fields are exposed through RPC using the `show_metadata` option in your domain configuration:

```elixir
defmodule MyApp.Domain do
  use Ash.Domain, extensions: [AshTypescript.Rpc]

  typescript_rpc do
    resource MyApp.Task do
      # Expose all metadata fields (default behavior)
      rpc_action :read_with_all_metadata, :read_with_metadata, show_metadata: nil

      # Disable metadata entirely
      rpc_action :read_no_metadata, :read_with_metadata, show_metadata: false

      # Expose specific metadata fields only
      rpc_action :read_selected_metadata, :read_with_metadata,
        show_metadata: [:processing_time_ms, :cache_status]

      # Empty list also disables metadata
      rpc_action :read_empty_metadata, :read_with_metadata, show_metadata: []
    end
  end
end
```

### Configuration Options

- `show_metadata: nil` (default) - All metadata fields from the action are exposed
- `show_metadata: false` or `[]` - Metadata is completely disabled
- `show_metadata: [:field1, :field2]` - Only specified fields are exposed

## TypeScript Usage

### Read Actions (Metadata Merged into Records)

For read actions, metadata fields are merged directly into each record:

```typescript
import { readWithAllMetadata } from './ash_rpc';

// Select which metadata fields to include
const tasks = await readWithAllMetadata({
  fields: ["id", "title"],
  metadataFields: ["processingTimeMs", "cacheStatus", "apiVersion"]
});

if (tasks.success) {
  tasks.data.forEach(task => {
    console.log(task.id);                    // Standard field
    console.log(task.title);                 // Standard field
    console.log(task.processingTimeMs);     // Metadata field (merged in)
    console.log(task.cacheStatus);          // Metadata field (merged in)
    console.log(task.apiVersion);           // Metadata field (merged in)
  });
}

// Select subset of metadata fields
const tasksSubset = await readWithAllMetadata({
  fields: ["id", "title"],
  metadataFields: ["cacheStatus"]  // Only request specific metadata
});

// Omit metadataFields to not include any metadata
const tasksNoMetadata = await readWithAllMetadata({
  fields: ["id", "title"]
  // No metadataFields = no metadata included
});
```

### Mutation Actions (Metadata as Separate Field)

For create, update, and destroy actions, metadata is returned as a separate `metadata` field:

```typescript
import { createTask } from './ash_rpc';

const result = await createTask({
  fields: ["id", "title"],
  input: { title: "New Task" }
});

if (result.success) {
  // Access the created task
  console.log(result.data.id);
  console.log(result.data.title);

  // Access metadata separately
  console.log(result.metadata.operationId);        // Metadata field
  console.log(result.metadata.createdAtServer);    // Metadata field
}
```

## Selective Metadata Field Selection

When `show_metadata` exposes specific fields, only those fields can be selected:

```elixir
# Only :processing_time_ms and :cache_status are exposed
rpc_action :read_limited, :read_with_metadata,
  show_metadata: [:processing_time_ms, :cache_status]
```

```typescript
// ✅ Allowed: Request exposed fields
const tasks = await readLimited({
  fields: ["id", "title"],
  metadataFields: ["processingTimeMs", "cacheStatus"]
});

// ✅ Allowed: Request subset of exposed fields
const tasksPartial = await readLimited({
  fields: ["id", "title"],
  metadataFields: ["processingTimeMs"]
});

// ⚠️ Silently filtered: Non-exposed fields are ignored
const tasksFiltered = await readLimited({
  fields: ["id", "title"],
  metadataFields: ["processingTimeMs", "apiVersion"]  // apiVersion not exposed
});
// Result will only include processingTimeMs, apiVersion is filtered out
```

## Field Name Formatting

Metadata field names follow the same formatting rules as regular fields:

```elixir
# Elixir: snake_case
metadata :processing_time_ms, :integer
metadata :cache_status, :string
```

```typescript
// TypeScript: camelCase (with default formatter)
result.metadata.processingTimeMs   // Formatted
result.metadata.cacheStatus        // Formatted
```

## Type Safety

Generated TypeScript types include metadata fields with full type inference:

```typescript
// For read actions with metadata merged in
type TaskWithMetadata = {
  id: string;
  title: string;
  processingTimeMs?: number | null;    // Metadata field
  cacheStatus?: string | null;         // Metadata field
  apiVersion?: string | null;          // Metadata field
}

// For mutations with separate metadata
type CreateTaskResult = {
  success: true;
  data: {
    id: string;
    title: string;
  };
  metadata: {
    operationId: string;
    createdAtServer: string;
  }
} | {
  success: false;
  errors: Array<ErrorType>;
}
```

## Metadata Field Name Mapping

TypeScript has stricter identifier rules than Elixir. If your action's metadata fields use invalid TypeScript names, use the `metadata_field_names` option to map them to valid identifiers.

### Invalid Metadata Field Name Patterns

- **Underscores before digits**: `field_1`, `metric_2`, `item__3`
- **Question marks**: `is_cached?`, `valid?`

### Mapping Invalid Names

Map invalid metadata field names using the `metadata_field_names` option:

```elixir
defmodule MyApp.Domain do
  use Ash.Domain, extensions: [AshTypescript.Rpc]

  typescript_rpc do
    resource MyApp.Task do
      rpc_action :read_with_metadata, :read_with_metadata,
        show_metadata: [:field_1, :is_cached?, :metric_2],
        metadata_field_names: [
          field_1: :field1,
          is_cached?: :isCached,
          metric_2: :metric2
        ]
    end
  end
end
```

### Generated TypeScript with Mapped Names

```typescript
// Read actions - metadata merged into records
const tasks = await readWithMetadata({
  fields: ["id", "title"],
  metadataFields: ["field1", "isCached", "metric2"]  // Mapped names
});

if (tasks.success) {
  tasks.data.forEach(task => {
    console.log(task.id);          // Standard field
    console.log(task.title);       // Standard field
    console.log(task.field1);      // Mapped metadata field
    console.log(task.isCached);    // Mapped metadata field
    console.log(task.metric2);     // Mapped metadata field
  });
}

// Create/Update/Destroy actions - metadata as separate field
const result = await createTask({
  fields: ["id", "title"],
  input: { title: "New Task" }
});

if (result.success) {
  console.log(result.data.id);
  console.log(result.metadata.field1);    // Mapped metadata field
  console.log(result.metadata.isCached);  // Mapped metadata field
}
```

## Compile-time Verification

AshTypescript includes compile-time verification that detects invalid metadata field names:

```
Invalid metadata field name found in action :read_with_metadata on resource MyApp.Task

Metadata field 'field_1' contains invalid pattern (underscore before digit).
Suggested mapping: field_1 → field1

Metadata field 'is_cached?' contains invalid pattern (question mark).
Suggested mapping: is_cached? → isCached

Use the metadata_field_names option to provide valid TypeScript identifiers.
```

## See Also

- [Troubleshooting Guide](/documentation/reference/troubleshooting.md) - Learn about field and argument name mapping
- [Ash Action Metadata](https://hexdocs.pm/ash/dsl-ash-resource.html#actions-read-metadata) - Learn about Ash metadata in depth