Issho.State (Issho v0.4.0)

Copy Markdown View Source

Shared state without an engine of your own: a map that people and agents change together (Issho.Engine.Map), as a LiveView assign that stays up to date. A retro board, a poll, a checklist, a kanban's order, a game's turn.

Define the document in your policy, as any document, with its initial content from your database and an on_transaction hook that checks a change and saves it (see Issho.Documents.open/4):

def document(_who, "retro:" <> id = doc) do
  {:ok,
   %{
     engine: Issho.Engine.Map,
     initial: Retros.get!(id).state,
     on_transaction: fn _ops, %{after: state} -> Retros.save(id, state) end
   }}
end

Then, in a LiveView (with on_mount Issho.Live):

def mount(%{"id" => id}, _session, socket) do
  room = "retro:" <> id

  if connected?(socket),
    do: {:ok, _} = Issho.Live.join(MyApp.Issho, socket.assigns.current_user, room)

  {:ok, socket |> assign(:room, room) |> Issho.State.assign(:retro, room)}
end

def handle_event("add", %{"text" => text}, socket) do
  card = %{"id" => Ecto.UUID.generate(), "text" => text, "votes" => 0}
  :ok = Issho.State.change(socket, :retro, [Issho.State.insert(["cards"], card)])
  {:noreply, socket}
end

def handle_event("vote", %{"id" => id}, socket) do
  :ok = Issho.State.change(socket, :retro, [Issho.State.increment(["cards", id, "votes"])])
  {:noreply, socket}
end

@retro is the state, a map, and changes as anyone changes it: this LiveView, another person's, an agent (the update_state tool), or your server (Issho.transact/4). It is the state the authority accepted, in its order, the same in every LiveView of the room.

Operations

set/2, delete/1, increment/2, and, for lists of items by id (the cards of a column), insert/3, move/3 and remove/2. A path is a list of keys, and of items' ids through a list: ["columns", "todo", card_id, "title"]. Concurrent changes merge as Issho.Engine.Map says: two people who add a card at once both have theirs.

Summary

Functions

Assigns a document of the room as key, kept up to date as it changes: the LiveView must be in the room (Issho.Live.join/4), and the document's engine Issho.Engine.Map. In the static render, before the LiveView is connected and in the room, key is :default (%{}).

Changes the state assigned as key, with operations (set/2, insert/3...), as one transaction: all of them, or none. The assign changes when the room's authority accepts it, as everyone's does. Gives :ok, or {:error, reason} (your hook refused it, the policy, an operation).

Deletes the value at a path: a key of a map, or an item of a list.

The value at a path of a state, through lists by items' ids, or nil.

Adds to the number at a path (1 unless given); a missing one is 0.

Inserts an item (a map with an "id") in the list at a path. Options: :after or :before an item's id, or :at (:start or :end, the end unless given).

Moves the item id of the list at a path, within it or to the list at :to, placed :after or :before an item's id, or :at (:start or :end). An :after or :before of nil is not given.

Removes the item id from the list at a path.

Sets the value at a path, making the maps on the way.

Types

op()

@type op() :: map()

path()

@type path() :: [String.t() | atom() | integer()]

Functions

assign(socket, key, room, document \\ nil, opts \\ [])

Assigns a document of the room as key, kept up to date as it changes: the LiveView must be in the room (Issho.Live.join/4), and the document's engine Issho.Engine.Map. In the static render, before the LiveView is connected and in the room, key is :default (%{}).

change(socket, key, ops)

@spec change(Phoenix.LiveView.Socket.t(), atom(), [op()]) :: :ok | {:error, term()}

Changes the state assigned as key, with operations (set/2, insert/3...), as one transaction: all of them, or none. The assign changes when the room's authority accepts it, as everyone's does. Gives :ok, or {:error, reason} (your hook refused it, the policy, an operation).

delete(path)

@spec delete(path()) :: op()

Deletes the value at a path: a key of a map, or an item of a list.

get(state, path)

@spec get(map() | nil, path()) :: term()

The value at a path of a state, through lists by items' ids, or nil.

iex> state = %{"cols" => %{"todo" => [%{"id" => "c1", "title" => "Plan"}]}}
iex> Issho.State.get(state, ["cols", "todo", "c1", "title"])
"Plan"
iex> Issho.State.get(state, ["cols", "done"])
nil

increment(path, by \\ 1)

@spec increment(path(), number()) :: op()

Adds to the number at a path (1 unless given); a missing one is 0.

insert(path, item, opts \\ [])

@spec insert(path(), map(), keyword()) :: op()

Inserts an item (a map with an "id") in the list at a path. Options: :after or :before an item's id, or :at (:start or :end, the end unless given).

iex> Issho.State.insert(["cards"], %{"id" => "c2"}, after: "c1")
%{"op" => "insert", "path" => ["cards"], "value" => %{"id" => "c2"}, "after" => "c1"}

move(path, id, opts \\ [])

@spec move(path(), String.t(), keyword()) :: op()

Moves the item id of the list at a path, within it or to the list at :to, placed :after or :before an item's id, or :at (:start or :end). An :after or :before of nil is not given.

iex> Issho.State.move(["cols", "todo"], "c1", to: ["cols", "done"], at: :start)
%{"op" => "move", "path" => ["cols", "todo"], "id" => "c1", "to" => ["cols", "done"], "at" => "start"}

remove(path, id)

@spec remove(path(), String.t()) :: op()

Removes the item id from the list at a path.

set(path, value)

@spec set(path(), term()) :: op()

Sets the value at a path, making the maps on the way.

iex> Issho.State.set(["plan", :title], "Q3")
%{"op" => "set", "path" => ["plan", "title"], "value" => "Q3"}