StatifierPersistence.Migration.Plan (StatifierPersistence v0.15.1)

Copy Markdown View Source

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}]
}

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.

t()

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

content_hash()

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

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

datamodel_op()

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

One operation on a top-level datamodel key.

field()

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

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

finding()

@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()

@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()

@type literal() ::
  nil
  | boolean()
  | number()
  | String.t()
  | [literal()]
  | %{required(String.t()) => literal()}

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

malformed()

@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()

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

An author-written state id.

t()

@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

from_map(map)

@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(attrs)

@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(plan)

@spec to_map(t()) :: %{required(String.t()) => 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(plan, from_machine, to_machine)

@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.