defmodule EventodbEx do @moduledoc """ Elixir client for EventoDB - a simple, fast message store. ## Usage # Create client client = EventodbEx.Client.new("http://localhost:8080", token: "ns_...") # Write message {:ok, result, client} = EventodbEx.stream_write( client, "account-123", %{type: "Deposited", data: %{amount: 100}} ) # Read stream {:ok, messages, client} = EventodbEx.stream_get(client, "account-123") """ alias EventodbEx.{Client, Error, Types} # ==================== # Stream Operations # ==================== @doc """ Writes a message to a stream. ## Options * `:id` - Custom message UUID (auto-generated if omitted) * `:expected_version` - Expected stream version for optimistic locking ## Examples {:ok, result, client} = EventodbEx.stream_write( client, "account-123", %{type: "Deposited", data: %{amount: 100}}, %{expected_version: 0} ) """ @spec stream_write(Client.t(), String.t(), Types.message(), map()) :: {:ok, Types.write_result(), Client.t()} | {:error, Error.t()} def stream_write(client, stream_name, message, opts \\ %{}) do opts = normalize_options(opts) with {:ok, result, client} <- Client.rpc(client, "stream.write", [stream_name, message, opts]) do {:ok, snake_case_keys(result), client} end end @doc """ Reads messages from a stream. ## Options * `:position` - Starting position (inclusive, default: 0) * `:global_position` - Alternative: filter by global position * `:batch_size` - Max messages to return (default: 1000, -1 for unlimited, max 10000) ## Examples {:ok, messages, client} = EventodbEx.stream_get( client, "account-123", %{position: 0, batch_size: 10} ) """ @spec stream_get(Client.t(), String.t(), map()) :: {:ok, list(Types.stream_message()), Client.t()} | {:error, Error.t()} def stream_get(client, stream_name, opts \\ %{}) do opts = normalize_options(opts) Client.rpc(client, "stream.get", [stream_name, opts]) end @doc """ Gets the last message from a stream. ## Options * `:type` - Filter by event type Returns `nil` if stream is empty or doesn't exist. ## Examples {:ok, message, client} = EventodbEx.stream_last(client, "account-123") {:ok, message, client} = EventodbEx.stream_last(client, "account-123", %{type: "Deposited"}) """ @spec stream_last(Client.t(), String.t(), map()) :: {:ok, Types.stream_message() | nil, Client.t()} | {:error, Error.t()} def stream_last(client, stream_name, opts \\ %{}) do Client.rpc(client, "stream.last", [stream_name, opts]) end @doc """ Gets the current version (latest position) of a stream. Returns `nil` if stream doesn't exist. Note: Version is 0-based, so version 5 means 6 messages (positions 0-5). ## Examples {:ok, version, client} = EventodbEx.stream_version(client, "account-123") """ @spec stream_version(Client.t(), String.t()) :: {:ok, integer() | nil, Client.t()} | {:error, Error.t()} def stream_version(client, stream_name) do Client.rpc(client, "stream.version", [stream_name]) end # ==================== # Category Operations # ==================== @doc """ Reads messages from all streams in a category. ## Options * `:position` - Starting global position (default: 0) * `:global_position` - Alternative to position * `:batch_size` - Max messages to return (default: 1000) * `:correlation` - Filter by correlationStreamName category * `:consumer_group` - Map with `:member` (0-based index) and `:size` (total consumers) ## Examples {:ok, messages, client} = EventodbEx.category_get(client, "account") {:ok, messages, client} = EventodbEx.category_get( client, "account", %{ batch_size: 100, consumer_group: %{member: 0, size: 4} } ) """ @spec category_get(Client.t(), String.t(), map()) :: {:ok, list(Types.category_message()), Client.t()} | {:error, Error.t()} def category_get(client, category_name, opts \\ %{}) do opts = normalize_options(opts) Client.rpc(client, "category.get", [category_name, opts]) end # ======================= # Namespace Operations # ======================= @doc """ Creates a new namespace. ## Options * `:description` - Human-readable description * `:token` - Custom token (must be valid format for namespace) ## Examples {:ok, result, client} = EventodbEx.namespace_create( client, "my-namespace", %{description: "My application namespace"} ) """ @spec namespace_create(Client.t(), String.t(), map()) :: {:ok, map(), Client.t()} | {:error, Error.t()} def namespace_create(client, namespace_id, opts \\ %{}) do with {:ok, result, client} <- Client.rpc(client, "ns.create", [namespace_id, opts]) do {:ok, snake_case_keys(result), client} end end @doc """ Deletes a namespace and all its data. ⚠️ Warning: This operation is irreversible. ## Examples {:ok, result, client} = EventodbEx.namespace_delete(client, "my-namespace") """ @spec namespace_delete(Client.t(), String.t()) :: {:ok, map(), Client.t()} | {:error, Error.t()} def namespace_delete(client, namespace_id) do with {:ok, result, client} <- Client.rpc(client, "ns.delete", [namespace_id]) do {:ok, snake_case_keys(result), client} end end @doc """ Lists all namespaces. ## Examples {:ok, namespaces, client} = EventodbEx.namespace_list(client) """ @spec namespace_list(Client.t()) :: {:ok, list(map()), Client.t()} | {:error, Error.t()} def namespace_list(client) do with {:ok, result, client} <- Client.rpc(client, "ns.list", []) do {:ok, Enum.map(result, &snake_case_keys/1), client} end end @doc """ Gets detailed information about a namespace. ## Examples {:ok, info, client} = EventodbEx.namespace_info(client, "my-namespace") """ @spec namespace_info(Client.t(), String.t()) :: {:ok, map(), Client.t()} | {:error, Error.t()} def namespace_info(client, namespace_id) do with {:ok, result, client} <- Client.rpc(client, "ns.info", [namespace_id]) do {:ok, snake_case_keys(result), client} end end # ======================= # Subscription Operations # ======================= @doc """ Subscribes to real-time notifications for a stream. Returns immediately with subscription reference. The `on_poke` callback is invoked when new messages are written to the stream. ## Options * `:name` - Required. Unique string name for this subscription * `:position` - Starting position (default: 0) * `:on_poke` - Required. Callback function `fn poke -> ... end` * `:on_error` - Optional. Error callback `fn error -> ... end` ## Examples {:ok, _pid} = EventodbEx.subscribe_to_stream( client, "account-123", name: "my-processor", position: 0, on_poke: fn poke -> IO.inspect(poke) end ) # Close subscription EventodbEx.Subscription.close("my-processor") """ @spec subscribe_to_stream(Client.t(), String.t(), keyword()) :: {:ok, pid()} | {:error, term()} def subscribe_to_stream(client, stream_name, opts) do name = Keyword.fetch!(opts, :name) on_poke = Keyword.fetch!(opts, :on_poke) on_error = Keyword.get(opts, :on_error) position = Keyword.get(opts, :position, 0) params = [ {"stream", stream_name}, {"position", to_string(position)}, {"token", client.token} ] url = build_subscribe_url(client.base_url, params) sub_opts = [ name: name, url: url, on_poke: on_poke, on_error: on_error ] EventodbEx.Subscription.start_link(sub_opts) end @doc """ Subscribes to real-time notifications for a category. Returns immediately with subscription reference. The `on_poke` callback is invoked when new messages are written to any stream in the category. ## Options * `:name` - Required. Unique string name for this subscription * `:position` - Starting position (default: 0) * `:consumer_group` - Map with `:member` and `:size` for partitioning * `:on_poke` - Required. Callback function `fn poke -> ... end` * `:on_error` - Optional. Error callback `fn error -> ... end` ## Examples {:ok, _pid} = EventodbEx.subscribe_to_category( client, "account", name: "account-processor", consumer_group: %{member: 0, size: 4}, on_poke: fn poke -> # Fetch and process new messages {:ok, messages, _} = EventodbEx.category_get( client, "account", position: poke.global_position ) process_messages(messages) end ) # Close subscription EventodbEx.Subscription.close("account-processor") """ @spec subscribe_to_category(Client.t(), String.t(), keyword()) :: {:ok, pid()} | {:error, term()} def subscribe_to_category(client, category_name, opts) do name = Keyword.fetch!(opts, :name) on_poke = Keyword.fetch!(opts, :on_poke) on_error = Keyword.get(opts, :on_error) position = Keyword.get(opts, :position, 0) consumer_group = Keyword.get(opts, :consumer_group) params = [ {"category", category_name}, {"position", to_string(position)}, {"token", client.token} ] params = case consumer_group do %{member: m, size: s} -> params ++ [ {"consumer", to_string(m)}, {"size", to_string(s)} ] _ -> params end url = build_subscribe_url(client.base_url, params) sub_opts = [ name: name, url: url, on_poke: on_poke, on_error: on_error ] EventodbEx.Subscription.start_link(sub_opts) end @doc """ Subscribes to real-time notifications for all events in the namespace. Returns immediately with subscription reference. The `on_poke` callback is invoked when any message is written to any stream in the namespace. ## Options * `:name` - Required. Unique string name for this subscription * `:position` - Starting global position (default: 0) * `:on_poke` - Required. Callback function `fn poke -> ... end` * `:on_error` - Optional. Error callback `fn error -> ... end` ## Examples {:ok, _pid} = EventodbEx.subscribe_to_all( client, name: "global-hub", position: 0, on_poke: fn poke -> IO.inspect(poke, label: "poke") end ) # Close subscription EventodbEx.Subscription.close("global-hub") """ @spec subscribe_to_all(Client.t(), keyword()) :: {:ok, pid()} | {:error, term()} def subscribe_to_all(client, opts) do name = Keyword.fetch!(opts, :name) on_poke = Keyword.fetch!(opts, :on_poke) on_error = Keyword.get(opts, :on_error) position = Keyword.get(opts, :position, 0) params = [ {"all", "true"}, {"position", to_string(position)}, {"token", client.token} ] url = build_subscribe_url(client.base_url, params) sub_opts = [ name: name, url: url, on_poke: on_poke, on_error: on_error ] EventodbEx.Subscription.start_link(sub_opts) end defp build_subscribe_url(base_url, params) do query = URI.encode_query(params) "#{base_url}/subscribe?#{query}" end # =================== # System Operations # =================== @doc """ Gets the server version. ## Examples {:ok, version, client} = EventodbEx.system_version(client) """ @spec system_version(Client.t()) :: {:ok, String.t(), Client.t()} | {:error, Error.t()} def system_version(client) do Client.rpc(client, "sys.version", []) end @doc """ Gets server health status. ## Examples {:ok, health, client} = EventodbEx.system_health(client) """ @spec system_health(Client.t()) :: {:ok, map(), Client.t()} | {:error, Error.t()} def system_health(client) do with {:ok, result, client} <- Client.rpc(client, "sys.health", []) do {:ok, snake_case_keys(result), client} end end # Private helpers defp snake_case_keys(map) when is_map(map) do Map.new(map, fn {k, v} -> snake_key = k |> Macro.underscore() |> String.to_atom() {snake_key, v} end) end # Convert Elixir-style snake_case options to API camelCase defp normalize_options(opts) when is_map(opts) do opts |> Enum.map(fn {k, v} -> {to_camel_case(k), normalize_value(v)} end) |> Map.new() end defp normalize_value(%{member: member, size: size}) do # Consumer group %{"member" => member, "size" => size} end defp normalize_value(v), do: v defp to_camel_case(atom) when is_atom(atom) do atom |> Atom.to_string() |> to_camel_case() end defp to_camel_case("batch_size"), do: "batchSize" defp to_camel_case("global_position"), do: "globalPosition" defp to_camel_case("consumer_group"), do: "consumerGroup" defp to_camel_case("expected_version"), do: "expectedVersion" defp to_camel_case(s) when is_binary(s), do: s end