defmodule Scout.Store do @moduledoc """ Storage facade with runtime adapter configuration. Delegates to configured storage adapter while providing interface stability. Uses UNIFIED behaviour contract to prevent interface mismatches. ## Available Adapters - `Scout.Store.Postgres` - PostgreSQL storage, persistent and distributed-ready (default) - `Scout.Store.ETS` - In-memory storage, fast but non-persistent ## Configuration # Runtime configuration (preferred - not compile-time locked) config :scout_core, :store_adapter, Scout.Store.Postgres """ alias Scout.Store.Adapter # Runtime adapter configuration (prevents compile-time lock-in) # Auto-detects Postgres if available, falls back to ETS defp adapter do configured = Application.get_env(:scout_core, :store_adapter) cond do # Explicit configuration wins configured != nil -> configured # Auto-detect: use Postgres if database is available postgres_available?() -> Scout.Store.Postgres # Fall back to ETS (no setup required) true -> Scout.Store.ETS end end defp postgres_available? do case Application.get_env(:scout_core, Scout.Repo) do nil -> false _config -> # Check if Repo process is alive Process.whereis(Scout.Repo) != nil end end @doc """ Returns a child specification for the configured storage adapter. """ @spec child_spec(term()) :: Supervisor.child_spec() def child_spec(arg), do: adapter().child_spec(arg) # Import types from unified behaviour @type study_id :: Adapter.study_id() @type trial_id :: Adapter.trial_id() @type status :: Adapter.status() # Facade delegates with fixed signatures @spec put_study(map()) :: :ok | {:error, term()} def put_study(study), do: adapter().put_study(study) @spec set_study_status(study_id(), status()) :: :ok | {:error, term()} def set_study_status(id, status), do: adapter().set_study_status(id, status) @spec get_study(study_id()) :: {:ok, map()} | :error def get_study(id), do: adapter().get_study(id) @spec add_trial(study_id(), map()) :: {:ok, trial_id()} | {:error, term()} def add_trial(study_id, trial), do: adapter().add_trial(study_id, trial) # FIXED: update_trial now requires study_id for proper scoping @spec update_trial(study_id(), trial_id(), map()) :: :ok | {:error, term()} def update_trial(study_id, trial_id, updates), do: adapter().update_trial(study_id, trial_id, updates) # FIXED: record_observation requires study_id for proper scoping @spec record_observation(study_id(), trial_id(), non_neg_integer(), non_neg_integer(), number()) :: :ok | {:error, term()} def record_observation(study_id, trial_id, bracket, rung, score) do adapter().record_observation(study_id, trial_id, bracket, rung, score) end @spec list_trials(study_id(), keyword()) :: [map()] def list_trials(study_id, filters \\ []), do: adapter().list_trials(study_id, filters) # FIXED: fetch_trial now requires study_id to match DB uniqueness constraint @spec fetch_trial(study_id(), trial_id()) :: {:ok, map()} | :error def fetch_trial(study_id, trial_id), do: adapter().fetch_trial(study_id, trial_id) @spec observations_at_rung(study_id(), non_neg_integer(), non_neg_integer()) :: [{trial_id(), number()}] def observations_at_rung(study_id, bracket, rung) do adapter().observations_at_rung(study_id, bracket, rung) end @spec delete_study(study_id()) :: :ok | {:error, term()} def delete_study(study_id), do: adapter().delete_study(study_id) @spec list_studies() :: [map()] def list_studies(), do: adapter().list_studies() @spec health_check() :: :ok | {:error, term()} def health_check(), do: adapter().health_check() @doc """ Returns the currently active storage adapter. ## Examples Scout.Store.current_adapter() # => Scout.Store.Postgres (if database available) # => Scout.Store.ETS (if no database) """ @spec current_adapter() :: module() def current_adapter(), do: adapter() @doc """ Returns the storage mode as an atom. ## Examples Scout.Store.storage_mode() # => :postgres (persistent, survives restarts) # => :ets (in-memory, fast but ephemeral) """ @spec storage_mode() :: :postgres | :ets def storage_mode do case adapter() do Scout.Store.Postgres -> :postgres Scout.Store.ETS -> :ets _ -> :unknown end end # ============================================================================ # EXPLICIT STATE TRANSITIONS (prevent leaky abstractions) # ============================================================================ @doc """ Start a new trial with proper state initialization. Enforces: - Status must be :running - Started_at timestamp is set - Params are validated """ @spec start_trial(study_id(), map(), non_neg_integer()) :: {:ok, trial_id()} | {:error, term()} def start_trial(study_id, params, bracket \\ 0) do trial_id = generate_trial_id() trial = %{ id: trial_id, params: params, bracket: bracket, status: :running, started_at: System.system_time(:millisecond), score: nil # Required field for samplers like TPE } adapter().add_trial(study_id, trial) end @doc """ Complete a trial successfully with score and metrics. Enforces: - Status transition to :completed - Completed_at timestamp is set - Score is recorded """ @spec finish_trial(study_id(), trial_id(), number(), map()) :: :ok | {:error, term()} def finish_trial(study_id, trial_id, score, metrics \\ %{}) do updates = %{ status: :completed, score: score, metrics: metrics, completed_at: DateTime.utc_now() } adapter().update_trial(study_id, trial_id, updates) end @doc """ Mark a trial as failed with error information. Enforces: - Status transition to :failed - Error message is recorded - Completed_at timestamp is set """ @spec fail_trial(study_id(), trial_id(), String.t()) :: :ok | {:error, term()} def fail_trial(study_id, trial_id, error_message) do updates = %{ status: :failed, error: error_message, completed_at: DateTime.utc_now() } adapter().update_trial(study_id, trial_id, updates) end @doc """ Prune a trial at a specific rung with optional score. Enforces: - Status transition to :pruned - Records pruning rung - Completed_at timestamp is set """ @spec prune_trial(study_id(), trial_id(), non_neg_integer(), number() | nil) :: :ok | {:error, term()} def prune_trial(study_id, trial_id, rung, score \\ nil) do updates = %{ status: :pruned, score: score, metadata: %{pruned_at_rung: rung}, completed_at: DateTime.utc_now() } adapter().update_trial(study_id, trial_id, updates) end defp generate_trial_id do Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) end end