# `Mix.Ecto` lives in `ecto_sql`, which is optional for attesto_mcp (a consumer
# brings its own). Compile-guard the task so a consumer without ecto_sql can
# still compile the dependency; the fallback errors with install guidance.
if Code.ensure_loaded?(Mix.Ecto) do
defmodule Mix.Tasks.AttestoMcp.Gen.SessionMigration do
@shortdoc "Generates the Ecto migration backing AttestoMCP.Anubis.SessionStore.Ecto"
@moduledoc """
Generates an Ecto migration that creates the `attesto_mcp_sessions` table
backing `AttestoMCP.Anubis.SessionStore.Ecto` (a Postgres-backed
`Anubis.Server.Session.Store` adapter).
One row per MCP session, keyed by the client's `Mcp-Session-Id`; `state` is the
serialized Anubis session map (client_info, capabilities, `frame`), persisted
so a client can reconnect after a deploy with its initialized state restored.
## Usage
mix attesto_mcp.gen.session_migration --repo MyApp.Repo
## Options
* `--repo` - the Ecto repo the migration is generated for. May be given more
than once. When omitted the host application's configured repos are used.
* `--migrations-path` - directory the migration file is written to. Defaults
to the repo's `priv/<repo>/migrations`.
The generated migration is reversible.
"""
use Mix.Task
import Mix.Ecto, only: [parse_repo: 1, ensure_repo: 2]
import Mix.Generator
@switches [repo: [:keep], migrations_path: :string]
@impl Mix.Task
def run(args) do
repos = parse_repo(args)
{opts, _, _} = OptionParser.parse(args, switches: @switches)
case repos do
[] ->
Mix.raise("""
no Ecto repos available.
Pass one explicitly with --repo, e.g.
mix attesto_mcp.gen.session_migration --repo MyApp.Repo
""")
repos ->
Enum.each(repos, &generate_for_repo(&1, opts))
end
end
defp generate_for_repo(repo, opts) do
ensure_repo(repo, [])
path = migrations_path(repo, opts)
create_directory(path)
base_name = "create_attesto_mcp_sessions"
file = Path.join(path, "#{timestamp()}_#{base_name}.exs")
if !Enum.empty?(Path.wildcard(Path.join(path, "*_#{base_name}.exs"))) do
Mix.raise("migration #{inspect(base_name)} already exists in #{path}; remove it before regenerating")
end
assigns = [module: Module.concat([repo, Migrations, Macro.camelize(base_name)])]
create_file(file, migration_template(assigns))
file
end
defp migrations_path(repo, opts) do
case Keyword.fetch(opts, :migrations_path) do
{:ok, path} ->
path
:error ->
config = repo.config()
priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}"
Path.join([File.cwd!(), priv, "migrations"])
end
end
defp timestamp do
{{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()
Enum.map_join([y, m, d, hh, mm, ss], &pad/1)
end
defp pad(i) when i < 10, do: "0" <> Integer.to_string(i)
defp pad(i), do: Integer.to_string(i)
embed_template(:migration, """
defmodule <%= inspect @module %> do
@moduledoc false
# Generated by `mix attesto_mcp.gen.session_migration`.
#
# Backs AttestoMCP.Anubis.SessionStore.Ecto (an Anubis.Server.Session.Store
# adapter). One row per MCP session, keyed by the client's Mcp-Session-Id;
# `state` is the serialized Anubis session map. Expired rows are reaped on
# load and by the store's cleanup_expired/1.
use Ecto.Migration
def up do
create table(:attesto_mcp_sessions, primary_key: false) do
add :session_id, :string, primary_key: true, null: false
add :state, :map, null: false
add :expires_at, :utc_datetime_usec, null: false
timestamps(type: :utc_datetime_usec)
end
# Supports both the active-session lookup and the expired-row sweep.
create index(:attesto_mcp_sessions, [:expires_at])
end
def down do
drop table(:attesto_mcp_sessions)
end
end
""")
end
else
defmodule Mix.Tasks.AttestoMcp.Gen.SessionMigration do
@shortdoc "Generates the AttestoMCP session migration | Requires ecto_sql"
@moduledoc false
use Mix.Task
@impl Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'attesto_mcp.gen.session_migration' requires ecto_sql.
Add `{:ecto_sql, "~> 3.10"}` (and a driver such as `:postgrex`) to your
deps and run `mix deps.get`, then re-run this task.
""")
exit({:shutdown, 1})
end
end
end