# `StatifierPersistence.Migration.Plan`
[🔗](https://github.com/riddler/statifier_persistence/blob/v0.18.0/lib/statifier_persistence/migration/plan.ex#L1)

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):

- `:from` and `: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 from `0`.
- `: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}]
    }

# `content_hash`

```elixir
@type content_hash() :: String.t()
```

A chart's content hash, as `Statifier.Machine.Identity` writes it.

# `datamodel_op`

```elixir
@type datamodel_op() ::
  {:add, String.t(), literal()}
  | {:rename, String.t(), String.t()}
  | {:remove, String.t()}
```

One operation on a top-level datamodel key.

# `field`

```elixir
@type field() ::
  :plan
  | :from
  | :to
  | :states
  | :drop
  | :history
  | :invocations
  | :timers
  | :datamodel
```

A plan field, or `:plan` for the plan as a whole.

# `finding`

```elixir
@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_hash` is `nil` for 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`, `:drop` and `:history`.
- `{:history_mismatch, field, source_id, target_id}` - a history state is
  mapped to a state that is not a history state, or `:history` names 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; `side` is
  `:from` or `:to`.
- `{:duplicate_invocation, :from | :to, {state_id, ordinal}}` - two moved
  invocations share a source, or share a target.

# `invocation`

```elixir
@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}`.

# `literal`

```elixir
@type literal() ::
  nil
  | boolean()
  | number()
  | String.t()
  | [literal()]
  | %{required(String.t()) =&gt; literal()}
```

A JSON literal: the only kind of value a datamodel operation carries.

# `malformed`

```elixir
@type malformed() :: {:malformed_plan, field(), term()}
```

Why `new/1` or `from_map/1` refused a plan: the field at fault and what is
wrong with it.

# `state_id`

```elixir
@type state_id() :: String.t()
```

An author-written state id.

# `t`

```elixir
@type t() :: %StatifierPersistence.Migration.Plan{
  datamodel: [datamodel_op()],
  drop: [state_id()],
  from: content_hash(),
  history: %{required(state_id()) =&gt; state_id()},
  invocations: [invocation()],
  states: %{required(state_id()) =&gt; state_id()},
  timers: %{keep_mapped: true},
  to: content_hash()
}
```

# `from_map`

```elixir
@spec from_map(map()) :: {:ok, t()} | {:error, malformed()}
```

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.

# `new`

```elixir
@spec new(keyword() | map()) :: {:ok, t()} | {:error, malformed()}
```

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.

# `to_map`

```elixir
@spec to_map(t()) :: %{required(String.t()) =&gt; term()}
```

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}`.

# `validate`

```elixir
@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; `t: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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
