Skip to main content

lib/skuld/serializable_coroutine.ex

defmodule Skuld.SerializableCoroutine do
  @moduledoc """
  Helpers for building coroutines with serializable effect logs.

  Part of the `skuld_durable` package, which provides durable execution
  through SerializableCoroutine (pause-serialize-resume workflows) and
  EffectLogger (execution logging and replay). See the
  [architecture guide](https://hexdocs.pm/skuld/architecture.html)
  for how these fit into the Skuld ecosystem.

  `new/2` constructs a struct with `EffectLogger` installed innermost,
  so every effect invocation across all handlers is captured in a
  JSON-serializable log.

  When the coroutine suspends, the log is accessible via `get_log/1`
  (from the env in `%Coroutine.ExternalSuspended{}`). `serialize/1` and
  `deserialize/1` convert the log to/from JSON.

  `run` handles all states — fresh start, live resume, and cold resume
  from serialised state.

  ## Usage

      sc = SerializableCoroutine.new(wizard, fn comp ->
        comp |> State.with_handler(0) |> Yield.with_handler() |> Throw.with_handler()
      end)

      # Run fresh — suspends at first yield
      suspended = SerializableCoroutine.run(sc)

      # Serialize and persist
      json = SerializableCoroutine.serialize(SerializableCoroutine.get_log(suspended))

      # Later: cold resume from serialised state
      SerializableCoroutine.run(json, sc, "Alice")

      # Or resume a live suspended coroutine directly
      SerializableCoroutine.run(suspended, "Alice")
  """

  alias Skuld.Comp.Env
  alias Skuld.Comp.Types
  alias Skuld.Coroutine
  alias Skuld.Effects.EffectLogger
  alias Skuld.Effects.EffectLogger.Log

  defstruct [:comp, :handlers_fun]

  @typedoc """
  A serialisable coroutine capturing the computation and handler stack.
  """
  @type t :: %__MODULE__{
          comp: Types.computation(),
          handlers_fun: (Types.computation() -> Types.computation())
        }

  @doc """
  Build a serialisable coroutine.

  `handlers_fun` receives the computation after EffectLogger is installed.
  Install application-level handlers here (State, Throw, Yield, etc.).

  ## Example

      SerializableCoroutine.new(my_comp, fn comp ->
        comp |> State.with_handler(0) |> Throw.with_handler()
      end)
  """
  @spec new(Types.computation(), (Types.computation() -> Types.computation())) :: t()
  def new(comp, handlers_fun)
      when is_function(comp, 2) and is_function(handlers_fun, 1) do
    %__MODULE__{comp: comp, handlers_fun: handlers_fun}
  end

  @doc """
  Run a serialisable coroutine.

  Clauses dispatch on the input type:

  - `t()` — start fresh, returns a Coroutine sum-type
  - `t()`, `value` — resume a live suspended fiber (delegates to `Coroutine.run`)
  - `%Log{}`, `t()`, `value` — cold resume from a deserialised log
  - `binary`, `t()`, `value` — deserialise JSON to a log, then cold resume
  - `Coroutine.t()`, `value` — resume a live Coroutine fiber directly

  ## Examples

      sc = SerializableCoroutine.new(wizard, handlers)

      # Start fresh
      suspended = SerializableCoroutine.run(sc)

      # Resume a live suspended fiber
      SerializableCoroutine.run(suspended, "Alice")

      # Cold resume from a deserialised log
      SerializableCoroutine.run(log, sc, "Alice")

      # Cold resume from serialised JSON
      SerializableCoroutine.run(json, sc, "Alice")
  """
  @spec run(t()) :: Coroutine.t()
  def run(%__MODULE__{comp: comp, handlers_fun: handlers_fun}) do
    comp
    |> EffectLogger.with_logging()
    |> handlers_fun.()
    |> then(&Coroutine.new(&1, Env.new()))
    |> Coroutine.run()
  end

  def run(%Coroutine.ExternalSuspended{} = fiber, value) do
    Coroutine.run(fiber, value)
  end

  @spec run(Log.t(), t(), term()) :: Coroutine.t()
  def run(%Log{} = log, %__MODULE__{comp: comp, handlers_fun: handlers_fun}, value) do
    comp
    |> EffectLogger.with_resume(log, value)
    |> handlers_fun.()
    |> then(&Coroutine.new(&1, Env.new()))
    |> Coroutine.run()
  end

  @spec run(String.t(), t(), term()) :: Coroutine.t()
  def run(json, %__MODULE__{} = sc, value) when is_binary(json) do
    {:ok, log} = deserialize(json)
    run(log, sc, value)
  end

  @doc """
  Extract the EffectLogger log from a suspended coroutine.

  The log is stored in the environment at suspension time.
  Returns `nil` if no log is found.
  """
  @spec get_log(Coroutine.ExternalSuspended.t()) :: Log.t() | nil
  def get_log(%Coroutine.ExternalSuspended{env: env}) do
    EffectLogger.get_log(env)
  end

  @doc """
  Serialize a log to JSON.

  Returns a JSON string suitable for storage.
  """
  @spec serialize(Log.t()) :: String.t()
  def serialize(log) do
    log
    |> Log.finalize()
    |> Jason.encode!()
  end

  @doc """
  Deserialize a log from JSON.

  Returns `{:ok, log}` on success.
  """
  @spec deserialize(String.t()) :: {:ok, Log.t()} | {:error, Jason.DecodeError.t()}
  def deserialize(json) when is_binary(json) do
    case Jason.decode(json) do
      {:ok, data} -> {:ok, Log.from_json(data)}
      {:error, _} = error -> error
    end
  end
end