LemonPlatformTest.BackendCase (lemon_platform_test v0.1.0)

View Source

Compliance suite for LemonCore.Store.Backend implementations.

What a backend is

LemonCore.Store is the platform's key/value store: a GenServer that owns a backend and serialises access to it. The backend is the part that actually persists, and it is deliberately tiny — eight callbacks, no supervision, no process of its own. LemonCore.Store.EtsBackend (ephemeral), LemonCore.Store.SqliteBackend (durable) and LemonCore.Store.JsonlBackend (append-only files) are the built-ins; a Redis or Postgres backend is a reasonable thing to write, and this suite is what tells you it will work.

The contract

A backend stores values under a {table, key} pair, where table is an atom chosen by the caller and key and value are arbitrary terms. Beyond the @callback signatures, implementations must obey these rules — each is a test in this suite.

State is threaded, never global

init/1 returns opaque state; every other callback takes state and returns new state, including the read paths (get/3, list/2) which return {:ok, result, state} rather than {:ok, result}. The store always uses the returned state for the next call. A backend may keep its real data outside the state (EtsBackend keeps table references; SqliteBackend keeps a connection), but it must never require a caller to discard the state it was handed.

Tables spring into existence

Callers pass whatever table atom they like, including on the very first call and including for reads. A backend must not error on an unknown table: reading from one yields nil/[], writing to one creates it. init/1 cannot know the set of tables in advance.

Reads are total

get/3 on a missing key returns {:ok, nil, state} — not {:error, ...}, not :not_found. Consequently a stored nil is indistinguishable from an absent key through get/3; list/2 is where the difference shows up, and it must show up there.

Writes are idempotent, deletes are forgiving

put/4 overwrites silently. delete/3 on a key that is not there succeeds. put_new/4 is the one conditional write: {:ok, state} when it inserted, {:exists, state} when it did not. {:exists, state} must leave the existing value untouched — it is the platform's only compare-and-set primitive, and idempotency keys depend on it.

Terms round-trip

Keys and values are Erlang terms, not strings. Binaries (including non-ASCII), atoms, integers, floats, tuples, nested maps and lists must come back equal to what went in. Backends that serialise (SQLite via :erlang.term_to_binary/1, JSONL via a JSON encoding) inherit the limits of their encoding: pids, references and functions are not required to round-trip, and this suite does not test them.

Optional callbacks are all-or-nothing

list_recent/3 and ping/1 are optional. If you export them they must honour their contracts (list_recent/3 returns at most limit entries, all of which are real entries of that table; ping/1 answers without disturbing data). If you do not export them the store degrades: it falls back to list/2 and reports the backend as unpingable.

Minimal implementation

defmodule MyApp.MapBackend do
  @behaviour LemonCore.Store.Backend

  @impl true
  def init(opts), do: {:ok, Keyword.get(opts, :seed, %{})}

  @impl true
  def put(state, table, key, value) do
    {:ok, Map.update(state, table, %{key => value}, &Map.put(&1, key, value))}
  end

  @impl true
  def put_new(state, table, key, value) do
    if Map.has_key?(Map.get(state, table, %{}), key) do
      {:exists, state}
    else
      put(state, table, key, value)
    end
  end

  @impl true
  def get(state, table, key), do: {:ok, state |> Map.get(table, %{}) |> Map.get(key), state}

  @impl true
  def delete(state, table, key) do
    {:ok, Map.update(state, table, %{}, &Map.delete(&1, key))}
  end

  @impl true
  def list(state, table), do: {:ok, state |> Map.get(table, %{}) |> Map.to_list(), state}
end

Running the suite

defmodule MyApp.MapBackendComplianceTest do
  use LemonPlatformTest.BackendCase, async: true, backend: MyApp.MapBackend
end

A backend that needs a directory or a file gets one per test — the suite tags every test with :tmp_dir, so a {Module, :function} supplier can read context.tmp_dir:

defmodule MyApp.DiskBackendComplianceTest do
  use LemonPlatformTest.BackendCase,
    async: true,
    backend: MyApp.DiskBackend,
    backend_opts: {__MODULE__, :backend_opts},
    persistent: true

  def backend_opts(context), do: [path: context.tmp_dir]
end

Options

  • :backend — required, the module under test.
  • :backend_opts — options passed to init/1. Either a literal keyword list (default []) or {Module, :function}, called with the test context and returning a keyword list.
  • :persistent — set to true for a backend that survives re-init/1 with the same options (SQLite, JSONL, anything on disk or over a network). Adds a test that data written through one state is visible through a second init/1. Default false.
  • :tables — the two table atoms the suite writes to. Default [:lemon_platform_test_alpha, :lemon_platform_test_beta]. Override if your backend only supports a fixed set of tables.

Known gaps in the behaviour

Recorded here because they bound what this suite can check, and because they are the places the contract is most likely to change:

  • There is no teardown callback. A backend that holds a connection or a file handle has nowhere to close it; LemonCore.Store never tells the backend it is going away. SqliteBackend exposes a close/1 that is not part of the behaviour.
  • Error reasons are convention, not contract. The {:error, term()} returns are typed as term(), and the built-in SQLite backend answers with implementation-specific atoms such as :sqlite_busy. Callers cannot portably match on "temporarily unavailable", so this suite asserts the {:error, _} shape and nothing about the reason.