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

# AshTypescript Usage Rules

## Quick Reference

**Critical**: Declare a manifest module + `config :ash_typescript, manifest: ...`, add `AshTypescript.Rpc` extension to domain, run `mix ash_typescript.codegen`
**Authentication**: Use `buildCSRFHeaders()` for Phoenix CSRF protection
**Controller Routes**: Use `AshTypescript.TypedController` for controller-style actions with `conn` access
**Typed Channels**: Use `AshTypescript.TypedChannel` for typed PubSub event subscriptions
**Validation**: Always verify generated TypeScript compiles

## Essential Syntax Table

| Pattern | Syntax | Example |
|---------|--------|---------|
| **Domain Setup** | `use Ash.Domain, extensions: [AshTypescript.Rpc]` | Required extension |
| **RPC Action** | `rpc_action :name, :action_type` | `rpc_action :list_todos, :read` |
| **Basic Call** | `functionName({ fields: [...] })` | `listTodos({ fields: ["id", "title"] })` |
| **Field Selection** | `["field1", {"nested": ["field2"]}]` | Relationships in objects |
| **Union Fields** | `{ unionField: ["member1", {"member2": [...]}] }` | Selective union member access |
| **Union Input** | `input: { field: { memberName: value } }` | Wrapped format required (exactly one member key) |
| **Calculation (no args)** | `{ calc: ["field1", ...] }` | Simple nested syntax |
| **Calculation (with args)** | `{ calc: { args: {...}, fields: [...] } }` | Args + fields object |
| **Filter Syntax** | `{ field: { eq: value } }` | Always use operator objects |
| **Sort** | `"-field"` or `["-field1", "field2"]` | `-`/`+`/`--`/`++` prefixes; array for multi-field |
| **CSRF Headers** | `headers: buildCSRFHeaders()` | Phoenix CSRF protection |
| **Input Args** | `input: { argName: value }` | Action arguments |
| **Identity (PK)** | `identity: "id-123"` | Primary key lookup |
| **Identity (Named)** | `identity: { email: "a@b.com" }` | Named identity lookup |
| **Identities Config** | `identities: [:_primary_key, :email]` | Allowed lookup methods |
| **Actor-Scoped** | `identities: []` | No identity param needed |
| **Get Action** | `get?: true` or `get_by: [:email]` | Single record lookup |
| **Not Found** | `not_found_error?: false` | Return null instead of error |
| **Custom Fetch** | `customFetch: myFetchFn` | Replace native fetch |
| **Pagination** | `page: { limit: 10 }` | Offset/keyset pagination |
| **Disable Filter** | `enable_filter?: false` | Disable client filtering |
| **Disable Sort** | `enable_sort?: false` | Disable client sorting |
| **Allowed Loads** | `allowed_loads: [:user, comments: [:author]]` | Whitelist loadable fields |
| **Denied Loads** | `denied_loads: [:user]` | Blacklist loadable fields |
| **Field Mapping** | `field_names [field_1: "field1"]` | Map invalid field names |
| **Arg Mapping** | `argument_names [action: [arg_1: "arg1"]]` | Map invalid arg names |
| **Type Mapping** | `def typescript_field_names, do: [...]` | NewType/TypedStruct callback |
| **Metadata Config** | `show_metadata: [:field1]` | Control metadata exposure |
| **Metadata Mapping** | `metadata_field_names: [field_1: "field1"]` | Map metadata names |
| **Metadata (Read)** | `metadataFields: ["field1"]` | Merged into records |
| **Metadata (Mutation)** | `result.metadata.field1` | Separate metadata field |
| **Domain Namespace** | `typescript_rpc do namespace "api"` | Default for all resources (strings only) |
| **Resource Namespace** | `resource X do namespace "todos"` | Override domain default |
| **Action Namespace** | `namespace: "custom"` | Override resource default |
| **Enable Namespaces** | `config :ash_typescript, enable_namespace_files: true` | Required — namespaces emit no files without it |
| **Deprecation** | `deprecated: true` or `"message"` | Mark action deprecated |
| **Related Actions** | `see: [:create_todo]` | Link in JSDoc |
| **Description** | `description: "Custom desc"` | Override JSDoc description |
| **Channel Function** | `actionNameChannel({channel, resultHandler})` | Phoenix channel RPC |
| **Validation Fn** | `validateActionName({...})` | Client-side validation |
| **Type Overrides** | `type_mapping_overrides: [{Module, "TSType"}]` | Map dependency types |
| **Typed Controller** | `use AshTypescript.TypedController` | Controller-style routes |
| **Controller Module** | `typed_controller do module_name MyWeb.Ctrl` | Generated controller module |
| **Verb Shortcut** | `get :auth do run fn ... end end` | Preferred route syntax |
| **Positional Method** | `route :login, :post do run fn ... end end` | Method as 2nd arg |
| **Default GET** | `route :home do run fn ... end end` | Method defaults to :get |
| **Route Argument** | `argument :code, :string, allow_nil?: false` | Colocated in route |
| **Array Argument** | `argument :tags, {:array, :string}` | Any Ash type form, arrays included |
| **Route Schema Names** | `zod_schema_name "x"` / `valibot_schema_name "x"` | Avoid collisions with RPC action schemas |
| **Route Namespace** | `namespace "auth"` | Inside typed_controller or route do block |
| **Route Description** | `description "..."` | JSDoc on route (inside do block) |
| **Route Deprecated** | `deprecated true` | Deprecation notice (inside do block) |
| **Route @see Tags** | `see [:auth, :logout]` | JSDoc `@see` cross-references |
| **Typed Controllers** | `config :ash_typescript, typed_controllers: [M]` | Module discovery |
| **Router Config** | `config :ash_typescript, router: MyWeb.Router` | Path introspection |
| **Routes Output** | `config :ash_typescript, routes_output_file: "routes.ts"` | Route file path |
| **Paths-Only Mode** | `config :ash_typescript, typed_controller_mode: :paths_only` | Skip fetch functions |
| **GET Query Params** | `argument :q, :string, allow_nil?: false` on GET route | Becomes `?q=value`; arrays become `?tags[]=a&tags[]=b` |
| **Route Validation Schemas** | Zod/Valibot schemas for any route with non-path args | Emitted into `ash_zod.ts` / `ash_valibot.ts`, GET included |
| **Typed Channel** | `use AshTypescript.TypedChannel` | Server-push event subscriptions |
| **Channel Topic** | `typed_channel do topic "org:*"` | Wildcard or static topic |
| **Channel Resource** | `resource MyApp.Post do publish :event end` | Declare events per resource |
| **Channel Create** | `createOrgChannel(socket, suffix)` | Factory with branded type |
| **Channel Subscribe** | `onOrgChannelMessages(channel, handlers)` | Multi-event subscription |
| **Channel Unsubscribe** | `unsubscribeOrgChannel(channel, refs)` | Cleanup all refs |
| **Typed Channels** | `config :ash_typescript, typed_channels: [M]` | Module discovery |
| **Channels Output** | `config :ash_typescript, typed_channels_output_file: "..."` | Channel functions file |
| **JSON Manifest** | `config :ash_typescript, json_manifest_file: "manifest.json"` | Machine-readable action metadata |
| **Manifest Filename** | `json_manifest_filename_format: :relative` | `:relative`, `:absolute`, or `:basename` |

