CI Hex Version License

Declared state transitions as pre/post contracts.

A transition names the states it leaves and the state it reaches, the guards that must hold, and the effects it performs on the way. {from, guards} body {to} is a Hoare triple; Hoare.Transition.run/4 discharges it and keeps one law: a run ends in to or leaves the record in from, never between.

check   from  guards            pure, on the resolved record
perform effects                  IO; bare = idempotent, {run, undo} = reverted on failure
commit  lock  re-read  body  write to  assert to     one transaction

Installation

def deps do
  [
    {:hoare, "~> 0.1"}
  ]
end

States

A state is a module: the status that tags a record, the reason when it does not, and the properties that refine the tag and extract witnesses.

defmodule PendingUnpack do
  @behaviour Hoare.State
  defstruct [:record, :package]

  def status, do: :PENDING_UNPACK
  def missing, do: :not_pending_unpack
  def properties, do: [&single_package/1]

  defp single_package(%{record: %{packages: [package]}} = state), do: {:ok, %{state | package: package}}
  defp single_package(%{record: %{packages: []}}), do: {:error, :no_packages}
  defp single_package(_), do: {:error, :multiple_packages}
end

Hoare.State.match/2 builds the struct or says why the record is not in that state. A state entered by one transition is the same module another leaves from, so the graph is nominal and inspectable.

Transitions

defmodule Repack do
  defstruct [:record, :state, :label]

  def transition do
    %Hoare.Transition{
      from: [PendingUnpack],
      to: Packed,
      effects: [{&purchase_label/1, &release_label/1}]
    }
  end

  defp purchase_label(ctx), do: with({:ok, label} <- Carrier.buy(ctx.state.package), do: {:ok, %{ctx | label: label}})
  defp release_label(%{label: label}), do: :ok = Carrier.release(label)
end

The context is any struct with record and state keys. The module owning the records supplies the body of the commit and the store:

Hoare.Transition.run(Repack.transition(), %Repack{record: order}, &write_package/1, store: Repo)
# {:ok, %Repack{record: %Order{status: :PACKED}, ...}} | {:error, reason}

commit/5 takes the store's lock on {schema, id} (or opts[:lock]), re-reads the record and refuses it unless still tagged by a from state, runs the body, writes to's status through the schema's changeset/2, and asserts to before the transaction closes. A record already in to is a concurrent run: it converges as {:ok, record} when every completed effect was bare, and is {:error, :status_changed} when one must be undone.

Store

Hoare.Store is the three operations the commit needs: transact_with_lock/2, fetch/2, update/1. An Ecto repo supplies the last as is and declares the other two.

Results

Hoare.Result holds the few combinators the runner is built from: bind/2, kleisli/1, tap_ok/2, tap_error/2, map_error/2. Tagged tuples throughout; nothing is wrapped.