Skip to content

Commit 1fbc362

Browse files
committed
docs(typed-channel): recommend calc transforms and document Ash version requirements
1 parent 64b4628 commit 1fbc362

File tree

7 files changed

+205
-40
lines changed

7 files changed

+205
-40
lines changed

AGENTS.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,34 @@ listTodosChannel({
130130
131131
### Typed Channel Event Subscriptions
132132
133-
For typed one-way push events from Ash PubSub publications:
133+
For typed one-way push events from Ash PubSub publications.
134+
135+
**Recommended**: Use `transform :some_calc` to reference a resource calculation.
136+
Ash auto-derives the `returns` type when the calculation uses `:auto`, so
137+
AshTypescript gets the type information it needs without manual `returns` declarations.
134138
135139
```elixir
140+
# Resource with calculation transforms (recommended)
141+
defmodule MyApp.Post do
142+
use Ash.Resource, notifiers: [Ash.Notifier.PubSub]
143+
144+
pub_sub do
145+
module MyApp.Endpoint
146+
prefix "posts"
147+
148+
publish :create, [:id], event: "post_created", public?: true, transform: :post_summary
149+
publish :update, [:id], event: "post_updated", public?: true, transform: :post_summary
150+
end
151+
152+
calculations do
153+
calculate :post_summary, :auto, expr(%{id: id, title: title}) do
154+
public? true
155+
end
156+
end
157+
# ...
158+
end
159+
160+
# Channel definition (unchangedonly references events)
136161
defmodule MyApp.OrgChannel do
137162
use AshTypescript.TypedChannel
138163

@@ -147,6 +172,18 @@ defmodule MyApp.OrgChannel do
147172
end
148173
```
149174
175+
You can also use explicit `returns` with an anonymous function transform, but
176+
this requires manually keeping the type and transform in sync:
177+
178+
```elixir
179+
publish :create, [:id],
180+
event: "post_created",
181+
public?: true,
182+
returns: :map,
183+
constraints: [fields: [id: [type: :uuid], title: [type: :string]]],
184+
transform: fn notification -> %{id: notification.data.id, title: notification.data.title} end
185+
```
186+
150187
```typescript
151188
import { createOrgChannel, onOrgChannelMessages, unsubscribeOrgChannel } from './ash_typed_channels';
152189

@@ -367,7 +404,7 @@ mix credo --strict # Linting
367404
| "No publication with event X found" | Typed channel event doesn't match any publication | Check `event:` option on the resource's `pub_sub` block |
368405
| "Duplicate event names found in typed_channel" | Same event name across resources in one channel | Use unique event names per channel |
369406
| "Payload type name conflict" | Same event name across different channels maps to different TS types | Rename events or ensure same `returns` type |
370-
| Channel `unknown` payload type | Publication missing `returns` option | Add `returns: :some_type` to the publication |
407+
| Channel `unknown` payload type | Publication missing `returns` type (no `transform :calc` or explicit `returns`) | Use `transform :some_calc` with an `:auto`-typed calculation (recommended), or add explicit `returns:` |
371408
| "not `public?`" error on RPC action | Action has `public? false` | Set `public? true` on the action or remove it from `typescript_rpc` |
372409
| "not `public?`" error on read_action | `read_action` has `public? false` | Set `public? true` on the read action |
373410
| "not `public?`" error on relationship read action | Relationship destination's read action has `public? false` | Set `public? true` on the destination's read action |

agent-docs/features/typed-channel.md

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ SPDX-License-Identifier: MIT
1010

1111
The `AshTypescript.TypedChannel` DSL generates typed TypeScript event subscriptions from Ash PubSub publications. It reads the `returns` type from each declared publication and generates branded channel types, typed payload aliases, event maps, and subscription helper functions.
1212

13+
**Recommended approach**: Use `transform :some_calc` on publications to reference a resource calculation. When the calculation uses `:auto` typing, Ash automatically derives the `returns` type from the expression, so AshTypescript gets full type information without any manual `returns` declarations. This keeps the type and the transform logic in sync via a single source of truth (the calculation). You can also use explicit `returns: :type` with an anonymous function transform, but this requires manually keeping the type and transform in sync.
14+
1315
**Key distinction**: `AshTypescript.Rpc` with `generate_phx_channel_rpc_actions` is for request/response RPC over channels. `AshTypescript.TypedChannel` is for one-way push events (PubSub broadcasts) where the server pushes typed payloads to the client.
1416

1517
**Important**: `AshTypescript.TypedChannel` is a standalone Spark DSL — completely independent from `Ash.Resource` and `AshTypescript.Rpc`. The developer owns channel authorization via Phoenix's `join/3`.
1618

19+
## Requirements
20+
21+
Typed channels require **Ash >= 3.17.1**, which introduced `returns`, `public?`, and calculation `transform` support on PubSub publications. **Ash >= 3.21.1 is recommended**, as it added support for `:auto`-typed calculations as transforms, allowing Ash to automatically derive the `returns` type from the calculation expression.
22+
1723
## Architecture
1824

1925
### Three-Layer Design
@@ -26,7 +32,8 @@ The `AshTypescript.TypedChannel` DSL generates typed TypeScript event subscripti
2632
│ - Compile-time verification (event existence, uniq) │
2733
├─────────────────────────────────────────────────────────┤
2834
│ Type Resolution Layer: Codegen │
29-
│ - Reads publication `returns` type from Ash PubSub │
35+
│ - Reads `returns` type from publication (auto-derived │
36+
│ via `transform :calc` or explicit `returns:`) │
3037
│ - Maps types via TypeMapper.map_channel_payload_type │
3138
│ - Plain object types (no __type/__primitiveFields) │
3239
├─────────────────────────────────────────────────────────┤
@@ -51,10 +58,67 @@ The Orchestrator appends channel types to the end of `ash_types.ts` after all re
5158

5259
### Type Mapping
5360

54-
Channel payload types use `TypeMapper.map_channel_payload_type/2` instead of `map_type/3`. The difference: typed containers (maps/structs with `:fields`) generate plain object types without the `__type`/`__primitiveFields` metadata that the RPC field-selection system needs. Non-container types (primitives, lists, etc.) delegate to `map_type/3` with `:output` direction.
61+
Channel payload types use `TypeMapper.map_channel_payload_type/2` instead of `map_type/3`. The codegen reads the publication's `returns` type — which Ash auto-populates when `transform :some_calc` references a calculation, or which can be set explicitly via `returns:`. The difference from RPC types: typed containers (maps/structs with `:fields`) generate plain object types without the `__type`/`__primitiveFields` metadata that the RPC field-selection system needs. Non-container types (primitives, lists, etc.) delegate to `map_type/3` with `:output` direction.
5562

5663
## DSL Reference
5764

65+
### Resource setup (recommended: calculation transforms)
66+
67+
Publications should use `transform :some_calc` to reference a resource calculation.
68+
When the calculation uses `:auto` typing, Ash auto-derives `returns` from the expression:
69+
70+
```elixir
71+
defmodule MyApp.Post do
72+
use Ash.Resource, notifiers: [Ash.Notifier.PubSub]
73+
74+
pub_sub do
75+
module MyApp.Endpoint
76+
prefix "posts"
77+
78+
publish :create, [:id], event: "post_created", public?: true, transform: :post_summary
79+
publish :update, [:id], event: "post_updated", public?: true, transform: :post_summary
80+
end
81+
82+
calculations do
83+
calculate :post_summary, :auto, expr(%{id: id, title: title}) do
84+
public? true
85+
end
86+
end
87+
# ...
88+
end
89+
90+
defmodule MyApp.Comment do
91+
use Ash.Resource, notifiers: [Ash.Notifier.PubSub]
92+
93+
pub_sub do
94+
module MyApp.Endpoint
95+
prefix "comments"
96+
97+
publish :create, [:id], event: "comment_created", public?: true, transform: :comment_body
98+
end
99+
100+
calculations do
101+
calculate :comment_body, :auto, expr(body) do
102+
public? true
103+
end
104+
end
105+
# ...
106+
end
107+
```
108+
109+
You can also use explicit `returns` with an anonymous function transform:
110+
111+
```elixir
112+
publish :create, [:id],
113+
event: "post_created",
114+
public?: true,
115+
returns: :map,
116+
constraints: [fields: [id: [type: :uuid], title: [type: :string]]],
117+
transform: fn notification -> %{id: notification.data.id, title: notification.data.title} end
118+
```
119+
120+
### Channel definition
121+
58122
```elixir
59123
defmodule MyApp.OrgChannel do
60124
use AshTypescript.TypedChannel
@@ -101,7 +165,7 @@ The `VerifyTypedChannel` verifier runs at compile time:
101165
| Event exists | Error | Each declared event must match a publication on the resource |
102166
| Unique events | Error | Event names must be unique across all resources in a channel |
103167
| `public?: true` | Warning | Publications should be marked `public?: true` |
104-
| `returns` set | Warning | Publications without `returns` produce `unknown` TypeScript type |
168+
| `returns` set | Warning | Publications without `returns` (no `transform :calc` or explicit `returns:`) produce `unknown` TypeScript type |
105169

106170
## Generated TypeScript
107171

@@ -177,7 +241,7 @@ When `generate_all_channel_types/1` processes multiple channels, payload type al
177241

178242
Before deduplication, `validate_no_payload_type_conflicts!/2` checks that events sharing a payload type name also share the same TypeScript type. If two events across different channels produce the same type name (e.g., `ItemCreatedPayload`) but map to different TypeScript types (e.g., `{id: UUID}` vs `string`), codegen raises a `RuntimeError` with details about the conflicting events and channels.
179243

180-
This can happen when two resources declare publications with the same event name but different `returns` types, and those resources are used in separate channels. The verifier's unique-event check only applies within a single channel, so this cross-channel conflict is caught at codegen time instead.
244+
This can happen when two resources declare publications with the same event name but different `returns` types (whether auto-derived from calculation transforms or explicitly declared), and those resources are used in separate channels. The verifier's unique-event check only applies within a single channel, so this cross-channel conflict is caught at codegen time instead.
181245

182246
## Configuration
183247

@@ -258,6 +322,6 @@ mix test test/ash_typescript/typed_channel/ # Typed channel tests
258322
| "No publication with event X found" | Event name doesn't match any publication on the resource | Check the `event:` option (or action name fallback) on the resource's `pub_sub` block |
259323
| "Duplicate event names found" | Same event name used across multiple resources in one channel | Use unique event names per channel |
260324
| "Payload type name conflict" | Same event name across different channels maps to different TypeScript types | Rename conflicting events or ensure they return the same type |
261-
| `unknown` TypeScript payload type | Publication missing `returns` option | Add `returns: :some_type` to the publication |
325+
| `unknown` TypeScript payload type | Publication missing `returns` type (no `transform :calc` or explicit `returns:`) | Use `transform :some_calc` with an `:auto`-typed calculation (recommended), or add explicit `returns:` |
262326
| Channel types not in output | `typed_channels` not configured | Add modules to `typed_channels: [...]` in config |
263327
| Channel functions not generated | `typed_channels_output_file` not configured | Set `typed_channels_output_file:` in config |

documentation/features/typed-channels.md

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,19 @@ Use `AshTypescript.TypedChannel` when your application pushes events to clients
2020
| Client sends requests over WebSocket | [Channel-based RPC](phoenix-channels.md) |
2121
| Controller-style routes (Inertia, redirects) | [Typed Controllers](../guides/typed-controllers.md) |
2222

23+
## Requirements
24+
25+
Typed channels require **Ash >= 3.17.1**, which introduced `returns`, `public?`, and calculation `transform` support on PubSub publications. **Ash >= 3.21.1 is recommended**, as it added support for `:auto`-typed calculations as transforms, allowing Ash to automatically derive the `returns` type from the calculation expression.
26+
2327
## Quick Start
2428

25-
### 1. Add PubSub publications with `returns` types
29+
### 1. Add PubSub publications with calculation transforms
30+
31+
The recommended way to get typed payloads is to use `transform :some_calc` on
32+
publications, pointing to a resource calculation with `:auto` typing. Ash
33+
automatically derives the `returns` type from the calculation expression, so
34+
AshTypescript gets the type information it needs without manual `returns`
35+
declarations.
2636

2737
```elixir
2838
defmodule MyApp.Post do
@@ -37,28 +47,47 @@ defmodule MyApp.Post do
3747
publish :create, [:id],
3848
event: "post_created",
3949
public?: true,
40-
returns: :map,
41-
constraints: [
42-
fields: [
43-
id: [type: :uuid, allow_nil?: false],
44-
title: [type: :string, allow_nil?: true]
45-
]
46-
],
47-
transform: fn notification ->
48-
%{id: notification.data.id, title: notification.data.title}
49-
end
50+
transform: :post_summary
5051

5152
publish :update, [:id],
5253
event: "post_updated",
5354
public?: true,
54-
returns: :string,
55-
transform: fn notification -> notification.data.title end
55+
transform: :post_title
56+
end
57+
58+
calculations do
59+
calculate :post_summary, :auto, expr(%{id: id, title: title}) do
60+
public? true
61+
end
62+
63+
calculate :post_title, :auto, expr(title) do
64+
public? true
65+
end
5666
end
5767

5868
# ... attributes, actions, etc.
5969
end
6070
```
6171

72+
You can also use explicit `returns` with an anonymous function transform, but
73+
this requires manually keeping the type and transform in sync:
74+
75+
```elixir
76+
publish :create, [:id],
77+
event: "post_created",
78+
public?: true,
79+
returns: :map,
80+
constraints: [
81+
fields: [
82+
id: [type: :uuid, allow_nil?: false],
83+
title: [type: :string, allow_nil?: true]
84+
]
85+
],
86+
transform: fn notification ->
87+
%{id: notification.data.id, title: notification.data.title}
88+
end
89+
```
90+
6291
### 2. Define your channel
6392

6493
A typed channel consists of two parts: a DSL module that declares which events get TypeScript types, and a Phoenix channel that handles runtime behavior. You can put them in the same module or keep them separate.
@@ -274,25 +303,25 @@ Wildcard topics require a `suffix` parameter that replaces the `*`. The factory
274303

275304
## Payload Type Resolution
276305

277-
The TypeScript payload type is derived from the publication's `returns` option:
306+
The TypeScript payload type is derived from the publication's `returns` type. When using `transform :some_calc`, Ash auto-populates `returns` from the calculation's type. You can also set `returns` explicitly.
278307

279-
| `returns` Value | TypeScript Type |
280-
|----------------|-----------------|
281-
| `:string` | `string` |
282-
| `:integer` | `number` |
283-
| `:boolean` | `boolean` |
284-
| `:uuid` | `UUID` |
285-
| `:utc_datetime` | `UtcDateTime` |
286-
| `:map` with `fields` | `{fieldName: type, ...}` |
287-
| Not set | `unknown` |
308+
| `returns` Value | TypeScript Type | How to Get It |
309+
|----------------|-----------------|---------------|
310+
| `:string` | `string` | `calculate :my_calc, :auto, expr(name)` or explicit `returns: :string` |
311+
| `:integer` | `number` | `calculate :my_calc, :auto, expr(priority)` or explicit `returns: :integer` |
312+
| `:boolean` | `boolean` | `calculate :my_calc, :auto, expr(active == true)` or explicit `returns: :boolean` |
313+
| `:uuid` | `UUID` | `calculate :my_calc, :auto, expr(id)` or explicit `returns: :uuid` |
314+
| `:utc_datetime` | `UtcDateTime` | Explicit `returns: :utc_datetime` |
315+
| `:map` with `fields` | `{fieldName: type, ...}` | `calculate :my_calc, :auto, expr(%{id: id, name: name})` or explicit `returns: :map` with `constraints` |
316+
| Not set | `unknown` | Missing `transform :calc` and no explicit `returns` |
288317

289318
Map types with `:fields` constraints generate plain object types without the `__type`/`__primitiveFields` metadata used by the RPC field-selection system.
290319

291320
### Multi-Channel Payload Deduplication
292321

293322
When multiple channels are configured, payload type aliases are deduplicated by name. If two channels both subscribe to `article_published` from the same resource, only one `ArticlePublishedPayload` type is emitted in `ash_types.ts`.
294323

295-
If two different resources declare publications with the same event name but different `returns` types and those resources appear in separate channels, codegen will raise an error:
324+
If two different resources declare publications with the same event name but different `returns` types (whether auto-derived or explicit) and those resources appear in separate channels, codegen will raise an error:
296325

297326
```
298327
Payload type name conflict detected across typed channels.
@@ -355,7 +384,7 @@ The DSL verifier checks your configuration at compile time:
355384
| Event exists | Error | Declared event doesn't match any publication on the resource |
356385
| Unique events | Error | Same event name used across multiple resources in one channel |
357386
| `public?: true` | Warning | Publication not marked as public |
358-
| `returns` set | Warning | Publication missing `returns` (payload type becomes `unknown`) |
387+
| `returns` set | Warning | Publication missing `returns` — no `transform :calc` or explicit `returns:` (payload type becomes `unknown`) |
359388

360389
## Configuration
361390

lib/ash_typescript/typed_channel.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,39 @@ defmodule AshTypescript.TypedChannel do
1111
generate typed TypeScript payload types and a subscription helper for each
1212
channel. The developer owns authorization (via `join/3`).
1313
14+
Publications should use `transform :some_calc` to reference a resource
15+
calculation. When the calculation uses `:auto` typing, Ash automatically
16+
derives the `returns` type from the expression, giving AshTypescript the
17+
type information it needs without manual `returns` declarations. You can
18+
also use explicit `returns:` with an anonymous function transform.
19+
1420
Register typed channels in application config:
1521
1622
config :ash_typescript,
1723
typed_channels: [MyApp.OrgAdminChannel]
1824
1925
## Usage
2026
27+
# Resource with calculation transforms (recommended)
28+
defmodule MyApp.Post do
29+
use Ash.Resource, notifiers: [Ash.Notifier.PubSub]
30+
31+
pub_sub do
32+
module MyApp.Endpoint
33+
prefix "posts"
34+
35+
publish :create, [:id], event: "post_created", public?: true, transform: :post_summary
36+
publish :update, [:id], event: "post_updated", public?: true, transform: :post_summary
37+
end
38+
39+
calculations do
40+
calculate :post_summary, :auto, expr(%{id: id, title: title}) do
41+
public? true
42+
end
43+
end
44+
end
45+
46+
# Channel definition
2147
defmodule MyApp.OrgAdminChannel do
2248
use AshTypescript.TypedChannel
2349

lib/ash_typescript/typed_channel/codegen.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ defmodule AshTypescript.TypedChannel.Codegen do
77
Generates TypeScript types and functions for typed channel event subscriptions.
88
99
For each declared event in a typed channel module, introspects the matching
10-
Ash PubSub publication's `returns` type and maps it to a TypeScript type.
10+
Ash PubSub publication's `returns` type (auto-derived via `transform :calc`
11+
or explicitly set) and maps it to a TypeScript type.
1112
1213
## Generated Output
1314

0 commit comments

Comments
 (0)