Skip to main content

lib/skuld/effects/effect_logger/env_state_snapshot.ex

# A serializable snapshot of `env.state` for cold resume.
#
# All env.state keys are strings, so they pass through JSON natively
# without needing encode/decode conversion.
#
# ## Filtering
#
# EffectLogger's internal state keys are filtered out when capturing:
# - Log state key: would create circular reference (log contains snapshot)
# - Resume value key: only needed during active resume, not for persistence
# - State keys filter: internal config that doesn't survive JSON round-trip
#
# ## Example
#
#     env_state = %{
#       "Elixir.Skuld.Effects.State" => 42,
#       "Elixir.Skuld.Effects.EffectLogger::log" => %Log{...}
#     }
#
#     snapshot = EnvStateSnapshot.capture(env_state)
#     # => %EnvStateSnapshot{entries: %{"Elixir.Skuld.Effects.State" => 42}}
#
#     restored = EnvStateSnapshot.restore(snapshot)
#     # => %{"Elixir.Skuld.Effects.State" => 42}
defmodule Skuld.Effects.EffectLogger.EnvStateSnapshot do
  @moduledoc false

  alias Skuld.Comp.SerializableStruct

  @effect_logger_state_key "Elixir.Skuld.Effects.EffectLogger::log"
  @resume_value_key "Elixir.Skuld.Effects.EffectLogger::resume_value"
  @state_keys_key "Elixir.Skuld.Effects.EffectLogger::state_keys"

  defstruct entries: %{}

  @type t :: %__MODULE__{
          entries: %{String.t() => term()}
        }

  @doc """
  Capture a snapshot of env.state for serialization.

  Filters out EffectLogger's internal state. All keys are already
  strings so no conversion is needed.

  ## Options

  - `:state_keys` - List of state keys to include. Default `:all` captures everything.
    Keys should be in the format used in env.state (strings).

  ## Examples

      # Capture all state
      EnvStateSnapshot.capture(env_state)

      # Capture only specific State effect keys
      EnvStateSnapshot.capture(env_state, state_keys: [
        State.state_key(MyApp.Counter)
      ])
  """
  @spec capture(map(), keyword()) :: t()
  def capture(env_state, opts \\ [])

  def capture(env_state, opts) when is_map(env_state) do
    state_keys = Keyword.get(opts, :state_keys, :all)

    entries =
      env_state
      |> Enum.reject(fn {key, _value} ->
        key == @effect_logger_state_key or key == @resume_value_key or key == @state_keys_key
      end)
      |> Enum.filter(fn {key, _value} ->
        case state_keys do
          :all -> true
          nil -> true
          keys when is_list(keys) -> key in keys
        end
      end)
      |> Enum.map(fn {key, value} ->
        {key, encode_value(value)}
      end)
      |> Map.new()

    %__MODULE__{entries: entries}
  end

  @doc """
  Restore env.state from a snapshot.

  Keys are already strings, so they pass through unchanged.
  """
  @spec restore(t()) :: map()
  def restore(%__MODULE__{entries: entries}) do
    entries
    |> Enum.map(fn {key, value} ->
      {key, decode_value(value)}
    end)
    |> Map.new()
  end

  @doc """
  Reconstruct from decoded JSON map.
  """
  @spec from_json(map()) :: t()
  def from_json(map) when is_map(map) do
    entries =
      (map["entries"] || %{})
      |> Enum.map(fn {k, v} ->
        {k, decode_value(v)}
      end)
      |> Map.new()

    %__MODULE__{entries: entries}
  end

  # Encode a value for JSON serialization
  defp encode_value(%_{} = struct) do
    SerializableStruct.encode(struct)
  end

  defp encode_value(value), do: value

  # Decode a value from JSON
  defp decode_value(map) when is_map(map) do
    if Map.has_key?(map, "__struct__") or Map.has_key?(map, :__struct__) do
      SerializableStruct.decode(map)
    else
      map
    end
  end

  defp decode_value(value), do: value
end

defimpl Jason.Encoder, for: Skuld.Effects.EffectLogger.EnvStateSnapshot do
  alias Skuld.Comp.SerializableStruct

  def encode(value, opts) do
    value
    |> SerializableStruct.encode()
    |> Jason.Encode.map(opts)
  end
end