The Statifier.Invoke.Handler every host writing
Statifier.Invoke.SyncHandler modules would otherwise write by hand,
plus the two derived registrations those modules feed.
A host uses this in one module, naming its sync handlers:
defmodule MyApp.InvokeHandler do
use Statifier.Invoke.SyncHandler.Adapter,
handlers: [MyApp.CardAuth.Handlers, MyApp.Signup.Handlers]
endand gets the four Statifier.Invoke.Handler callbacks plus three
readings of that list: invoke_types/0 and invoke_handlers/0 - the two
sets a host has to declare, both derived from it - and sync_handlers/0,
the list itself, for a host driving the pure core with no session to
report to.
Statifier.Compiler.compile(document, known_invoke_types: MyApp.InvokeHandler.invoke_types())
Statifier.Session.start_link(machine, invoke_handlers: MyApp.InvokeHandler.invoke_handlers())One adapter, not one per handler module
Three sync-handler modules do not need three Statifier.Invoke.Handler
implementations. The four callbacks are identical for all of them - the
only difference is which type strings each answers, and that is data, not
code. So invoke_handlers/0 points every registered type at the one
adapter module, and the adapter routes each call back to whichever
handler module claimed that type. Three modules each implementing four
callbacks identically would be this adapter written three times.
Nothing stops a host from having several adapters - one per bounded context, say - each with its own handler list. What it must not do is register the same type string in two of them and merge the maps, since the merge silently keeps one.
The two registrations cannot come apart
invoke_handlers/0 is built from invoke_types/0, not beside it:
one union of Statifier.Invoke.SyncHandler.invoke_types/0 across the
handler list, sorted and deduplicated, then every member mapped to this
adapter. Session start closes the same loop from the other end -
Statifier.Invoke.Types.from_handlers/1 derives the core's registered-type
snapshot from the :invoke_handlers map's own keys (ADR-0051 decision
3's "one constructor"). So the set the compiler lints a document against,
the set a session dispatches on, and the set the pure core classifies
against are three readings of one list of modules, and registering a new
type is one line in one handler module and nothing anywhere else.
Routing, and the type claimed twice
dispatch/4 routes a type to the first module in the list that
claims it. A type claimed by two modules is a host bug this module does
not raise on: the union still contains it exactly once, so nothing
observable goes wrong except that the second module never answers. The
list is ordered and the host wrote it, which is the same posture
Statifier.Send.Routes and a palette take toward the caller's own
ordering. A type no module claims is {:error, {:unknown_invoke_type, type}} - reachable only for a session started with a hand-built
:invoke_handlers map that names this adapter for a type its handlers do
not serve, since a map built by invoke_handlers/0 cannot contain one.
What the callbacks do
start/2 plans exactly one {:handler, __MODULE__, payload} instruction
carrying the three facts perform/2 needs: the invoke_id the answer is
reported against, the type to route on, and the <param> values. Pure.
cancel/2 and forward/3 plan nothing, and there is nothing dishonest
about that. A sync call is answered inside the performing turn, so by the
time a cancel could be planned the invocation is either already reported
or never will be - and the contract's "a handler MUST tolerate cancelling
an invoke_id it no longer knows" is satisfied for free by a handler
that keeps no table to look one up in. Autoforwarding (6.4.2) delivers
the parent's external events to a running invocation's inbox, and a call
with no inbox has nowhere to put the copy.
perform/2 is the impure half: it routes the call, then reports the
answer to the session ctx.session_id names through
Statifier.Session.done_invocation/3 or
Statifier.Session.failed_invocation/3. Reaching the session needs
Statifier.Registry running, which is what Statifier.Supervisor places;
a session id that resolves to nothing is
{:error, {:session_not_registered, id}} rather than a raise, because a
session that went away while its call was out is an ordinary thing to
observe. Errors are events here too.
Idempotency
perform/2 MAY be called more than once for the same invoke_id, and
this adapter deduplicates nothing - it has no view of a host's durable
store, exactly as Statifier.Invoke.Handler says the library has none. A
second call re-runs Statifier.Invoke.SyncHandler.handle/3 and reports
again; the reporting half is harmless twice
(Statifier.Session.done_invocation/3 for an invocation already popped
is a documented no-op), so the whole obligation lands on the handler,
where the moduledoc of Statifier.Invoke.SyncHandler leaves it.
Failure reporting
{:error, reason} from a sync handler is permanent by construction - see
Statifier.Invoke.SyncHandler's "Terminal failure" - so it is reported
through failed_invocation/3 immediately, with reason normalized to
the string a chart reads as _event.data.reason: a binary passes
through, anything else is inspect/1-ed. No :attempts is sent, and
that absence is the honest datum: this adapter is a retry policy that
makes no attempts to count, and Statifier.Session.failed_invocation/3
documents an absent :attempts as reading undefined (ADR-0037), which
is distinct from a host that counted zero.
Summary
Types
The instruction payload start/2 plans and perform/2 consumes. Not part
of the public contract - Statifier.Session.Effects's instruction
vocabulary is opaque outside the library - but named because perform/2's
spec has to say something, and a host reading a trace of planned
instructions will see this shape.
Functions
Generates the Statifier.Invoke.Handler implementation and the two
derived registrations over :handlers.
Routes one call to the first module in modules claiming type.
The %{invoke type => module} map a session is started with: every type
modules claim, pointed at adapter.
The union of Statifier.Invoke.SyncHandler.invoke_types/0 across
modules, sorted and deduplicated.
Runs one planned call and reports the answer to the session
ctx.session_id names. The impure half; the perform/2 a use-ing
module delegates to.
Plans nothing. Pure; see the moduledoc on why a sync call has nothing to cancel.
Plans nothing. Pure; see the moduledoc on why a sync call has no inbox to autoforward into.
Plans the one {:handler, adapter, payload} instruction a sync call
needs. Pure; the start/2 a use-ing module delegates to.
Types
The instruction payload start/2 plans and perform/2 consumes. Not part
of the public contract - Statifier.Session.Effects's instruction
vocabulary is opaque outside the library - but named because perform/2's
spec has to say something, and a host reading a trace of planned
instructions will see this shape.
Functions
Generates the Statifier.Invoke.Handler implementation and the two
derived registrations over :handlers.
:handlers is required and is the list of
Statifier.Invoke.SyncHandler modules this adapter serves, in the order
dispatch/4 resolves a type against.
@spec dispatch( modules :: [module()], type :: String.t(), params :: map(), ctx :: Statifier.Invoke.SyncHandler.ctx() ) :: {:ok, Statifier.Invoke.SyncHandler.donedata()} | {:error, term()}
Routes one call to the first module in modules claiming type.
Public because a host driving the pure core itself - a durable stepper
with no Statifier.Session process to report to - performs its
invocations its own way and still wants the routing, without the
reporting half perform/3 supplies.
@spec invoke_handlers(modules :: [module()], adapter :: module()) :: %{ required(String.t()) => module() }
The %{invoke type => module} map a session is started with: every type
modules claim, pointed at adapter.
adapter is the module implementing Statifier.Invoke.Handler - the one
that uses this module, not one of the sync handlers, which implement no
Statifier.Invoke.Handler callback of their own.
The union of Statifier.Invoke.SyncHandler.invoke_types/0 across
modules, sorted and deduplicated.
The single union in the library: invoke_handlers/2 is built from this
answer rather than from a second walk of modules, so the compiler's set
and the session's map are two readings of one list (see the moduledoc's
"The two registrations cannot come apart").
Raises ArgumentError for a module that does not export invoke_types/0
- a host naming a module that is not a sync handler learns it here rather
than at the
UndefinedFunctionErrora session would raise mid-drive.
@spec perform( modules :: [module()], instruction :: payload(), ctx :: Statifier.Invoke.SyncHandler.ctx() ) :: :ok | {:error, {:session_not_registered, String.t()}}
Runs one planned call and reports the answer to the session
ctx.session_id names. The impure half; the perform/2 a use-ing
module delegates to.
@spec plan_cancel(invoke_id :: String.t(), ctx :: Statifier.Invoke.SyncHandler.ctx()) :: {:ok, []}
Plans nothing. Pure; see the moduledoc on why a sync call has nothing to cancel.
@spec plan_forward( invoke_id :: String.t(), event :: Statifier.Event.t(), ctx :: Statifier.Invoke.SyncHandler.ctx() ) :: {:ok, []}
Plans nothing. Pure; see the moduledoc on why a sync call has no inbox to autoforward into.
@spec plan_start( adapter :: module(), invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.SyncHandler.ctx() ) :: {:ok, [{:handler, module(), payload()}]}
Plans the one {:handler, adapter, payload} instruction a sync call
needs. Pure; the start/2 a use-ing module delegates to.
Named plan_* rather than start/cancel/forward because a
use-ing module's generated callbacks carry those names at those
arities, and one module defining cancel/2 twice - once as the callback,
once as the helper it delegates to - reads as a mistake even where the
compiler is fine with it.