lib/charon/session_store/local_store.ex

defmodule Charon.SessionStore.LocalStore do
  @moduledoc """
  An in-memory persistent session store, implements behaviour `Charon.SessionStore`.
  In addition to the required callbacks, this store also provides `get_all/3` and `delete_all/3` (for a user) functions.

  ## Usage

  Add this store to your supervision tree to use it.

      children = [
        Charon.SessionStore.LocalStore
      ]

  """
  use Agent
  @behaviour Charon.SessionStore.Behaviour

  import Charon.Internal
  require Logger

  @agent_name __MODULE__

  def start_link(_opts \\ []), do: Agent.start_link(fn -> {0, %{}} end, name: @agent_name)

  @impl true
  def get(session_id, user_id, type, _config) do
    Agent.get(@agent_name, fn _state = {_count, store} ->
      session = Map.get(store, to_key(session_id, user_id, type))
      if is_nil(session) or expired?(session, now()), do: nil, else: session
    end)
  end

  @impl true
  def upsert(session = %{lock_version: lock_version}, _config) do
    key = to_key(session)

    Agent.get_and_update(@agent_name, fn state = {count, store} ->
      old_session = Map.get(store, key)
      insert? = is_nil(old_session)
      session = %{session | lock_version: lock_version + 1}

      cond do
        insert? ->
          {:ok, maybe_prune_expired({count + 1, Map.put(store, key, session)})}

        old_session.lock_version == lock_version or expired?(old_session, now()) ->
          {:ok, maybe_prune_expired({count, Map.put(store, key, session)})}

        true ->
          {{:error, :conflict}, state}
      end
    end)
  end

  @impl true
  def delete(session_id, user_id, type, _config) do
    Agent.update(@agent_name, fn _state = {count, store} ->
      {found_session, store} = Map.pop(store, to_key(session_id, user_id, type))
      {if(found_session, do: count - 1, else: count), store}
    end)
  end

  @impl true
  def get_all(user_id, type, _config) do
    Agent.get(@agent_name, fn _state = {_count, store} ->
      store
      |> Stream.filter(match_user_and_type(user_id, type))
      |> Stream.reject(match_expired(now()))
      |> Enum.map(&value_only/1)
    end)
  end

  @impl true
  def delete_all(user_id, type, _config) do
    Agent.update(@agent_name, fn state ->
      delete_matching(state, match_user_and_type(user_id, type))
    end)
  end

  ###########
  # Private #
  ###########

  defp expired?(session, now), do: now > session.refresh_expires_at

  defp to_key(%{id: sid, user_id: uid, type: type}), do: to_key(sid, uid, type)
  defp to_key(sid, uid, type), do: {sid, uid, type}

  defp match_user_and_type(uid, type), do: &match?({_key = {_, ^uid, ^type}, _session}, &1)

  defp match_expired(now), do: fn {_key, session} -> expired?(session, now) end

  defp key_only({k, _v}), do: k
  defp value_only({_k, v}), do: v

  defp delete_matching({count, store}, matcher) do
    keys = store |> Stream.filter(matcher) |> Enum.map(&key_only/1)
    {count - Enum.count(keys), Map.drop(store, keys)}
  end

  defp maybe_prune_expired(state = {count, _store}) when rem(count, 1000) == 0 do
    delete_matching(state, match_expired(now()))
  end

  defp maybe_prune_expired(state), do: state
end