The Ecto StatifierPersistence.Storage.Adapter: the storage contract
over the schemas a host generates with use StatifierPersistence.Ecto
(ADR-0002), against the tables the versioned migrations helper
creates. Requires the optional ecto_sql dependency (ADR-0005).
defmodule MyApp.Persistence do
use StatifierPersistence.Ecto, repo: MyApp.Repo
end
{:ok, store} =
StatifierPersistence.Storage.new(
StatifierPersistence.Storage.Ecto,
persistence: MyApp.Persistence
)Options init/1 accepts:
:persistence- required, a module that calleduse StatifierPersistence.Ecto. The repo, the schema modules, and the table names all come from its resolved configuration, so this adapter adds no knobs of its own (ADR-0002 decision 3).:sandbox- whentrue,isolate/1checks out anEcto.Adapters.SQL.Sandboxconnection: the hook a test suite (this package's conformance suite included) uses to wrap each test in its own transaction. Defaultfalse, andisolate/1is then a no-op.
Engine identities (content_hash, session_id, run_id) are
stored verbatim in text columns and blobs in bytea columns, so
both round-trip byte-identically (ADR-0002 decision 1, ADR-0003
decision 1). The identity guard lives in
StatifierPersistence.Storage, above this adapter like above every
other one (ADR-0003 decision 2); nothing here decodes a blob.
insert_run/2's :run_exists refusal rides the V01 unique index on
run_id - one atomic insert, never a check-then-insert. A backend
failure a callback cannot observe as a value (the database down, a
timeout) raises the driver's own exception rather than being
flattened into a default (this package's errors-are-events rule).
Summary
Functions
Fetches the chart stored under content_hash, or :chart_not_found.
Fetches the position stored for session_id, or
:position_not_found.
Fetches the run stored under run_id, or :run_not_found.
Resolves the :persistence host module into the handle every other
callback takes: the host's repo, its three generated schema modules,
and its runs table name (for the unique-constraint mapping).
Inserts run_record, refusing a duplicate run_id with
{:error, :run_exists}.
Per-test isolation (the optional
StatifierPersistence.Storage.Adapter.isolate/1): checks out an
Ecto.Adapters.SQL.Sandbox connection when this handle was built
with sandbox: true, and is a no-op otherwise.
Lists the runs whose stored metadata contains every key/value
pair in metadata (ADR-0006 decision 3's equality-match list helper).
Runs fun under per-run mutual exclusion for run_id (the optional
StatifierPersistence.Storage.Adapter.lock_run/3, ADR-0004
decision 5 as amended 2026-08-22).
Stores chart_record, idempotent on its content_hash: an insert
with on_conflict: :nothing against the unique index, so a repeated
save of the same hash neither duplicates the row nor rewrites it.
Stores position_record under its session_id, overwriting any
position already stored for that session: an upsert replacing the
record columns (and updated_at) on the unique index.
Declares metadata support (the optional
StatifierPersistence.Storage.Adapter.supports_metadata?/1): this
adapter stores a run's metadata in the V02 jsonb column (ADR-0006
decision 3).
Overwrites the run stored under run_record's run_id with the
full record, or refuses with :run_not_found.
Functions
@spec fetch_chart( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, StatifierPersistence.Storage.Adapter.chart_record()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Fetches the chart stored under content_hash, or :chart_not_found.
@spec fetch_position( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.session_id() ) :: {:ok, StatifierPersistence.Storage.Adapter.position_record()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Fetches the position stored for session_id, or
:position_not_found.
@spec fetch_run( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.run_id() ) :: {:ok, StatifierPersistence.Storage.Adapter.run_record()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Fetches the run stored under run_id, or :run_not_found.
@spec init(StatifierPersistence.Storage.Adapter.opts()) :: {:ok, StatifierPersistence.Storage.Adapter.opts()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Resolves the :persistence host module into the handle every other
callback takes: the host's repo, its three generated schema modules,
and its runs table name (for the unique-constraint mapping).
Refuses a module that never called use StatifierPersistence.Ecto
with {:error, {:adapter, {:not_a_persistence_host, module}}}.
Makes no database call: reachability surfaces on first use, per
call site.
@spec insert_run( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.run_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Inserts run_record, refusing a duplicate run_id with
{:error, :run_exists}.
The refusal is the V01 unique index on run_id speaking: the insert
carries a unique_constraint/3 on that index's name, so two
concurrent inserts of one run_id cannot both return :ok and no
separate existence check ever runs.
metadata is stored in the V02 jsonb column, NULL for the empty
map. jsonb holds only JSON-representable values, which makes term
narrower here than in Elixir (ADR-0006 decision 3): a tuple, a pid, a
reference, an atom, a struct, or a binary that is not valid UTF-8 has
no jsonb form. This adapter refuses such a map at open with
{:error, :metadata_unsupported} - the failure shape ADR-0006
decision 3 leaves to the implementation - rather than letting the
encoder raise from inside a transaction or, worse, storing something
that is not what the caller handed over. Refusing at open is the
principle the decision already sets for an adapter that cannot store a
map; a value it cannot store is the same answer at a finer grain.
@spec isolate(StatifierPersistence.Storage.Adapter.opts()) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Per-test isolation (the optional
StatifierPersistence.Storage.Adapter.isolate/1): checks out an
Ecto.Adapters.SQL.Sandbox connection when this handle was built
with sandbox: true, and is a no-op otherwise.
@spec list_runs_by_metadata( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.run_record()]} | {:error, StatifierPersistence.Storage.Adapter.error()}
Lists the runs whose stored metadata contains every key/value
pair in metadata (ADR-0006 decision 3's equality-match list helper).
Equality match on all pairs is the whole query surface: no ranges, no partial matches, no containment operators exposed to the caller, and no ordering guarantee. A host needing more than that queries its own column directly - ADR-0002's configurable table names already make that a supported thing to do.
The query is one jsonb containment predicate, which a GIN index on
the column serves directly; V02 ships no index, because which pairs a
host queries by is the host's call (ADR-0006 decision 4).
metadata must be a non-empty map of string keys: a zero-pair
"contains every given pair" matches every run with any metadata at
all, which is a caller bug far more often than a request, so it
raises ArgumentError rather than answering it.
StatifierPersistence.Storage.Ecto.list_runs_by_metadata(
store.opts,
%{"tenant_id" => "acct_01H8X"}
)Returns records in fetch_run/2's shape.
@spec lock_run( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.run_id(), (-> result) ) :: {:ok, result} | {:error, StatifierPersistence.Storage.Adapter.error()} when result: term()
Runs fun under per-run mutual exclusion for run_id (the optional
StatifierPersistence.Storage.Adapter.lock_run/3, ADR-0004
decision 5 as amended 2026-08-22).
Everything happens inside one transaction that spans fun. It takes
pg_advisory_xact_lock(hashtextextended(run_id, 0)) first -
unconditional per-run exclusion whether or not the run row exists
yet - and then SELECT ... FOR UPDATE on the run row when it does,
keeping the row itself locked against every other writer for the
rest of the transaction. Both locks are transaction-scoped, so any
exit from fun releases them: a normal return commits, and a raise
rolls back and propagates to the caller with nothing leaked.
@spec save_chart( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.chart_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Stores chart_record, idempotent on its content_hash: an insert
with on_conflict: :nothing against the unique index, so a repeated
save of the same hash neither duplicates the row nor rewrites it.
@spec save_position( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.position_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Stores position_record under its session_id, overwriting any
position already stored for that session: an upsert replacing the
record columns (and updated_at) on the unique index.
@spec supports_metadata?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares metadata support (the optional
StatifierPersistence.Storage.Adapter.supports_metadata?/1): this
adapter stores a run's metadata in the V02 jsonb column (ADR-0006
decision 3).
@spec update_run( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.run_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Overwrites the run stored under run_record's run_id with the
full record, or refuses with :run_not_found.
One update_all/3 keyed on run_id: the match count is the
existence check, so refusal and overwrite are a single statement.
metadata is not in the set: list, and that is the documented
exception to the full overwrite: the map is write-once (ADR-0006
decision 1 grants it at create and grants no way to change it), so the
stored column is left exactly as insert_run/2 wrote it and the given
record's metadata is ignored.