diff --git a/CHANGELOG.md b/CHANGELOG.md index f2a3a05..45abb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- State is now returned as a struct (`%MyApp.Counter.State{count: 0}`) instead of a plain atom-keyed map (`%{count: 0}`) + - The DSL automatically generates a nested `State` struct module from the declared fields and defaults + - `%{state | field: value}` update syntax continues to work unchanged + - State is persisted to the database as a plain JSON map (no `__struct__` key) + - Unknown keys in persisted state are silently dropped on load (forward-compatible with field removal) + - `get_persisted_state/3` in `DurableObject.Testing` now returns the module's `State` struct + - **Breaking:** `state[:field]` bracket access no longer works — use `state.field` dot access instead + ## [0.2.1] - 2026-02-03 ### Added diff --git a/README.md b/README.md index 45da67d..f18f713 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,12 @@ defmodule MyApp.Counter do end def handle_increment(amount \\ 1, state) do - new_count = Map.get(state, :count, 0) + amount - new_state = %{state | count: new_count, last_incremented_at: DateTime.utc_now()} - {:reply, new_count, new_state} + new_count = state.count + amount + {:reply, new_count, %{state | count: new_count, last_incremented_at: DateTime.utc_now()}} end def handle_get(state) do - {:reply, Map.get(state, :count, 0), state} + {:reply, state.count, state} end def handle_reset(state) do diff --git a/lib/durable_object.ex b/lib/durable_object.ex index 286ff5b..48c98ae 100644 --- a/lib/durable_object.ex +++ b/lib/durable_object.ex @@ -45,6 +45,10 @@ defmodule DurableObject do {:ok, count} = MyApp.Counter.increment("user-123", 5) {:ok, count} = MyApp.Counter.get("user-123") + The DSL generates a `MyApp.Counter.State` struct with the declared fields + and defaults. State is passed to handlers as a struct, so `state.count` + and `%{state | count: new_count}` work as expected. + ## Manual Usage (without DSL) You can also call Durable Objects directly without the DSL: diff --git a/lib/durable_object/behaviour.ex b/lib/durable_object/behaviour.ex index 424f9b8..cea205f 100644 --- a/lib/durable_object/behaviour.ex +++ b/lib/durable_object/behaviour.ex @@ -48,23 +48,23 @@ defmodule DurableObject.Behaviour do """ @type handler_result :: - {:reply, result :: term(), new_state :: map()} - | {:reply, result :: term(), new_state :: map(), + {:reply, result :: term(), new_state :: struct() | map()} + | {:reply, result :: term(), new_state :: struct() | map(), {:schedule_alarm, name :: atom(), delay_ms :: pos_integer()}} - | {:noreply, new_state :: map()} - | {:noreply, new_state :: map(), + | {:noreply, new_state :: struct() | map()} + | {:noreply, new_state :: struct() | map(), {:schedule_alarm, name :: atom(), delay_ms :: pos_integer()}} | {:error, reason :: term()} @type alarm_result :: - {:noreply, new_state :: map()} - | {:noreply, new_state :: map(), + {:noreply, new_state :: struct() | map()} + | {:noreply, new_state :: struct() | map(), {:schedule_alarm, name :: atom(), delay_ms :: pos_integer()}} | {:error, reason :: term()} @type after_load_result :: - {:ok, new_state :: map()} - | {:ok, new_state :: map(), + {:ok, new_state :: struct() | map()} + | {:ok, new_state :: struct() | map(), {:schedule_alarm, name :: atom(), delay_ms :: pos_integer()}} @doc """ @@ -72,7 +72,7 @@ defmodule DurableObject.Behaviour do This callback is optional. If not defined, alarms are silently acknowledged. """ - @callback handle_alarm(alarm_name :: atom(), state :: map()) :: alarm_result() + @callback handle_alarm(alarm_name :: atom(), state :: struct() | map()) :: alarm_result() @doc """ Called after object state is loaded (or initialized with defaults for new objects). @@ -92,7 +92,7 @@ defmodule DurableObject.Behaviour do end end """ - @callback after_load(state :: map()) :: after_load_result() + @callback after_load(state :: struct() | map()) :: after_load_result() @optional_callbacks [handle_alarm: 2, after_load: 1] end diff --git a/lib/durable_object/dsl/transformers/build_introspection.ex b/lib/durable_object/dsl/transformers/build_introspection.ex index 54cd7d4..b8dfde4 100644 --- a/lib/durable_object/dsl/transformers/build_introspection.ex +++ b/lib/durable_object/dsl/transformers/build_introspection.ex @@ -1,15 +1,16 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do @moduledoc """ - Transformer that generates `__durable_object__/1` introspection functions. + Transformer that generates `__durable_object__/1` introspection functions + and a nested `State` struct. - This transformer runs at compile time and generates functions that allow - runtime introspection of the Durable Object's DSL configuration: + This transformer runs at compile time and generates: + - A `State` struct with fields and defaults from the DSL - `__durable_object__(:fields)` - Returns list of Field structs - `__durable_object__(:handlers)` - Returns list of Handler structs - `__durable_object__(:hibernate_after)` - Returns hibernate_after value - `__durable_object__(:shutdown_after)` - Returns shutdown_after value - - `__durable_object__(:default_state)` - Returns map with field defaults + - `__durable_object__(:default_state)` - Returns `%__MODULE__.State{}` struct """ use Spark.Dsl.Transformer @@ -24,11 +25,12 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do shutdown_after = Transformer.get_option(dsl_state, [:options], :shutdown_after) object_keys = Transformer.get_option(dsl_state, [:options], :object_keys) - # Build default state map from fields - default_state = - fields - |> Enum.map(fn field -> {field.name, field.default} end) - |> Map.new() + # Build defstruct keyword list from fields (field_name => default) + struct_fields = + Enum.map(fields, fn field -> {field.name, field.default} end) + + # Build default state map from fields (for persisted data compatibility) + default_state = Map.new(struct_fields) # Persist values for later retrieval via Spark.Dsl.Extension.get_persisted/3 dsl_state = @@ -44,6 +46,19 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do fields_data = Enum.map(fields, &Map.from_struct/1) handlers_data = Enum.map(handlers, &Map.from_struct/1) + # Generate nested State struct module + dsl_state = + Transformer.eval( + dsl_state, + [struct_fields: struct_fields], + quote do + defmodule State do + @moduledoc false + defstruct unquote(Macro.escape(struct_fields)) + end + end + ) + # Generate __durable_object__/1 functions dsl_state = Transformer.eval( @@ -53,7 +68,6 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do handlers_data: handlers_data, hibernate_after: hibernate_after, shutdown_after: shutdown_after, - default_state: default_state, object_keys: object_keys ], quote do @@ -72,7 +86,7 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do def __durable_object__(:hibernate_after), do: unquote(hibernate_after) def __durable_object__(:shutdown_after), do: unquote(shutdown_after) - def __durable_object__(:default_state), do: unquote(Macro.escape(default_state)) + def __durable_object__(:default_state), do: %__MODULE__.State{} def __durable_object__(:object_keys), do: unquote(object_keys) end ) diff --git a/lib/durable_object/server.ex b/lib/durable_object/server.ex index d3015db..7d2b337 100644 --- a/lib/durable_object/server.ex +++ b/lib/durable_object/server.ex @@ -154,7 +154,11 @@ defmodule DurableObject.Server do case DurableObject.Storage.load(repo, object_type, object_id, prefix: prefix) do {:ok, nil} -> # New object - persist default state (already set in init) - case DurableObject.Storage.save(repo, object_type, object_id, server.state, + case DurableObject.Storage.save( + repo, + object_type, + object_id, + serialize_state(server.state), prefix: prefix ) do {:ok, _object} -> @@ -169,9 +173,9 @@ defmodule DurableObject.Server do end {:ok, object} -> - # Atomize string keys from JSON, merge with defaults for missing fields + # Atomize string keys from JSON, apply to default struct loaded_state = atomize_keys(object.state, server.object_keys) - merged_state = Map.merge(server.state, loaded_state) + merged_state = struct(server.state, loaded_state) run_after_load(%{server | state: merged_state}) {:error, reason} -> @@ -303,12 +307,17 @@ defmodule DurableObject.Server do %{repo: repo, module: module, object_id: object_id, state: state, prefix: prefix} = server object_type = to_string(module) - case DurableObject.Storage.save(repo, object_type, object_id, state, prefix: prefix) do + case DurableObject.Storage.save(repo, object_type, object_id, serialize_state(state), + prefix: prefix + ) do {:ok, _object} -> :ok {:error, reason} -> {:error, reason} end end + defp serialize_state(%_{} = state), do: Map.from_struct(state) + defp serialize_state(state) when is_map(state), do: state + defp schedule_shutdown(%{shutdown_after: nil} = server), do: server defp schedule_shutdown(%{shutdown_after: timeout, shutdown_timer: old_timer} = server) do diff --git a/lib/durable_object/testing.ex b/lib/durable_object/testing.ex index 78bb015..e07237c 100644 --- a/lib/durable_object/testing.ex +++ b/lib/durable_object/testing.ex @@ -587,7 +587,7 @@ defmodule DurableObject.Testing do # Returns nil if not persisted assert nil == get_persisted_state(Counter, "nonexistent") """ - @spec get_persisted_state(module(), String.t(), keyword()) :: map() | nil + @spec get_persisted_state(module(), String.t(), keyword()) :: struct() | map() | nil def get_persisted_state(module, object_id, opts \\ []) do {repo, prefix} = get_repo_and_prefix(opts) @@ -596,7 +596,14 @@ defmodule DurableObject.Testing do nil state -> - Map.new(state, fn {k, v} -> {String.to_existing_atom(k), v} end) + atom_state = Map.new(state, fn {k, v} -> {String.to_existing_atom(k), v} end) + state_module = Module.concat(module, State) + + if Code.ensure_loaded?(state_module) do + struct(state_module, atom_state) + else + atom_state + end end end diff --git a/test/durable_object/dsl/transformers_test.exs b/test/durable_object/dsl/transformers_test.exs index 9e82348..fd494ff 100644 --- a/test/durable_object/dsl/transformers_test.exs +++ b/test/durable_object/dsl/transformers_test.exs @@ -32,9 +32,9 @@ defmodule DurableObject.Dsl.TransformersTest do assert BasicCounter.__durable_object__(:shutdown_after) == :timer.hours(1) end - test "generates __durable_object__(:default_state)" do + test "generates __durable_object__(:default_state) as struct" do default_state = BasicCounter.__durable_object__(:default_state) - assert default_state == %{count: 0} + assert %DurableObject.DslTest.BasicCounter.State{count: 0} = default_state end test "uses default hibernate_after when not specified" do @@ -49,11 +49,11 @@ defmodule DurableObject.Dsl.TransformersTest do test "handles multiple fields in default_state" do default_state = ChatRoom.__durable_object__(:default_state) - assert default_state == %{ + assert %DurableObject.DslTest.ChatRoom.State{ messages: [], participants: [], created_at: nil - } + } = default_state end test "handles multiple handlers" do diff --git a/test/durable_object/integration_test.exs b/test/durable_object/integration_test.exs index 7cbaec7..ca67d71 100644 --- a/test/durable_object/integration_test.exs +++ b/test/durable_object/integration_test.exs @@ -23,17 +23,17 @@ defmodule DurableObject.IntegrationTest do end def handle_increment(amount, state) do - new_count = Map.get(state, :count, 0) + amount - {:reply, new_count, Map.put(state, :count, new_count)} + new_count = state.count + amount + {:reply, new_count, %{state | count: new_count}} end def handle_decrement(amount, state) do - new_count = max(0, Map.get(state, :count, 0) - amount) - {:reply, new_count, Map.put(state, :count, new_count)} + new_count = max(0, state.count - amount) + {:reply, new_count, %{state | count: new_count}} end def handle_set_name(name, state) do - {:reply, :ok, Map.put(state, :name, name)} + {:reply, :ok, %{state | name: name}} end def handle_get(state) do @@ -41,7 +41,7 @@ defmodule DurableObject.IntegrationTest do end def handle_reset(state) do - {:noreply, Map.put(state, :count, 0)} + {:noreply, %{state | count: 0}} end end @@ -54,7 +54,9 @@ defmodule DurableObject.IntegrationTest do test "module defines __durable_object__/1 introspection" do assert TestCounter.__durable_object__(:hibernate_after) == 300_000 assert TestCounter.__durable_object__(:shutdown_after) == nil - assert TestCounter.__durable_object__(:default_state) == %{count: 0, name: "unnamed"} + + assert %TestCounter.State{count: 0, name: "unnamed"} = + TestCounter.__durable_object__(:default_state) fields = TestCounter.__durable_object__(:fields) assert length(fields) == 2 @@ -109,23 +111,23 @@ defmodule DurableObject.IntegrationTest do # Decrement {:ok, 7} = TestCounter.decrement(object_id, 3) - # Get state - note: Server starts with empty state, not DSL defaults + # Get state {:ok, state} = TestCounter.get(object_id) - assert state[:count] == 7 + assert state.count == 7 # Set name {:ok, :ok} = TestCounter.set_name(object_id, "my-counter") # Verify name was set {:ok, state} = TestCounter.get(object_id) - assert state[:name] == "my-counter" + assert state.name == "my-counter" # Reset {:ok, :noreply} = TestCounter.reset(object_id) # Verify reset {:ok, state} = TestCounter.get(object_id) - assert state[:count] == 0 + assert state.count == 0 # Cleanup DurableObject.stop(TestCounter, object_id) diff --git a/test/durable_object_test.exs b/test/durable_object_test.exs index a2007a3..790f1cf 100644 --- a/test/durable_object_test.exs +++ b/test/durable_object_test.exs @@ -108,7 +108,7 @@ defmodule DurableObjectTest do id = unique_id("state") {:ok, _} = DurableObject.ensure_started(Counter, id) DurableObject.call(Counter, id, :increment_by, [42]) - assert DurableObject.get_state(Counter, id) == %{count: 42} + assert %{count: 42} = DurableObject.get_state(Counter, id) end end