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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `object_keys` option to control how string keys within field values are converted when loading state from JSON
- `:strings` (default) - leaves keys as strings
- `:atoms!` - converts to existing atoms only (raises on unknown keys)
- `:atoms` - creates atoms as needed (use with caution)
- Configurable per-object in the DSL `options` block, or globally via `config :durable_object, object_keys: :atoms!`
- DSL setting takes precedence over application config
- `DurableObject.Testing` module with ergonomic test helpers
- `use DurableObject.Testing, repo: MyApp.Repo` sets up Ecto sandbox and imports helpers
- Unit testing: `perform_handler/4` and `perform_alarm_handler/3` for testing handler logic in isolation
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ mix ecto.migrate
config :durable_object,
repo: MyApp.Repo,
registry_mode: :local, # or :horde for distributed
object_keys: :strings, # :strings | :atoms! | :atoms — controls map key conversion on load
scheduler: DurableObject.Scheduler.Polling,
scheduler_opts: [
polling_interval: :timer.seconds(30),
Expand Down
5 changes: 5 additions & 0 deletions lib/durable_object/dsl/extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ defmodule DurableObject.Dsl.Extension do
type: {:or, [:pos_integer, {:literal, :infinity}, nil]},
default: nil,
doc: "Stop process after this many ms of inactivity (nil = never)"
],
object_keys: [
type: {:in, [:strings, :atoms!, :atoms]},
doc:
"How to convert string keys within field values when loading from JSON. :strings (default, no conversion), :atoms! (existing atoms only, raises otherwise), :atoms (creates atoms if needed)."
]
]
}
Expand Down
6 changes: 5 additions & 1 deletion lib/durable_object/dsl/transformers/build_introspection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do
handlers = Transformer.get_entities(dsl_state, [:handlers])
hibernate_after = Transformer.get_option(dsl_state, [:options], :hibernate_after) || 300_000
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 =
Expand All @@ -37,6 +38,7 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do
|> Transformer.persist(:durable_object_hibernate_after, hibernate_after)
|> Transformer.persist(:durable_object_shutdown_after, shutdown_after)
|> Transformer.persist(:durable_object_default_state, default_state)
|> Transformer.persist(:durable_object_object_keys, object_keys)

# Convert structs to a format that can be safely used in quoted expressions
fields_data = Enum.map(fields, &Map.from_struct/1)
Expand All @@ -51,7 +53,8 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do
handlers_data: handlers_data,
hibernate_after: hibernate_after,
shutdown_after: shutdown_after,
default_state: default_state
default_state: default_state,
object_keys: object_keys
],
quote do
@doc false
Expand All @@ -70,6 +73,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__(:object_keys), do: unquote(object_keys)
end
)

Expand Down
53 changes: 47 additions & 6 deletions lib/durable_object/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ defmodule DurableObject.Server do

@default_hibernate_after :timer.minutes(5)

defstruct [:module, :object_id, :state, :shutdown_after, :shutdown_timer, :repo, :prefix]
defstruct [
:module,
:object_id,
:state,
:shutdown_after,
:shutdown_timer,
:repo,
:prefix,
:object_keys
]

# --- Client API ---

Expand Down Expand Up @@ -117,6 +126,7 @@ defmodule DurableObject.Server do
repo = Keyword.get(opts, :repo)
prefix = Keyword.get(opts, :prefix)
default_state = module.__durable_object__(:default_state)
object_keys = get_object_keys_config(module)

server = %__MODULE__{
module: module,
Expand All @@ -125,7 +135,8 @@ defmodule DurableObject.Server do
shutdown_after: shutdown_after,
shutdown_timer: nil,
repo: repo,
prefix: prefix
prefix: prefix,
object_keys: object_keys
}

if repo do
Expand Down Expand Up @@ -159,7 +170,7 @@ defmodule DurableObject.Server do

{:ok, object} ->
# Atomize string keys from JSON, merge with defaults for missing fields
loaded_state = atomize_keys(object.state)
loaded_state = atomize_keys(object.state, server.object_keys)
merged_state = Map.merge(server.state, loaded_state)
run_after_load(%{server | state: merged_state})

Expand Down Expand Up @@ -344,10 +355,40 @@ defmodule DurableObject.Server do
end
end

defp atomize_keys(map) when is_map(map) do
# Field names (from DSL) are atomized; object_keys controls keys within field values
defp atomize_keys(map, object_keys) when is_map(map) do
Map.new(map, fn
{key, value} when is_binary(key) -> {String.to_existing_atom(key), value}
{key, value} -> {key, value}
{key, value} when is_binary(key) ->
{String.to_existing_atom(key), convert_value(value, object_keys)}

{key, value} ->
{key, convert_value(value, object_keys)}
end)
end

defp convert_value(value, :strings), do: value

defp convert_value(map, strategy) when is_map(map) do
Map.new(map, fn
{key, value} when is_binary(key) ->
{convert_key(key, strategy), convert_value(value, strategy)}

{key, value} ->
{key, convert_value(value, strategy)}
end)
end

defp convert_value(list, strategy) when is_list(list) do
Enum.map(list, &convert_value(&1, strategy))
end

defp convert_value(value, _strategy), do: value

defp convert_key(key, :atoms!), do: String.to_existing_atom(key)
defp convert_key(key, :atoms), do: String.to_atom(key)

defp get_object_keys_config(module) do
module.__durable_object__(:object_keys) ||
Application.get_env(:durable_object, :object_keys, :strings)
end
end
6 changes: 5 additions & 1 deletion lib/durable_object/testing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,8 @@ defmodule DurableObject.Testing do
Returns the persisted state for an object, or `nil` if not found.

Useful for custom assertions beyond what `assert_persisted/4` provides.
Keys are returned as atoms.
Top-level field keys are returned as atoms. Keys within field values
remain as strings (the raw DB form), regardless of the `object_keys` setting.

## Options

Expand All @@ -580,6 +581,9 @@ defmodule DurableObject.Testing do
assert state.count > 0
assert state.name =~ ~r/test/

# Nested keys are always strings, even with object_keys: :atoms!
assert state.metadata == %{"foo" => "bar"}

# Returns nil if not persisted
assert nil == get_persisted_state(Counter, "nonexistent")
"""
Expand Down
Loading