Listex (Listex v0.1.0)

View Source

One 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, optionally after: a given id
  • update/3 — replace an item's content
  • move/3 — put an item after another id (or :start / :end)
  • delete/2 — remove an item
  • start_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 :version numbers; 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

error()

@type error() :: {:error, :not_found | :anchor_not_found | :id_taken | :no_list}

list_ref()

@type list_ref() :: Listex.ID.t() | pid() | GenServer.name()

Functions

alive?(list)

@spec alive?(list_ref()) :: boolean()

Whether a process is currently holding this list.

cast(list, op)

@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})

contents(list)

@spec contents(list_ref()) :: [term()] | error()

The contents, in order, without ids.

delete(list, item_id)

@spec delete(list_ref(), Listex.ID.t()) :: :ok | error()

Removes item_id. Its id is never reused.

insert(list, content, opts \\ [])

@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.

items(list)

@spec items(list_ref()) :: [Listex.Item.t()] | error()

The items, in order.

list_ids()

@spec list_ids() :: [Listex.ID.t()]

Ids of every list held in a process on this node.

move(list, item_id, anchor)

@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.

new(contents \\ [], opts \\ [])

@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, :infinity to 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))

open(list_id, contents \\ [], opts \\ [])

@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.

snapshot(list)

@spec snapshot(list_ref()) :: Listex.Snapshot.t() | error()

The whole list, with its version.

start_editing(list, item_id, data \\ nil, opts \\ [])

@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.

stop(list)

@spec stop(list_ref()) :: :ok

Shuts a list down now, without waiting for the idle timeout.

subscribe(list, mode \\ :updates, opts \\ [])

@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.

unsubscribe(list, pid \\ self())

@spec unsubscribe(list_ref(), pid()) :: :ok | error()

Stops sending messages to pid (the caller by default).

update(list, item_id, content)

@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.

whereis(list_id)

@spec whereis(Listex.ID.t()) :: pid() | nil

The pid holding list_id, or nil.