# Counter Broadcasting
AshDispatch 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:
```elixir
# ❌ 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}
end
```
With AshDispatch, counters are **declarative and automatic**:
```elixir
# ✅ 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"]
end
```
**Benefits:**
- ✅ 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
```elixir
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
end
```
### 2. Configure Broadcasting
```elixir
# config/config.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}
```
### 3. Setup Phoenix Channel
```elixir
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
end
```
### 4. Use on Frontend
```typescript
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.
```elixir
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 resource
```
#### Options
**`:trigger_on`** (required) - List of actions that trigger this counter update
```elixir
trigger_on: [:create]
trigger_on: [:create, :update, :destroy]
trigger_on: [:complete, :cancel]
```
**`:counter_name`** (required) - Name of the counter (atom)
```elixir
counter_name: :pending_orders
counter_name: :cart_items
counter_name: :admin_pending_reseller_requests
```
**`:query_filter`** (required) - Filter for counting records
```elixir
query_filter: [status: :pending]
query_filter: [active: true, archived: false]
query_filter: [user_id: {:context, :user_id}]
```
**`:audience`** (required) - Who receives this counter
```elixir
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
```elixir
invalidates: ["orders"]
invalidates: ["orders", "analytics"]
```
**`:resource`** (optional) - Resource to query (defaults to current resource)
```elixir
resource: MyApp.Orders.ProductOrder # Explicit resource
# Defaults to the resource where counter is defined
```
**`:group`** (optional) - Counter group for TypeScript organization
```elixir
group: :orders
group: :tickets
group: :cart
```
Groups are used by the TypeScript generator to create organized type definitions.
**`:authorize?`** (optional) - Whether to use Ash authorization (policies)
```elixir
authorize?: false # Bypass policies, count ALL matching records
authorize?: true # Default: use Ash authorization
```
Set to `false` for admin counters that need system-wide totals regardless of policies.
**`:scope`** (optional) - Ash expression for scoping counter queries
```elixir
# 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](#scope-expressions) for details.
**`:aggregate`** (optional) - Use Ash aggregate instead of query_filter
```elixir
aggregate: :pending_order_count
```
When 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
```elixir
user_id_path: [:cart, :user_id] # For CartItem -> Cart -> User
```
**Note:** 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](#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:
1. Looks at the resource's `belongs_to` relationships
2. Finds relationships pointing to the configured `user_module`
3. Uses the `source_attribute` (e.g., `:user_id`) as the path
```elixir
# 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]
end
```
### Ambiguity Handling
If a resource has **multiple** `belongs_to` relationships to the user module, AshDispatch logs a warning and requires explicit configuration:
```elixir
# 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
end
```
### When 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
```elixir
# Nested path example
counter :cart_items,
trigger_on: [:create, :destroy],
query_filter: [],
audience: :user,
user_id_path: [:cart, :user_id] # CartItem -> Cart.user_id
```
---
## Scope 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:
```elixir
^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 access
```
### Common Patterns
**My Records (simple):**
```elixir
counter :my_orders,
audience: :user,
scope: expr(user_id == ^actor(:id))
```
**My Assigned Records (relationship attribute):**
```elixir
counter :my_assigned_tickets,
audience: :admin,
scope: expr(assigned_to_id == ^actor(:id))
```
**Regional Scoping (attribute matching):**
```elixir
counter :regional_orders,
audience: :admin,
scope: expr(region == ^actor(:region))
```
**Team Scoping (through relationship):**
```elixir
counter :team_tickets,
audience: :team_lead,
scope: expr(assigned_support.team_id == ^actor(:team_id))
```
**Records with My Products (exists):**
```elixir
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_path` for simple user_id relationships (cleaner syntax)
- Use `scope` when you need any of the advanced features
### Combining scope with authorize?
The `scope` and `authorize?` options work independently:
```elixir
# 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:
```elixir
# 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:
```elixir
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., `:user` extracts from `order.user`
**Filter-based** (tuples):
- Query ALL users matching the filter
- Broadcast to MULTIPLE users
- E.g., `:admin` broadcasts to all users where `admin: 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.
```elixir
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 orders
```
**Behavior:**
- 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:**
```elixir
# 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.
```elixir
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 records
```
**Configuration:**
```elixir
config :ash_dispatch,
user_module: MyApp.Accounts.User,
recipient_filters: [
audiences: [
admin: [admin: true]
]
]
```
**Behavior:**
- Broadcasts to ALL users matching admin filter
- `authorize?: false` bypasses policies (system-wide count)
- Perfect for admin dashboards
**Example:**
```elixir
# 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.
```elixir
# 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?: false
```
**Configuration:**
```elixir
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
```elixir
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
end
```
### Using Ash Aggregates
For complex counting logic, use Ash aggregates:
```elixir
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
end
```
### Support Ticket Counters
```elixir
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
end
```
### Shopping Cart Counter
```elixir
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
end
```
---
## Query 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:
```typescript
// 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:
```elixir
counter :pending_orders,
# ...
invalidates: ["orders"] # ← Tell frontend to refetch order queries
```
### Frontend Integration
```typescript
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
```elixir
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:
```elixir
# 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: []
end
```
### Runtime Flow
1. **Action Executes**: User creates an order
2. **Counter Change Runs**: After action success, `BroadcastCounterUpdate` runs
3. **Count Query**: Executes count query with filter
4. **Resolve Recipients**: Determines who receives the update based on audience
5. **Broadcast**: Calls configured `counter_broadcast_fn` for each recipient
```mermaid
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
end
```
---
## Auto-Discovery with CounterLoader
Counters defined in the DSL are automatically discovered by `AshDispatch.Helpers.CounterLoader` when users connect to Phoenix Channels.
### How It Works
```elixir
# 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}
end
```
**What happens:**
1. Scans all configured Ash domains
2. Finds all resources with `counters do` blocks
3. Reads counter definitions from DSL
4. Filters counters by user's audiences
5. Executes each counter's query
6. Returns map of counter names to values
**Zero configuration needed!** Just define counters in resources, they're automatically available.
---
## Configuration
### Required Configuration
```elixir
# 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:
```elixir
config :ash_dispatch,
recipient_filters: [
audiences: [
admin: [admin: true],
partner: [role: :partner, active: true],
support: [role: :support],
user: [] # All authenticated users
]
]
```
### Complete Example
```elixir
# 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
```elixir
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
end
```
### Mock Counter Broadcasting
```elixir
# 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
end
```
### Test Counter Queries
```elixir
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
end
```
---
## Performance Optimization
### Database Indexes
Add indexes for fast counter queries:
```elixir
# 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:
```elixir
# ✅ 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:
```elixir
# Instead of broadcasting on every item add/remove
# Broadcast once after bulk operation completes
```
---
## Troubleshooting
### Counter Not Broadcasting
**Check:**
1. `trigger_on` matches action name exactly
2. `counter_broadcast_fn` configured
3. Action completes successfully
4. No errors in logs
**Debug:**
```elixir
# 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:**
1. `query_filter` matches intended records
2. Counter scoping (`:user` audience adds user_id filter automatically)
3. Database state
**Debug:**
```elixir
# 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:**
1. Counter audience matches user
2. `:domains` configuration includes resource's domain
3. `:user_module` configured
4. User has correct attributes for audience filter
**Debug:**
```elixir
# 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
```bash
# 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:
```typescript
// 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
```typescript
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):**
```typescript
// 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):**
```typescript
// 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:**
```typescript
function MyComponent() {
const { cartItems, pendingOrders, adminOpenTickets } = useCounters()
// Full TypeScript autocomplete!
}
```
### Zero-Maintenance Workflow
When you add new counters in Elixir:
1. Add counter to resource DSL
2. Run `mix ash.codegen` (or `mix ash_dispatch.gen`)
3. **Done!** No frontend changes needed
The generated file includes:
- `DEFAULT_COUNTERS` - Store initialization
- `AllCounters` type - Full type definition
- `CounterAccessors` type - camelCase return type
- `getCounterAccessors()` - Converts snake_case to camelCase
- `COUNTER_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:
1. **Merges invalidates** - Union of all invalidates from all sources
2. **Tracks sources** - Shows which resources define the counter
3. **Validates consistency** - Warns if audience differs between resources
This allows different actions to trigger the same counter update:
```elixir
# 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: :cart
```
Both 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
```typescript
// ❌ 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
1. **Real-time sync** - Counter store receives broadcasts from all tabs/sessions
2. **No duplication** - Single source means no sync bugs
3. **Type-safe** - Counter names validated by generated types
### Pattern for Feature Hooks
```typescript
// 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:
```elixir
# LiveView can use PubSub directly
def handle_info({:counter_updated, counter, value}, socket) do
{:noreply, assign(socket, counter, value)}
end
```
The Elixir-side counter DSL, broadcasting, and Phoenix channel helpers work identically - only the frontend consumption layer differs.
---
## Next Steps
- [Phoenix Channel Integration](phoenix-integration.md) - Setup channels with helpers
- [Configuration](configuration.md) - Complete configuration reference