Skip to main content

lib/mix/tasks/attesto_mcp.gen.session_migration.ex

# `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