Packages

Event-driven notification system for Ash Framework with multiple transport types

Current section

Files

Jump to
ash_dispatch lib transport.ex
Raw

lib/transport.ex

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