## Action Feature Matrix

| Action Type | Fields | Filter | Page | Sort | Input | Identity |
|-------------|--------|--------|------|------|-------|----------|
| **read** | ✓ | ✓* | ✓ | ✓* | ✓ | - |
| **read (get?/get_by)** | ✓ | - | - | - | ✓ | - |
| **create** | ✓ | - | - | - | ✓ | - |
| **update** | ✓ | - | - | - | ✓† | ✓ |
| **destroy** | - | - | - | - | ✓ | ✓ |

*Can be disabled with `enable_filter?: false` / `enable_sort?: false`

†Update `input` is optional (`input?:`) — accepted attributes are never required on an update.

## Core Patterns

### Basic Setup

Requires ash `~> 3.27`. Three required pieces: a manifest module, the `manifest:` config, and a domain with the RPC extension. `mix igniter.install ash_typescript` scaffolds the first two.

```elixir
# 1. Manifest module (required — codegen and runtime read all shape from it)
defmodule MyApp.AshTypescriptManifest do
  use AshTypescript.Manifest, otp_app: :my_app
end

# 2. config/config.exs
config :ash_typescript, manifest: MyApp.AshTypescriptManifest

# 3. Domain with RPC actions
defmodule MyApp.Domain do
  use Ash.Domain, extensions: [AshTypescript.Rpc]

  typescript_rpc do
    resource MyApp.Todo do
      rpc_action :list_todos, :read
      rpc_action :create_todo, :create
      rpc_action :update_todo, :update
    end
  end
end
```

