lib/spex/instance_manager/simple_instance_manager.ex

defmodule Spex.InstanceManager.SimpleInstanceManager do
  @moduledoc """
  Single-node instance manager backed by one `Spex.InstanceManager.Server` process.

  Use this manager when you want all instance operations handled by a single
  GenServer and a single DETS table.

  This module is designed to be added directly to your supervision tree, e.g.
  via `child_spec/1`:

      children = [
        {Spex.InstanceManager.SimpleInstanceManager,
         impl_models_dir: "./spex_impl_models",
         dets_dir: "./spex_dets"}
      ]

  You may provide runtime options when starting this manager, or provide the
  same values via application environment (`config/*.exs`).

  ## Configuration

  `SimpleInstanceManager` forwards server options to
  `Spex.InstanceManager.Server` (see `server_opt()`), with one internal detail:

  - `:dets_table` is always set to `Spex.InstanceManager.SimpleInstanceManager`
    by this module and should not be provided by callers.

  Supported options:

  - `:impl_models_dir` (`String.t()`)
    Path to `.spex` implementation model files loaded on startup.
    Default: `"./spex_impl_models"`.

  - `:dets_dir` (`String.t()`)
    Directory where the DETS file for this manager is stored.
    Default: `"./spex_dets"`.

  - `:check_transition_timeouts_on_start?` (`boolean()`)
    When `true`, existing instances in DETS are checked during startup and
    transition timeout errors are emitted if needed.
    Default: `true`.

  - `:prune_interval` (`timeout()`)
    Interval for periodic pruning checks. Use `:infinity` to disable periodic
    checks after startup.
    Default: `to_timeout(%Duration{hour: 6})`.

  Pruning semantics:

  - one prune pass is always executed immediately on startup,
  - when `:prune_interval != :infinity`, additional periodic prune passes are
    scheduled,
  - when `:prune_interval == :infinity`, no periodic passes are scheduled after
    the initial startup pass.

  ## Option Resolution Order

  For each server option, effective value is resolved in this order:

  1. option passed to `start_link/1` / supervisor child spec,
  2. application environment key under `:spex`,
  3. server default.

  Missing required values raise with a descriptive `ArgumentError`.

  ## Application Config Example

      config :spex,
        impl_models_dir: "./priv/spex_impl_models",
        dets_dir: "./priv/spex_dets",
        check_transition_timeouts_on_start?: true,
        prune_interval: :timer.hours(6)
  """

  use Spex.InstanceManager

  @server_name Module.concat(__MODULE__, "Server")

  @doc "Callback implementation for `c:Spex.InstanceManager.start_link/1`."
  @impl Spex.InstanceManager
  def start_link(opts) do
    server_opts = Keyword.put(opts, :dets_table, __MODULE__)

    children = [
      %{
        id: :server,
        start:
          {GenServer, :start_link,
           [Spex.InstanceManager.Server, server_opts, [name: @server_name]]}
      }
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__)
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.init_instance/4`."
  @impl Spex.InstanceManager
  def init_instance(spec, instance_identifier, meta \\ nil, initial_state \\ nil) do
    GenServer.call(
      @server_name,
      {:init_instance, spec, instance_identifier, meta, initial_state}
    )
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.init_instance_async/4`."
  @impl Spex.InstanceManager
  def init_instance_async(spec, instance_identifier, meta \\ nil, initial_state \\ nil) do
    GenServer.cast(
      @server_name,
      {{:init_instance, spec, instance_identifier, meta, initial_state}, self()}
    )
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.transition/3`."
  @impl Spex.InstanceManager
  def transition(instance_identifier, action, state) do
    GenServer.call(
      @server_name,
      {:transition, instance_identifier, action, state}
    )
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.transition_async/3`."
  @impl Spex.InstanceManager
  def transition_async(instance_identifier, action, state) do
    GenServer.cast(
      @server_name,
      {{:transition, instance_identifier, action, state}, self()}
    )
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.get_instance/1`."
  @impl Spex.InstanceManager
  def get_instance(instance_identifier) do
    GenServer.call(@server_name, {:get_instance, instance_identifier})
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.all_instances/0`."
  @impl Spex.InstanceManager
  def all_instances do
    GenServer.call(@server_name, :all_instances)
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.all_instances/1`."
  @impl Spex.InstanceManager
  def all_instances(specification) do
    GenServer.call(@server_name, {:all_instances, specification})
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.delete_instance/1`."
  @impl Spex.InstanceManager
  def delete_instance(instance_identifier) do
    GenServer.call(@server_name, {:delete_instance, instance_identifier})
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.delete_instances/1`."
  @impl Spex.InstanceManager
  def delete_instances(filter_fun) do
    GenServer.call(@server_name, {:delete_instances, filter_fun})
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.all_impl_models/0`."
  @impl Spex.InstanceManager
  def all_impl_models do
    GenServer.call(@server_name, :all_impl_models)
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.export_impl_models/0`."
  @impl Spex.InstanceManager
  def export_impl_models do
    with {:ok, impl_models} <- all_impl_models() do
      impl_models
      |> Enum.map(&{"#{&1.specification}.spex", Spex.ImplModel.serialise(&1)})
      |> then(&{:ok, &1})
    end
  end

  @doc "Callback implementation for `c:Spex.InstanceManager.mock_instance!/4`."
  @impl Spex.InstanceManager
  def mock_instance!(spec, instance_identifier, state, meta \\ nil) do
    GenServer.call(
      @server_name,
      {:mock_instance, spec, instance_identifier, state, meta}
    )
    |> __ok__!()
  end
end