Mailglass.Outbound.Projector (Mailglass v0.1.0)

Copy Markdown View Source

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_atmax(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.

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

broadcast_delivery_updated(delivery, event_type, meta)

(since 0.1.0)
@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

update_projections(delivery, event)

(since 0.1.0)

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.