Skip to main content

lib/skuld/repo/open_in_memory.ex

# Open-world stateful in-memory Repo handler for tests.
#
# Thin wrapper around DoubleDown.Repo.OpenInMemory providing skuld's
# effect-based `with_handler/3` API. See Repo.InMemory for the
# recommended closed-world variant.
#
defmodule Skuld.Repo.OpenInMemory do
  @moduledoc """
  Open-world stateful in-memory Repo handler for tests.

  Thin wrapper around `DoubleDown.Repo.OpenInMemory` that provides
  skuld's effect-based `with_handler/3` API. The underlying DD fake
  handles all Repo operations — writes mutate state, PK reads check
  state then fallback, everything else goes to fallback.

  For most use cases, prefer `Repo.InMemory` (closed-world) which
  is authoritative for all bare-schema reads without a fallback.
  Use `OpenInMemory` when the state is deliberately partial.

  ## Usage

      alias Skuld.Repo

      # Basic — PK reads only, no fallback:
      comp
      |> Repo.OpenInMemory.with_handler(Repo.OpenInMemory.new())
      |> Comp.run!()

      # With seed data and fallback:
      state = Repo.OpenInMemory.new(
        seed: [%User{id: 1, name: "Alice"}],
        fallback_fn: fn
          :all, [User], state ->
            Map.get(state, User, %{}) |> Map.values()
          :get_by, [User, [email: "alice@example.com"]], _state ->
            %User{id: 1}
        end
      )
      comp
      |> Repo.OpenInMemory.with_handler(state)
      |> Comp.run!()

  ## Extracting Final State

  Use the `:output` option to access the final handler state:

      {result, final_store} =
        comp
        |> Repo.OpenInMemory.with_handler(Repo.OpenInMemory.new(),
          output: fn result, state -> {result, state.handler_state} end
        )
        |> Comp.run!()

  ## Fallback function

  The fallback function receives `(operation, args, state)` where
  `state` is the clean store map (without internal keys). If it raises
  `FunctionClauseError`, dispatch falls through to an error.

  This is the skuld-idiomatic 3-arity convention. The wrapper adapts
  it to DD's 4-arity `(contract, operation, args, state)` convention
  internally.
  """

  alias Skuld.Effects.Port
  alias DoubleDown.Repo.OpenInMemory, as: DDOpenInMemory

  @type store :: DDOpenInMemory.store()

  @doc """
  Create a new OpenInMemory state map.

  ## Options

    * `:seed` - a list of structs to pre-populate the store
    * `:fallback_fn` - a 3-arity function `(operation, args, state) -> result`
      that handles operations the state cannot answer authoritatively.

  ## Examples

      Repo.OpenInMemory.new()
      Repo.OpenInMemory.new(seed: [%User{id: 1, name: "Alice"}])
      Repo.OpenInMemory.new(
        seed: [%User{id: 1, name: "Alice"}],
        fallback_fn: fn
          :all, [User], state -> Map.get(state, User, %{}) |> Map.values()
        end
      )
  """
  @spec new(keyword()) :: store()
  def new(opts \\ []) do
    seed_records = Keyword.get(opts, :seed, [])
    skuld_fallback = Keyword.get(opts, :fallback_fn, nil)

    dd_opts =
      if skuld_fallback do
        [fallback_fn: fn _contract, op, args, state -> skuld_fallback.(op, args, state) end]
      else
        []
      end

    DDOpenInMemory.new(seed_records, dd_opts)
  end

  @doc """
  Convert a list of structs into the nested state map for seeding.
  """
  @spec seed(list(struct())) :: store()
  defdelegate seed(records), to: DDOpenInMemory

  @doc """
  Install the open-world in-memory Repo handler for a computation.

  ## Options

  All options from `Port.with_stateful_handler/4` are supported:

    * `:log` — enable dispatch logging
    * `:output` — transform `(result, %Port.State{}) -> output` on scope exit.
  """
  @spec with_handler(Skuld.Comp.Types.computation(), store(), keyword()) ::
          Skuld.Comp.Types.computation()
  def with_handler(comp, initial_store \\ %{}, opts \\ []) do
    Port.with_stateful_handler(comp, initial_store, &DDOpenInMemory.dispatch/4, opts)
  end

  @doc """
  Returns the stateful handler function.

  The function has the signature `(contract, operation, args, state) -> {result, new_state}`.
  """
  def handler, do: &DDOpenInMemory.dispatch/4
end