### TypeScript Usage

Every RPC result is a discriminated union — `{ success: true; data } | { success: false; errors }`. Narrow on `success` before accessing `data`.

```typescript
// Read with all features
const todos = await listTodos({
  fields: ["id", "title", { user: ["name"] }],
  filter: { completed: { eq: false } },
  page: { limit: 10 },
  sort: "-createdAt",
  headers: buildCSRFHeaders()
});
if (todos.success) console.log(todos.data);

// Update requires identity
await updateTodo({
  identity: "todo-123",
  input: { title: "Updated" },
  fields: ["id", "title"]
});

// Phoenix channel
createTodoChannel({
  channel: myChannel,
  input: { title: "New" },
  fields: ["id"],
  resultHandler: (r) => {
    if (r.success) console.log(r.data);
    else console.error(r.errors);
  }
});
```

### Field Name Mapping (Invalid Names)

```elixir
# Resource attributes/calculations
typescript do
  field_names [field_1: "field1", is_active?: "isActive"]
  argument_names [search: [filter_1: "filter1"]]
end

# Custom types (NewType, TypedStruct, map constraints)
def typescript_field_names, do: [field_1: "field1"]

# Metadata fields
rpc_action :read_todo_with_meta, :read_with_meta,
  metadata_field_names: [meta_1: "meta1"]
```

## Typed Controller (Route Helpers)

### When to Use

| Use Case | Extension |
|----------|-----------|
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |

### Setup

```elixir
defmodule MyApp.Session do
  use AshTypescript.TypedController

  typed_controller do
    module_name MyAppWeb.SessionController

    # Verb shortcut (preferred)
    get :auth do
      run fn conn, _params -> render_inertia(conn, "Auth") end
    end

    # Verb shortcut with args
    post :login do
      see [:auth, :logout]
      run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
      argument :code, :string, allow_nil?: false
      argument :remember_me, :boolean
    end

    # Positional method arg
    route :logout, :post do
      run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
    end

    # Default method (GET when omitted)
    route :home do
      run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "Home") end
    end
  end
end
```

### Generated TypeScript

```typescript
// GET → path helper
authPath()                          // → "/auth"

// GET with query args → path with query params
searchPath({ q: "test", page: 1 }) // → "/search?q=test&page=1"

// POST → typed async function (via executeTypedControllerRequest helper)
login({ code: "abc" }, { headers: { "X-CSRF-Token": token } })

// PATCH with path params + input
updateProvider({ provider: "github" }, { enabled: true })
```

**Function parameter order**: `path` (if path params) → `input` (if args) → `config?: TypedControllerConfig`

**Modes**: `:full` generates path helpers + fetch functions (+ Zod schemas if enabled). `:paths_only` generates only path helpers.

