adk_ex_ecto provides database-backed session persistence for the Elixir ADK (adk_ex). It implements the ADK.Session.Service behaviour using Ecto, with support for SQLite3 (dev/test) and PostgreSQL (production).
Setup
1. Add Dependencies
def deps do
[
{:adk_ex, "~> 0.2"},
{:adk_ex_ecto, "~> 1.0"},
{:ecto_sqlite3, "~> 0.17"} # or {:postgrex, "~> 0.19"} for PostgreSQL
]
end2. Create the Migration
In a new Ecto migration file, delegate to ADKExEcto.Migration.up/0:
defmodule MyApp.Repo.Migrations.CreateADKTables do
use Ecto.Migration
def change do
ADKExEcto.Migration.up()
end
endRun mix ecto.migrate. This creates 4 tables: adk_sessions, adk_events, adk_app_states, adk_user_states.
3. Wire Up the Runner
Pass your Ecto Repo as session_service and ADKExEcto.SessionService as session_module:
{:ok, runner} = ADK.Runner.new(
app_name: "my_app",
root_agent: agent,
session_service: MyApp.Repo,
session_module: ADKExEcto.SessionService
)The Repo module plays the role of the GenServer.server() argument in the ADK.Session.Service behaviour — no extra process is started.
State Routing
State keys are routed by prefix (matching ADK.Session.InMemory):
| Prefix | Table | Scope |
|---|---|---|
| (none) | adk_sessions.state | Session-local |
app: | adk_app_states.state | Cross-session for the app |
user: | adk_user_states.state | Cross-session for the user |
temp: | (not stored) | Current invocation only — discarded on append |
On read, get/3 merges app/user state (prefix re-added) with session state into a single flat map.
Database Schema
Composite primary keys, utc_datetime_usec timestamps:
adk_sessions—(app_name, user_id, id)+state(map)adk_events—(id, app_name, user_id, session_id)+content,actions, metadataadk_app_states—(app_name)+stateadk_user_states—(app_name, user_id)+state
Serialization
ADK Content, Part, FunctionCall, FunctionResponse, and Actions structs are JSON-serialized to/from maps automatically. Binary Blob data is Base64-encoded.
Critical Rules
- Pass the Repo as
session_service, not theSessionServicemodule — e.g.session_service: MyApp.Repo, session_module: ADKExEcto.SessionService. The Repo is the "server"; the SessionService module implements the behaviour callbacks against it. - Call
ADKExEcto.Migration.up/0inside an Ecto migration — don't call it directly at runtime. It wrapscreate table/2calls that must run under the migrator. - Partial events are not persisted — events with
partial: trueare skipped onappend_event, matchingADK.Session.InMemorybehaviour. Only finalized events are stored. temp:state is discarded —temp:-prefixed keys in a state delta are dropped duringappend_eventand never written to any table.- Staleness is not checked — the Runner may call
append_eventwith an older session snapshot without re-fetching; this service does not reject on staleupdated_at. - Don't use the Ecto sandbox with SQLite3 in-memory + pool_size 1 — connections are serialized and the sandbox deadlocks. Clean tables in a
setupblock instead. - Events are deleted before the session on
delete/3— foreign-key-like cleanup is done in application code inside a transaction, since composite keys don't auto-cascade. create/5upserts app/user state — passing state withapp:/user:prefixes on creation will create-or-update the shared-state rows. Existing shared state for other sessions is preserved.
Testing Pattern
Use SQLite3 in-memory with manual cleanup in setup:
# test/test_helper.exs
{:ok, _} = MyApp.TestRepo.start_link()
Ecto.Migrator.up(MyApp.TestRepo, 0, MyApp.TestMigration, log: false)
ExUnit.start()# in each test module
setup do
MyApp.TestRepo.delete_all(ADKExEcto.Schemas.Event)
MyApp.TestRepo.delete_all(ADKExEcto.Schemas.Session)
MyApp.TestRepo.delete_all(ADKExEcto.Schemas.AppState)
MyApp.TestRepo.delete_all(ADKExEcto.Schemas.UserState)
:ok
end