# `Mailglass.Outbound.Projector`
[🔗](https://github.com/szTheory/mailglass/blob/v0.1.0/lib/mailglass/outbound/projector.ex#L1)

The single place where `mailglass_deliveries` projection columns are
updated (D-14). Consumed by Phase 3 dispatch, Phase 4 webhook ingest,
and Phase 4+ orphan reconciliation. No projection update happens
outside this module — a Phase 6 candidate Credo check
(`NoProjectorOutsideOutbound`) will enforce at lint time.

## App-level monotonic rule (D-15)

- `last_event_at` — `max(current, event.occurred_at)`; monotonic.
- `last_event_type` — advances together with `last_event_at`. The two
  fields are a joined "latest observed event" pointer: an earlier
  out-of-order event moves neither. This keeps the denormalized
  summary internally consistent with the event-ledger truth (WR-02).
- `dispatched_at` / `delivered_at` / `bounced_at` / `complained_at` /
  `suppressed_at` — set ONCE when the matching event type arrives;
  never overwritten. Note that `:rejected` and `:failed` events DO
  flip `terminal` but have no corresponding `*_at` column (D-13
  scoped five lifecycle timestamps) — querying "when did this
  delivery fail?" joins the event ledger on (delivery_id, type)
  rather than reading a single column on `mailglass_deliveries`
  (IN-07).
- `terminal` — flips `false → true` on any of
  `:delivered | :bounced | :complained | :rejected | :failed |
  :suppressed`. Never flips back.

Why app-enforced: provider event ordering is non-monotonic in
practice (`:opened` arriving before `:delivered` is routine during
webhook batches). DB CHECK constraints on lifecycle ordering would
cause production failures on valid provider behavior.

## Optimistic locking (D-18)

Every returned changeset chains `Ecto.Changeset.optimistic_lock(:lock_version)`.
Concurrent dispatch attempts on the same delivery raise
`Ecto.StaleEntryError` on the loser. Phase 3's dispatch worker adds
the single-retry; Phase 2 proves the mechanism works.

## Telemetry

Emits `[:mailglass, :persist, :delivery, :update_projections, :*]` with
`tenant_id` + `delivery_id` metadata per Phase 1 D-31 whitelist.

# `broadcast_delivery_updated`
*since 0.1.0* 

```elixir
@spec broadcast_delivery_updated(Mailglass.Outbound.Delivery.t(), atom(), map()) ::
  :ok
```

Broadcasts a post-commit `{:delivery_updated, delivery_id, event_type, meta}`
payload to the relevant Mailglass.PubSub topics (D-04).

Called AFTER the caller's `Repo.transact/1` (or `Repo.multi/1`) returns
`{:ok, _}`. Broadcasting INSIDE the transaction would couple PubSub
availability to DB commit success — violates D-04's "broadcast runs AFTER
commit" rule.

Broadcasts to BOTH topics (D-27, SEND-05):

- `Mailglass.PubSub.Topics.events(tenant_id)` — tenant-wide event stream
  (admin dashboard, tenant-wide observers)
- `Mailglass.PubSub.Topics.events(tenant_id, delivery_id)` — per-delivery
  stream (single-delivery LiveView views, `assert_mail_delivered/2`)

Broadcast failure never rolls back — if Phoenix.PubSub is unreachable
(application stopping, node partition), the broadcast is best-effort
and returns `:ok`. The event ledger is the durable source of truth;
PubSub is the realtime fan-out.

## Callers

- `Mailglass.Outbound.send/2` (Plan 05 Multi#2 success path)
- `Mailglass.Outbound.Worker.perform/1` (Plan 05 async Multi#2 success)
- `Mailglass.Adapters.Fake.trigger_event/3` (after its own transact)
- `Mailglass.Webhook.Plug` (Phase 4 — after webhook Multi commits)

# `update_projections`
*since 0.1.0* 

```elixir
@spec update_projections(Mailglass.Outbound.Delivery.t(), Mailglass.Events.Event.t()) ::
  Ecto.Changeset.t()
```

Returns a changeset that applies D-15 monotonic projection updates for
the given `%Delivery{}` against `%Event{}`. The changeset chains
`Ecto.Changeset.optimistic_lock(:lock_version)` so concurrent updates
on the same delivery raise `Ecto.StaleEntryError` on the loser.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
