A migration plan: how one execution's position crosses from one chart to another, as data.
ADR-0013 (docs/adr/0013-the-migration-plan.md) decides the plan. This
module implements its decision 1 (the plan's fields and its one map
encoding) and the static half of its decision 3 (the validation against
the two machines). It does not migrate anything and reads no execution;
applying a plan to an execution is Executions' job, not this module's.
This is the plan for moving an execution between charts. It has
nothing to do with StatifierPersistence.Ecto.Migrations, the helper that
creates and upgrades this package's tables.
The fields
A plan names the chart it moves from and the chart it moves to by content hash, and says how every part of a position crosses between them (ADR-0013 decision 1):
:fromand:to- the two content hashes.:states- a source state id to a target state id. A state the plan does not name maps to the state of the same id in the to chart, when there is one.:drop- source state ids the plan removes on purpose.:history- a source history state id to a target history state id, with the same default as:states.:invocations- one{from_state_id, from_ordinal, to_state_id, to_ordinal}per moved invocation; the ordinal is the engine's within-state document-order ordinal of an<invoke>, counted from0.:timers-%{keep_mapped: true}, the only value the format admits (ADR-0013 decision 6).:datamodel- an ordered list of{:add, key, value},{:rename, from_key, to_key}and{:remove, key}on top-level datamodel keys. A value is a JSON literal, never an expression, and a key that begins with_is refused because those are the engine's system variables.
The struct is the in-memory form. What crosses a package boundary or
reaches storage is the map form to_map/1 writes and from_map/1 reads:
string keys only, an invocation as the four-element list
[from_state_id, from_ordinal, to_state_id, to_ordinal], and a datamodel
operation as an object with an "op" key.
An example
A library hold waits in awaiting_pickup for copy.collected or
pickup.expired. The library then renames that state ready_for_pickup
and gives the routing step before it a transferred outcome leading to a
new state. The plan that moves a waiting hold across that edit renames one
state and adds one datamodel key:
{:ok, plan} =
Plan.new(
from: from_hash,
to: to_hash,
states: %{"awaiting_pickup" => "ready_for_pickup"},
datamodel: [{:add, "transfer_branch", nil}]
)
:ok = Plan.validate(plan, from_machine, to_machine)In the map form the same plan is:
%{
"from" => from_hash,
"to" => to_hash,
"states" => %{"awaiting_pickup" => "ready_for_pickup"},
"drop" => [],
"history" => %{},
"invocations" => [],
"timers" => %{"keep_mapped" => true},
"datamodel" => [%{"op" => "add", "key" => "transfer_branch", "value" => nil}]
}
Summary
Types
A chart's content hash, as Statifier.Machine.Identity writes it.
One operation on a top-level datamodel key.
A plan field, or :plan for the plan as a whole.
One static finding. Each names the offending id.
One moved invocation: {from_state_id, from_ordinal, to_state_id, to_ordinal}.
A JSON literal: the only kind of value a datamodel operation carries.
Why new/1 or from_map/1 refused a plan: the field at fault and what is
wrong with it.
An author-written state id.
Functions
Reads a plan from its map form (ADR-0013 decision 1), refusing a
malformed one exactly as new/1 does.
Builds a plan from its fields, refusing a malformed one (ADR-0013 decision 1).
Writes a plan in its map form: the one encoding of a plan (ADR-0013 decision 1).
The static validation: checks a plan against the two compiled machines, before any execution is read (ADR-0013 decision 3, its first half).
Types
@type content_hash() :: String.t()
A chart's content hash, as Statifier.Machine.Identity writes it.
@type datamodel_op() :: {:add, String.t(), literal()} | {:rename, String.t(), String.t()} | {:remove, String.t()}
One operation on a top-level datamodel key.
@type field() ::
:plan
| :from
| :to
| :states
| :drop
| :history
| :invocations
| :timers
| :datamodel
A plan field, or :plan for the plan as a whole.
@type finding() :: {:identity_mismatch, :from | :to, content_hash(), content_hash() | nil} | {:unknown_source, :states | :drop | :history | :invocations, state_id()} | {:unknown_target, :states | :history | :invocations, state_id()} | {:duplicate_source, state_id()} | {:history_mismatch, :states | :history, state_id(), state_id()} | {:invocation_out_of_range, :from | :to, state_id(), non_neg_integer(), non_neg_integer()} | {:duplicate_invocation, :from | :to, {state_id(), non_neg_integer()}}
One static finding. Each names the offending id.
{:identity_mismatch, :from | :to, plan_hash, machine_hash}- the machine does not carry the identity whose content hash the plan names;machine_hashisnilfor a machine with no identity.{:unknown_source, field, state_id}- a source id is not a state of the from chart.{:unknown_target, field, state_id}- a target id is not a state of the to chart.{:duplicate_source, state_id}- a source id appears more than once across:states,:dropand:history.{:history_mismatch, field, source_id, target_id}- a history state is mapped to a state that is not a history state, or:historynames a state that is not a history state.{:invocation_out_of_range, side, state_id, ordinal, invoke_count}- the ordinal is not one of that state's<invoke>children;sideis:fromor:to.{:duplicate_invocation, :from | :to, {state_id, ordinal}}- two moved invocations share a source, or share a target.
@type invocation() :: {state_id(), non_neg_integer(), state_id(), non_neg_integer()}
One moved invocation: {from_state_id, from_ordinal, to_state_id, to_ordinal}.
@type literal() :: nil | boolean() | number() | String.t() | [literal()] | %{required(String.t()) => literal()}
A JSON literal: the only kind of value a datamodel operation carries.
Why new/1 or from_map/1 refused a plan: the field at fault and what is
wrong with it.
@type state_id() :: String.t()
An author-written state id.
@type t() :: %StatifierPersistence.Migration.Plan{ datamodel: [datamodel_op()], drop: [state_id()], from: content_hash(), history: %{required(state_id()) => state_id()}, invocations: [invocation()], states: %{required(state_id()) => state_id()}, timers: %{keep_mapped: true}, to: content_hash() }
Functions
Reads a plan from its map form (ADR-0013 decision 1), refusing a
malformed one exactly as new/1 does.
Keys are strings only; an atom key is an unknown key. "from" and "to"
are required and every other key defaults as in new/1. The input is
what a JSON decoder answers, so a plan a host stored as JSON reads back
unchanged.
Builds a plan from its fields, refusing a malformed one (ADR-0013 decision 1).
Takes a keyword list or a map with the struct's atom keys. :from and
:to are required; every other field defaults to the empty plan's value
(:timers to %{keep_mapped: true}). Answers
{:error, {:malformed_plan, field, reason}} naming the first field at
fault: an unknown key, a hash that is not a non-empty string, a state id
that is not a non-empty string, an invocation that is not a four-tuple of
ids and non-negative ordinals, a :timers other than
%{keep_mapped: true}, a datamodel operation of another shape, a
datamodel key that begins with _, or a datamodel value that is not a
JSON literal.
A plan new/1 answers is well-formed, not valid: whether its ids name
states of the two charts is validate/3's question.
Writes a plan in its map form: the one encoding of a plan (ADR-0013 decision 1).
Every key is a string and every value is JSON-safe: an invocation is the
list [from_state_id, from_ordinal, to_state_id, to_ordinal] and a
datamodel operation is an object whose "op" is "add", "rename" or
"remove". Every field is written, defaults included, so
from_map(to_map(plan)) answers {:ok, plan}.
@spec validate(t(), Statifier.Machine.t(), Statifier.Machine.t()) :: :ok | {:error, [finding()]}
The static validation: checks a plan against the two compiled machines, before any execution is read (ADR-0013 decision 3, its first half).
Answers :ok, or {:error, findings} with every finding at once,
never only the first; finding/0 lists them. The checks are: each
machine carries the identity whose content hash the plan names; every
source id in :states, :drop, :history and :invocations is a state
of the from chart; every target id is a state of the to chart; no source
id appears twice across :states, :drop and :history; a history state
maps only to a history state, and :history names only history states;
every invocation's from ordinal is in range of its from state's <invoke>
children and its to ordinal in range of its to state's; and no two
invocations share a source or a target.
It reads the two machines through Statifier.Machine's accessors and
nothing else: it runs no interpreter and reads no position. Whether an
execution's states are all mapped is the second validation's question,
asked at apply.