Mailglass.Events (Mailglass v1.0.0)

Copy Markdown View Source

Append-only event writer. The only legitimate path to mailglass_events. Phase 6 NoRawEventInsert Credo check enforces that no other code calls Repo.insert(%Event{}) or Repo.insert_all("mailglass_events", ...).

Public API

  • append/1 — standalone audit write. Opens its own Repo.transact/1. Returns {:ok, %Event{}}. Use for admin actions, mix mail.doctor breadcrumbs, tenant provisioning — anything without a companion domain mutation.
  • append_multi/3 — composable Ecto.Multi step. Use when writing an event alongside a Delivery insert or projection update. Returns an enriched %Ecto.Multi{}.

Idempotency (PERSIST-03, MAIL-03 prevention)

When attrs carries an :idempotency_key, both paths use the partial-unique-index conflict target:

on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "(idempotency_key) WHERE idempotency_key IS NOT NULL"},
returning: true

append/1 detects the conflict-replay case and re-fetches the existing row by idempotency_key. append_multi/3 surfaces replay to the caller via a follow-up Multi.run step inspecting the returned event's inserted_at.

The replay-detection sentinel: inserted_at, not id

The accrue precedent (bigserial PK) detects replay via id == nil because Postgres auto-generates the id server-side; on conflict, no row is inserted, RETURNING yields nothing, and Ecto populates the struct with the client-side nil id. Mailglass uses UUIDv7 (autogenerated client-side by the Mailglass.Schema macro), so the struct's id field is NEVER nil — Ecto has always filled it before sending to the DB. The sentinel that IS nil on conflict is any DB-defaulted column that only gets its value back via RETURNING: inserted_at (populated by the migration's default: fragment("now()")). Ecto issues #3132/#3910/#2694 describe the same underlying behavior; the detection mechanic is just the column that tells the story.

Auto-capture (D-05)

When :tenant_id is not in attrs, Mailglass.Tenancy.current/0 fills it. When :trace_id is not set, an OTel context probe fills it (nil-tolerant; the probe is guarded so the module compiles even when :otel_propagator_text_map is absent).

Telemetry (D-04)

Both paths (well, append/1 directly; append_multi/3 via the caller's Repo.transact/1 call) emit [:mailglass, :events, :append, :start | :stop | :exception]. :stop metadata carries inserted?: boolean and idempotency_key_present?: boolean so downstream replay counters can hook without a second DB query.

Summary

Functions

Standalone audit append — opens a transaction, inserts, translates 45A01 errors. Returns {:ok, %Event{}} on success (including replay).

Appends an insert step to an existing Ecto.Multi. The caller owns the transact/commit lifecycle. Replay observability is the caller's responsibility (add a Multi.run step inspecting event.inserted_at == nil — see moduledoc "The replay-detection sentinel" for why inserted_at, not id).

Types

attrs()

@type attrs() :: %{
  optional(:type) => atom() | String.t(),
  optional(:delivery_id) => Ecto.UUID.t() | nil,
  optional(:occurred_at) => DateTime.t(),
  optional(:idempotency_key) => String.t() | nil,
  optional(:reject_reason) => atom() | nil,
  optional(:normalized_payload) => map(),
  optional(:metadata) => map(),
  optional(:tenant_id) => String.t(),
  optional(:trace_id) => String.t() | nil,
  optional(:needs_reconciliation) => boolean()
}

Functions

append(attrs)

(since 0.1.0)
@spec append(attrs()) :: {:ok, Mailglass.Events.Event.t()} | {:error, term()}

Standalone audit append — opens a transaction, inserts, translates 45A01 errors. Returns {:ok, %Event{}} on success (including replay).

append_multi(multi, name, attrs)

(since 0.1.0)
@spec append_multi(Ecto.Multi.t(), atom(), attrs() | (map() -> attrs())) ::
  Ecto.Multi.t()

Appends an insert step to an existing Ecto.Multi. The caller owns the transact/commit lifecycle. Replay observability is the caller's responsibility (add a Multi.run step inspecting event.inserted_at == nil — see moduledoc "The replay-detection sentinel" for why inserted_at, not id).

Function-form attrs (I-03, Phase 3)

When attrs is a 1-arity function, it is called inside a Multi.run step with the prior changes map. This allows callers to reference a prior step's result (e.g. a just-inserted Delivery's :id) in the event's :delivery_id field. The step producing the attrs map is named :"<name>_attrs" and the insert step is named name.

Examples

# Map form (existing):
Events.append_multi(multi, :queued, %{type: :queued, delivery_id: id})

# Function form (new in Phase 3):
Events.append_multi(multi, :queued, fn %{delivery: d} ->
  %{type: :queued, delivery_id: d.id}
end)