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 withlast_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:rejectedand:failedevents DO flipterminalbut have no corresponding*_atcolumn (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 onmailglass_deliveries(IN-07).terminal— flipsfalse → trueon 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.
Summary
Functions
Broadcasts a post-commit {:delivery_updated, delivery_id, event_type, meta}
payload to the relevant Mailglass.PubSub topics (D-04).
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.
Functions
@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)
@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.