Counter Broadcasting

View Source

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:

# ❌ 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:

# ✅ 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

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

# 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
end

4. 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 resource

Options

: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: :cart

Groups 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 authorization

Set 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_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

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 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
# 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:

# 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
# 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:

^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):

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

Featureuser_id_pathscope
Simple user_id[:user_id]expr(user_id == ^actor(:id))
Nested paths[:cart, :user_id]expr(cart.user_id == ^actor(:id))
Attribute matchingexpr(region == ^actor(:region))
Relationship traversalexpr(assigned_support.team_id == ^actor(:team_id))
exists/has_manyexpr(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:

# 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:

LayerPurposeMechanism
AudienceWHO receives the broadcastRecipient resolution
AuthorizationWHAT records actor CAN seeAsh policies (authorize?)
ScopingWHAT subset we WANT to countscope 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., :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.

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:

# 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 records

Configuration:

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:

# 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?: false

Configuration:

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
end

Using 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
end

Support 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
end

Shopping 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
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:

// 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 queries

Frontend 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: []
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
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

# 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

# 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
end

Mock 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
end

Test 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
end

Performance 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 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:

# 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:

# 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:

# 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:

  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:

# 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

// ❌ 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

// 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)}
end

The Elixir-side counter DSL, broadcasting, and Phoenix channel helpers work identically - only the frontend consumption layer differs.


Next Steps