Listex (Listex v0.1.0)
View SourceOne process per list, for simple concurrent editing.
A list is spawned from a plain Elixir list and lives in its own process. Editors send it operations; it applies them one at a time, in arrival order, and tells subscribers what happened. Items keep the id they were given for as long as they exist, so an editor can hold on to an id and refer to it later even if the list has been reordered underneath it.
{:ok, list} = Listex.new(["milk", "eggs"])
{:ok, _snapshot} = Listex.subscribe(list, :updates)
{:ok, id} = Listex.insert(list, "bread")
:ok = Listex.move(list, id, :start)
{:ok, _item} = Listex.update(list, id, "sourdough")
:ok = Listex.delete(list, id)
Listex.contents(list)
#=> ["milk", "eggs"]Content
An item's content is any Elixir term — a string, a map, a struct, a tuple, a
binary, nil. Listex never looks inside it, never compares it and never
serialises it; it only ever hands it back to you. Order and identity are the
library's business, meaning is yours.
Listex.new([
"a string",
%{"title" => "a map", "done" => false},
{:whatever, :you, :like}
])The one term with a second meaning is Listex.Item itself: a %Listex.Item{}
in the list you spawn from is taken as an item with an id you have chosen,
which is how you restore a list without losing the ids it already had. Wrap it
(in a tuple, say) if you really want one as content.
Conflict policy
Arrival order wins. There is no operational transform and no merge: whichever
operation reaches the process second is the one that ends up in the list.
Two editors renaming the same item both get {:ok, item}; the list holds the
second one's text. An operation naming an item that is already gone gets
{:error, :not_found} — its author lost, and the event stream (or a fresh
snapshot/1) tells them why.
Operations
insert/3— add content, optionallyafter:a given idupdate/3— replace an item's contentmove/3— put an item after another id (or:start/:end)delete/2— remove an itemstart_editing/4— tell the other subscribers someone is editing an item. Changes nothing, bumps no version; a hint for the UI, nothing more.
Subscribers
subscribe(list, :full)— receives{:listex, list_id, %Listex.Snapshot{}}after every change. Simplest thing to render.subscribe(list, :updates)— receives{:listex, list_id, %Listex.Event{}}, one per change. Consecutive:versionnumbers; a gap means a message was missed and the list should be re-read.
Both kinds receive %Listex.Event{type: :editing} (it has no snapshot to
fold into) and {:listex, list_id, {:closed, :idle}} when the process shuts
down. Subscribers are monitored, so a subscriber that dies is dropped.
Lifetime
A list process exits after five minutes without a client message
(configurable with :idle_timeout). State is in memory only: when the
process goes, so does the list. Reopen it from storage with new/2 or
open/2 and the contents you have.
Summary
Functions
Whether a process is currently holding this list.
Sends an operation without waiting for the result.
The contents, in order, without ids.
Removes item_id. Its id is never reused.
Inserts content and returns its stable id.
The items, in order.
Ids of every list held in a process on this node.
Moves item_id to sit directly after anchor, which may be another item's
id, :start or :end.
Spawns a list process from contents, a plain list of terms.
Returns the pid holding list_id, spawning it from contents if it has died
or never existed.
The whole list, with its version.
Announces that someone has started editing item_id.
Shuts a list down now, without waiting for the idle timeout.
Subscribes a process to a list and returns the current snapshot as a baseline.
Stops sending messages to pid (the caller by default).
Replaces the content of item_id and returns the updated item.
The pid holding list_id, or nil.
Types
@type error() :: {:error, :not_found | :anchor_not_found | :id_taken | :no_list}
@type list_ref() :: Listex.ID.t() | pid() | GenServer.name()
Functions
Whether a process is currently holding this list.
@spec cast( list_ref(), {:insert, term(), keyword()} | {:update, Listex.ID.t(), term()} | {:move, Listex.ID.t(), Listex.ID.t() | :start | :end} | {:delete, Listex.ID.t()} ) :: :ok
Sends an operation without waiting for the result.
Same ordering guarantee, no round trip, no error reporting — a rejected
operation is dropped silently and the event stream is your feedback. Pass
id: on an insert if you need to know the id up front.
Listex.cast(list, {:insert, "bread", id: my_id})
Listex.cast(list, {:update, my_id, "sourdough"})
Listex.cast(list, {:move, my_id, :start})
Listex.cast(list, {:delete, my_id})
The contents, in order, without ids.
@spec delete(list_ref(), Listex.ID.t()) :: :ok | error()
Removes item_id. Its id is never reused.
@spec insert(list_ref(), term(), keyword()) :: {:ok, Listex.ID.t()} | error()
Inserts content and returns its stable id.
Options
:after—:end(default),:start, or the id to insert behind. An id that is no longer there gives{:error, :anchor_not_found}.:id— use this id instead of a generated one, for a client that minted the id itself.{:error, :id_taken}if it is already in use.
@spec items(list_ref()) :: [Listex.Item.t()] | error()
The items, in order.
@spec list_ids() :: [Listex.ID.t()]
Ids of every list held in a process on this node.
@spec move(list_ref(), Listex.ID.t(), Listex.ID.t() | :start | :end) :: :ok | error()
Moves item_id to sit directly after anchor, which may be another item's
id, :start or :end.
Moving an item after itself does nothing and tells nobody.
@spec new( [term() | Listex.Item.t()], keyword() ) :: {:ok, Listex.ID.t()} | {:error, term()}
Spawns a list process from contents, a plain list of terms.
Each term becomes an item with a fresh id, whatever the term is — string,
map, struct, tuple, nil. Pass Listex.Item structs instead when you are
restoring a list and want to keep the ids it already had; duplicate ids are
refused.
Options
:id— the list id; generated when absent:idle_timeout— ms of inactivity before shutdown (default: 5 minutes,:infinityto disable)
Returns the list id, which is what you pass to every other function here.
{:ok, list} = Listex.new(["a", "b"], idle_timeout: :timer.minutes(30))
@spec open(Listex.ID.t(), [term() | Listex.Item.t()], keyword()) :: {:ok, pid()} | {:error, term()}
Returns the pid holding list_id, spawning it from contents if it has died
or never existed.
The natural companion to the idle timeout: an editor coming back after lunch reopens the list from storage without caring whether the process outlived it.
@spec snapshot(list_ref()) :: Listex.Snapshot.t() | error()
The whole list, with its version.
@spec start_editing(list_ref(), Listex.ID.t(), term(), keyword()) :: :ok | error()
Announces that someone has started editing item_id.
A no-op as far as the list is concerned: nothing changes, the version stays
put, and the announcement is not replayed to anyone who subscribes later. It
goes to every other subscriber — both kinds — as
%Listex.Event{type: :editing, id: item_id, data: data, by: pid}, so a UI can
show that a row is busy.
data is any term you want to carry along (a user id, a cursor position, a
:done marker of your own devising). Pass by: to attribute the signal to a
pid other than the caller; that pid is the one excluded from the broadcast.
@spec stop(list_ref()) :: :ok
Shuts a list down now, without waiting for the idle timeout.
@spec subscribe(list_ref(), Listex.List.mode(), keyword()) :: {:ok, Listex.Snapshot.t()} | error()
Subscribes a process to a list and returns the current snapshot as a baseline.
mode is :updates (default) for one %Listex.Event{} per change, or
:full for a %Listex.Snapshot{} after every change — a :full subscriber
also gets one immediately, so it can just wait for messages.
Messages arrive as {:listex, list_id, payload}. Subscribing twice replaces
the previous mode rather than doubling the traffic, and a subscriber that
dies is dropped automatically.
Pass by: to subscribe a process other than the caller.
Stops sending messages to pid (the caller by default).
@spec update(list_ref(), Listex.ID.t(), term()) :: {:ok, Listex.Item.t()} | error()
Replaces the content of item_id and returns the updated item.
The item's :rev goes up by one. No compare-and-swap: a caller working from
a stale copy still wins if it arrives last.
@spec whereis(Listex.ID.t()) :: pid() | nil
The pid holding list_id, or nil.