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

### Added

- Built-in `state.id` field on every object's `State` struct, automatically set to the object's ID at init time
- Available in handlers, `after_load`, and `handle_alarm` callbacks
- Not persisted to the database — it's runtime metadata, not domain state
- Defaults to `nil` in test helpers like `perform_handler/4` (set it yourself if your handler reads `state.id`)
- Auto-generated DSL reference documentation via `mix spark.cheat_sheets`
- `mix docs` alias now chains `spark.cheat_sheets` → `docs` → `spark.replace_doc_links`
- CI check to verify DSL documentation is up-to-date (`mix spark.cheat_sheets --check`)

### Changed

- **Breaking:** `field :id` is now a reserved name and will raise a compile-time error. If you have an existing `field :id` in your state block, rename it (e.g., to `:external_id` or `:resource_id`) before upgrading.
- 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
Expand Down
1 change: 1 addition & 0 deletions lib/durable_object/dsl/extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ defmodule DurableObject.Dsl.Extension do
DurableObject.Dsl.Transformers.GenerateClient
],
verifiers: [
DurableObject.Dsl.Verifiers.ValidateFields,
DurableObject.Dsl.Verifiers.ValidateHandlers
]
end
11 changes: 8 additions & 3 deletions lib/durable_object/dsl/transformers/build_introspection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do
object_keys = Transformer.get_option(dsl_state, [:options], :object_keys)

# Build defstruct keyword list from fields (field_name => default)
# Prepend built-in :id field (not user-declared, not persisted)
struct_fields =
Enum.map(fields, fn field -> {field.name, field.default} end)
[{:id, nil} | 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)
# Build default state map from user-declared fields only (for persistence compatibility)
# :id is metadata injected at runtime, not domain state
default_state =
fields
|> Enum.map(fn field -> {field.name, field.default} end)
|> Map.new()

# Persist values for later retrieval via Spark.Dsl.Extension.get_persisted/3
dsl_state =
Expand Down
38 changes: 38 additions & 0 deletions lib/durable_object/dsl/verifiers/validate_fields.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule DurableObject.Dsl.Verifiers.ValidateFields do
@moduledoc """
Verifier that validates field names in the state block.

Checks that user-declared fields do not conflict with built-in field names
(e.g., `:id` is injected automatically and cannot be redeclared).
"""

use Spark.Dsl.Verifier

alias Spark.Dsl.Verifier

@reserved_fields [:id]

@impl true
def verify(dsl_state) do
module = Verifier.get_persisted(dsl_state, :module)
fields = Verifier.get_persisted(dsl_state, :durable_object_fields) || []

errors =
fields
|> Enum.filter(fn field -> field.name in @reserved_fields end)
|> Enum.map(fn field ->
%Spark.Error.DslError{
module: module,
path: [:state, :field],
message:
"Field name `#{field.name}` is reserved. " <>
"The `#{field.name}` field is built-in and automatically available on the state."
}
end)

case errors do
[] -> :ok
errors -> {:error, errors}
end
end
end
5 changes: 3 additions & 2 deletions lib/durable_object/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ defmodule DurableObject.Server do
repo = Keyword.get(opts, :repo)
prefix = Keyword.get(opts, :prefix)
default_state = module.__durable_object__(:default_state)
default_state = %{default_state | id: object_id}
object_keys = get_object_keys_config(module)

server = %__MODULE__{
Expand Down Expand Up @@ -315,8 +316,8 @@ defmodule DurableObject.Server do
end
end

defp serialize_state(%_{} = state), do: Map.from_struct(state)
defp serialize_state(state) when is_map(state), do: state
defp serialize_state(%_{} = state), do: state |> Map.from_struct() |> Map.delete(:id)
defp serialize_state(state) when is_map(state), do: Map.delete(state, :id)

defp schedule_shutdown(%{shutdown_after: nil} = server), do: server

Expand Down
163 changes: 163 additions & 0 deletions test/durable_object/state_id_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
defmodule DurableObject.StateIdTest do
use ExUnit.Case

alias DurableObject.{Server, Storage, TestRepo}
import DurableObject.TestHelpers

defmodule IdCounter do
use DurableObject

state do
field(:count, :integer, default: 0)
end

handlers do
handler(:get_id)
handler(:increment)
end

def handle_get_id(state) do
{:reply, state.id, state}
end

def handle_increment(state) do
new_count = state.count + 1
{:reply, new_count, %{state | count: new_count}}
end
end

defmodule IdAfterLoad do
use DurableObject

state do
field(:loaded_id, :string, default: nil)
end

handlers do
handler(:get)
end

def after_load(state) do
{:ok, %{state | loaded_id: state.id}}
end

def handle_get(state) do
{:reply, state, state}
end
end

describe "state.id in handlers (no persistence)" do
test "state.id is set to the object_id" do
id = unique_id("id")
{:ok, _pid} = Server.start_link(module: IdCounter, object_id: id)

assert {:ok, ^id} = Server.call(IdCounter, id, :get_id)
end

test "state.id persists across handler calls" do
id = unique_id("id")
{:ok, _pid} = Server.start_link(module: IdCounter, object_id: id)

Server.call(IdCounter, id, :increment)
Server.call(IdCounter, id, :increment)

assert {:ok, ^id} = Server.call(IdCounter, id, :get_id)
end
end

describe "state.id with persistence" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(TestRepo)
Ecto.Adapters.SQL.Sandbox.mode(TestRepo, {:shared, self()})
:ok
end

test "state.id is available in handler with repo" do
id = unique_id("id-persist")

{:ok, _pid} =
Server.start_link(module: IdCounter, object_id: id, repo: TestRepo)

assert {:ok, ^id} = Server.call(IdCounter, id, :get_id)
end

test "id is NOT persisted to the database" do
id = unique_id("id-not-stored")

{:ok, _pid} =
Server.start_link(module: IdCounter, object_id: id, repo: TestRepo)

Server.call(IdCounter, id, :increment)

{:ok, object} = Storage.load(TestRepo, "#{IdCounter}", id)
refute Map.has_key?(object.state, "id")
refute Map.has_key?(object.state, :id)
assert Map.has_key?(object.state, "count")
end

test "state.id survives reload from database" do
id = unique_id("id-reload")

# Start, increment, stop
{:ok, pid} =
Server.start_link(module: IdCounter, object_id: id, repo: TestRepo)

Server.call(IdCounter, id, :increment)
GenServer.stop(pid)

# Restart - id should be set again
{:ok, _pid} =
Server.start_link(module: IdCounter, object_id: id, repo: TestRepo)

assert {:ok, ^id} = Server.call(IdCounter, id, :get_id)
end

test "state.id is available in after_load" do
id = unique_id("id-after-load")

{:ok, _pid} =
Server.start_link(module: IdAfterLoad, object_id: id, repo: TestRepo)

{:ok, state} = Server.call(IdAfterLoad, id, :get)
assert state.loaded_id == id
assert state.id == id
end
end

describe "compile-time validation" do
test "declaring field :id raises a compile error" do
output =
ExUnit.CaptureIO.capture_io(:stderr, fn ->
defmodule ReservedIdField do
use DurableObject.Dsl

state do
field(:id, :string)
end

handlers do
handler(:get)
end

def handle_get(state), do: {:reply, state, state}
end
end)

assert output =~ "reserved"
end
end

describe "State struct" do
test "State struct includes :id field with nil default" do
state = %IdCounter.State{}
assert Map.has_key?(state, :id)
assert state.id == nil
end

test "default_state includes :id field" do
default = IdCounter.__durable_object__(:default_state)
assert Map.has_key?(default, :id)
assert default.id == nil
end
end
end