### Typed Controller Constraints

- Handlers must return `%Plug.Conn{}` directly — no `{:ok, conn}` wrapping
- Multi-mount requires unique `as:` options on scopes for disambiguation
- Not an Ash resource — standalone Spark DSL with colocated arguments
- Path param `allow_nil?` must match presence: always present → `false`, sometimes present (multi-mount) → `true`
- Arguments follow Ash action-argument semantics: `constraints:` are validated at compile time and enforced at runtime — a failure returns 422 without invoking the handler
- Empty string on a required string argument → 422 `is required`; on a nilable string argument the handler receives `nil`. Strings are trimmed by default (`trim?`)
- Route arguments accept any Ash type form, including `{:array, inner}`; item constraints go under `constraints: [items: [...]]`
- Validation schemas are generated for every route with non-path arguments (GET query params included), not just mutations — they live in the shared `ash_zod.ts`/`ash_valibot.ts`, and the JSON manifest advertises them under `typedControllerRoutes[].types`

## Typed Channel (Event Subscriptions)

### When to Use

| Use Case | Extension |
|----------|-----------|
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
| Server pushes events to clients (notifications, updates) | `AshTypescript.TypedChannel` |

### Setup

```elixir
defmodule MyAppWeb.OrgChannel do
  use AshTypescript.TypedChannel
  use Phoenix.Channel

  typed_channel do
    topic "org:*"

    resource MyApp.Post do
      publish :post_created
      publish :post_updated
    end
  end

  @impl true
  def join("org:" <> org_id, _payload, socket), do: {:ok, socket}
end
```

Resources must have `pub_sub` publications with matching `event:` names. For typed payloads, use `transform :some_calc` referencing an `:auto`-typed calculation (recommended — Ash derives `returns` automatically), or set `returns:` explicitly. Without either, the payload type is `unknown`.

The module must also `use Phoenix.Channel` (compile warning otherwise): the declared events are automatically intercepted and their payloads formatted with the configured `output_field_formatter` before being pushed, so the wire payload matches the generated TypeScript types.

### Generated TypeScript

```typescript
// Create branded channel + subscribe
const channel = createOrgChannel(socket, orgId);

const refs = onOrgChannelMessages(channel, {
  post_created: (payload) => console.log(payload),  // typed payload
  post_updated: (payload) => updatePost(payload),
});

// Single event: onOrgChannelMessage(channel, "post_created", handler)

// Cleanup
unsubscribeOrgChannel(channel, refs);
```

### Topic Patterns

| Topic Pattern | Factory Signature |
|--------------|-------------------|
| `"org:*"` (wildcard) | `createOrgChannel(socket, suffix)` |
| `"global"` (no wildcard) | `createGlobalChannel(socket)` |

### Typed Channel Constraints

- Event names must be unique across all resources in a channel
- Publications need `public?: true` (warning if missing — toggle with `warn_on_non_public_publications`)
- Publications need `transform :some_calc` or an explicit `returns:` for typed payloads (warning if missing, falls back to `unknown` — toggle with `warn_on_missing_channel_returns`)
- The branded channel type exposes `on`/`off`/`join`/`leave` — cast to Phoenix's `Channel` if you need anything else (e.g. `push`)
- Channel types go in `ash_types.ts`; channel functions go in `typed_channels_output_file`

## JSON Manifest (Third-Party Integrations)

When `json_manifest_file` is configured, `mix ash_typescript.codegen` generates a machine-readable JSON manifest. This enables third-party packages (e.g., TanStack Query wrappers) to introspect the generated API without coupling to ash_typescript internals.

```elixir
config :ash_typescript,
  json_manifest_file: "assets/js/ash_rpc_manifest.json",
  json_manifest_filename_format: :relative  # :relative | :absolute | :basename
```

