From a369819818c4115c26ae8799001523c4a131dbd5 Mon Sep 17 00:00:00 2001 From: Lang Martin Date: Wed, 17 Sep 2025 17:05:49 -0400 Subject: [PATCH] feat: evaluate optionally returns {:ok, [timestamps] | state} - Add the option `evaluate(command, return: :events | :state)` which returns events generated by the command, or the aggregate state after the command. - Keep the default behavio(u)r of evaluate which does not return events or state across the process boundary. This simplifies a caller that wants to use valid events for applications outside the scope of maestro itself. --- lib/maestro/aggregate/root.ex | 50 +++++++++++++++++++++++++-------- mix.exs | 2 +- test/maestro/aggregate_test.exs | 31 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/lib/maestro/aggregate/root.ex b/lib/maestro/aggregate/root.ex index 7ea8d00..dcec6c8 100644 --- a/lib/maestro/aggregate/root.ex +++ b/lib/maestro/aggregate/root.ex @@ -87,11 +87,13 @@ defmodule Maestro.Aggregate.Root do def replay(agg_id, seq), do: call(agg_id, {:replay, seq}) - def evaluate(%Maestro.Types.Command{} = command) do - call(command.aggregate_id, {:eval_command, command}) + def evaluate(command, opts \\ []) + + def evaluate(%Maestro.Types.Command{} = command, opts) do + call(command.aggregate_id, {:eval_command, command, opts}) end - def evaluate(_), do: raise(ArgumentError, "invalid command") + def evaluate(_, _), do: raise(ArgumentError, "invalid command") def snapshot(agg_id) do with {:ok, snap} <- call(agg_id, :get_snapshot) do @@ -166,8 +168,17 @@ defmodule Maestro.Aggregate.Root do err -> {:reply, {:error, err, __STACKTRACE__}, agg} end - def handle_call({:eval_command, command}, _from, agg) do - {:reply, :ok, Root.eval_command(agg, command)} + def handle_call({:eval_command, command, opts}, _from, agg) do + {:ok, agg, events} = Root.eval_command(agg, command) + + result = + case opts[:return] do + :state -> {:ok, agg.state} + :events -> {:ok, events} + _ -> :ok + end + + {:reply, result, agg} rescue err -> {:reply, {:error, err, __STACKTRACE__}, agg} end @@ -234,10 +245,23 @@ defmodule Maestro.Aggregate.Root do """ @callback replay(id(), sequence()) :: {:ok, any()} | {:error, any(), stack()} + @type evaluate_opt :: {:return, :events | :state} + @type evaluate_opts :: [evaluate_opt()] + @doc """ - Evaluate the command within the aggregate's context. + Evaluate the command within the aggregate's context. With the option `:return` return either the events or the state. """ - @callback evaluate(command()) :: :ok | {:error, any(), stack()} + @callback evaluate(command()) :: + :ok + | {:ok, [Maestro.Types.Event.t()]} + | {:ok, state :: any()} + | {:error, any(), stack()} + + @callback evaluate(command(), evaluate_opts()) :: + :ok + | {:ok, [Maestro.Types.Event.t()]} + | {:ok, state :: any()} + | {:error, any(), stack()} @doc """ Using the aggregate root's `prepare_snapshot` function, generate and store a @@ -345,8 +369,9 @@ defmodule Maestro.Aggregate.Root do with agg <- update_aggregate(agg), com_module <- lookup_module(agg.command_prefix, command.type), events <- com_module.eval(agg, command), - events <- prepare_events(agg, events) do - persist_events(agg, command, events) + events <- prepare_events(agg, events), + agg <- persist_events(agg, command, events) do + {:ok, agg, events} end end @@ -371,9 +396,12 @@ defmodule Maestro.Aggregate.Root do apply_events(agg, events) {:error, :retry_command} -> + {:ok, agg, _events} = + agg + |> update_aggregate() + |> eval_command(command) + agg - |> update_aggregate() - |> eval_command(command) end end diff --git a/mix.exs b/mix.exs index 395177f..b188d68 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Maestro.Mixfile do use Mix.Project - @version "0.4.0" + @version "0.5.0" @source_url "https://github.com/elixir-toniq/maestro" def project do diff --git a/test/maestro/aggregate_test.exs b/test/maestro/aggregate_test.exs index f4c5e98..294210e 100644 --- a/test/maestro/aggregate_test.exs +++ b/test/maestro/aggregate_test.exs @@ -79,6 +79,37 @@ defmodule Maestro.AggregateTest do assert value == 3 end + test "return options" do + {:ok, agg_id} = SampleAggregate.new() + + {:ok, %{"value" => value}} = SampleAggregate.get(agg_id) + assert value == 0 + + {:ok, events} = + SampleAggregate.evaluate( + %Command{ + type: "increment_counter", + aggregate_id: agg_id, + data: %{} + }, + return: :events + ) + + assert [%{type: "counter_incremented"}] = events + + {:ok, state} = + SampleAggregate.evaluate( + %Command{ + type: "increment_counter", + aggregate_id: agg_id, + data: %{} + }, + return: :state + ) + + assert %{"value" => 2} = state + end + test "recover an intermediate state" do {:ok, agg_id} = SampleAggregate.new()