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
@type commit_error() :: :not_found | :status_changed
@type ctx() :: %{ :record => Hoare.Store.subject(), :state => struct() | nil, optional(atom()) => term() }
@type error(reason) :: {:error, reason | commit_error()}
@type undo() :: (ctx() -> :ok)
Callbacks
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.
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'.
Matches from into the context's state, then runs the guards.
@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.
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.
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}.