Skip to main content

lib/documentation/topics/counter-broadcasting.md

# 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