defmodule AshDispatch.Transport do @moduledoc """ Behaviour for AshDispatch transports. Before this behaviour, adding a new transport (e.g. `:oban`) required edits in 3-4 places in `AshDispatch.Dispatcher`: 1. The `dispatch_to_transport/4` case statement (routing) 2. The `skip_receipt_for_transport?/1` truth table 3. The moduledoc transport list 4. (Optionally) tests against any of the above With this behaviour, each transport `use`s the macro with its atom + receipt-skip declaration, and `AshDispatch.Transport.Registry` aggregates them at compile time. The dispatcher's case statement becomes a single registry lookup; adding a transport is one new file + one entry in the registry's module list. ## Implementing defmodule MyApp.Transports.Foo do use AshDispatch.Transport, atom: :foo, skip_receipt?: false def deliver(receipt, context, channel, event_config) do # ... transport-specific work {:ok, updated_receipt} end end Both `transport_atom/0` and `skip_receipt?/0` are generated by the macro and `defoverridable`, so subclasses can override either if a transport's receipt-skip behaviour is conditional (rare). ## Receipt-skip semantics `skip_receipt?: true` means the transport is "lightweight" — no `DeliveryReceipt` row is created when this channel fires. Used by `:broadcast` (the audit trail is the PubSub log) and `:oban` (the audit trail is the enqueued `oban_jobs` row). Lightweight transports receive a pseudo-receipt in `deliver/4`: `%{id: nil, ...}`. They should NOT call `Ash.update` on the pseudo-receipt; instead return `{:ok, Map.put(receipt, :status, :sent | :skipped)}`. """ @doc """ Deliver an event payload to a recipient/channel. Returns `{:ok, receipt}` on success (the receipt may be updated to reflect terminal status) or `{:error, reason}`. Errors propagate to `Dispatcher.dispatch_to_transport/4` which logs + folds them into the per-recipient verdict; the dispatcher itself absorbs errors so one transport's failure doesn't block the others on the same event. """ @callback deliver( receipt :: map(), context :: map(), channel :: map(), event_config :: keyword() ) :: {:ok, map()} | {:error, term()} @doc "The transport atom this module handles (e.g. `:oban`, `:broadcast`)." @callback transport_atom() :: atom() @doc """ Whether to skip `DeliveryReceipt` creation for this transport. Defaults to `false` (Receipt + status-tracking is the default semantics; opt out only for transports where another artifact IS the audit trail — see `:broadcast` / `:oban`). """ @callback skip_receipt?() :: boolean() @doc """ Event metadata keys this transport REQUIRES at the event-registration layer (F4). `ValidateChannels` enforces presence at compile-time — an event registering a `[transport: :oban]` channel must declare `metadata: [oban_worker: ...]` or the DSL fails with a precise `Spark.Error.DslError` pointing at the registration site. Defaults to `[]` (no required keys). `:oban` overrides to `[:oban_worker]`. Optional — transports that don't need event metadata return `[]`. """ @callback required_event_metadata_keys() :: [atom()] @optional_callbacks required_event_metadata_keys: 0 defmacro __using__(opts) do atom = Keyword.fetch!(opts, :atom) skip_receipt? = Keyword.get(opts, :skip_receipt?, false) required_keys = Keyword.get(opts, :required_event_metadata_keys, []) quote do @behaviour AshDispatch.Transport @impl AshDispatch.Transport def transport_atom, do: unquote(atom) @impl AshDispatch.Transport def skip_receipt?, do: unquote(skip_receipt?) @impl AshDispatch.Transport def required_event_metadata_keys, do: unquote(required_keys) defoverridable transport_atom: 0, skip_receipt?: 0, required_event_metadata_keys: 0 end end end