Hoare.Transition behaviour (hoare v0.2.0)

Copy Markdown View Source

A declared transition: the states it leaves, the state it reaches, the guards that must hold and the effects it performs, each list composed as one Kleisli arrow. {from, guards} body {to} is the Hoare triple; run/4 discharges it.

from and to are Hoare.State modules. The context is any map or struct with a record key, the record whose status moves, and a state key, which check/2 fills by matching from. Each arrow is ctx -> {:ok, ctx} | {:error, reason} and may refine the context it returns. The domain declares the transition; the module owning the records supplies the body of the commit when it runs it, and commit/5 wraps that body in the store's locked transaction, the re-check and the to write.

The commit re-reads the record under the lock and runs check/2 again on it, so a guard runs twice and must be a function of the context alone. A guard writes only keys no effect writes, since its second run overwrites them. Whatever the caller resolved into the context is as stale the second time as the first: a condition that has to hold under the lock belongs in a state property or a guard that reads the record, with its needs named in preloads. preloads/1 is the one list both the caller's fetch and the commit's re-read load.

The law: a run ends in to or leaves the record in from, never between. A bare effect must be idempotent, so a commit that fails after it is recovered by running again. An effect paired with an undo is reverted, newest first, when anything after it fails; an undo that fails raises once the older ones have run. A bare effect that ran before a failure cannot be reverted: stranded is told, with the context and the reason, so the domain can alert or queue the retry. A raise is its own report and does not reach it.

Summary

Callbacks

The effects, in order; none by default.

The guards, in order; none by default.

Told when a run fails after a bare effect; wired only when defined.

Functions

Declares the transition as the module: the literal parts are options, the arrows are callbacks, and the context struct is record, state and :ctx.

Matches from into the context's state, then runs the guards.

Commits in one locked transaction: re-reads the record with the transition's preloads, checks it again, runs body on the re-checked context, writes to's status and asserts to on a second read.

The statuses that tag the states the transition leaves.

Everything the states and guards read: the states' preloads, then the transition's own.

Runs the transition end to end: guards, then effects, then the commit.

Types

arrow()

@type arrow() :: (ctx() -> {:ok, ctx()} | {:error, term()})

body()

@type body() :: (ctx() -> {:ok, term()} | {:error, term()})

commit_error()

@type commit_error() :: :not_found | :status_changed

ctx()

@type ctx() :: %{
  :record => Hoare.Store.subject(),
  :state => struct() | nil,
  optional(atom()) => term()
}

effect()

@type effect() :: arrow() | {arrow(), undo()}

error(reason)

@type error(reason) :: {:error, reason | commit_error()}

opts()

@type opts() :: [store: module(), lock: term()]

stranded()

@type stranded() :: (ctx(), reason :: term() -> term())

t()

@type t() :: %Hoare.Transition{
  effects: [effect()],
  from: [module(), ...],
  guards: [arrow()],
  preloads: [term()],
  stranded: stranded() | nil,
  to: module()
}

undo()

@type undo() :: (ctx() -> :ok)

Callbacks

effects()

@callback effects() :: [effect()]

The effects, in order; none by default.

guards()

@callback guards() :: [arrow()]

The guards, in order; none by default.

stranded(ctx, reason)

(optional)
@callback stranded(ctx(), reason :: term()) :: term()

Told when a run fails after a bare effect; wired only when defined.

Functions

__using__(opts)

(macro)

Declares the transition as the module: the literal parts are options, the arrows are callbacks, and the context struct is record, state and :ctx.

defmodule Pay do
  use Hoare.Transition, from: [Issued], to: Paid, ctx: [:charge]

  import Hoare.Result, only: [ensure: 2]

  @impl Hoare.Transition
  def guards, do: [ensure(&amount_due?/1, :nothing_due)]

  @impl Hoare.Transition
  def effects, do: [&cancel_reminder/1, {&charge/1, &refund/1}]

  @impl Hoare.Transition
  def stranded(ctx, reason), do: Alerts.reminder_cancelled_unpaid(ctx.record, reason)
end

Pay.run(%Pay{record: invoice}, &record_payment/1, store: Repo)

Injects transition/0, check/1, run/3, preloads/0 and from_statuses/0. :preloads are the transition's own, beyond its states'.

check(transition, ctx)

@spec check(t(), ctx()) :: {:ok, ctx()} | {:error, term()}

Matches from into the context's state, then runs the guards.

commit(transition, ctx, body, converges?, opts)

@spec commit(t(), ctx(), body(), boolean(), opts()) ::
  {:ok, Hoare.Store.subject()} | {:error, :not_found | :status_changed | term()}

Commits in one locked transaction: re-reads the record with the transition's preloads, checks it again, runs body on the re-checked context, writes to's status and asserts to on a second read.

A record already tagged by to is a concurrent run of the same transition: it commits nothing and is {:ok, record} when converges? (no effect left anything behind), {:error, :status_changed} otherwise. A record tagged by neither is {:error, :status_changed}; one still tagged by from whose properties or guards no longer hold returns their reason. A violated to raises, rolling the transaction back. The status is written through the schema's changeset/2.

from_statuses(transition)

@spec from_statuses(t()) :: [atom()]

The statuses that tag the states the transition leaves.

preloads(transition)

@spec preloads(t()) :: [term()]

Everything the states and guards read: the states' preloads, then the transition's own.

run(transition, ctx, body, opts)

@spec run(t(), ctx(), body(), opts()) :: {:ok, ctx()} | {:error, term()}

Runs the transition end to end: guards, then effects, then the commit.

body is the domain's own writes; commit/5 wraps them. On success the context comes back with record replaced by the committed record. opts needs :store, a Hoare.Store; :lock defaults to {schema, id}.