Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/durable_object.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 10 additions & 10 deletions lib/durable_object/behaviour.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,31 +48,31 @@ 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 """
Called when a scheduled alarm fires.

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).
Expand All @@ -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
36 changes: 25 additions & 11 deletions lib/durable_object/dsl/transformers/build_introspection.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 =
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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
)
Expand Down
17 changes: 13 additions & 4 deletions lib/durable_object/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand All @@ -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} ->
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions lib/durable_object/testing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down
8 changes: 4 additions & 4 deletions test/durable_object/dsl/transformers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 13 additions & 11 deletions test/durable_object/integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,25 @@ 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
{:reply, state, state}
end

def handle_reset(state) do
{:noreply, Map.put(state, :count, 0)}
{:noreply, %{state | count: 0}}
end
end

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion test/durable_object_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down