Skip to main content

lib/skuld/effects/effect_logger/effect_log_entry.ex

# A flat log entry for an effect invocation.
#
# Each entry represents a single effect handler invocation and captures:
#
# - `id` - Unique identifier so wrapped_k can verify it's closing the correct entry
# - `sig` - Effect signature (module)
# - `data` - Effect arguments (the input to the effect)
# - `value` - Result value (if handler called wrapped_k)
# - `state` - Current state in the effect lifecycle
#
# ## Flat Log Structure
#
# Entries are stored in a flat list, ordered by when they started. The tree
# structure of the computation is NOT captured - instead, we use `leave_scope`
# handlers to mark entries as `:discarded` when their continuations are
# abandoned (e.g., by a Throw effect).
#
# ## State Machine
#
# The `state` field tracks where the effect is in its lifecycle:
#
# - `:started` - Entry created, handler invoked, continuation not yet completed.
#   This includes effects that have suspended (e.g., Yield) - they remain
#   `:started` until their continuation is eventually called.
#
# - `:executed` - Handler called wrapped_k with a value. The `value` field
#   contains the result passed to the continuation. Can be short-circuited
#   during replay.
#
# - `:discarded` - Handler discarded the continuation (never called wrapped_k).
#   This is the effect that caused the discard (e.g., Throw effect itself).
#   Cannot be short-circuited during replay, must re-execute the handler.
#
# ## State Transitions
#
#     :started → :executed   (wrapped_k called)
#     :started → :discarded  (leave_scope triggered before wrapped_k called)
#
# ## Replay Semantics
#
# - `:executed` entries can be short-circuited with their logged value
# - `:discarded` entries must re-execute the handler (they caused the discard)
# - `:started` entries indicate suspension points (for cold resume)
defmodule Skuld.Effects.EffectLogger.EffectLogEntry do
  @moduledoc false

  alias Skuld.Comp.SerializableStruct

  @type state :: :started | :executed | :discarded

  @enforce_keys [:id, :sig]
  defstruct [:id, :sig, data: nil, value: nil, state: :started]

  @type t :: %__MODULE__{
          id: String.t(),
          sig: module(),
          data: any(),
          value: any(),
          state: state()
        }

  @doc """
  Create a new effect log entry in `:started` state.
  """
  @spec new(String.t(), module(), any()) :: t()
  def new(id, sig, data) do
    %__MODULE__{
      id: id,
      sig: sig,
      data: data,
      value: nil,
      state: :started
    }
  end

  @doc """
  Set the value and transition to `:executed` state.
  """
  @spec set_executed(t(), any()) :: t()
  def set_executed(%__MODULE__{} = entry, value) do
    %{entry | value: value, state: :executed}
  end

  @doc """
  Transition to `:discarded` state.

  Called by leave_scope when a handler doesn't call wrapped_k (e.g., Throw).
  """
  @spec set_discarded(t()) :: t()
  def set_discarded(%__MODULE__{} = entry) do
    %{entry | state: :discarded}
  end

  @doc """
  Returns true if the entry has completed (handler called wrapped_k).
  """
  @spec completed?(t()) :: boolean()
  def completed?(%__MODULE__{state: :executed}), do: true
  def completed?(%__MODULE__{}), do: false

  @doc """
  Returns true if the entry is in a terminal state.
  """
  @spec terminal?(t()) :: boolean()
  def terminal?(%__MODULE__{state: state}) when state in [:executed, :discarded], do: true
  def terminal?(%__MODULE__{}), do: false

  @doc """
  Returns true if the entry can be short-circuited during replay.

  Only `:executed` entries can be short-circuited (they have a value).
  `:discarded` entries must be re-executed.
  """
  @spec can_short_circuit?(t()) :: boolean()
  def can_short_circuit?(%__MODULE__{state: :executed}), do: true
  def can_short_circuit?(%__MODULE__{}), do: false

  @doc """
  Check if this entry matches the given effect signature and data.
  """
  @spec matches?(t(), module(), any()) :: boolean()
  def matches?(%__MODULE__{sig: entry_sig, data: entry_data}, sig, data) do
    entry_sig == sig and entry_data == data
  end

  @doc """
  Reconstruct EffectLogEntry from decoded JSON map.
  """
  @spec from_json(map()) :: t()
  def from_json(map) when is_map(map) do
    %__MODULE__{
      id: map["id"],
      sig: decode_sig(map["sig"]),
      data: decode_data(map["data"]),
      value: decode_value(map["value"]),
      state: decode_state(map["state"])
    }
  end

  defp decode_sig(nil), do: nil

  defp decode_sig(sig) when is_binary(sig) do
    String.to_existing_atom(sig)
  end

  defp decode_data(value), do: SerializableStruct.decode_term(value)

  defp decode_value(value), do: SerializableStruct.decode_term(value)

  defp decode_state(nil), do: :started
  defp decode_state("started"), do: :started
  defp decode_state("executed"), do: :executed
  defp decode_state("discarded"), do: :discarded
end

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

  def encode(value, opts) do
    Jason.Encode.map(
      %{
        id: value.id,
        sig: value.sig,
        data: SerializableStruct.encode_term(value.data),
        value: SerializableStruct.encode_term(value.value),
        state: value.state
      },
      opts
    )
  end
end