lib/mix/tasks/pgflow.gen.postgres_extensions_migration.ex

defmodule Mix.Tasks.Pgflow.Gen.PostgresExtensionsMigration do
  @shortdoc "Generate a migration that installs Postgres extensions required by pgflow"

  @moduledoc """
  Generates a single Ecto migration that runs `CREATE EXTENSION` for the
  Postgres extensions pgflow relies on.

  Defaults to: `citext`, `pg_trgm`, `pgcrypto`, `pg_cron`.

  Pairs with `mix pgflow.setup` (installs the pgflow schema) and
  `mix pgflow.gen.pgmq_migration` (installs pgmq via SQL-only method,
  which is the portable path for pgmq since most Postgres distributions
  don't ship it as a native extension).

  ## Usage

      mix pgflow.gen.postgres_extensions_migration
      mix pgflow.gen.postgres_extensions_migration --repo MyApp.OtherRepo
      mix pgflow.gen.postgres_extensions_migration --no-cron

  ## Options

    * `--repo` - Ecto repo module to generate the migration for. Defaults
      to the first entry in `config :my_app, ecto_repos: [...]`.

    * `--no-cron` - Skip `pg_cron`. Use on managed Postgres hosts that
      don't permit it. Default: `pg_cron` is included (pgflow schedules
      cron-based flows via pg_cron).

  ## pgmq note

  `pgmq` is NOT included in this task. On most Postgres distributions
  pgmq isn't available as a native extension — use
  `mix pgflow.gen.pgmq_migration` to install it via SQL-only method.
  On environments where pgmq IS pre-built (Supabase,
  atlas-postgres-pgflow), hand-edit the generated migration to add
  `execute("CREATE EXTENSION IF NOT EXISTS pgmq")`.

  ## Migration order

  This migration should run BEFORE `mix pgflow.gen.pgmq_migration`'s
  output and before `mix pgflow.setup`'s output:

    1. `install_extensions` (this task)
    2. `install_pgmq` (from `mix pgflow.gen.pgmq_migration`, if needed)
    3. `setup_pgflow` (from `mix pgflow.setup`)
  """

  use Mix.Task

  alias Mix.Tasks.Pgflow.Helpers

  @impl Mix.Task
  def run(args) do
    {opts, _, _} =
      OptionParser.parse(args, switches: [repo: :string, cron: :boolean])

    repo = Helpers.resolve_repo(opts[:repo])
    cron? = Keyword.get(opts, :cron, true)

    migrations_dir = Path.join(Helpers.priv_path(repo), "migrations")
    File.mkdir_p!(migrations_dir)

    timestamp = timestamp()
    filename = "#{timestamp}_install_extensions.exs"
    full_path = Path.join(migrations_dir, filename)

    File.write!(full_path, migration_content(repo, cron?))

    Mix.shell().info("""
    Created migration: #{full_path}

    Run `mix ecto.migrate` to apply, or generate the remaining pgflow
    migrations first (see `mix help pgflow.setup`).
    """)
  end

  defp timestamp do
    {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()

    :io_lib.format("~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", [y, m, d, hh, mm, ss])
    |> IO.iodata_to_binary()
  end

  # Base extensions — always installed. pgflow's query layer depends on
  # citext (case-insensitive identifiers), pg_trgm (fuzzy search on slugs
  # and step names in the dashboard), and pgcrypto (gen_random_uuid used
  # in several core tables).
  @base_extensions ~w(citext pg_trgm pgcrypto)

  defp migration_content(repo, cron?) do
    extensions = if cron?, do: @base_extensions ++ ["pg_cron"], else: @base_extensions

    up_body =
      Enum.map_join(extensions, "\n    ", &~s|execute("CREATE EXTENSION IF NOT EXISTS #{&1}")|)

    down_body =
      extensions
      |> Enum.reverse()
      |> Enum.map_join("\n    ", &~s|execute("DROP EXTENSION IF EXISTS #{&1}")|)

    gen_command =
      "mix pgflow.gen.postgres_extensions_migration" <> if(cron?, do: "", else: " --no-cron")

    cron_note =
      if cron? do
        "  - pg_cron    — scheduled flow/job support"
      else
        "  (pg_cron skipped — regenerate without `--no-cron` if your host supports it)"
      end

    cron_setup_note =
      if cron? do
        "  pg_cron requires TWO server-level settings in `postgresql.conf`:\n" <>
          "    - `shared_preload_libraries = 'pg_cron'`  (add + restart Postgres)\n" <>
          "    - `cron.database_name = '<your_app_db>'` (pg_cron's metadata lives\n" <>
          "      in exactly one DB; defaults to `postgres`. If your app DB isn't\n" <>
          "      `postgres`, set this or install pg_cron against `postgres`\n" <>
          "      instead — cron functions will only exist in the configured DB).\n" <>
          "\n" <>
          "  Supported on Neon, AWS RDS (PG 12.5+), Aurora (PG 12.6+), and\n" <>
          "  Supabase — each with its own setup path (parameter groups, API\n" <>
          "  calls, etc.). On hosts that don't support pg_cron, regenerate\n" <>
          "  with `mix pgflow.gen.postgres_extensions_migration --no-cron`."
      else
        ""
      end

    """
    defmodule #{inspect(repo)}.Migrations.InstallExtensions do
      @moduledoc \"\"\"
      Installs Postgres extensions required by pgflow.

      Generated by: `#{gen_command}`

      Extensions:
        - citext     — case-insensitive text columns
        - pg_trgm    — trigram indexes (dashboard slug search)
        - pgcrypto   — `gen_random_uuid()` for primary keys
      #{cron_note}

      #{cron_setup_note}

      `@disable_ddl_transaction true` is set because `CREATE EXTENSION` can't
      run inside a migration transaction on some Postgres distributions.
      `@disable_migration_lock true` is set for the same reason.

      On hosts with native pgmq (Supabase, atlas-postgres-pgflow), hand-add
      `execute("CREATE EXTENSION IF NOT EXISTS pgmq")` here instead of
      running `mix pgflow.gen.pgmq_migration`.
      \"\"\"
      use Ecto.Migration

      @disable_ddl_transaction true
      @disable_migration_lock true

      def up do
        #{up_body}
      end

      def down do
        #{down_body}
      end
    end
    """
  end
end