Skip to main content

lib/skuld/foreign_resolver.ex

defprotocol Skuld.ForeignResolver do
  @moduledoc """
  Protocol for resolving `ForeignSuspend` values across different platforms.

  The protocol dispatches on the `payload` field of the first `ForeignSuspend`
  in the list. Consumers implement this protocol for their payload type to
  resolve foreign suspensions via platform-specific mechanisms (e.g., JS
  Promises, OS I/O completion ports).

  The protocol uses continuation-passing style — the implementation calls
  `continuation.(resolved)` with a map of `%{suspend_id => resolved_value}`
  when ready, enabling both synchronous and asynchronous resolution.

  ## Example (synchronous, for tests)

      defmodule MyPayload do
        defstruct [:value]
      end

      defimpl Skuld.ForeignResolver, for: MyPayload do
        def await_resolutions(suspends, continuation) do
          resolved = Map.new(suspends, &{&1.id, &1.payload.value})
          continuation.(resolved)
        end
      end

  ## Example (async, for Hologram)

      defimpl Skuld.ForeignResolver, for: Hologram.JS.PromiseRef do
        def await_resolutions(suspends, continuation) do
          promises = Enum.map(suspends, & &1.payload.promise)
          JS.Promise.all(promises).then(fn results ->
            resolved =
              suspends
              |> Enum.zip(results)
              |> Map.new(fn {s, val} -> {s.id, val} end)

            continuation.(resolved)
          end)
        end
      end
  """

  @doc """
  Resolve the given foreign suspensions and call the `continuation` with a map
  of `%{ForeignSuspend.id => resolved_value}`.

  The first parameter is any `ForeignSuspend.payload` value from the list,
  used for protocol dispatch. The full list of suspends is also passed so
  the implementation can batch-resolve them.

  The implementation must call `continuation.(resolved)` exactly once. It may
  do so synchronously or asynchronously.
  """
  @spec await_resolutions(
          payload :: term(),
          [Skuld.Comp.ForeignSuspend.t()],
          (map() -> term())
        ) :: term()
  def await_resolutions(payload, suspends, continuation)
end

defimpl Skuld.ForeignResolver, for: Atom do
  def await_resolutions(:skuld_foreign_resolver_fallback, _suspends, _continuation) do
    :ok
  end
end

defmodule Skuld.ForeignResolver.Runner do
  @moduledoc """
  Resolution loop for `ForeignSuspend` values.

  Runs a computation through the `ForeignResolver` protocol until all
  foreign suspensions are resolved and the computation completes.

  ## Example

      comp
      |> FiberPool.with_handler()
      |> ForeignResolver.Runner.run()
  """
  alias Skuld.Comp
  alias Skuld.Coroutine.Completed
  alias Skuld.Coroutine.ForeignSuspensions

  @doc """
  Run a computation through the foreign resolution loop.

  Returns the computation's final result once all foreign suspensions
  are resolved.
  """
  @spec run(Comp.Types.computation()) :: term()
  def run(comp) do
    {result, _env} = Comp.run(comp)

    case result do
      %ForeignSuspensions{} = fs ->
        resolve_loop(fs)

      other ->
        other
    end
  end

  defp resolve_loop(fs) do
    first_payload = hd(fs.suspensions).payload

    Skuld.ForeignResolver.await_resolutions(first_payload, fs.suspensions, fn resolved ->
      next = Skuld.Coroutine.call(fs, resolved)

      case next do
        %ForeignSuspensions{} = next_fs ->
          resolve_loop(next_fs)

        %Completed{result: result} ->
          result
      end
    end)
  end
end