Introspection helpers for AshWorkflow resources.
Summary
Types
The merged view of a transition name, as returned by transition/2.
Functions
Returns the list of user-facing action names available at a given step.
Returns the every entities declared on a given step, or [] if the step
has none or does not exist.
Returns true if the given record is in a terminal state.
Returns the initial step for the workflow.
Returns the composite indexes that make the generated Oban triggers cheap, as a list of attribute-name lists, most useful first.
Returns every AshWorkflow.Scheduler.Work the workflow declares.
Returns the workflow's scheduler as {module, options}.
Returns the attribute the workflow stores its current step in.
Returns a single workflow step by name, or nil if not found.
Returns all workflow step entities for a resource.
Returns true if the given step is terminal (an end state with no outgoing transitions).
Returns the merged view of a transition name, or nil if no step declares it.
Returns the workflow's transition_log configuration, or nil if no
transition log is configured.
Returns the step the transition transition_name would move record to,
without running it.
Returns the workflow's undo configuration, or nil if undo is not enabled.
Returns true if the forward move from_state -> to_state may be rewound.
Returns the set of state changes that may be rewound, as {from_state, to_state} tuples describing the forward move.
Returns a graph representation of the workflow as a map.
Types
@type merged_transition() :: %{ name: atom(), from: [atom()], routes: [%{from: atom(), to: atom(), when: Ash.Expr.t() | nil}], accepted_inputs: [atom()], generated_action: atom() }
The merged view of a transition name, as returned by transition/2.
Functions
@spec available_actions(Ash.Resource.t(), atom()) :: [atom()]
Returns the list of user-facing action names available at a given step.
For manual steps, returns the transition names. For automatic and terminal steps, returns an empty list.
@spec everys(Ash.Resource.t(), atom()) :: [AshWorkflow.Entities.Every.t()]
Returns the every entities declared on a given step, or [] if the step
has none or does not exist.
@spec in_terminal_state?(Ash.Resource.record()) :: boolean()
Returns true if the given record is in a terminal state.
The record must have its state attribute loaded.
@spec initial_step(Ash.Resource.t()) :: AshWorkflow.Entities.Step.t() | nil
Returns the initial step for the workflow.
@spec recommended_indexes(Ash.Resource.t() | map()) :: [[atom()]]
Returns the composite indexes that make the generated Oban triggers cheap, as a list of attribute-name lists, most useful first.
Every trigger's where clause filters on the state attribute, and every
timeout also filters on the datetime field it reads: its field, or its
fire_at when it declares one. Because ago/2 compiles to a bind
parameter rather than a per-row function call, a timeout's filter reaches the
data layer as state = $1 AND state_entered_at <= $2 — an ordinary composite
range scan. Without these indexes each poll is a sequential scan.
A [state, field] index also serves the automatic-step triggers, which
filter on the state attribute alone, since it is the leading column. The
state attribute is returned on its own when a workflow declares no timeouts
at all. A workflow that renames the attribute with state_attribute gets
indexes on that name instead.
Calculation-backed timeout fields are omitted: they are not columns, so they cannot be indexed directly.
For resources using AshPostgres.DataLayer these are added automatically as
custom_indexes — see AshWorkflow.Transformers.AddIndexes. This function is
for everyone else, and for tooling.
Example
AshWorkflow.Info.recommended_indexes(MyApp.CandidatePipeline)
#=> [[:state, :state_entered_at], [:state, :last_session_date]]
@spec scheduled_work(Ash.Resource.t() | map()) :: [AshWorkflow.Scheduler.Work.t()]
Returns every AshWorkflow.Scheduler.Work the workflow declares.
One per automatic step and one per timeout, as
AshWorkflow.Transformers.AddScheduler built them and handed them to the
selected scheduler. A runtime scheduler reads this rather than re-deriving
the list from steps and timeouts, so both see exactly the same work.
@spec scheduler(Ash.Resource.t() | map()) :: {module(), keyword()}
Returns the workflow's scheduler as {module, options}.
Falls back to the :scheduler application environment for :ash_workflow,
and then to AshWorkflow.Scheduler.Oban.
@spec state_attribute(Ash.Resource.t() | map()) :: atom()
Returns the attribute the workflow stores its current step in.
Defaults to :state. A workflow overrides it with state_attribute on the
workflow section, which is passed down to ash_state_machine.
@spec step(Ash.Resource.t(), atom()) :: AshWorkflow.Entities.Step.t() | nil
Returns a single workflow step by name, or nil if not found.
@spec steps(Ash.Resource.t() | map()) :: [AshWorkflow.Entities.Step.t()]
Returns all workflow step entities for a resource.
Accepts either a compiled resource module or an in-progress DSL state, so transformers can share the same introspection.
@spec terminal?(Ash.Resource.t(), atom()) :: boolean()
Returns true if the given step is terminal (an end state with no outgoing transitions).
@spec transition(Ash.Resource.t() | map(), atom()) :: merged_transition() | nil
Returns the merged view of a transition name, or nil if no step declares it.
A transition name declared on more than one step becomes a single generated
action, and the declarations merge: from lists every step the action moves
out of, routes holds one entry per declared target with the when
expression that selects it, and accepted_inputs is the union of every
declaration's accept.
A route from a static transition has a when of nil. The step it leaves is
on the route itself, so a caller reading a route never has to pair it back up
with a step.
Example
AshWorkflow.Info.transition(MyApp.OnboardingWorkflow, :complete)
#=> %{
#=> name: :complete,
#=> from: [:initial_review, :detailed_review],
#=> routes: [
#=> %{from: :initial_review, to: :detailed_review, when: nil},
#=> %{from: :detailed_review, to: :approved, when: nil}
#=> ],
#=> accepted_inputs: [:notes],
#=> generated_action: :complete
#=> }
@spec transition_log(Ash.Resource.t() | map()) :: AshWorkflow.Entities.TransitionLog.t() | nil
Returns the workflow's transition_log configuration, or nil if no
transition log is configured.
@spec transition_target(Ash.Resource.record(), atom(), map()) :: {:ok, atom() | nil} | {:error, term()}
Returns the step the transition transition_name would move record to,
without running it.
Resolves routes the same way the generated transition action does
(AshWorkflow.Changes.ConditionalTransition): in declaration order, first
match wins, scoped to the step the record is in. A static transition such as
transition :reject, to: :rejected resolves to its to.
Returns:
{:ok, step}— the step the transition would land in{:ok, nil}— the transition would not move the record: the record's current step does not declare it, or no route matches. The action itself fails in both cases{:error, error}— a route'swhenfailed to evaluate. The action fails on the same error
Raises ArgumentError when no step declares transition_name.
Routes are evaluated against record as given, and this function loads
nothing. A when that reads an unloaded relationship or calculation sees
nil. The generated action does the same with the record it is called on,
so load what the routes read before previewing or running the transition.
The :transition_targets calculation loads it for you.
A route that reads accepted input sees that input only when the transition
runs. To preview a particular call, pass its input as input. Only the keys
the transition accepts are applied, the same as the action applies them.
Keys are attribute names as atoms, and values are used as given, not cast.
Example
AshWorkflow.Info.transition_target(candidate, :advance)
#=> {:ok, :compliance}
AshWorkflow.Info.transition_target(document, :decide, %{decision: :approve})
#=> {:ok, :approved}
@spec undo(Ash.Resource.t() | map()) :: AshWorkflow.Entities.Undo.t() | nil
Returns the workflow's undo configuration, or nil if undo is not enabled.
@spec undoable_edge?(Ash.Resource.t() | map(), atom(), atom()) :: boolean()
Returns true if the forward move from_state -> to_state may be rewound.
@spec undoable_edges(Ash.Resource.t() | map()) :: [{atom(), atom()}]
Returns the set of state changes that may be rewound, as {from_state, to_state} tuples describing the forward move.
A conditional transition contributes one edge per route, so undo permits exactly the moves the transition could actually have made. Returns an empty list when undo is not enabled.
Example
AshWorkflow.Info.undoable_edges(MyApp.OnboardingWorkflow)
#=> [{:review, :approved}, {:review, :rejected}]
@spec workflow_graph(Ash.Resource.t()) :: %{required(atom()) => map()}
Returns a graph representation of the workflow as a map.
Each key is a step name, and the value describes that step and every edge leaving it, so a caller can draw and label the whole workflow without reaching back into the DSL.
The step itself carries:
:name— the step name, repeated so an entry stands alone once taken out of the map:action— the action an automatic step runs,nilfor every other step:policy— the step'spolicycheck, ornil:retry— the step'sAshWorkflow.Entities.Retry, ornil:initial—truefor the step the workflow starts in, asAshWorkflow.Entities.Step.find_initial/1picks it:terminal—truefor an end state:manual—truewhen nothing runs on entry:wait_state—truewhen a timeout is the step's only exit
The edges are five lists:
:transitions— one entry per reachable target, as%{name:, to:, condition:, undoable?:, accept:}. A conditional transition contributes one entry per route, each carrying that route'swhenexpression as:condition; a simple transition contributes one entry with anilcondition.:undoable?istrueonly when the workflow declares anundoblock and the transition opts in.:on_success— one entry per declaredon_successroute, as%{to:, condition:}.:on_error— the step an automatic step falls to on failure, ornil.:timeouts— one entry per timeout, as%{name:, to:, fire_after:, fire_at:, field:, action:, retry:}. A timeout that runs an action rather than moving the workflow has anil:toand a non-nil:action. Afire_attimeout names the field holding its deadline, so it has anil:fire_afterand anil:field.:everys— one entry perevery, as%{name:, interval:, action:, until:, retry:}. Always measures againststate_entered_atand never has a target, since firing never leaves the step.:untilisnilunless theeverybounds its firing.
Example
AshWorkflow.Info.workflow_graph(MyApp.OnboardingWorkflow)
#=> %{
#=> screening: %{
#=> name: :screening,
#=> action: nil,
#=> policy: nil,
#=> retry: nil,
#=> initial: true,
#=> terminal: false,
#=> manual: true,
#=> wait_state: false,
#=> transitions: [
#=> %{name: :advance, to: :interviewing, condition: nil, undoable?: true, accept: [:notes]},
#=> %{name: :reject, to: :rejected, condition: nil, undoable?: false, accept: []}
#=> ],
#=> on_success: [],
#=> on_error: nil,
#=> timeouts: [
#=> %{name: :breach, to: :escalated, fire_after: {1, :hours}, fire_at: nil, field: :state_entered_at, action: nil, retry: nil},
#=> %{name: :expire, to: :expired, fire_after: nil, fire_at: :offer_expires_at, field: nil, action: nil, retry: nil}
#=> ],
#=> everys: [
#=> %{name: :chase, interval: {3, :days}, action: :send_reminder, until: nil, retry: nil}
#=> ]
#=> },
#=> ...
#=> }