lib/supabase/storage/cache.ex

defmodule Supabase.Storage.Cache do
  @moduledoc """
  Provides caching mechanisms for Supabase Storage Buckets.

  This module acts as a GenServer that offers caching capabilities, especially for bucket-related operations in Supabase Storage. The caching is backed by the `:ets` (Erlang Term Storage) to provide in-memory storage and fast retrieval of cached data.

  ## Features

  - **Bucket Caching**: Store and retrieve buckets by their unique identifier.
  - **Cache Flushing**: Clear the cache when necessary.
  - **Configurable Cache Size**: Limit the number of items that can be stored in the cache.

  ## Usage

  ### Starting the Cache Server

      Supabase.Storage.Cache.start_link(%{cache_max_size: 200})

  ### Caching Buckets

      buckets = [%{id: "bucket_1", ...}, %{id: "bucket_2", ...}]
      Supabase.Storage.Cache.cache_buckets(buckets)

  ### Retrieving a Cached Bucket by ID

      Supabase.Storage.Cache.find_bucket_by_id("bucket_1")

  ### Clearing the Cache

      Supabase.Storage.Cache.flush()

  ## Implementation Details

  The cache uses the `:ets` module for in-memory storage of buckets. The number of buckets cached is controlled by the `:cache_max_size` option (default: 100). When the cache is close to exceeding its maximum size, older entries are removed to accommodate new ones.
  """

  use GenServer

  ## Client

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  def find_bucket_by_id(id) do
    GenServer.call(__MODULE__, {:find_bucket, id: id})
  end

  def cache_buckets(buckets) do
    GenServer.cast(__MODULE__, {:cache_buckets, buckets})
  end

  def flush do
    GenServer.cast(__MODULE__, :flush)
  end

  ## API

  @impl true
  def init(args) do
    Process.flag(:trap_exit, true)
    table = :ets.new(:buckets_cache, [:set, :public, :named_table])
    max_size = Keyword.get(args, :cache_max_size, 100)
    {:ok, %{table: table, max_size: max_size, size: 0}}
  end

  @impl true
  def handle_cast(:flush, table) do
    :ets.delete_all_objects(table)
    {:noreply, table}
  end

  def handle_cast({:cache_buckets, buckets}, state) do
    if overflowed_max_size?(state, buckets) do
      :ets.delete_all_objects(state.table)
    end

    # prefer atomic operations
    for bucket <- buckets do
      :ets.insert_new(state.table, {bucket.id, bucket})
    end

    {:noreply, %{state | size: length(buckets)}}
  end

  defp overflowed_max_size?(state, buckets) do
    state.size + length(buckets) > state.max_size
  end

  @impl true
  def handle_call({:find_bucket, id: id}, _from, state) do
    bucket = :ets.lookup_element(state.table, id, 2)
    {:reply, bucket, state}
  rescue
    _ -> {:reply, nil, state}
  end
end