Counter Broadcasting
View SourceAshDispatch provides automatic real-time counter updates through a declarative DSL. Define counters once in your resources, and AshDispatch automatically broadcasts updates to Phoenix Channels when actions complete.
Why Counter Broadcasting?
Traditional approaches require manual counter management scattered across your codebase:
# ❌ Manual approach - error-prone and scattered
def create_order(params) do
{:ok, order} = Orders.create(params)
# Manually update counter
count = Orders.count_pending(order.user_id)
Phoenix.PubSub.broadcast("user:#{order.user_id}", {:counter, :pending_orders, count})
{:ok, order}
endWith AshDispatch, counters are declarative and automatic:
# ✅ AshDispatch approach - declarative and automatic
counters do
counter :pending_orders,
trigger_on: [:create],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id], # Scope to user's orders
invalidates: ["orders"]
endBenefits:
- ✅ Define once, works everywhere
- ✅ Automatic broadcasting on action completion
- ✅ Type-safe with compile-time validation
- ✅ Query invalidation hints for frontend
- ✅ Multi-audience support (user, admin, system)
- ✅ Zero boilerplate in actions
Quick Start
1. Define Counter in Resource
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
actions do
create :create_from_cart
update :complete
update :cancel
end
# Define counters using DSL
counters do
counter :pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id], # Scope to user's orders
invalidates: ["orders"]
end
end2. Configure Broadcasting
# config/config.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}3. Setup Phoenix Channel
defmodule MyAppWeb.UserChannel do
use MyAppWeb, :channel
# Receive counter broadcasts from AshDispatch
def broadcast_counter(user_id, counter_name, value, opts \\ []) do
metadata = Keyword.get(opts, :metadata, %{})
MyAppWeb.Endpoint.broadcast("user:#{user_id}", "counter_updated", %{
counter: counter_name,
value: value,
metadata: metadata
})
end
end4. Use on Frontend
channel.on("counter_updated", (payload) => {
// Update counter in UI
setCounters(prev => ({
...prev,
[payload.counter]: payload.value
}));
// Invalidate related queries
payload.metadata.invalidate_queries?.forEach(queryKey => {
queryClient.invalidateQueries([queryKey]);
});
});That's it! When orders are created, completed, or canceled, the pending_orders counter automatically updates in real-time.
Counter DSL Reference
counter/2
Defines a counter that automatically broadcasts when actions complete.
counter :counter_identifier,
trigger_on: [:action1, :action2],
counter_name: :counter_name,
query_filter: [filter_options],
audience: :user | :admin | :partner | :system,
invalidates: ["query_key1", "query_key2"],
resource: MyApp.SomeResource # Optional, defaults to current resourceOptions
:trigger_on (required) - List of actions that trigger this counter update
trigger_on: [:create]
trigger_on: [:create, :update, :destroy]
trigger_on: [:complete, :cancel]:counter_name (required) - Name of the counter (atom)
counter_name: :pending_orders
counter_name: :cart_items
counter_name: :admin_pending_reseller_requests:query_filter (required) - Filter for counting records
query_filter: [status: :pending]
query_filter: [active: true, archived: false]
query_filter: [user_id: {:context, :user_id}]:audience (required) - Who receives this counter
audience: :user # Send to user who triggered the action
audience: :admin # Send to all admins
audience: :partner # Send to partner users
audience: :system # Send to system recipients:invalidates (optional) - Query keys to invalidate on frontend
invalidates: ["orders"]
invalidates: ["orders", "analytics"]:resource (optional) - Resource to query (defaults to current resource)
resource: MyApp.Orders.ProductOrder # Explicit resource
# Defaults to the resource where counter is defined:group (optional) - Counter group for TypeScript organization
group: :orders
group: :tickets
group: :cartGroups are used by the TypeScript generator to create organized type definitions.
:authorize? (optional) - Whether to use Ash authorization (policies)
authorize?: false # Bypass policies, count ALL matching records
authorize?: true # Default: use Ash authorizationSet to false for admin counters that need system-wide totals regardless of policies.
:scope (optional) - Ash expression for scoping counter queries
# Simple: My orders
scope: expr(user_id == ^actor(:id))
# Regional: Orders in my region
scope: expr(region == ^actor(:region))
# Team: Tickets assigned to my team
scope: expr(assigned_support.team_id == ^actor(:team_id))
# Complex: Orders containing my products
scope: expr(exists(items, product.seller_id == ^actor(:id)))The scope expression is evaluated with the broadcast recipient as the "actor". This enables powerful filtering beyond simple user_id relationships. See Scope Expressions for details.
:aggregate (optional) - Use Ash aggregate instead of query_filter
aggregate: :pending_order_countWhen specified, uses an Ash aggregate defined on the resource instead of running a separate count query.
:user_id_path (optional) - Path to resolve user_id through relationships
user_id_path: [:cart, :user_id] # For CartItem -> Cart -> UserNote: In most cases, you don't need to specify user_id_path. AshDispatch automatically derives it by introspecting your resource's belongs_to relationships to the configured user_module. See Automatic user_id_path Derivation below.
Automatic user_id_path Derivation
AshDispatch automatically derives user_id_path from your resource's Ash relationships, so you rarely need to configure it explicitly.
How It Works
When a counter fires, AshDispatch:
- Looks at the resource's
belongs_torelationships - Finds relationships pointing to the configured
user_module - Uses the
source_attribute(e.g.,:user_id) as the path
# Your resource
defmodule MyApp.Orders.ProductOrder do
belongs_to :user, MyApp.Accounts.User # source_attribute: :user_id
end
# No user_id_path needed!
counters do
counter :pending_orders,
trigger_on: [:create],
query_filter: [status: :pending],
audience: :user # Automatically scoped via derived [:user_id]
endAmbiguity Handling
If a resource has multiple belongs_to relationships to the user module, AshDispatch logs a warning and requires explicit configuration:
# Resource with multiple user relationships
defmodule MyApp.Tickets.Ticket do
belongs_to :user, MyApp.Accounts.User
belongs_to :assigned_admin, MyApp.Accounts.User
end
# Warning logged:
# [ResourceIntrospection] Ambiguous user relationships on MyApp.Tickets.Ticket.
# Found multiple belongs_to relationships to user module: [:user, :assigned_admin]
# Solution: Be explicit
counters do
counter :open_tickets,
trigger_on: [:create],
query_filter: [status: :open],
audience: :user,
user_id_path: [:user_id] # Explicitly choose :user relationship
endWhen to Use Explicit user_id_path
Use explicit user_id_path when:
- Resource has multiple relationships to user module (ambiguous)
- User relationship is nested (e.g.,
CartItem -> Cart -> User) - Non-standard relationship naming
# Nested path example
counter :cart_items,
trigger_on: [:create, :destroy],
query_filter: [],
audience: :user,
user_id_path: [:cart, :user_id] # CartItem -> Cart.user_idScope Expressions
For complex scoping beyond simple user_id relationships, use the scope option with Ash expressions.
Expression Templates
Scope expressions can use ^actor(:field) to reference the broadcast recipient's attributes:
^actor(:id) # Recipient's ID
^actor(:region) # Recipient's region attribute
^actor(:team_id) # Recipient's team_id attribute
^actor([:profile, :org_id]) # Nested path accessCommon Patterns
My Records (simple):
counter :my_orders,
audience: :user,
scope: expr(user_id == ^actor(:id))My Assigned Records (relationship attribute):
counter :my_assigned_tickets,
audience: :admin,
scope: expr(assigned_to_id == ^actor(:id))Regional Scoping (attribute matching):
counter :regional_orders,
audience: :admin,
scope: expr(region == ^actor(:region))Team Scoping (through relationship):
counter :team_tickets,
audience: :team_lead,
scope: expr(assigned_support.team_id == ^actor(:team_id))Records with My Products (exists):
counter :seller_orders,
audience: :seller,
scope: expr(exists(items, product.seller_id == ^actor(:id)))scope vs user_id_path
| Feature | user_id_path | scope |
|---|---|---|
| Simple user_id | ✅ [:user_id] | ✅ expr(user_id == ^actor(:id)) |
| Nested paths | ✅ [:cart, :user_id] | ✅ expr(cart.user_id == ^actor(:id)) |
| Attribute matching | ❌ | ✅ expr(region == ^actor(:region)) |
| Relationship traversal | ❌ | ✅ expr(assigned_support.team_id == ^actor(:team_id)) |
| exists/has_many | ❌ | ✅ expr(exists(items, ...)) |
| Complex conditions | ❌ | ✅ Any Ash expression |
Recommendation:
- Use
user_id_pathfor simple user_id relationships (cleaner syntax) - Use
scopewhen you need any of the advanced features
Combining scope with authorize?
The scope and authorize? options work independently:
# Admin sees ALL records (no authorization check, no scoping)
counter :all_orders, authorize?: false
# Admin sees THEIR assigned records (authorization enabled + custom scope)
counter :my_assigned_orders,
scope: expr(assigned_to_id == ^actor(:id))
# Admin sees regional records WITHOUT policy check
counter :regional_totals,
authorize?: false,
scope: expr(region == ^actor(:region))Audience vs Query Scoping
Important: audience and query scoping are separate concepts:
| Layer | Purpose | Mechanism |
|---|---|---|
| Audience | WHO receives the broadcast | Recipient resolution |
| Authorization | WHAT records actor CAN see | Ash policies (authorize?) |
| Scoping | WHAT subset we WANT to count | scope or user_id_path |
This separation allows flexible combinations:
# User sees their own count (auto-derived scoping)
counter :my_orders, audience: :user
# Admin sees system-wide count (bypass authorization)
counter :all_orders, audience: :admin, authorize?: false
# Admin sees THEIR assigned tickets (custom scope)
counter :my_assigned_tickets,
audience: :admin,
scope: expr(assigned_to_id == ^actor(:id))
# Regional admin sees orders in their region
counter :regional_orders,
audience: :admin,
scope: expr(region == ^actor(:region))
# Partner sees their scoped count
counter :partner_orders, audience: :partner, user_id_path: [:partner_id]Audience Types
Audience Configuration Pattern
Audiences are configured in config :ash_dispatch, :audiences using two formats:
config :ash_dispatch,
audiences: [
# Bare atom = relationship-based (extract from record)
:user,
:creator,
:partner,
# Tuple = filter-based (query all matching users)
{:admin, [:user, {:admin, true}]},
{:super_admin, [:user, {:super_admin, true}]}
]Relationship-based (bare atoms):
- Extract recipient from the record's relationship
- Broadcast to ONE user (the record owner)
- E.g.,
:userextracts fromorder.user
Filter-based (tuples):
- Query ALL users matching the filter
- Broadcast to MULTIPLE users
- E.g.,
:adminbroadcasts to all users whereadmin: true
This distinction affects both recipient resolution (who gets broadcast) and query scoping (how counts are calculated).
:user - Broadcast to Acting User
Broadcasts counter update to the user who triggered the action.
counter :pending_orders,
trigger_on: [:create, :complete],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id] # Scope query to this user's ordersBehavior:
- Broadcasts to the user who triggered the action
- Query scoped via
user_id_path(if provided) - Perfect for user-specific counters (cart items, my orders, my tickets)
Example:
# User creates an order
Order.create!(%{user_id: "user-123", ...})
# AshDispatch automatically:
# 1. Counts orders WHERE status = :pending AND user_id = "user-123"
# 2. Broadcasts ONLY to "user-123":admin - Broadcast to All Admins
Broadcasts counter update to all users matching the admin filter.
counter :admin_pending_reseller_requests,
trigger_on: [:create, :accept, :decline],
counter_name: :admin_pending_reseller_requests,
query_filter: [status: :pending],
audience: :admin,
authorize?: false # No authorization - count ALL recordsConfiguration:
config :ash_dispatch,
user_module: MyApp.Accounts.User,
recipient_filters: [
audiences: [
admin: [admin: true]
]
]Behavior:
- Broadcasts to ALL users matching admin filter
authorize?: falsebypasses policies (system-wide count)- Perfect for admin dashboards
Example:
# Anyone creates a reseller request
ResellerRequest.create!(...)
# AshDispatch automatically:
# 1. Counts requests WHERE status = :pending (ALL requests)
# 2. Finds all users WHERE admin = true
# 3. Broadcasts to each admin: "user:admin-1", "user:admin-2", etc.:partner, :system, Custom Audiences
Define custom audiences for specialized counter routing.
# Partner sees their own scoped data
counter :partner_pending_orders,
trigger_on: [:create],
counter_name: :partner_pending_orders,
query_filter: [status: :pending],
audience: :partner,
user_id_path: [:partner_id] # Scope to partner's orders
# System counter - global view
counter :system_failed_jobs,
trigger_on: [:fail],
counter_name: :system_failed_jobs,
query_filter: [status: :failed],
audience: :system,
authorize?: falseConfiguration:
config :ash_dispatch,
recipient_filters: [
audiences: [
partner: [role: :partner, active: true],
system: [] # No filter, uses system_recipients
]
],
system_recipients: [
%{email: "ops@myapp.com", name: "Operations"}
]Real-World Examples
E-Commerce Counters
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# User sees their own pending orders
counter :user_pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id], # Scope to user's orders
group: :orders, # TypeScript grouping
invalidates: ["orders", "cart_items"]
# User sees their own processing orders
counter :user_processing_orders,
trigger_on: [:create, :process, :complete],
counter_name: :processing_orders,
query_filter: [status: :processing],
audience: :user,
user_id_path: [:user_id],
group: :orders,
invalidates: ["orders"]
# Admins see ALL pending orders (bypass authorization)
counter :admin_pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :admin_pending_orders,
query_filter: [status: :pending],
audience: :admin,
group: :orders,
authorize?: false, # No authorization, count all
invalidates: ["orders", "analytics"]
# Admins see ALL processing orders
counter :admin_processing_orders,
trigger_on: [:create, :process, :complete],
counter_name: :admin_processing_orders,
query_filter: [status: :processing],
audience: :admin,
group: :orders,
authorize?: false,
invalidates: ["orders", "analytics"]
end
endUsing Ash Aggregates
For complex counting logic, use Ash aggregates:
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshDispatch.Resource]
aggregates do
# Define aggregate on resource
count :unread_notification_count, :notifications do
filter expr(read_at == nil)
end
end
counters do
# Use aggregate instead of query_filter
counter :unread_notifications,
trigger_on: [:mark_read, :mark_unread],
aggregate: :unread_notification_count, # References aggregate above
audience: :user,
group: :notifications,
invalidates: ["notifications"]
end
endSupport Ticket Counters
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# User sees their open tickets
counter :user_open_tickets,
trigger_on: [:create, :resolve, :close],
counter_name: :open_tickets,
query_filter: [status: :open],
audience: :user,
user_id_path: [:user_id],
invalidates: ["tickets"]
# User sees their in-progress tickets
counter :user_in_progress_tickets,
trigger_on: [:start, :resolve, :close],
counter_name: :in_progress_tickets,
query_filter: [status: :in_progress],
audience: :user,
user_id_path: [:user_id],
invalidates: ["tickets"]
# Support team sees ALL open tickets
counter :admin_open_tickets,
trigger_on: [:create, :resolve, :close],
counter_name: :admin_open_tickets,
query_filter: [status: :open],
audience: :admin,
authorize?: false,
invalidates: ["tickets", "support_dashboard"]
# Support team sees ALL in-progress tickets
counter :admin_in_progress_tickets,
trigger_on: [:start, :resolve, :close],
counter_name: :admin_in_progress_tickets,
query_filter: [status: :in_progress],
audience: :admin,
authorize?: false,
invalidates: ["tickets", "support_dashboard"]
end
endShopping Cart Counter
defmodule MyApp.Catalog.Cart do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# Real-time cart item count
counter :cart_items,
trigger_on: [:add_item, :remove_item, :clear],
counter_name: :cart_items,
query_filter: [], # Count all items in user's cart
audience: :user,
user_id_path: [:user_id], # Scope to user's cart
invalidates: ["cart", "checkout"]
end
endQuery Invalidation
Counters can specify which frontend queries should be invalidated when they update.
Why Query Invalidation?
When a counter changes, related data on the frontend may be stale:
// Counter says 5 pending orders
const { counters } = useUserChannel();
// => { pending_orders: 5 }
// But the order list query might have old data!
const { data: orders } = useQuery(['orders', 'pending']);
// => Still showing 4 orders (stale!)Solution: Counter broadcasts include invalidation hints:
counter :pending_orders,
# ...
invalidates: ["orders"] # ← Tell frontend to refetch order queriesFrontend Integration
channel.on("counter_updated", (payload) => {
// Update counter
setCounters(prev => ({
...prev,
[payload.counter]: payload.value
}));
// Invalidate related queries
payload.metadata.invalidate_queries?.forEach(queryKey => {
queryClient.invalidateQueries([queryKey]);
});
});Result: Counter updates automatically trigger data refetches!
Multiple Invalidations
counter :pending_orders,
trigger_on: [:create],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id],
invalidates: [
"orders", # Refetch order lists
"analytics", # Refetch analytics dashboard
"reports" # Refetch report data
]How It Works
Compile-Time Transformation
The counter DSL is transformed at compile-time into Ash changes:
# You write:
counters do
counter :pending_orders,
trigger_on: [:create],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id]
end
# AshDispatch injects:
create :create do
# ... your existing logic
change AshDispatch.Changes.BroadcastCounterUpdate,
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
user_id_path: [:user_id],
invalidates: []
endRuntime Flow
- Action Executes: User creates an order
- Counter Change Runs: After action success,
BroadcastCounterUpdateruns - Count Query: Executes count query with filter
- Resolve Recipients: Determines who receives the update based on audience
- Broadcast: Calls configured
counter_broadcast_fnfor each recipient
sequenceDiagram
participant Action
participant BroadcastCounterUpdate
participant CountQuery
participant Recipients
participant Broadcast
Action->>BroadcastCounterUpdate: After success
BroadcastCounterUpdate->>CountQuery: Count with filter
CountQuery-->>BroadcastCounterUpdate: count = 5
BroadcastCounterUpdate->>Recipients: Resolve for audience
Recipients-->>BroadcastCounterUpdate: [user_ids]
loop For each recipient
BroadcastCounterUpdate->>Broadcast: broadcast_counter(user_id, :pending_orders, 5)
Broadcast->>Phoenix: Phoenix.PubSub
endAuto-Discovery with CounterLoader
Counters defined in the DSL are automatically discovered by AshDispatch.Helpers.CounterLoader when users connect to Phoenix Channels.
How It Works
# In your UserChannel
alias AshDispatch.Helpers.CounterLoader
def handle_info(:after_join, socket) do
# Automatically discovers and loads ALL counters
counters = CounterLoader.load_counters_for_user(socket.assigns.user_id)
# => %{pending_orders: 5, cart_items: 3, open_tickets: 2}
push(socket, "initial_state", %{counters: counters})
{:noreply, socket}
endWhat happens:
- Scans all configured Ash domains
- Finds all resources with
counters doblocks - Reads counter definitions from DSL
- Filters counters by user's audiences
- Executes each counter's query
- Returns map of counter names to values
Zero configuration needed! Just define counters in resources, they're automatically available.
Configuration
Required Configuration
# config/config.exs
config :ash_dispatch,
# Domains to scan for counter definitions
domains: [MyApp.Orders, MyApp.Tickets, MyApp.Catalog],
# User module for audience checking
user_module: MyApp.Accounts.User,
# Function to call when broadcasting counters
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}Audience Filters
Define how to identify users for each audience:
config :ash_dispatch,
recipient_filters: [
audiences: [
admin: [admin: true],
partner: [role: :partner, active: true],
support: [role: :support],
user: [] # All authenticated users
]
]Complete Example
# config/config.exs
config :ash_dispatch,
# Resource discovery
domains: [
MyApp.Orders,
MyApp.Tickets,
MyApp.Catalog,
MyApp.Accounts,
MyApp.Requests
],
# User resolution
user_module: MyApp.Accounts.User,
# Audience filters
recipient_filters: [
audiences: [
admin: [admin: true],
partner: [role: :partner],
user: []
]
],
# Broadcasting
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}Testing
Test Counter Broadcasts
defmodule MyApp.OrdersTest do
use MyApp.DataCase
test "broadcasts pending_orders counter on create" do
user = build(:user) |> create!()
# Create order
order = build(:product_order, %{user_id: user.id, status: :pending})
|> create!()
# Assert counter broadcast
assert_received {:counter_broadcast, ^user.id, :pending_orders, 1, _opts}
end
endMock Counter Broadcasting
# config/test.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppTest.MockCounterBroadcaster, :broadcast}
# test/support/mock_counter_broadcaster.ex
defmodule MyAppTest.MockCounterBroadcaster do
def broadcast(user_id, counter_name, value, opts) do
send(self(), {:counter_broadcast, user_id, counter_name, value, opts})
:ok
end
endTest Counter Queries
test "counter query returns correct count" do
user = build(:user) |> create!()
# Create 3 pending orders
build_list(3, :product_order, %{user_id: user.id, status: :pending})
|> Enum.each(&create!/1)
# Load counter
counters = CounterLoader.load_counters_for_user(user.id)
assert counters[:pending_orders] == 3
endPerformance Optimization
Database Indexes
Add indexes for fast counter queries:
# In migration
create index(:orders, [:user_id, :status])
create index(:tickets, [:user_id, :status])
create index(:carts, [:user_id])Counter Query Optimization
Use efficient filters:
# ✅ Good - indexed fields
query_filter: [status: :pending]
query_filter: [user_id: {:context, :user_id}, active: true]
# ❌ Avoid - unindexed or complex queries
query_filter: [fragment("expensive_calculation(?) > 10", field(:amount))]Batch Counter Updates
If multiple actions update the same counter, consider batching:
# Instead of broadcasting on every item add/remove
# Broadcast once after bulk operation completesTroubleshooting
Counter Not Broadcasting
Check:
trigger_onmatches action name exactlycounter_broadcast_fnconfigured- Action completes successfully
- No errors in logs
Debug:
# Check counter definitions
AshDispatch.Dsl.Info.counters(MyApp.Orders.ProductOrder)
# Verify broadcast function (using Config module)
AshDispatch.Config.counter_broadcast_fn()
# Test manually
AshDispatch.Changes.BroadcastCounterUpdate.broadcast_counter_update(
%{user_id: "test"},
:pending_orders,
[status: :pending],
:user,
[]
)Wrong Counter Value
Check:
query_filtermatches intended records- Counter scoping (
:useraudience adds user_id filter automatically) - Database state
Debug:
# Test query manually
MyApp.Orders.ProductOrder
|> Ash.Query.filter(status: :pending)
|> Ash.Query.filter(user_id == ^user_id) # For :user audience
|> Ash.count!()Counter Not Loading on Join
Check:
- Counter audience matches user
:domainsconfiguration includes resource's domain:user_moduleconfigured- User has correct attributes for audience filter
Debug:
# Check domains (using Config module)
AshDispatch.Config.domains()
# Test counter loading manually
CounterLoader.load_counters_for_user(user.id)TypeScript Generation
AshDispatch generates TypeScript types and constants for your counters automatically via mix ash.codegen.
Generate Counter Types
# Generate all Ash codegen (including AshDispatch types)
mix ash.codegen
# Or run AshDispatch codegen directly
mix ash_dispatch.gen
# Preview what would be generated
mix ash_dispatch.gen --dry-run
# CI mode - fail if files are out of date
mix ash_dispatch.gen --check
Counter types are generated to {ash_typescript_output_dir}/ash-dispatch/types.ts alongside your other AshDispatch SDK files.
Generated Output
The generator creates a TypeScript file with:
// Auto-generated by mix ash_dispatch.gen
// Do not edit manually
// Types grouped by counter group
export type OrdersCounters = {
pending_orders: number;
processing_orders: number;
admin_pending_orders: number;
};
export type CartCounters = {
cart_items: number;
};
export type AllCounters = CartCounters & OrdersCounters;
// Counter names organized by source resource
export const COUNTERS = {
cart: {
cart_items: "cart_items",
},
cart_item: {
cart_items: "cart_items", // Same counter, different triggers
},
product_order: {
pending_orders: "pending_orders",
processing_orders: "processing_orders",
admin_pending_orders: "admin_pending_orders",
},
} as const;
// Merged metadata from all sources
export const COUNTER_METADATA = {
cart_items: {
audience: "user",
invalidates: ["cart"],
sources: ["cart", "cart_item"], // Defined in multiple resources
},
pending_orders: {
audience: "user",
invalidates: ["cart_items", "orders"],
sources: ["product_order"],
},
admin_pending_orders: {
audience: "admin",
invalidates: ["orders"],
sources: ["product_order"],
},
} as const;
export type CounterName = "cart_items" | "pending_orders" | "admin_pending_orders";
export function isValidCounter(name: string): name is CounterName {
return name in COUNTER_METADATA;
}Using Generated Types
import {
COUNTERS,
COUNTER_METADATA,
getCounterAccessors,
type AllCounters,
type CounterName
} from "./counters";
// Type-safe counter access with snake_case
const pendingCount = counters[COUNTERS.product_order.pending_orders];
// Auto-generated camelCase accessors (no manual maintenance!)
const accessors = getCounterAccessors(counters);
console.log(accessors.pendingOrders);
console.log(accessors.adminOpenTickets);
// Check if counter should be shown to user
function shouldShowCounter(name: CounterName, isAdmin: boolean) {
const meta = COUNTER_METADATA[name];
return meta.audience === "user" || (meta.audience === "admin" && isAdmin);
}
// Get invalidation queries for a counter
function getInvalidations(name: CounterName): string[] {
return COUNTER_METADATA[name].invalidates;
}React Hook Integration
The generated types and helpers eliminate almost all manual frontend work.
Store (one-time setup, never changes):
// lib/stores/use-counter-store.ts
import { create } from 'zustand'
import { DEFAULT_COUNTERS, type AllCounters, type CounterName } from '@/lib/counters'
export type Counters = AllCounters
interface CounterState {
counters: Counters
setCounters: (counters: Partial<Counters>) => void
setCounter: (key: CounterName, value: number) => void
resetCounters: () => void
}
export const useCounterStore = create<CounterState>()((set) => ({
counters: DEFAULT_COUNTERS,
setCounters: (newCounters) => set((state) => ({ counters: { ...state.counters, ...newCounters } })),
setCounter: (key, value) => set((state) => ({ counters: { ...state.counters, [key]: value } })),
resetCounters: () => set({ counters: DEFAULT_COUNTERS }),
}))Hook (one-time setup, never changes):
// hooks/use-counters.ts
import { useCounterStore } from '@/lib/stores/use-counter-store'
import { getCounterAccessors } from '@/lib/counters'
export function useCounters() {
const counters = useCounterStore((state) => state.counters)
return {
...getCounterAccessors(counters),
counters,
}
}Usage in components:
function MyComponent() {
const { cartItems, pendingOrders, adminOpenTickets } = useCounters()
// Full TypeScript autocomplete!
}Zero-Maintenance Workflow
When you add new counters in Elixir:
- Add counter to resource DSL
- Run
mix ash.codegen(ormix ash_dispatch.gen) - Done! No frontend changes needed
The generated file includes:
DEFAULT_COUNTERS- Store initializationAllCounterstype - Full type definitionCounterAccessorstype - camelCase return typegetCounterAccessors()- Converts snake_case to camelCaseCOUNTER_METADATA- Invalidates, audience, sources
Multi-Resource Counters
When the same counter is defined in multiple resources (e.g., cart_items in both Cart and CartItem), the generator:
- Merges invalidates - Union of all invalidates from all sources
- Tracks sources - Shows which resources define the counter
- Validates consistency - Warns if audience differs between resources
This allows different actions to trigger the same counter update:
# In Cart resource
counter :cart_items,
trigger_on: [:add_item, :clear], # Triggered by cart actions
audience: :user,
group: :cart
# In CartItem resource
counter :cart_items,
trigger_on: [:create, :destroy], # Triggered by item actions
audience: :user,
group: :cartBoth will broadcast the same cart_items counter but from different action triggers.
Counter Store as Single Source of Truth
When building features that need real-time counts, always read from the counter store rather than maintaining separate state.
Example: Notification Badge
// ❌ Wrong - separate state that won't sync
export function useNotifications() {
const [unreadCount, setUnreadCount] = useState(0) // Gets out of sync!
// ...
}
// ✅ Correct - read from counter store
export function useNotifications() {
const unreadCount = useCounterStore(
(state) => state.counters.unread_notifications
)
// unreadCount automatically updates via WebSocket
}Why This Matters
- Real-time sync - Counter store receives broadcasts from all tabs/sessions
- No duplication - Single source means no sync bugs
- Type-safe - Counter names validated by generated types
Pattern for Feature Hooks
// Any feature that needs a real-time count
export function useFeatureWithCount() {
const featureStore = useFeatureStore()
const count = useCounterStore((state) => state.counters.my_counter)
return {
items: featureStore.items,
count, // From counter store, not feature store
// ...actions
}
}LiveView vs AshTypescript/SPA
This TypeScript counter system is designed for Single Page Applications (React, Vue, etc.) using AshTypescript.
For SPA/AshTypescript Apps
Use the full TypeScript integration:
- Generated types and store
- Phoenix channels for real-time updates
- Zustand/Redux for state management
For LiveView Apps
LiveView already has real-time updates via its socket connection. The counter DSL and broadcasting still work, but frontend consumption differs:
# LiveView can use PubSub directly
def handle_info({:counter_updated, counter, value}, socket) do
{:noreply, assign(socket, counter, value)}
endThe Elixir-side counter DSL, broadcasting, and Phoenix channel helpers work identically - only the frontend consumption layer differs.
Next Steps
- Phoenix Channel Integration - Setup channels with helpers
- Configuration - Complete configuration reference