defmodule Mix.Tasks.Scoria.Pgvector.Bootstrap do use Mix.Task alias Scoria.Repo @shortdoc "Verifies or provisions a pgvector-capable Postgres for Scoria knowledge work" @switches [check: :boolean, compose_file: :string] @default_compose_file "dev/pgvector-compose.yml" @default_port 55432 @impl Mix.Task def run(args) do {opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches) configure_runtime_env() Mix.Task.run("app.start") compose_file = Keyword.get(opts, :compose_file, @default_compose_file) case opts[:check] do true -> check_current_database!() _ -> provision_or_fail!(compose_file) end end def configure_runtime_env do port = System.get_env("SCORIA_DB_PORT") if port in [nil, ""] and File.exists?(@default_compose_file) do System.put_env("SCORIA_PGVECTOR_COMPOSE_FILE", @default_compose_file) end :ok end def ensure_pgvector! do case check_vector_support() do {:ok, metadata} -> metadata {:missing_extension, metadata} -> raise """ pgvector prerequisite failed: database #{metadata.database} on #{metadata.hostname}:#{metadata.port} is reachable but does not have the vector extension enabled Next step: mix scoria.pgvector.bootstrap export SCORIA_DB_PORT=#{@default_port} MIX_ENV=test mix test test/scoria/knowledge_test.exs """ {:connection_error, message} -> raise """ pgvector prerequisite failed: #{message} Next step: mix scoria.pgvector.bootstrap export SCORIA_DB_PORT=#{@default_port} MIX_ENV=test mix test test/scoria/knowledge_test.exs """ end end defp check_current_database! do case check_vector_support() do {:ok, metadata} -> Mix.shell().info( "pgvector is available for #{metadata.database} on #{metadata.hostname}:#{metadata.port}" ) {:missing_extension, metadata} -> Mix.raise( with_next_steps( "database #{metadata.database} on #{metadata.hostname}:#{metadata.port} is reachable but does not have the vector extension enabled" ) ) {:connection_error, message} -> Mix.raise(with_next_steps(message)) end end defp provision_or_fail!(compose_file) do case check_vector_support() do {:ok, metadata} -> Mix.shell().info( "pgvector is available for #{metadata.database} on #{metadata.hostname}:#{metadata.port}" ) {:missing_extension, metadata} -> enable_vector_extension!(metadata) {:connection_error, _message} -> start_pgvector_service!(compose_file) case check_vector_support() do {:ok, metadata} -> Mix.shell().info( "pgvector is available for #{metadata.database} on #{metadata.hostname}:#{metadata.port}" ) {:missing_extension, metadata} -> enable_vector_extension!(metadata) {:connection_error, message} -> Mix.raise(with_next_steps(message)) end end end defp check_vector_support do config = Repo.config() hostname = Keyword.get(config, :hostname, "localhost") port = Keyword.get(config, :port, 5432) database = Keyword.fetch!(config, :database) case Ecto.Adapters.SQL.query( Repo, "select extname from pg_extension where extname = 'vector'", [] ) do {:ok, %{rows: [["vector"]]}} -> {:ok, %{database: database, hostname: hostname, port: port}} {:ok, %{rows: []}} -> {:missing_extension, %{database: database, hostname: hostname, port: port}} {:error, error} -> {:connection_error, Exception.message(error)} end end defp enable_vector_extension!(metadata) do case Ecto.Adapters.SQL.query(Repo, "CREATE EXTENSION IF NOT EXISTS vector", []) do {:ok, _result} -> case check_vector_support() do {:ok, _verified} -> Mix.shell().info( "Enabled pgvector for #{metadata.database} on #{metadata.hostname}:#{metadata.port}" ) {:missing_extension, _} -> Mix.raise( with_next_steps( "database #{metadata.database} on #{metadata.hostname}:#{metadata.port} is reachable but the vector extension could not be enabled" ) ) {:connection_error, message} -> Mix.raise(with_next_steps(message)) end {:error, error} -> Mix.raise(with_next_steps(Exception.message(error))) end end defp start_pgvector_service!(compose_file) do unless File.exists?(compose_file) do Mix.raise("Missing compose asset: #{compose_file}") end Mix.shell().info("Starting pgvector service via #{compose_file}") case System.cmd("docker", ["compose", "-f", compose_file, "up", "-d"], stderr_to_stdout: true) do {output, 0} -> Mix.shell().info(output) Mix.shell().info(""" pgvector service started. Export these environment variables before running migrations or tests: export SCORIA_DB_HOST=localhost export SCORIA_DB_PORT=#{@default_port} export SCORIA_DB_USERNAME=postgres export SCORIA_DB_PASSWORD=postgres Then verify with: mix scoria.pgvector.bootstrap --check """) {output, status} -> Mix.raise("docker compose failed (#{status}):\n#{output}") end end defp with_next_steps(message) do """ #{message} Next steps: 1. Start the bundled pgvector service: mix scoria.pgvector.bootstrap 2. Point Scoria at that database for dev/test commands: export SCORIA_DB_HOST=localhost export SCORIA_DB_PORT=#{@default_port} export SCORIA_DB_USERNAME=postgres export SCORIA_DB_PASSWORD=postgres 3. Re-run the prerequisite check: mix scoria.pgvector.bootstrap --check """ end end