PhoenixAI.Store separates its public API from its storage backend. You choose which adapter to use; the rest of the library works identically.

Adapter Comparison

PropertyETSEcto
PersistenceNo (in-memory only)Yes (Postgres / SQLite3)
SpeedVery fast (in-process ETS)Network round-trip
Extra depsNoneecto, ecto_sql, a DB driver
Token budget queriesO(n) full scanSQL SUM aggregate
Cost reportingIn-memory filterSQL SUM with filters
Event logIn-memory, paginatedSQL, paginated
Use caseDev, test, ephemeral prodDurable production

Both adapters implement all sub-behaviours: FactStore, ProfileStore, CostStore, EventStore, and TokenUsage.

ETS Adapter

PhoenixAI.Store.Adapters.ETS stores everything in an ETS table owned by a supervised TableOwner GenServer. The store supervisor starts the TableOwner automatically.

When to Use

  • Local development and testing
  • Production workloads that tolerate losing data on node restart
  • High-throughput scenarios where latency matters more than durability

Configuration

{PhoenixAI.Store,
 name: :my_store,
 adapter: PhoenixAI.Store.Adapters.ETS}

No additional dependencies required. The ETS table is created during init/1 and lives as long as the TableOwner process.

Limitations

  • All data is lost when the node restarts
  • count_conversations/2 and token-sum operations are O(n) — they materialize the full filtered list before counting
  • No SQL SUM pushdown for cost aggregation — the full cost record list is filtered in-process

Ecto Adapter

PhoenixAI.Store.Adapters.Ecto persists all data to a relational database through an Ecto Repo. It is only compiled when ecto is available as a dependency.

Dependencies

Add to mix.exs:

{:ecto_sql, "~> 3.13"},
{:postgrex, "~> 0.19"}  # or {:ecto_sqlite3, "~> 0.22"} for SQLite3

Configuration

{PhoenixAI.Store,
 name: :my_store,
 adapter: PhoenixAI.Store.Adapters.Ecto,
 repo: MyApp.Repo}

The :repo option is required for the Ecto adapter and must point at an Ecto.Repo module that is already started in your supervision tree.

Migrations

Generate migration files with the Mix task:

# Core tables (conversations, messages)
mix phoenix_ai_store.gen.migration

# Optional: long-term memory tables (facts, profiles)
mix phoenix_ai_store.gen.migration --ltm

# Optional: cost tracking tables (cost_records)
mix phoenix_ai_store.gen.migration --cost

# Optional: event log tables (events)
mix phoenix_ai_store.gen.migration --events

Supported flags:

FlagTables created
(none)conversations, messages
--ltmfacts, profiles
--costcost_records
--eventsevents
--prefix myapp_ai_use a custom table prefix (default: phoenix_ai_store_)
--migrations-path priv/repo/migrationsoutput directory

Apply migrations:

mix ecto.migrate

Table Prefix

All table names are prefixed with phoenix_ai_store_ by default. Override with the :prefix config option if you want to avoid conflicts or namespace tables per tenant:

{PhoenixAI.Store,
 name: :my_store,
 adapter: PhoenixAI.Store.Adapters.Ecto,
 repo: MyApp.Repo,
 prefix: "acme_ai_"}

The same prefix must be passed to the migration generator:

mix phoenix_ai_store.gen.migration --prefix acme_ai_

Custom Adapters

Implement PhoenixAI.Store.Adapter to build a custom backend (Redis, S3, a multi-tenant router, etc.).

Required Behaviour

All adapters must implement the PhoenixAI.Store.Adapter callbacks:

defmodule MyApp.MyAdapter do
  @behaviour PhoenixAI.Store.Adapter

  alias PhoenixAI.Store.{Conversation, Message}

  @impl true
  def save_conversation(%Conversation{} = conv, opts), do: ...

  @impl true
  def load_conversation(id, opts), do: ...

  @impl true
  def list_conversations(filters, opts), do: ...

  @impl true
  def delete_conversation(id, opts), do: ...

  @impl true
  def count_conversations(filters, opts), do: ...

  @impl true
  def conversation_exists?(id, opts), do: ...

  @impl true
  def add_message(conversation_id, %Message{} = msg, opts), do: ...

  @impl true
  def get_messages(conversation_id, opts), do: ...
end

Optional Sub-Behaviours

Implement these to unlock additional features. Each sub-behaviour is checked at runtime via function_exported?/3 — missing callbacks gracefully degrade.

Sub-behaviourEnables
PhoenixAI.Store.Adapter.FactStoreLong-term memory facts (save, get, delete, count)
PhoenixAI.Store.Adapter.ProfileStoreUser profiles (save, load, delete)
PhoenixAI.Store.Adapter.TokenUsageTokenBudget guardrail (sum_conversation_tokens, sum_user_tokens)
PhoenixAI.Store.Adapter.CostStoreCost tracking (save_cost_record, get_cost_records, sum_cost)
PhoenixAI.Store.Adapter.EventStoreAppend-only event log (log_event, list_events, count_events)

Example — implementing FactStore:

@behaviour PhoenixAI.Store.Adapter.FactStore

alias PhoenixAI.Store.LongTermMemory.Fact

@impl PhoenixAI.Store.Adapter.FactStore
def save_fact(%Fact{} = fact, opts), do: ...

@impl PhoenixAI.Store.Adapter.FactStore
def get_facts(user_id, opts), do: ...

@impl PhoenixAI.Store.Adapter.FactStore
def delete_fact(user_id, key, opts), do: ...

@impl PhoenixAI.Store.Adapter.FactStore
def count_facts(user_id, opts), do: ...

Testing Custom Adapters

The built-in ETS adapter is the recommended test double. Prefer starting a real PhoenixAI.Store with adapter: PhoenixAI.Store.Adapters.ETS in test setup rather than mocking the adapter behaviour:

# test/support/store_case.ex
defmodule MyApp.StoreCase do
  use ExUnit.CaseTemplate

  setup do
    {:ok, _pid} =
      start_supervised(
        {PhoenixAI.Store,
         name: :test_store,
         adapter: PhoenixAI.Store.Adapters.ETS}
      )

    %{store: :test_store}
  end
end

Use Mox when you need to verify specific adapter call patterns:

# test/support/mocks.ex
Mox.defmock(MyApp.MockAdapter, for: PhoenixAI.Store.Adapter)

See Also