From ba796580b058b9faf970f260ece8a2b2dba3e552 Mon Sep 17 00:00:00 2001 From: Christian Alexander Date: Wed, 25 Feb 2026 07:53:39 -0700 Subject: [PATCH] Add built-in `id` field to object state Inject a built-in `id` field into every State struct, populated with the object_id at init time. The field is not persisted to the database. - Prepend `{:id, nil}` to struct fields in BuildIntrospection - Set `state.id = object_id` in Server.init/1 - Strip `:id` in serialize_state before persisting - Add ValidateFields verifier to reject `field :id` declarations - Add tests for handler access, after_load, non-persistence, and compile-time validation --- CHANGELOG.md | 5 + lib/durable_object/dsl/extension.ex | 1 + .../dsl/transformers/build_introspection.ex | 11 +- .../dsl/verifiers/validate_fields.ex | 38 ++++ lib/durable_object/server.ex | 5 +- test/durable_object/state_id_test.exs | 163 ++++++++++++++++++ 6 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 lib/durable_object/dsl/verifiers/validate_fields.ex create mode 100644 test/durable_object/state_id_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1a3a8..798b6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/durable_object/dsl/extension.ex b/lib/durable_object/dsl/extension.ex index 702f94d..640432f 100644 --- a/lib/durable_object/dsl/extension.ex +++ b/lib/durable_object/dsl/extension.ex @@ -92,6 +92,7 @@ defmodule DurableObject.Dsl.Extension do DurableObject.Dsl.Transformers.GenerateClient ], verifiers: [ + DurableObject.Dsl.Verifiers.ValidateFields, DurableObject.Dsl.Verifiers.ValidateHandlers ] end diff --git a/lib/durable_object/dsl/transformers/build_introspection.ex b/lib/durable_object/dsl/transformers/build_introspection.ex index b8dfde4..de46e1f 100644 --- a/lib/durable_object/dsl/transformers/build_introspection.ex +++ b/lib/durable_object/dsl/transformers/build_introspection.ex @@ -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 = diff --git a/lib/durable_object/dsl/verifiers/validate_fields.ex b/lib/durable_object/dsl/verifiers/validate_fields.ex new file mode 100644 index 0000000..b21707f --- /dev/null +++ b/lib/durable_object/dsl/verifiers/validate_fields.ex @@ -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 diff --git a/lib/durable_object/server.ex b/lib/durable_object/server.ex index 7d2b337..72e02f9 100644 --- a/lib/durable_object/server.ex +++ b/lib/durable_object/server.ex @@ -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__{ @@ -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 diff --git a/test/durable_object/state_id_test.exs b/test/durable_object/state_id_test.exs new file mode 100644 index 0000000..b3889c1 --- /dev/null +++ b/test/durable_object/state_id_test.exs @@ -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