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}
endRunning the suite
defmodule MyApp.MapBackendComplianceTest do
use LemonPlatformTest.BackendCase, async: true, backend: MyApp.MapBackend
endA 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]
endOptions
:backend— required, the module under test.:backend_opts— options passed toinit/1. Either a literal keyword list (default[]) or{Module, :function}, called with the test context and returning a keyword list.:persistent— set totruefor a backend that survives re-init/1with 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 secondinit/1. Defaultfalse.: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.Storenever tells the backend it is going away.SqliteBackendexposes aclose/1that is not part of the behaviour. - Error reasons are convention, not contract. The
{:error, term()}returns are typed asterm(), 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.