The manifest contains:
- **`files`** — generated file locations with `importPath` (for TS imports, always relative, no `.ts`) and `filename` (format controlled by config)
- **`actions`** — every RPC action with: `functionName`, `actionType` (read/create/update/destroy/action), `get`, `namespace`, `resource`, `description`, `types` (result, fields, inferResult, input, config, filterInput — only present when applicable), `pagination`, `enableFilter`, `enableSort`, `variants`/`variantNames`, `deprecated`, `see`, `input` (none/optional/required)
- **`typedControllerRoutes`** — each route with: `functionName`, `method`, `path`, `pathParams`, `mutation`, plus `types` when the route has input arguments
- **`version`** — semver string (currently `"1.0"`) for consumer compatibility, and `generatedAt` (date string)

### Consumer Example

```typescript
import manifest from "./ash_rpc_manifest.json";

for (const action of manifest.actions) {
  const isQuery = action.actionType === "read";
  // Import from manifest.files.rpc.importPath
  // Generate queryOptions/mutationOptions wrappers
}
```

## Common Gotchas

| Error Pattern | Fix |
|---------------|-----|
| "No `:manifest` module configured" | Add `config :ash_typescript, manifest: MyApp.AshTypescriptManifest` + define the module (`use AshTypescript.Manifest, otp_app: :my_app`) |
| Missing `extensions: [AshTypescript.Rpc]` | Add to domain |
| Missing `typescript` block on resource | Add `AshTypescript.Resource` extension + `typescript do type_name "X" end` |
| No `rpc_action` declarations | Explicitly declare each action |
| Compile error points at your manifest module | Expected — all RPC verifiers run when the manifest module compiles, not the domain |
| Filter syntax `{ field: false }` | Use operators: `{ field: { eq: false } }` |
| "Union input must be a map" / "contains multiple member keys" | Wrap union inputs in exactly one member key: `{ memberName: value }` |
| Missing `fields` parameter | Always include `fields: [...]` |
| Get action error on not found | Add `not_found_error?: false` |
| Invalid field name `field_1` or `is_active?` | Add field mapping |
| Identity not found | Check `identities` config; use `{ field: value }` for named |
| Load not allowed/denied | Check `allowed_loads`/`denied_loads` config |
| Channel/validation fn undefined | Enable in config |
| Typed controller 500 error | Handler must return `%Plug.Conn{}` |
| Routes not generated | Set `typed_controllers:` (gates generation) and `router:` in config; `routes_output_file:` auto-derives |
| Multi-mount ambiguity error | Add unique `as:` option to each scope |
| Path param without matching argument | Add `argument :param, :string` to route |
| Path param `allow_nil?` mismatch | Always-present → `false`; sometimes-present → `true` |
| Route hooks not firing | Check `typed_controller_import_into_generated` + hook names |
| Typed channel event not found | Event name must match `event:` option on resource's `pub_sub` publication |
| Duplicate channel event names | Use unique event names across all resources in one channel |
| Channel payload is `unknown` | Use `transform :some_calc` (an `:auto`-typed calculation) on the publication, or add explicit `returns:` |
| TypedMap field with embedded-resource member rejects string selection | Use nested selection: `{ field: [{ rows: [...] }] }` (composite members require explicit field selection since 0.18) |
| Typed channels not generated | Set `typed_channels:` and `typed_channels_output_file:` in config |

## Error Quick Reference

