Skip to main content

lib/gen_durable/state.ex

defmodule GenDurable.State do
  @moduledoc """
  Typed FSM state: an Ecto embedded schema per FSM, encoded to/from jsonb.
  Typically defined as a nested `State` module inside the FSM, where it is
  adopted by convention (see `GenDurable.FSM`):

      defmodule Checkout do
        use GenDurable.FSM, version: 1

        defmodule State do
          use GenDurable.State

          embedded_schema do
            field :order, :integer
            field :n, :integer, default: 0
          end
        end
      end

  The engine loads the jsonb column into the struct before `step/2`
  (`from_db/2`) and dumps the returned struct back to jsonb on the outcome
  (`to_db/2`). FSMs may also run with no state module, in which case state is a
  plain string-keyed map (allowed, but unsupported — you are on your own).
  """

  defmacro __using__(_opts) do
    quote do
      use Ecto.Schema

      @primary_key false
    end
  end

  @doc "Load a DB jsonb value (raw string or already-decoded map) into the FSM state."
  def from_db(nil, data), do: decode(data)
  def from_db(module, data), do: Ecto.embedded_load(module, decode(data), :json)

  @doc "Dump an FSM state value returned by a step into a JSON string for a jsonb param."
  def to_db(_module, %_{} = struct), do: Jason.encode!(Ecto.embedded_dump(struct, :json))
  def to_db(_module, map) when is_map(map), do: Jason.encode!(map)

  @doc """
  Normalize user-supplied state (for `insert`) into a JSON string. Accepts the
  state struct, or a map (atom or string keys) which is cast through the schema.
  """
  def cast(nil, value) when is_map(value), do: Jason.encode!(value)
  def cast(module, %_{} = struct), do: to_db(module, struct)

  def cast(module, map) when is_map(map),
    do: to_db(module, Ecto.embedded_load(module, json_normalize(map), :json))

  defp decode(data) when is_binary(data), do: Jason.decode!(data)
  defp decode(data) when is_map(data), do: data
  defp decode(nil), do: %{}

  defp json_normalize(map), do: Jason.decode!(Jason.encode!(map))
end