Skip to main content

lib/store.ex

defmodule ActiveMemory.Store do
  @moduledoc """
  # The Store

  ## Store API
    - `Store.all/0` Get all records stored
    - `Store.delete/1` Delete the record provided
    - `Store.delete_all/0` Delete all records stored
    - `Store.one/1` Get one record matching either an attributes search or `match` query
    - `Store.select/1` Get all records matching either an attributes search or `match` query
    - `Store.withdraw/1` Get one record matching either an attributes search or `match` query, delete the record and return it
    - `Store.write/1` Write a record into the memmory table

  ## Concurrency
  A `Store` is a `GenServer`, but the data functions above (`all/0`, `one/1`,
  `select/1`, `write/1`, `delete/1`, `withdraw/1`, `delete_all/0`) are **not**
  routed through that process and are **not** serialized by it. They are ordinary
  module functions that run in the **caller's** process, delegating straight to the
  table's adapter (and therefore to `:ets`/`:mnesia`). Concurrency is governed by
  ETS/Mnesia themselves, so many processes read and write in parallel — the single
  `GenServer` is not a bottleneck. Only lifecycle and metadata operations (`init`,
  `state/0`, `reload_seeds/0`) actually use the `GenServer`.

  These functions live on the `GenServer` module purely for **organization**: the
  `Store` is the single place responsible for how the application talks to its
  table, following the Single Responsibility Principle. See the
  [S.T.O.N.E principles](https://www.hpt-consulting.org/blog/stone-principles) for
  the broader design philosophy.

  ## Expiry (TTL)
  When the `Store`'s `Table` declares a `ttl` (see `ActiveMemory.Table`), records
  expire automatically. Expiry is enforced in two ways: reads (`one/1`, `select/1`,
  `all/0`, `withdraw/1`) never return an expired record, and the `Store` periodically
  sweeps expired records to reclaim memory. The sweep cadence defaults to one minute
  and can be set with the `sweep_interval` option (milliseconds):
  ```elixir
  defmodule MyApp.Tokens.Store do
  use ActiveMemory.Store,
    table: MyApp.Tokens.Token,
    sweep_interval: :timer.seconds(30)
  end
  ```
  The sweep only runs when the table has a `ttl`; otherwise it is never scheduled.

  ## Seeding
  When starting a `Store` there is an option to provide a valid seed file and have the `Store` auto load seeds contained in the file.
  ```elixir
  defmodule MyApp.People.Store do
  use ActiveMemory.Store,
    table: MyApp.People.Person,
    seed_file: Path.expand("person_seeds.exs", __DIR__)
  end
  ```

  ## Before `init`
  All stores are `GenServers` and have `init` functions. While those are abstracted you can still specify methods to run during the `init` phase of the GenServer startup. Use the `before_init` keyword and add the methods as tuples with the arguments.
  ```elixir
  defmodule MyApp.People.Store do
  use ActiveMemory.Store,
    table: MyApp.People.Person,
    before_init: [{:run_me, ["arg1", "arg2", ...]}, {:run_me_too, []}]
  end
  ```

  > #### `before_init` and table recovery {: .warning}
  >
  > For ETS stores, the table is preserved across a store crash/restart by
  > `ActiveMemory.TableHeir`. On such a recovery seed files are *not* re-run, but
  > `before_init` methods **always** run, including on recovery. If a
  > `before_init` method writes records with unique or generated keys (for
  > example a `uuid`), running it again on recovery can create duplicates.
  >
  > How to handle this is left to the implementer. One option is to make any
  > `before_init` write follow a "find or create" pattern — check with `one/1`
  > before calling `write/1` — so the method is idempotent across restarts:
  >
  > ```elixir
  > def run_me(args) do
  >   record = build_record(args)
  >
  >   case one(%{key: record.key}) do
  >     {:ok, existing} -> {:ok, existing}
  >     {:error, :not_found} -> write(record)
  >   end
  > end
  > ```

  ## Initial State
  All stores are `GenServers` and thus have a state. The default state is a map as such:
  ```elixir
  %{started_at: "date time when first started", table_name: MyApp.People.Person}
  ```
  This default state can be overwritten with a new state structure or values by supplying a method and arguments as a tuple to the keyword `initial_state`. The method must return `{:ok, new_state}`.

  ```elixir
  defmodule MyApp.People.Store do
  use ActiveMemory.Store,
    table: MyApp.People.Person,
    initial_state: {:initial_state_method, ["arg1", "arg2", ...]}
  end
  ```
  """

  @default_sweep_interval :timer.seconds(60)

  defmacro __using__(opts) do
    quote do
      import unquote(__MODULE__)

      use GenServer

      alias ActiveMemory.Operations

      opts = unquote(Macro.expand(opts, __CALLER__))

      @table Keyword.get(opts, :table)
      @before_init Keyword.get(opts, :before_init, :default)
      @initial_state Keyword.get(opts, :initial_state, :default)
      @seed_file Keyword.get(opts, :seed_file, nil)
      @sweep_interval Keyword.get(opts, :sweep_interval, unquote(@default_sweep_interval))

      def start_link(options \\ []) do
        GenServer.start_link(__MODULE__, options, name: __MODULE__)
      end

      @impl true
      def init(_) do
        with {:ok, table_status} <- create_table(),
             {:ok, :seed_success} <- __maybe_run_seeds__(table_status),
             {:ok, _result} <- Operations.before_init(@before_init, __MODULE__),
             {:ok, initial_state} <- __initial_state__() do
          __schedule_sweep__()
          {:ok, initial_state}
        end
      end

      @spec all() :: list(map())
      def all, do: Operations.all(@table)

      def create_table, do: Operations.create_table(@table)

      @spec delete(any()) :: :ok | {:error, any()}
      def delete(struct), do: Operations.delete(struct, @table)

      @spec delete_all() :: :ok | {:error, any()}
      def delete_all, do: Operations.delete_all(@table)

      @spec one(map() | list(any())) :: {:ok, map()} | {:error, any()}
      def one(query), do: Operations.one(query, @table)

      def reload_seeds do
        GenServer.call(__MODULE__, :reload_seeds)
      end

      @spec select(map() | list(any())) :: {:ok, list(map())} | {:error, any()}
      def select(query), do: Operations.select(query, @table)

      def state do
        GenServer.call(__MODULE__, :state)
      end

      @spec withdraw(map() | list(any())) :: {:ok, map()} | {:error, any()}
      def withdraw(query), do: Operations.withdraw(query, @table)

      @spec write(map()) :: {:ok, map()} | {:error, any()}
      def write(struct), do: Operations.write(struct, @table)

      @impl true
      def handle_call(:reload_seeds, _from, state) do
        {:reply, Operations.seed(@seed_file, @table), state}
      end

      @impl true
      def handle_call(:state, _from, state), do: {:reply, state, state}

      # Sent by `ActiveMemory.TableHeir` when it hands a recovered ETS table back
      # to this store on restart. Ownership transfers with the message; nothing
      # further is required here.
      @impl true
      def handle_info({:"ETS-TRANSFER", _table_ref, _from, _data}, state) do
        {:noreply, state}
      end

      # Periodically reclaims memory from expired records when the table has a `ttl`.
      def handle_info(:sweep, state) do
        Operations.delete_expired(@table, System.system_time(:millisecond))
        __schedule_sweep__()
        {:noreply, state}
      end

      def handle_info(_message, state), do: {:noreply, state}

      # A recovered table already holds its data, so seeding is skipped to avoid
      # duplicating or clobbering the surviving records.
      defp __maybe_run_seeds__(:recovered), do: {:ok, :seed_success}

      defp __maybe_run_seeds__(:created), do: Operations.seed(@seed_file, @table)

      # Schedules the next expiry sweep only when the table actually uses a `ttl`.
      defp __schedule_sweep__ do
        case @table.__attributes__(:ttl) do
          nil -> :ok
          _ttl -> Process.send_after(self(), :sweep, @sweep_interval)
        end
      end

      # Only the clause matching the compile-time option is generated so the
      # Elixir 1.19+ type checker never sees an unreachable clause.
      if @initial_state == :default do
        defp __initial_state__ do
          {:ok,
           %{
             started_at: DateTime.utc_now(),
             table_name: @table
           }}
        end
      else
        defp __initial_state__ do
          {method, args} = @initial_state
          :erlang.apply(__MODULE__, method, args)
        end
      end
    end
  end
end