defmodule Mix.Tasks.AshDispatch.Gen do
@shortdoc "Generates missing AshDispatch templates, TypeScript types, and SDK"
@moduledoc """
Generates missing files for AshDispatch events based on DSL definitions.
This generator introspects all events and counters defined in resources using
`AshDispatch.Resource` and generates missing template files, TypeScript types,
and the complete SDK.
## Usage
mix ash_dispatch.gen # Generate all missing files
mix ash_dispatch.gen --check # Exit with error if files missing (for CI)
mix ash_dispatch.gen --dry-run # Show what would be generated
mix ash_dispatch.gen --verbose # Show detailed output
## Integration with mix ash.codegen
This task is automatically called when running `mix ash.codegen`:
mix ash.codegen # Runs all extension codegens
mix ash.codegen --check # CI mode - fail if files missing
mix ash.codegen --dry-run # Preview all changes
## What Gets Generated
### Email Templates
For each `:email` channel defined in your events:
| File | When Generated |
|------|----------------|
| `email.html.heex` | Always for email channels |
| `email.text.eex` | Always for email channels |
| `email.{variant}.html.heex` | When channel has `variant: :xxx` |
| `email.{variant}.text.eex` | When channel has `variant: :xxx` |
Templates are placed in `{event_module_dir}/templates/`.
### TypeScript SDK
Generates a complete SDK in `lib/ash-dispatch/` (next to your ash_typescript output):
| File | Description |
|------|-------------|
| `types.ts` | Counter types, defaults, metadata, and accessors |
| `events.ts` | Event ID types and metadata |
| `store.ts` | Zustand store for counter state |
| `channel.ts` | Phoenix channel utilities |
| `index.ts` | Re-exports all SDK modules |
| `hooks/use-channel.ts` | Channel connection hook |
| `hooks/use-counter.ts` | Single counter access hook |
| `hooks/use-notifications.ts` | Notification actions hook |
## Configuration
TypeScript SDK generation requires `ash_typescript` to be configured:
# TypeScript output path (required for SDK generation)
config :ash_typescript,
output_file: "apps/frontend/src/lib/ash_rpc.ts"
# SDK will be generated to: apps/frontend/src/lib/ash-dispatch/
Without this configuration, only templates and event modules will be generated.
You can also explicitly configure the SDK output path:
config :ash_dispatch,
sdk_output_path: "apps/frontend/src/lib/ash-dispatch"
See the [Code Generation guide](lib/documentation/topics/code-generation.md) for details.
"""
use Mix.Task
@requirements ["app.config"]
@impl Mix.Task
def run(args) do
# Guard: prevent running from ash_dispatch library itself
if Mix.Project.config()[:app] == :ash_dispatch do
Mix.shell().error("This task cannot be run from the ash_dispatch library itself.")
Mix.shell().info("Run this task from your consuming application instead.")
exit({:shutdown, 1})
end
Mix.Task.run("compile")
{opts, _, _} =
OptionParser.parse(args,
switches: [
check: :boolean,
dry_run: :boolean,
verbose: :boolean
],
aliases: [v: :verbose]
)
otp_app = Mix.Project.config()[:app]
sdk_enabled = typescript_sdk_enabled?(otp_app)
# Introspect all events, counters, resources, and entity change config
events = AshDispatch.Introspection.all_events(otp_app)
counters = discover_counters(otp_app)
resource_metas = discover_resource_metas(otp_app)
entity_change_resources = discover_entity_change_resources(otp_app)
# Warn if no events or counters found (common misconfiguration)
warn_if_no_dispatch_resources(otp_app, events, counters)
if opts[:verbose] do
Mix.shell().info("Found #{length(events)} events, #{length(counters)} counters, #{length(resource_metas)} resource metas, #{length(entity_change_resources)} entity change resources")
if sdk_enabled do
Mix.shell().info("TypeScript SDK output: #{sdk_base_path(otp_app)}")
else
Mix.shell().info("TypeScript SDK generation disabled (ash_typescript not configured)")
end
end
# Find all missing files (SDK checks return nil/[] when disabled)
missing = %{
templates: AshDispatch.Introspection.all_missing_templates(otp_app),
event_modules: AshDispatch.Introspection.missing_event_modules(otp_app),
typescript_events:
if(sdk_enabled, do: check_typescript_events_status(otp_app, events), else: nil),
typescript_types:
if(sdk_enabled, do: check_typescript_types_status(otp_app, counters), else: nil),
sdk_files: if(sdk_enabled, do: check_sdk_status(otp_app, resource_metas, entity_change_resources), else: [])
}
total_missing = count_missing(missing)
# Handle --check flag
if opts[:check] && total_missing > 0 do
raise Ash.Error.Framework.PendingCodegen,
diff: build_diff(missing),
explain: true
end
# Handle --dry-run flag
if opts[:dry_run] do
print_dry_run(missing, counters, sdk_enabled)
{:ok, []}
else
generated_count = 0
# Generate missing files
generated_count = generated_count + generate_templates(missing.templates)
generated_count = generated_count + generate_event_modules(missing.event_modules, otp_app)
generated_count = generated_count + generate_typescript_events(missing.typescript_events)
generated_count =
generated_count + generate_typescript_types(missing.typescript_types, counters)
generated_count = generated_count + generate_sdk_files(missing.sdk_files)
# Generate Gettext catalog for content: strings (if gettext_backend configured)
generated_count = generated_count + generate_gettext_catalog(events, otp_app)
# Ensure .prettierignore includes generated files
generated_count = generated_count + ensure_prettierignore(otp_app)
if generated_count > 0 do
Mix.shell().info("\nGenerated #{generated_count} file(s)")
# Check for required dependencies and warn if missing
check_frontend_dependencies(otp_app)
end
{:ok, []}
end
end
# ============================================================================
# Dependency Checking
# ============================================================================
defp check_frontend_dependencies(otp_app) do
sdk_base = sdk_base_path(otp_app)
if sdk_base do
frontend_root = find_frontend_root(sdk_base)
if frontend_root do
package_json_path = Path.join(frontend_root, "package.json")
if File.exists?(package_json_path) do
case File.read(package_json_path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, package} ->
deps = Map.get(package, "dependencies", %{})
dev_deps = Map.get(package, "devDependencies", %{})
all_deps = Map.merge(deps, dev_deps)
missing = []
missing =
if Map.has_key?(all_deps, "zustand") do
missing
else
["zustand" | missing]
end
missing =
if Map.has_key?(all_deps, "phoenix") do
missing
else
["phoenix" | missing]
end
if length(missing) > 0 do
missing_str = Enum.join(missing, " ")
Mix.shell().info([
"\n",
:yellow,
"⚠ Missing peer dependencies:",
:reset,
" #{missing_str}\n",
" Install with: ",
:cyan,
"npm install #{missing_str}",
:reset,
"\n"
])
end
_ ->
:ok
end
_ ->
:ok
end
end
end
end
end
# ============================================================================
# Warnings and Diagnostics
# ============================================================================
defp warn_if_no_dispatch_resources(otp_app, events, counters) do
# Only warn if we have no events AND no counters - likely misconfiguration
if length(events) == 0 and length(counters) == 0 do
configured_domains = get_domains(otp_app)
Mix.shell().info([
"\n",
:yellow,
"⚠ No AshDispatch events or counters found.",
:reset,
"\n\n",
" If you have resources using ",
:cyan,
"AshDispatch.Resource",
:reset,
", ensure their domains\n",
" are listed in your config:\n\n",
:faint,
" # config/config.exs\n",
" config :#{otp_app}, :ash_domains, [\n",
" # ... your existing domains ...\n",
" MyApp.Notifications, # If using AshDispatch notifications\n",
" MyApp.Deliveries, # If using AshDispatch delivery receipts\n",
" ]\n",
:reset,
"\n",
" Currently configured domains: ",
:cyan,
if(configured_domains == [], do: "(none)", else: inspect(configured_domains)),
:reset,
"\n"
])
end
end
# ============================================================================
# Counter Discovery
# ============================================================================
defp discover_counters(otp_app) do
domains = get_domains(otp_app)
domains
|> Enum.flat_map(&Ash.Domain.Info.resources/1)
|> Enum.filter(&uses_ash_dispatch?/1)
|> Enum.flat_map(&extract_counters/1)
end
defp get_domains(otp_app) do
# Get domains from app config
app_domains = Application.get_env(otp_app, :ash_domains, [])
# Also check ash_dispatch domains config
dispatch_domains = Application.get_env(:ash_dispatch, :domains, [])
(app_domains ++ dispatch_domains) |> Enum.uniq()
end
defp uses_ash_dispatch?(resource) do
AshDispatch.Resource in Spark.extensions(resource)
end
defp extract_counters(resource) do
resource_name =
resource
|> Module.split()
|> List.last()
|> Macro.underscore()
case Spark.Dsl.Extension.get_entities(resource, [:counters]) do
[] ->
[]
counters ->
Enum.map(counters, fn counter ->
%{
name: counter.counter_name || counter.name,
source: resource_name,
group: counter.group || :ungrouped,
audience: counter.audience,
global?: Map.get(counter, :global?, false),
invalidates: counter.invalidates || []
}
end)
end
rescue
_ -> []
end
defp discover_resource_metas(otp_app) do
domains = get_domains(otp_app)
domains
|> Enum.flat_map(&Ash.Domain.Info.resources/1)
|> Enum.filter(&uses_ash_dispatch?/1)
|> Enum.flat_map(&extract_resource_meta/1)
|> Enum.sort_by(& &1.key)
end
defp extract_resource_meta(resource) do
meta = AshDispatch.Resource.Info.resource_meta(resource)
entity_changes = AshDispatch.Resource.Info.entity_changes(resource)
# Only include resources that have resource_meta or entity_changes enabled
if meta || (entity_changes && entity_changes.enabled) do
resource_name =
resource
|> Module.split()
|> List.last()
# Auto-derive label from resource name
label = (meta && meta.label) || resource_name
# Auto-derive plural from postgres table
plural = (meta && meta.plural) || derive_plural(resource)
# Auto-derive nav_path from plural
nav_path = (meta && meta.nav_path) || "/#{plural}"
# Auto-derive key (lowercase resource name)
key = String.downcase(resource_name)
# Introspect state machine states if AshStateMachine is used
states = introspect_states(resource)
[%{
key: key,
label: label,
plural: plural,
nav_path: nav_path,
states: states,
resource: resource,
color_theme: meta && meta.color_theme,
icon: meta && meta.icon,
discovery_mode: meta && meta.discovery_mode,
feature_key: meta && meta.feature_key,
order: meta && meta.order
}]
else
[]
end
rescue
_ -> []
end
defp derive_plural(resource) do
if Code.ensure_loaded?(resource) do
try do
table = AshPostgres.DataLayer.Info.table(resource)
if table, do: to_string(table), else: derive_plural_from_name(resource)
rescue
_ -> derive_plural_from_name(resource)
end
else
derive_plural_from_name(resource)
end
end
defp derive_plural_from_name(resource) do
resource
|> Module.split()
|> List.last()
|> Macro.underscore()
|> Kernel.<>("s")
end
defp introspect_states(resource) do
if AshStateMachine in Spark.extensions(resource) do
try do
initial = AshStateMachine.Info.state_machine_initial_states!(resource)
all_states = AshStateMachine.Info.state_machine_all_states(resource)
if all_states && all_states != [], do: Enum.map(all_states, &to_string/1), else: Enum.map(initial, &to_string/1)
rescue
_ -> []
end
else
[]
end
end
defp discover_entity_change_resources(otp_app) do
domains = get_domains(otp_app)
domains
|> Enum.flat_map(&Ash.Domain.Info.resources/1)
|> Enum.filter(&uses_ash_dispatch?/1)
|> Enum.flat_map(fn resource ->
case AshDispatch.Resource.Info.entity_changes(resource) do
%AshDispatch.Resource.Dsl.EntityChanges{enabled: true} = config ->
resource_name =
resource
|> Module.split()
|> List.last()
key = String.downcase(resource_name)
# Determine label fields — check which actually exist as attributes or calculations
# (AshCloak creates calculations for encrypted fields)
label_fields =
(config.label_fields || [:title, :name])
|> Enum.filter(fn field ->
try do
Ash.Resource.Info.attribute(resource, field) != nil ||
Ash.Resource.Info.calculation(resource, field) != nil
rescue
_ -> false
end
end)
# Auto-detect status field from AshStateMachine
status_field = config.status_field || detect_state_attribute(resource)
# Determine summary fields — label fields + status + id
summary_fields = [:id] ++ label_fields ++ (if status_field, do: [status_field], else: [])
[%{
key: key,
resource: resource,
trigger_on: config.trigger_on,
label_fields: label_fields,
status_field: status_field,
summary_fields: Enum.uniq(summary_fields)
}]
_ ->
[]
end
end)
|> Enum.sort_by(& &1.key)
end
defp detect_state_attribute(resource) do
if AshStateMachine in Spark.extensions(resource) do
try do
AshStateMachine.Info.state_machine_state_attribute!(resource)
rescue
_ -> nil
end
else
# Check for common status/state attributes
cond do
Ash.Resource.Info.attribute(resource, :status) -> :status
Ash.Resource.Info.attribute(resource, :state) -> :state
true -> nil
end
end
rescue
_ -> nil
end
# ============================================================================
# Missing File Detection
# ============================================================================
defp check_typescript_events_status(otp_app, events) do
output_path = sdk_path(otp_app, "events.ts")
if output_path && length(events) > 0 do
expected_content = generate_events_typescript_content(events) |> ensure_trailing_newline()
file_exists = File.exists?(output_path)
current_content = if file_exists, do: File.read!(output_path), else: ""
# Compare without timestamp and trailing whitespace (handles formatter differences)
expected_normalized = normalize_for_comparison(expected_content)
current_normalized = normalize_for_comparison(current_content)
if expected_normalized != current_normalized do
%{
path: output_path,
content: expected_content,
event_count: length(events),
is_new: !file_exists
}
else
nil
end
else
nil
end
end
defp check_typescript_types_status(otp_app, counters) do
output_path = sdk_path(otp_app, "types.ts")
if output_path && length(counters) > 0 do
expected_content = generate_types_typescript_content(counters) |> ensure_trailing_newline()
file_exists = File.exists?(output_path)
current_content = if file_exists, do: File.read!(output_path), else: ""
# Compare without timestamp and trailing whitespace (handles formatter differences)
expected_normalized = normalize_for_comparison(expected_content)
current_normalized = normalize_for_comparison(current_content)
if expected_normalized != current_normalized do
%{
path: output_path,
content: expected_content,
counter_count: length(Enum.uniq_by(counters, & &1.name)),
is_new: !file_exists
}
else
nil
end
else
nil
end
end
defp check_sdk_status(otp_app, resource_metas, entity_change_resources) do
base_path = sdk_base_path(otp_app)
if base_path do
sdk_files = [
{"store.ts", &generate_store_content/0},
{"channel.ts", &generate_channel_content/0},
{"socket-provider.tsx", fn -> generate_socket_provider_content(entity_change_resources) end},
{"index.ts", fn -> generate_index_content(resource_metas, entity_change_resources) end},
{"hooks/use-channel.ts", &generate_use_channel_content/0},
{"hooks/use-user-channel.ts", &generate_use_user_channel_content/0},
{"hooks/use-counter.ts", &generate_use_counter_content/0},
{"hooks/use-notifications.ts", &generate_use_notifications_content/0},
{"notification-provider.tsx", &generate_notification_provider_content/0},
{"notification-bell.tsx", &generate_notification_bell_content/0},
{"README.md", &generate_readme_content/0}
] ++
if(entity_change_resources != [], do: [
{"entity-store.ts", fn -> generate_entity_store_content(entity_change_resources) end},
{"hooks/use-entity.ts", &generate_use_entity_content/0}
], else: []) ++
if(resource_metas != [], do: [
{"resources.ts", fn -> generate_resources_content(resource_metas) end}
], else: [])
# Generate all SDK files and check for changes (like events.ts/types.ts)
sdk_files
|> Enum.map(fn {filename, generator} ->
path = Path.join(base_path, filename)
expected_content = generator.() |> ensure_trailing_newline()
file_exists = File.exists?(path)
current_content = if file_exists, do: File.read!(path), else: ""
# Compare without timestamp (handles regeneration without actual changes)
expected_normalized = normalize_for_comparison(expected_content)
current_normalized = normalize_for_comparison(current_content)
needs_write = expected_normalized != current_normalized
%{
path: path,
content: expected_content,
is_new: !file_exists,
needs_write: needs_write
}
end)
|> Enum.filter(& &1.needs_write)
else
[]
end
end
defp remove_timestamp(content) do
Regex.replace(~r/\/\/ Generated at: .*\n/, content, "")
end
# Normalize content for comparison - strips timestamp and trailing whitespace
defp normalize_for_comparison(content) do
content
|> remove_timestamp()
|> String.trim_trailing()
end
# Ensure content ends with exactly one newline (prettier standard)
defp ensure_trailing_newline(content) do
String.trim_trailing(content) <> "\n"
end
defp count_missing(missing) do
length(missing.templates) +
length(missing.event_modules) +
if(missing.typescript_events, do: 1, else: 0) +
if(missing.typescript_types, do: 1, else: 0) +
length(missing.sdk_files)
end
defp build_diff(missing) do
template_diff =
Map.new(missing.templates, fn t ->
{t.path, generate_template_content(t)}
end)
module_diff =
Map.new(missing.event_modules, fn m ->
{m.module_path, generate_event_module_content(m)}
end)
events_diff =
if missing.typescript_events do
%{missing.typescript_events.path => missing.typescript_events.content}
else
%{}
end
types_diff =
if missing.typescript_types do
%{missing.typescript_types.path => missing.typescript_types.content}
else
%{}
end
sdk_diff = Map.new(missing.sdk_files, fn f -> {f.path, f.content} end)
template_diff
|> Map.merge(module_diff)
|> Map.merge(events_diff)
|> Map.merge(types_diff)
|> Map.merge(sdk_diff)
end
# ============================================================================
# Dry Run Output
# ============================================================================
defp print_dry_run(missing, counters, sdk_enabled) do
if length(missing.templates) > 0 do
Mix.shell().info("\n#{IO.ANSI.cyan()}Templates to generate:#{IO.ANSI.reset()}")
Enum.each(missing.templates, fn t ->
Mix.shell().info(" #{t.path}")
end)
end
if length(missing.event_modules) > 0 do
Mix.shell().info("\n#{IO.ANSI.cyan()}Event modules to generate:#{IO.ANSI.reset()}")
Enum.each(missing.event_modules, fn m ->
Mix.shell().info(" #{m.module_path}")
end)
end
if missing.typescript_events do
Mix.shell().info("\n#{IO.ANSI.cyan()}TypeScript event types to generate:#{IO.ANSI.reset()}")
Mix.shell().info(" #{missing.typescript_events.path}")
end
if missing.typescript_types do
Mix.shell().info(
"\n#{IO.ANSI.cyan()}TypeScript counter types to generate:#{IO.ANSI.reset()}"
)
Mix.shell().info(" #{missing.typescript_types.path} (#{length(counters)} counters)")
end
if length(missing.sdk_files) > 0 do
Mix.shell().info("\n#{IO.ANSI.cyan()}SDK files to generate:#{IO.ANSI.reset()}")
Enum.each(missing.sdk_files, fn f ->
Mix.shell().info(" #{f.path}")
end)
end
unless sdk_enabled do
Mix.shell().info(
"\n#{IO.ANSI.yellow()}TypeScript SDK skipped#{IO.ANSI.reset()} (ash_typescript not configured)"
)
end
total = count_missing(missing)
if total == 0 do
Mix.shell().info("\nAll files are up to date.")
else
Mix.shell().info("\n#{total} file(s) would be generated.")
end
end
# ============================================================================
# Template Generation
# ============================================================================
defp generate_templates(templates) do
Enum.each(templates, fn template ->
dir = Path.dirname(template.path)
File.mkdir_p!(dir)
content = generate_template_content(template)
File.write!(template.path, content)
Mix.shell().info([:green, "* creating ", :reset, template.path])
end)
length(templates)
end
defp generate_template_content(template) do
case {template.transport, template.format} do
{:email, :html} -> generate_email_html_stub(template)
{:email, :text} -> generate_email_text_stub(template)
{:sms, :text} -> generate_sms_text_stub(template)
_ -> "<% # TODO: Implement template for #{template.transport} %>\n"
end
end
# Format locale info for template comments
defp locale_info(template) do
case template[:locale] do
nil -> ""
locale -> " (locale: #{locale})"
end
end
defp generate_email_html_stub(template) do
variant_info = if template.variant, do: " (variant: #{template.variant})", else: ""
locale_info = locale_info(template)
"""
Hi<%= if assigns[:display_name], do: " \#{@display_name}", else: "" %>,
TODO: Add your email content here.
"""
end
defp generate_email_text_stub(template) do
variant_info = if template.variant, do: " (variant: #{template.variant})", else: ""
locale_info = locale_info(template)
"""
<% # Template for: #{template.event_id} %>
<% # Transport: email, Format: text#{variant_info}#{locale_info} %>
Hej<%= if assigns[:display_name], do: " \#{@display_name}", else: "" %>!
TODO: Add your plain text email content here.
TODO: Add your plain text link here.
"""
end
defp generate_sms_text_stub(template) do
"""
<% # Template for: #{template.event_id} %>
<% # Transport: sms %>
TODO: Add your SMS content here (keep it short!)
"""
end
# ============================================================================
# Event Module Generation
# ============================================================================
defp generate_event_modules(modules, _otp_app) do
Enum.each(modules, fn module_info ->
dir = Path.dirname(module_info.module_path)
File.mkdir_p!(dir)
content = generate_event_module_content(module_info)
File.write!(module_info.module_path, content)
Mix.shell().info([:green, "* creating ", :reset, module_info.module_path])
end)
length(modules)
end
defp generate_event_module_content(module_info) do
event_info = module_info.event_info
module_name = inspect(module_info.module_name)
data_key = event_info[:data_key] || event_info[:name] || :record
"""
defmodule #{module_name} do
@moduledoc \"\"\"
Event module for #{event_info.event_id}.
Generated by `mix ash_dispatch.gen` - customize callbacks as needed.
\"\"\"
use AshDispatch.Event
# Override callbacks to customize behavior:
# @impl true
# def prepare_template_assigns(context, channel) do
# # Add custom template variables
# %{
# id: context.data.#{data_key}.id,
# # Add more assigns as needed
# }
# end
# @impl true
# def recipients(context, %{audience: :admin}) do
# # Custom admin recipients
# [%{email: "admin@example.com", name: "Admin"}]
# end
# def recipients(context, channel) do
# # Default recipient resolution
# super(context, channel)
# end
# @impl true
# def should_send?(context, channel) do
# # Add custom gating logic
# true
# end
end
"""
end
# ============================================================================
# TypeScript Events Generation
# ============================================================================
defp generate_typescript_events(nil), do: 0
defp generate_typescript_events(events_info) do
dir = Path.dirname(events_info.path)
File.mkdir_p!(dir)
File.write!(events_info.path, events_info.content)
action = if events_info.is_new, do: "creating ", else: "updating "
detail = "(#{events_info.event_count} events)"
Mix.shell().info([
:green,
"* #{action}",
:reset,
events_info.path,
" ",
:faint,
detail,
:reset
])
1
end
defp generate_events_typescript_content(events) do
event_ids =
events
|> Enum.map(& &1.event_id)
|> Enum.sort()
event_id_union =
if Enum.empty?(event_ids) do
"never"
else
event_ids
|> Enum.map(&~s("#{&1}"))
|> Enum.join("\n | ")
end
event_metadata =
events
|> Enum.sort_by(& &1.event_id)
|> Enum.map(&format_event_metadata/1)
|> Enum.join(",\n")
"""
// ⚠️ AUTO-GENERATED by mix ash_dispatch.gen — DO NOT EDIT
// Source: projects/ash_dispatch/lib/mix/tasks/ash_dispatch.gen.ex
// To modify, change the generator and re-run: mix ash_dispatch.gen
// Do not edit manually
// Generated at: #{DateTime.utc_now() |> DateTime.to_iso8601()}
/**
* All dispatch event IDs in the application.
*/
export type EventId =
| #{event_id_union};
/**
* Event metadata for each event type.
*/
export const EVENT_METADATA = {
#{event_metadata}
} as const;
/**
* Check if a string is a valid event ID.
*/
export function isValidEventId(id: string): id is EventId {
return id in EVENT_METADATA;
}
export type Transport = "email" | "in_app" | "sms" | "webhook" | "discord" | "slack";
export type Audience = "user" | "admin" | "system";
export type EventChannel = {
transport: Transport;
audience: Audience;
variant?: string;
};
"""
end
defp format_event_metadata(event) do
channels_json =
event.channels
|> Enum.map(fn ch ->
variant_str = if ch[:variant], do: ~s(, variant: "#{ch[:variant]}"), else: ""
transport = ch[:transport] || "unknown"
audience = ch[:audience] || "unknown"
~s({ transport: "#{transport}", audience: "#{audience}"#{variant_str} })
end)
|> Enum.join(", ")
domain = event.domain || "unknown"
# Optional fields — only included when non-empty
invalidates = event[:invalidates] || []
metadata = normalize_metadata(event[:metadata])
extra_fields = []
extra_fields =
if metadata != %{} do
metadata_json = format_ts_object(metadata)
extra_fields ++ [~s( metadata: #{metadata_json})]
else
extra_fields
end
extra_fields =
if invalidates != [] do
inv_json = invalidates |> Enum.map(&~s("#{&1}")) |> Enum.join(", ")
extra_fields ++ [~s( invalidates: [#{inv_json}])]
else
extra_fields
end
trailing =
case extra_fields do
[] -> ""
fields -> ",\n" <> Enum.join(fields, ",\n")
end
~s( "#{event.event_id}": {\n domain: "#{domain}",\n channels: [#{channels_json}]#{trailing},\n })
end
defp normalize_metadata(nil), do: %{}
defp normalize_metadata(metadata) when is_list(metadata), do: Map.new(metadata)
defp normalize_metadata(metadata) when is_map(metadata), do: metadata
defp format_ts_object(map) do
fields =
map
|> Enum.sort_by(fn {k, _v} -> to_string(k) end)
|> Enum.map(fn {key, value} ->
ts_key = to_string(key) |> String.replace("-", "_")
ts_val = format_ts_value(value)
~s(#{ts_key}: #{ts_val})
end)
|> Enum.join(", ")
"{ #{fields} }"
end
defp format_ts_value(v) when is_boolean(v), do: to_string(v)
defp format_ts_value(v) when is_atom(v), do: ~s("#{v}")
defp format_ts_value(v) when is_integer(v), do: to_string(v)
defp format_ts_value(v) when is_float(v), do: to_string(v)
defp format_ts_value(v) when is_binary(v), do: ~s("#{v}")
defp format_ts_value(v), do: ~s("#{inspect(v)}")
# ============================================================================
# TypeScript Types (Counters) Generation
# ============================================================================
defp generate_typescript_types(nil, _counters), do: 0
defp generate_typescript_types(types_info, _counters) do
dir = Path.dirname(types_info.path)
File.mkdir_p!(dir)
File.write!(types_info.path, types_info.content)
action = if types_info.is_new, do: "creating ", else: "updating "
detail = "(#{types_info.counter_count} counters)"
Mix.shell().info([
:green,
"* #{action}",
:reset,
types_info.path,
" ",
:faint,
detail,
:reset
])
1
end
defp generate_types_typescript_content(counters) do
# Sort by name for deterministic output
unique = counters |> Enum.uniq_by(& &1.name) |> Enum.sort_by(& &1.name)
grouped = Enum.group_by(unique, & &1.group)
merged_metadata = merge_counter_metadata(counters)
by_source = Enum.group_by(counters, & &1.source)
header = """
// ⚠️ AUTO-GENERATED by mix ash_dispatch.gen — DO NOT EDIT
// Source: projects/ash_dispatch/lib/mix/tasks/ash_dispatch.gen.ex
// To modify, change the generator and re-run: mix ash_dispatch.gen
// Do not edit manually
// Generated at: #{DateTime.utc_now() |> DateTime.to_iso8601()}
"""
# Generate type definitions for each group
type_defs =
grouped
|> Enum.sort_by(fn {group, _} -> to_string(group) end)
|> Enum.map(fn {group, group_counters} ->
type_name = group_type_name(group)
fields =
Enum.map_join(group_counters, "\n", fn c ->
" #{c.name}: number;"
end)
"""
export type #{type_name} = {
#{fields}
};
"""
end)
|> Enum.join("\n")
# Generate combined type
all_types =
grouped
|> Map.keys()
|> Enum.sort_by(&to_string/1)
|> Enum.map(&group_type_name/1)
|> Enum.join(" & ")
combined_type = """
export type AllCounters = #{all_types};
"""
# Generate default counters
default_counter_fields =
unique
|> Enum.map(fn c -> " #{c.name}: 0," end)
|> Enum.join("\n")
default_counters = """
/**
* Default counter values (all initialized to 0).
* Use in your store initialization.
*/
export const DEFAULT_COUNTERS: AllCounters = {
#{default_counter_fields}
};
"""
# Generate counter names constant grouped by source
counter_const =
by_source
|> Enum.sort_by(fn {source, _} -> source end)
|> Enum.map(fn {source, source_counters} ->
# Deduplicate counters by name within each source
unique_source_counters = Enum.uniq_by(source_counters, & &1.name)
fields =
Enum.map_join(unique_source_counters, "\n", fn c ->
" #{c.name}: \"#{c.name}\","
end)
" #{source}: {\n#{fields}\n },"
end)
|> Enum.join("\n")
counters_object = """
export const COUNTERS = {
#{counter_const}
} as const;
"""
# Generate counter metadata
metadata_entries =
merged_metadata
|> Enum.map(fn c ->
invalidates_str = c.invalidates |> Enum.map(fn i -> "\"#{i}\"" end) |> Enum.join(", ")
sources_str = c.sources |> Enum.map(fn s -> "\"#{s}\"" end) |> Enum.join(", ")
" #{c.name}: {\n audience: \"#{c.audience}\",\n invalidates: [#{invalidates_str}],\n sources: [#{sources_str}],\n },"
end)
|> Enum.join("\n")
metadata = """
export const COUNTER_METADATA = {
#{metadata_entries}
} as const;
"""
# Generate CounterName type and accessor helpers
counter_names =
unique
|> Enum.map(fn c -> "\"#{c.name}\"" end)
|> Enum.join(" | ")
camel_case_fields =
unique
|> Enum.map(fn c ->
camel = snake_to_camel(to_string(c.name))
{camel, c.name}
end)
accessor_type_fields =
camel_case_fields
|> Enum.map(fn {camel, _snake} -> " #{camel}: number;" end)
|> Enum.join("\n")
accessor_impl =
camel_case_fields
|> Enum.map(fn {camel, snake} -> " #{camel}: counters.#{snake}," end)
|> Enum.join("\n")
accessor_helper = """
export type CounterName = #{counter_names};
export function isValidCounter(name: string): name is CounterName {
return name in COUNTER_METADATA;
}
/**
* Type for camelCase counter accessors.
* Use with getCounterAccessors() or in your useCounters hook.
*/
export type CounterAccessors = {
#{accessor_type_fields}
};
/**
* Convert snake_case counters to camelCase accessors.
* Auto-generated from counter definitions.
*
* @example
* ```tsx
* // In your useCounters hook:
* export function useCounters() {
* const counters = useCounterStore((state) => state.counters)
* return {
* ...getCounterAccessors(counters),
* counters,
* }
* }
* ```
*/
export function getCounterAccessors(counters: AllCounters): CounterAccessors {
return {
#{accessor_impl}
};
}
"""
header <>
type_defs <>
"\n" <>
combined_type <>
"\n" <>
default_counters <> "\n" <> counters_object <> "\n" <> metadata <> "\n" <> accessor_helper
end
defp merge_counter_metadata(counters) do
counters
|> Enum.group_by(& &1.name)
|> Enum.map(fn {name, instances} ->
sources = instances |> Enum.map(& &1.source) |> Enum.uniq() |> Enum.sort()
invalidates =
instances
|> Enum.flat_map(& &1.invalidates)
|> Enum.uniq()
|> Enum.sort()
audiences = instances |> Enum.map(& &1.audience) |> Enum.uniq()
audience = List.first(audiences)
group = List.first(instances).group
%{
name: name,
group: group,
audience: audience,
invalidates: invalidates,
sources: sources
}
end)
# Sort by name for deterministic output
|> Enum.sort_by(& &1.name)
end
defp snake_to_camel(string) do
[first | rest] = String.split(string, "_")
first <> Enum.map_join(rest, "", &String.capitalize/1)
end
defp group_type_name(:ungrouped), do: "UngroupedCounters"
defp group_type_name(group) do
group
|> to_string()
|> Macro.camelize()
|> Kernel.<>("Counters")
end
# ============================================================================
# SDK Files Generation
# ============================================================================
defp generate_sdk_files(sdk_files) do
Enum.each(sdk_files, fn file_info ->
dir = Path.dirname(file_info.path)
File.mkdir_p!(dir)
File.write!(file_info.path, file_info.content)
action = if file_info.is_new, do: "creating ", else: "updating "
Mix.shell().info([:green, "* #{action}", :reset, file_info.path])
end)
length(sdk_files)
end
# ============================================================================
# Prettier Ignore
# ============================================================================
@prettierignore_header "# Auto-generated by AshDispatch - do not edit this section"
@prettierignore_footer "# End AshDispatch section"
defp ensure_prettierignore(otp_app) do
case find_prettierignore_path(otp_app) do
nil ->
0
{prettierignore_path, paths_to_ignore} ->
if update_prettierignore(prettierignore_path, paths_to_ignore) do
action = if File.exists?(prettierignore_path), do: "updating ", else: "creating "
Mix.shell().info([:green, "* #{action}", :reset, prettierignore_path])
1
else
0
end
end
end
defp find_prettierignore_path(otp_app) do
sdk_base = sdk_base_path(otp_app)
ash_ts_output = Application.get_env(:ash_typescript, :output_file)
if sdk_base do
# Find the frontend project root (where package.json lives)
frontend_root = find_frontend_root(sdk_base)
if frontend_root do
prettierignore_path = Path.join(frontend_root, ".prettierignore")
# Calculate relative paths from frontend root
sdk_relative = Path.relative_to(sdk_base, frontend_root)
paths = [sdk_relative <> "/"]
# Also add ash_rpc.ts if it exists
paths =
if ash_ts_output do
rpc_relative = Path.relative_to(ash_ts_output, frontend_root)
paths ++ [rpc_relative]
else
paths
end
{prettierignore_path, paths}
else
nil
end
else
nil
end
end
defp find_frontend_root(path) do
# Walk up the directory tree looking for package.json
cond do
File.exists?(Path.join(path, "package.json")) ->
path
path == "/" or path == "." ->
nil
true ->
find_frontend_root(Path.dirname(path))
end
end
defp update_prettierignore(path, paths_to_ignore) do
existing_content = if File.exists?(path), do: File.read!(path), else: ""
# Check if all paths are already in the file
all_present =
Enum.all?(paths_to_ignore, fn p ->
String.contains?(existing_content, p)
end)
if all_present do
false
else
# Generate the AshDispatch section
section_content =
[@prettierignore_header] ++
Enum.map(paths_to_ignore, &"#{&1}") ++
[@prettierignore_footer]
new_section = Enum.join(section_content, "\n")
# Check if we already have an AshDispatch section
new_content =
if String.contains?(existing_content, @prettierignore_header) do
# Replace existing section
Regex.replace(
~r/#{Regex.escape(@prettierignore_header)}.*?#{Regex.escape(@prettierignore_footer)}/s,
existing_content,
new_section
)
else
# Append new section
if existing_content == "" do
new_section <> "\n"
else
String.trim_trailing(existing_content) <> "\n\n" <> new_section <> "\n"
end
end
File.write!(path, new_content)
true
end
end
# ============================================================================
# Path Helpers
# ============================================================================
# Default output path matching ash_typescript's installer default
@default_ash_typescript_output "assets/js/ash_rpc.ts"
defp typescript_sdk_enabled?(otp_app) do
# SDK generation requires either:
# 1. Explicit sdk_output_path in ash_dispatch config
# 2. Valid ash_typescript :output_file configuration
# 3. Any domain using AshTypescript.Rpc (uses default path)
explicit_path =
Application.get_env(:ash_dispatch, :sdk_output_path) ||
Application.get_env(otp_app, :ash_dispatch)[:sdk_output_path]
ash_ts_output = Application.get_env(:ash_typescript, :output_file)
cond do
explicit_path != nil -> true
ash_ts_output != nil and is_binary(ash_ts_output) and ash_ts_output != "" -> true
ash_typescript_in_use?(otp_app) -> true
true -> false
end
end
defp sdk_base_path(otp_app) do
explicit_path =
Application.get_env(:ash_dispatch, :sdk_output_path) ||
Application.get_env(otp_app, :ash_dispatch)[:sdk_output_path]
if explicit_path do
explicit_path
else
# Use configured output_file, or default if ash_typescript is in use
ash_ts_output =
Application.get_env(:ash_typescript, :output_file) ||
if(ash_typescript_in_use?(otp_app), do: @default_ash_typescript_output)
if ash_ts_output do
base_dir = Path.dirname(ash_ts_output)
Path.join(base_dir, "ash-dispatch")
else
nil
end
end
end
defp ash_typescript_in_use?(otp_app) do
# Check if any domain uses the AshTypescript.Rpc extension
if Code.ensure_loaded?(AshTypescript.Rpc) do
domains = Application.get_env(otp_app, :ash_domains, [])
Enum.any?(domains, fn domain ->
Code.ensure_loaded?(domain) and
function_exported?(domain, :spark_dsl_config, 0) and
has_ash_typescript_extension?(domain)
end)
else
false
end
end
defp has_ash_typescript_extension?(domain) do
try do
extensions = Spark.extensions(domain)
AshTypescript.Rpc in extensions
rescue
_ -> false
end
end
defp sdk_path(otp_app, filename) do
base = sdk_base_path(otp_app)
if base, do: Path.join(base, filename), else: nil
end
defp channel_topic do
Application.get_env(:ash_dispatch, :channel_topic, "user")
end
# ============================================================================
# SDK Content Generators
# ============================================================================
defp generate_store_content do
"""
// ⚠️ AUTO-GENERATED by mix ash_dispatch.gen — DO NOT EDIT
// Source: projects/ash_dispatch/lib/mix/tasks/ash_dispatch.gen.ex
// To modify, change the generator and re-run: mix ash_dispatch.gen
// Zustand store for counter state
import { create } from 'zustand'
import { DEFAULT_COUNTERS, type AllCounters, type CounterName } from './types'
export interface CounterState {
counters: AllCounters
setCounters: (counters: Partial) => void
setCounter: (key: CounterName, value: number) => void
incrementCounter: (key: CounterName, delta?: number) => void
resetCounters: () => void
}
export const useCounterStore = create()((set) => ({
counters: DEFAULT_COUNTERS,
setCounters: (newCounters: Partial) => {
set((state: CounterState) => ({
counters: { ...state.counters, ...newCounters },
}))
},
setCounter: (key: CounterName, value: number) => {
set((state: CounterState) => ({
counters: { ...state.counters, [key]: value },
}))
},
incrementCounter: (key: CounterName, delta: number = 1) => {
set((state: CounterState) => ({
counters: { ...state.counters, [key]: Math.max(0, state.counters[key] + delta) },
}))
},
resetCounters: () => {
set({ counters: DEFAULT_COUNTERS })
},
}))
"""
end
defp generate_channel_content do
"""
// ⚠️ AUTO-GENERATED by mix ash_dispatch.gen — DO NOT EDIT
// Source: projects/ash_dispatch/lib/mix/tasks/ash_dispatch.gen.ex
// To modify, change the generator and re-run: mix ash_dispatch.gen
// Phoenix channel utilities
import { Socket, Channel } from 'phoenix'
export interface ChannelConfig {
socketUrl: string
userToken: string
userId: string
}
export function createUserChannel(config: ChannelConfig): Channel {
const socket = new Socket(config.socketUrl, {
params: { token: config.userToken }
})
socket.connect()
const channel = socket.channel(`#{channel_topic()}:${config.userId}`, {})
return channel
}
"""
end
defp generate_socket_provider_content(entity_change_resources) do
entity_store_import = if entity_change_resources != [] do
"\nimport { useEntityStore } from './entity-store'"
else
""
end
entity_change_handler = if entity_change_resources != [] do
"""
// Register built-in event: entity_change → entity store
// Widens callback till `unknown` så strict-mode TS-consumers inte
// failar på contravarians-mismatch.
channel.on('entity_change', (rawPayload: unknown) => {
const payload = rawPayload as { resource: string; action: string; data: Record }
if (!mountedRef.current) return
useEntityStore.getState().handleChange(payload.resource, payload.action, payload.data)
// Also fan out to any listeners
const handlers = listenersRef.current.get('entity_change')
if (handlers) handlers.forEach((h) => h(payload))
})
registeredEventsRef.current.add('entity_change')
"""
else
""
end
"""
// ⚠️ AUTO-GENERATED by mix ash_dispatch.gen — DO NOT EDIT
// Source: projects/ash_dispatch/lib/mix/tasks/ash_dispatch.gen.ex
// To modify, change the generator and re-run: mix ash_dispatch.gen
//
// Shared Phoenix socket provider — one socket, one channel, pub/sub fan-out
"use client"
import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo, type ReactNode } from 'react'
import { Socket, Channel } from 'phoenix'
import { useCounterStore, type CounterState } from './store'
import { isValidCounter, type CounterName } from './types'#{entity_store_import}
// ============================================================================
// Types
// ============================================================================
export interface SocketContextValue {
/** Subscribe to a channel event. Returns an unsubscribe function. */
on: (event: E, handler: (payload: any) => void) => () => void
/** Push an event to the channel. */
push: (event: string, payload: Record) => void
/** Whether the socket is connected and channel has joined. */
isConnected: boolean
}
export interface SocketProviderProps {
/** User ID for the channel topic. When null, no connection is made. */
userId: string | null
/** Async function that returns the socket token string. Called on connect. */
getToken?: () => Promise
children: ReactNode
}
// ============================================================================
// Context
// ============================================================================
const SocketContext = createContext(null)
/**
* Access the shared socket context.
* Returns null if no SocketProvider ancestor exists (for backwards compatibility).
*/
export function useSocketContext(): SocketContextValue | null {
return useContext(SocketContext)
}
// ============================================================================
// Provider
// ============================================================================
/**
* Shared Phoenix socket provider.
*
* Creates one socket connection and one channel join to `#{channel_topic()}:{userId}`.
* All consumers subscribe to events via `on()` with automatic fan-out.
*
* @example
* ```tsx
*
*
*
*
*
* ```
*/
export function SocketProvider({ userId, getToken, children }: SocketProviderProps) {
const [isConnected, setIsConnected] = useState(false)
const socketRef = useRef(null)
const channelRef = useRef(null)
const mountedRef = useRef(true)
const listenersRef = useRef