| Error Contains | Fix |
|----------------|-----|
| "No `:manifest` module configured" | Define manifest module + set `manifest:` config |
| "Property does not exist" | Run `mix ash_typescript.codegen` |
| "Property 'fields' is missing" | Add `fields: [...]` |
| "Action not found" | Add `rpc_action` declaration |
| "403 Forbidden" | Use `buildCSRFHeaders()` |
| "Invalid field names" | Add mapping (see Field Name Mapping) |
| "load_not_allowed" / "load_denied" | Check load restrictions config |
| "show_metadata contains unknown metadata fields" | Only list fields the action's `metadata` declarations define |
| "allowed_loads contains invalid load paths" | Entries must be public relationships/calculations/aggregates/embedded attributes; nested keys checked against the destination resource |
| "allow_nil?: true" + path param | Set `allow_nil?: false` for always-present path params |
| "allow_nil?: false" + sometimes-present | Use `allow_nil?: true` for multi-mount path params |
| "No publication with event X found" | Check `event:` option on resource's `pub_sub` block |
| "Duplicate event names found" | Use unique event names per channel |

## Configuration

```elixir
config :ash_typescript,
  manifest: MyApp.AshTypescriptManifest,   # Required: app-wide manifest module
  output_file: "assets/js/ash_rpc.ts",
  run_endpoint: "/rpc/run",
  validate_endpoint: "/rpc/validate",
  generate_validation_functions: false,
  generate_phx_channel_rpc_actions: false,
  generate_zod_schemas: false,
  generate_valibot_schemas: false,
  require_tenant_parameters: false,
  not_found_error?: true,
  # JSDoc/Manifest
  add_ash_internals_to_jsdoc: false,
  add_ash_internals_to_manifest: false,
  manifest_file: nil,
  json_manifest_file: nil,              # Machine-readable JSON manifest for third-party tools
  json_manifest_filename_format: :relative,  # :relative | :absolute | :basename
  source_path_prefix: nil,  # For monorepos: "backend"
  # Namespace files (namespaces emit nothing unless enabled)
  enable_namespace_files: false,
  namespace_output_dir: nil,             # Defaults to the RPC output file's directory
  enable_controller_namespace_files: false,
  controller_namespace_output_dir: nil,
  # Warnings
  warn_on_missing_rpc_config: true,
  warn_on_non_rpc_references: true,
  warn_on_non_public_publications: true,
  warn_on_missing_channel_returns: true,
  # Dev codegen behavior
  always_regenerate: false,
  # Imports/Types
  import_into_generated: [%{import_name: "CustomTypes", file: "./customTypes"}],
  type_mapping_overrides: [{MyApp.CustomType, "string"}],
  # Typed Controller (route helpers)
  typed_controllers: [MyApp.Session],
  router: MyAppWeb.Router,
  routes_output_file: "assets/js/routes.ts",
  typed_controller_mode: :full,                # :full or :paths_only
  typed_controller_path_params_style: :object,  # :object or :args
  # typed_controller_base_path: "https://api.example.com",  # or {:runtime_expr, "..."}
  # Optional: lifecycle hooks, custom imports, error handling
  # typed_controller_before_request_hook: "RouteHooks.beforeRequest",
  # typed_controller_after_request_hook: "RouteHooks.afterRequest",
  # typed_controller_hook_context_type: "RouteHooks.RouteHookContext",
  # typed_controller_import_into_generated: [%{import_name: "RouteHooks", file: "./routeHooks"}],
  # typed_controller_error_handler: {MyApp.ErrorHandler, :handle, []},
  # typed_controller_show_raised_errors: false  # true only in dev
  # Typed Channel (event subscriptions)
  typed_channels: [MyApp.OrgChannel],
  typed_channels_output_file: "assets/js/ash_typed_channels.ts"
```

Not exhaustive — see `documentation/reference/configuration.md` for the full key list.

**Validation schemas**: generated Zod/Valibot string schemas carry `min(1)` whenever the attribute's constraints have `allow_empty?: false` (the Ash default), including nullable and optional fields. Send `null`, not `""`.

## Commands

```bash
mix igniter.install ash_typescript      # Scaffold config + manifest module
mix ash_typescript.codegen              # Generate
mix ash_typescript.codegen --check      # Verify up-to-date (CI)
mix ash_typescript.codegen --dry-run    # Preview
npx tsc ash_rpc.ts --noEmit             # Validate TS
```
