# 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