defmodule Baobab do alias Baobab.{Entry, Identity, Interchange, Persistence} @moduledoc """ Baobab is a pure Elixir implementation of the [Bamboo](https://github.com/AljoschaMeyer/bamboo) append-only log. It is fairly opinionated about the DETS persistence of the logs. They are considered to be a spool of the logs as retreived. Consumers of this library may wish to place a local copy of the logs in a store with better indexing and query properties. ### Configuration config :baobab, spool_dir: "/tmp" ### Options - `format`: `:entry` or `:binary`, default: `:entry` - `log_id`: the author's log identifier, default `0` - `clump_id`: the bamboo clump with which associated, default: `"default"` - `revalidate`: confirm the store contents are unchanged, default: `false` - `replace`: rewrite log contents even if it exists, default: `false` """ @defaults %{format: :entry, log_id: 0, revalidate: false, replace: false, clump_id: "default"} @doc """ Create and store a new log entry for a stored identity """ def append_log(payload, identity, options \\ []) do {log_id, clump_id} = options |> optvals([:log_id, :clump_id]) Entry.create(payload, clump_id, identity, log_id) end @doc """ Compact log contents to only items in the certificate pool for the latest entry. This allows validation while reducing space used """ def compact(author, options \\ []) do a = author |> Identity.as_base62() {log_id, clump_id} = options |> optvals([:log_id, :clump_id]) Baobab.Persistence.compact(a, log_id, clump_id) end @doc """ Retrieve an author log at a particular sequence number. Includes the available certificate pool for its verification. Using `:max` as the sequence number will use the latest """ def log_at(author, seqnum, options \\ []) do which = case seqnum do :max -> max_seqnum(author, options) n -> n end ak = author |> Identity.as_base62() {_, log_id, _, clump_id} = opts = options |> optvals([:format, :log_id, :revalidate, :clump_id]) certificate_pool(ak, which, log_id, clump_id) |> Enum.reverse() |> Enum.map(fn n -> Persistence.retrieve(ak, n, opts) end) end @doc """ Retrieve all available author log entries over a specified range: `{first, last}`. """ def log_range(author, range, options \\ []) def log_range(_, {first, last}, _) when first < 1 or last < first, do: {:error, "Improper range specification"} def log_range(author, {first, last}, options) do ak = author |> Identity.as_base62() {_, log_id, _, clump_id} = opts = options |> optvals([:format, :log_id, :revalidate, :clump_id]) first..last |> Enum.filter(fn n -> Persistence.content(:entry, :exists, {author, log_id, n}, clump_id) end) |> Enum.map(fn n -> Persistence.retrieve(ak, n, opts) end) end @doc """ Purges a given log. `:all` may be specified for `author` and/or the `log_id` option. Specifying both effectively clears the entire store. Returns `stored_info/0` ## Examples iex> Baobab.purge(:all, log_id: :all) [] """ def purge(author, options \\ []) do {log_id, clump_id} = optvals(options, [:log_id, :clump_id]) case {author, log_id} do {:all, :all} -> Persistence.action(:content, clump_id, :truncate) {:all, n} -> Persistence.action(:content, clump_id, :match_delete, {:_, n, :_}) {author, :all} -> Persistence.action( :content, clump_id, :match_delete, {author |> Identity.as_base62(), :_, :_} ) {author, n} -> Persistence.action( :content, clump_id, :match_delete, {author |> Identity.as_base62(), n, :_} ) end stored_info(clump_id) end @doc """ Retrieve all available entries in a particular log """ def full_log(author, options \\ []) do opts = options |> optvals([:format, :log_id, :revalidate, :clump_id]) author |> Identity.as_base62() |> gather_all_entries(opts, max_seqnum(author, options), []) end defp gather_all_entries(_, _, 0, acc), do: acc defp gather_all_entries(author, opts, n, acc) do newacc = case Persistence.retrieve(author, n, opts) do :error -> acc entry -> [entry | acc] end gather_all_entries(author, opts, n - 1, newacc) end @doc false def certificate_pool(author, seq, log_id, clump_id) do max = max_seqnum(author, log_id: log_id, clump_id: clump_id) seq |> Lipmaa.cert_pool() |> Enum.reject(fn n -> n > max or not Persistence.content(:entry, :exists, {author, log_id, n}, clump_id) end) end @doc """ Retrieve the latest sequence number on a particular log identified by the author key and log number """ def max_seqnum(author, options \\ []) do case all_seqnum(author, options) |> List.last() do nil -> 0 max -> max end end @doc """ Retrieve the list of sequence numbers on a particular log identified by the author key and log number """ def all_seqnum(author, options \\ []) do auth = author |> Identity.as_base62() {log_id, clump_id} = options |> optvals([:log_id, :clump_id]) :content |> Persistence.action(clump_id, :foldl, fn item, acc -> case item do {{^auth, ^log_id, e}, _} -> [e | acc] _ -> acc end end) |> Enum.sort() end @doc """ Retreive a paticular entry by author and sequence number. `:max` for the sequence number retrieves the latest known entry """ def log_entry(author, seqnum, options \\ []) def log_entry(author, seqnum, options) do which = case seqnum do :max -> max_seqnum(author, options) n -> n end opts = options |> optvals([:format, :log_id, :revalidate, :clump_id]) author |> Identity.as_base62() |> Persistence.retrieve(which, opts) end @doc """ A list of {author, log_id, max_seqnum} tuples in the configured store """ # I cared enough to do a little improvement on this. def stored_info(clump_id \\ "default") def stored_info(clump_id), do: Persistence.current_stored_info(clump_id) @doc """ A list of all {author, log_id, seqnum} tuples in the configured store """ def all_entries(clump_id \\ "default") def all_entries(clump_id), do: Persistence.current_value(clump_id) @doc """ Retrieve a list of all available clumps """ def clumps() do spool = Application.fetch_env!(:baobab, :spool_dir) |> Path.expand() Path.join([spool, "*/content.dets"]) |> Path.wildcard() |> Enum.map(fn p -> Interchange.clump_from_path(p) end) |> Enum.sort() end @doc """ Create a clump """ def create_clump(clump_id) when is_binary(clump_id) do spool = Application.fetch_env!(:baobab, :spool_dir) |> Path.expand() File.mkdir_p(Path.join([spool, clump_id])) Persistence.store(:content, clump_id, :open) Persistence.store(:content, clump_id, :close) :ok end def create_clump(_ci), do: {:error, "Improper clump_id"} @doc """ Drop a clump """ def drop_clump(clump_id) do spool = Application.fetch_env!(:baobab, :spool_dir) |> Path.expand() File.rm_rf(Path.join([spool, clump_id])) end @doc false def optvals(opts, keys), do: optvals(opts, keys, []) def optvals(_, [], acc), do: Enum.reverse(acc) |> List.to_tuple() def optvals(opts, [k | rest], acc), do: optvals(opts, rest, [Keyword.get(opts, k, @defaults[k]) | acc]) end