From 916403569ec511a41c85133ae0a5cb6df09f982e Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Mon, 18 Aug 2025 09:52:41 +0100 Subject: [PATCH 1/7] [wip] --- lib/mix/tasks/phoenix_sync.install.ex | 235 +++++++++++++++++++ mix.exs | 3 +- mix.lock | 7 + test/mix/tasks/phoenix_sync.install_test.exs | 13 + 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 lib/mix/tasks/phoenix_sync.install.ex create mode 100644 test/mix/tasks/phoenix_sync.install_test.exs diff --git a/lib/mix/tasks/phoenix_sync.install.ex b/lib/mix/tasks/phoenix_sync.install.ex new file mode 100644 index 0000000..4665822 --- /dev/null +++ b/lib/mix/tasks/phoenix_sync.install.ex @@ -0,0 +1,235 @@ +defmodule Mix.Tasks.PhoenixSync.Install.Docs do + @moduledoc false + + @spec short_doc() :: String.t() + def short_doc do + "Install Phoenix.Sync into an existing Phoenix or Plug application" + end + + @spec example() :: String.t() + def example do + "mix phoenix_sync.install --mode embedded --repo MyApp.Repo" + end + + @spec long_doc() :: String.t() + def long_doc do + """ + #{short_doc()} + + Longer explanation of your task + + ## Example + + ```sh + #{example()} + ``` + + ## Options + + * `--mode`- How to connect to Electric, either `embedded` or `http`. In `embedded` mode `:electric` will be added as a dependency and started automatically and you may have to include the `--repo` option (see below). For `http` mode you'll need to specify the `--url` to the Electric server. + * `--repo`- Which `Ecto.Repo` module to connect to in `embedded` mode. The installer should be able to find the right repo automatically, but if it can't, you can specify it here. + * `--url` - The URL of the Electric server, only needed for `--mode http`. + """ + end +end + +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.PhoenixSync.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()}" + + @moduledoc __MODULE__.Docs.long_doc() + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(argv, composing_task) do + {opts, _positional, _other} = OptionParser.parse(argv, strict: [mode: :string]) + + adds_deps = + case Keyword.fetch(opts, :mode) do + {:ok, "embedded"} -> + [{:electric, required_electric_version()}] + + {:ok, "http"} -> + [] + + {:ok, _other} -> + # errors will come later + [] + end + + %Igniter.Mix.Task.Info{ + # Groups allow for overlapping arguments for tasks by the same author + # See the generators guide for more. + group: :phoenix_sync, + # *other* dependencies to add + # i.e `{:foo, "~> 2.0"}` + adds_deps: adds_deps, + # *other* dependencies to add and call their associated installers, if they exist + # i.e `{:foo, "~> 2.0"}` + installs: [], + # An example invocation + example: __MODULE__.Docs.example(), + # A list of environments that this should be installed in. + only: nil, + # a list of positional arguments, i.e `[:file]` + positional: [], + # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv + # This ensures your option schema includes options from nested tasks + composes: [], + # `OptionParser` schema + schema: [ + mode: :string, + repo: :string, + url: :string, + sandbox: :boolean + ], + # Default values for the options in the `schema` + defaults: [], + # CLI aliases + aliases: [], + # A list of options in the schema that are required + required: [:mode] + } + end + + @valid_modes ~w(embedded http) + + @impl Igniter.Mix.Task + def igniter(igniter) do + {:ok, mode} = Keyword.fetch(igniter.args.options, :mode) + + if mode not in @valid_modes do + Igniter.add_issue( + igniter, + "mode #{inspect(mode)} is invlid, valid modes are: #{@valid_modes |> Enum.join(", ")}" + ) + else + add_dependencies(igniter, mode) + end + end + + defp add_dependencies(igniter, "http") do + case Keyword.fetch(igniter.args.options, :url) do + {:ok, url} -> + igniter + |> base_configuration(:http) + |> Igniter.Project.Config.configure_new( + "config.exs", + :phoenix_sync, + [:url], + url + ) + + :error -> + Igniter.add_issue(igniter, "`--url` is required for :http mode") + end + end + + defp add_dependencies(igniter, "embedded") do + # we should maybe confirm all this stuff before doing it + + igniter + |> base_configuration(:embedded) + |> find_repo() + |> configure_repo() + + # update the repo source with the sandbox adapter + # check if this is phoenix or plain plug + # add the plug_opts + end + + defp configure_repo(%{issues: [_ | _] = _issues} = igniter) do + igniter + end + + defp configure_repo(igniter) do + enable_sandbox? = Keyword.get(igniter.args.options, :sandbox, true) + + igniter = + igniter + |> Igniter.Project.Config.configure_new( + "config.exs", + :phoenix_sync, + [:repo], + igniter.assigns.repo + ) + + if enable_sandbox? do + igniter + |> Igniter.Project.Module.find_and_update_module!( + igniter.assigns.repo |> dbg, + fn zipper -> + with {:ok, zipper} <- + Igniter.Code.Module.move_to_use(zipper, Ecto.Repo) |> dbg, + zipper <- + Igniter.Code.Common.add_code(zipper, "use Phoenix.Sync.Sandbox.Postgres", + placement: :before + ) do + {:ok, zipper} + end + end + ) + else + igniter + end + end + + defp base_configuration(igniter, mode) do + igniter + |> Igniter.Project.Config.configure_new( + "config.exs", + :phoenix_sync, + [:mode], + mode + ) + |> Igniter.Project.Config.configure_new( + "config.exs", + :phoenix_sync, + [:env], + {:code, quote(do: config_env())} + ) + end + + defp required_electric_version do + Phoenix.Sync.MixProject.project() + |> Keyword.fetch!(:deps) + |> Enum.find(&match?({:electric, _, _}, &1)) + |> elem(1) + end + + defp find_repo(igniter) do + app_name = Igniter.Project.Application.app_name(igniter) |> dbg + + case Application.fetch_env(app_name, :ecto_repos) do + {:ok, [repo | _]} -> + Igniter.assign(igniter, :repo, repo) + + :error -> + Igniter.add_issue( + igniter, + "No Ecto.Repo found in application environment, please specify one using `--repo` option" + ) + end + end + end +else + defmodule Mix.Tasks.PhoenixSync.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" + + @moduledoc __MODULE__.Docs.long_doc() + + use Mix.Task + + @impl Mix.Task + def run(_argv) do + Mix.shell().error(""" + The task 'phoenix_sync.install' requires igniter. Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter/readme.html#installation + """) + + exit({:shutdown, 1}) + end + end +end diff --git a/mix.exs b/mix.exs index b1a1328..d311b8c 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,8 @@ defmodule Phoenix.Sync.MixProject do {:jason, "~> 1.0"}, {:ecto_sql, "~> 3.10", optional: true}, {:electric, "~> 1.1.2", optional: true}, - {:electric_client, "~> 0.7"} + {:electric_client, "~> 0.7"}, + {:igniter, "~> 0.6", optional: true} ] ++ deps_for_env(Mix.env()) ++ json_deps() end diff --git a/mix.lock b/mix.lock index b839875..7d49bb2 100644 --- a/mix.lock +++ b/mix.lock @@ -24,10 +24,12 @@ "fine": {:hex, :fine, "0.1.2", "85cf7dd190c7c6c54c2840754ae977c9acc0417316255b674fad9f2678e4ecc7", [:mix], [], "hexpm", "9113531982c2b60dbea6c7233917ddf16806947cd7104b5d03011bf436ca3072"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.6.27", "a7c01062db56f5c5ac0f36ff8ef3cce1d61cd6bf59e50c52f4a38dc926aa9728", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d1eda5271932dcb9f00f94936c3dc12a2b96466f895f4b3fb82a0caada6d6447"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.3", "8b9c8c135e95f7bc483de6195c4e1c0b2c913a5e2c57353ef4e82703b7ac8bd1", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "5f96f29587dcfed8a22281e8c44c6607e958ba821d90b9dfc003d1ef610f7d07"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, @@ -48,6 +50,7 @@ "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, "otel_metric_exporter": {:hex, :otel_metric_exporter, "0.2.5", "8b9e9253c85202ac47f4c1c16c4496e093b12d4afe11292c7e58a03e943e24c0", [:mix], [{:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.13.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "66f697a4aff251ba50a58564eb11efeed61aacbae12f151ba9a80bd87c92323e"}, "pg_query_ex": {:hex, :pg_query_ex, "0.9.0", "8e34bd2d0e0eb9e8d621c4697032fad4bfba46826950d3b46904a80ab589b43a", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:protox, "~> 2.0", [hex: :protox, repo: "hexpm", optional: false]}], "hexpm", "a3fada1704fa9e2bc11ff846ad545ef9a1d34f46d86206063c37128960f4f5f5"}, + "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, "phoenix": {:hex, :phoenix, "1.8.0", "dc5d256bb253110266ded8c4a6a167e24fabde2e14b8e474d262840ae8d8ea18", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "15f6e9cb76646ad8d9f2947240519666fc2c4f29f8a93ad9c7664916ab4c167b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.4", "619f80f7bc8e99d67bda9fafa28b0e3e0d69fad9fcbe859be8988bf9183d83b3", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad32d316c546b029ef22895355f9980c74657026ab5e093157d8990f2aa7bd5d"}, @@ -61,7 +64,10 @@ "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "retry": {:hex, :retry, "0.19.0", "aeb326d87f62295d950f41e1255fe6f43280a1b390d36e280b7c9b00601ccbc2", [:mix], [], "hexpm", "85ef376aa60007e7bff565c366310966ec1bd38078765a0e7f20ec8a220d02ca"}, + "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, "sentry": {:hex, :sentry, "10.8.1", "aa45309785e1521416225adb16e0b4d8b957578804527f3c7babb6fefbc5e456", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "495b3cdadad90ba72eef973aa3dec39b3b8b2a362fe87e2f4ef32133ac3b4097"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_split": {:hex, :stream_split, "0.1.7", "2d3fd1fd21697da7f91926768d65f79409086052c9ec7ae593987388f52425f8", [:mix], [], "hexpm", "1dc072ff507a64404a0ad7af90df97096183fee8eeac7b300320cea7c4679147"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, @@ -69,6 +75,7 @@ "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_metrics_statsd": {:hex, :telemetry_metrics_statsd, "0.7.1", "3502235bb5b35ce50d608bf0f34369ef76eb92a4dbc8708c7e8780ca0da2d53e", [:mix], [{:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "06338d9dc3b4a202f11a6e706fd3feba4c46100d0aca23688dea0b8f801c361f"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"}, "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, diff --git a/test/mix/tasks/phoenix_sync.install_test.exs b/test/mix/tasks/phoenix_sync.install_test.exs new file mode 100644 index 0000000..b430cf4 --- /dev/null +++ b/test/mix/tasks/phoenix_sync.install_test.exs @@ -0,0 +1,13 @@ +defmodule Mix.Tasks.PhoenixSync.InstallTest do + use ExUnit.Case, async: true + import Igniter.Test + + test "it warns when run" do + # generate a test project + test_project() + # run our task + |> Igniter.compose_task("phoenix_sync.install", []) + # see tools in `Igniter.Test` for available assertions & helpers + |> assert_has_warning("mix phoenix_sync.install is not yet implemented") + end +end From 5c25f3cde05a1d79544c0daae45df2ee00d09df5 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Tue, 19 Aug 2025 15:23:04 +0100 Subject: [PATCH 2/7] feat: Add phoenix_sync.install igniter task --- README.md | 43 +- lib/mix/tasks/phoenix_sync.install.ex | 344 ++++++++--- mix.exs | 5 +- test/mix/tasks/phoenix_sync.install_test.exs | 580 ++++++++++++++++++- 4 files changed, 871 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 708a8ce..d741df9 100644 --- a/README.md +++ b/README.md @@ -203,11 +203,44 @@ See the `Phoenix.Sync.Writer` module docs for more information. `Phoenix.Sync` can be used in two modes: 1. `:embedded` where Electric is included as an application dependency and Phoenix.Sync consumes data internally using Elixir APIs -2. `:http` where Electric does _not_ need to be included as an application dependency and Phoenix.Sync consumes data from an external Electric service using it's [HTTP API](https://electric-sql.com/docs/api/http) -### Embedded mode + In `:embedded` mode, Electric does not expose an HTTP API (internally or externally). Messages are streamed internally between Electric and Phoenix.Sync using Elixir function APIs. The only HTTP API for sync is that exposed via your Phoenix Router using the `sync/2` macro and `sync_render/3` function. -In `:embedded` mode, Electric must be included an application dependency but does not expose an HTTP API (internally or externally). Messages are streamed internally between Electric and Phoenix.Sync using Elixir function APIs. The only HTTP API for sync is that exposed via your Phoenix Router using the `sync/2` macro and `sync_render/3` function. +2. `:http` where Electric does _not_ need to be included as an application dependency and Phoenix.Sync consumes data from an external Electric service using it's [HTTP API](https://electric-sql.com/docs/api/http). As for `embedded` mode, the only HTTP API for sync is that exposed via `Phoenix.Sync.Router.sync/2` and `Phoenix.Sync.Controller.sync_render/3`. + +### Using Igniter + +`Phoenix.Sync` supports [`Igniter`](https://hexdocs.pm/igniter/readme.html) for automatic configuration of your application. + +Add Igniter to an existing Elixir project by adding it to your dependencies in mix.exs: + +```elixir +{:igniter, "~> 0.6", only: [:dev, :test]} +``` + +Then use it to install `Phoenix.Sync` in `embedded` mode: + +```elixir +mix igniter.install phoenix_sync --sync-mode embedded +``` + +This will install `Phoenix.Sync` into your application and also set up your `Ecto.Repo` with the [sandbox test adapter](`Phoenix.Sync.Sandbox`). + +If you don't want the sandbox support then pass `--no-sync-sandbox`: + +```elixir +mix igniter.install phoenix_sync --sync-mode embedded --no-sync-sandbox +``` + +For `http` mode: + +```elixir +mix igniter.install phoenix_sync --sync-mode http --sync-url "https://api.electric-sql.cloud" +``` + +### Manual installation + +#### Embedded mode Example config: @@ -234,9 +267,7 @@ children = [ ] ``` -### HTTP - -In `:http` mode, Electric does not need to be included as an application dependency. Instead, Phoenix.Sync consumes data from an external Electric service over HTTP. +#### HTTP mode ```elixir # mix.exs diff --git a/lib/mix/tasks/phoenix_sync.install.ex b/lib/mix/tasks/phoenix_sync.install.ex index 4665822..fc35f4d 100644 --- a/lib/mix/tasks/phoenix_sync.install.ex +++ b/lib/mix/tasks/phoenix_sync.install.ex @@ -8,7 +8,7 @@ defmodule Mix.Tasks.PhoenixSync.Install.Docs do @spec example() :: String.t() def example do - "mix phoenix_sync.install --mode embedded --repo MyApp.Repo" + "mix phoenix_sync.install --sync-mode embedded" end @spec long_doc() :: String.t() @@ -16,9 +16,13 @@ defmodule Mix.Tasks.PhoenixSync.Install.Docs do """ #{short_doc()} - Longer explanation of your task + Usually invoked using `igniter.install`: - ## Example + ```sh + mix igniter.install phoenix_sync --sync-mode embedded + ``` + + But can be invoked directly if `:phoenix_sync` is already a dependency: ```sh #{example()} @@ -26,9 +30,18 @@ defmodule Mix.Tasks.PhoenixSync.Install.Docs do ## Options - * `--mode`- How to connect to Electric, either `embedded` or `http`. In `embedded` mode `:electric` will be added as a dependency and started automatically and you may have to include the `--repo` option (see below). For `http` mode you'll need to specify the `--url` to the Electric server. - * `--repo`- Which `Ecto.Repo` module to connect to in `embedded` mode. The installer should be able to find the right repo automatically, but if it can't, you can specify it here. - * `--url` - The URL of the Electric server, only needed for `--mode http`. + * `--sync-mode` - How to connect to Electric, either `embedded` or `http`. + + - `embedded` - `:electric` will be added as a dependency and will connect to the database your repo is configured for. + - `http` - You'll need to specify the `--sync-url` to the remote Electric server. + + ### Options for `embedded` mode + + * `--no-sync-sandbox` - Disable the test sandbox + + ### Options for `http` mode + + * `--sync-url` (required) - The URL of the Electric server, required for `http` mode. """ end end @@ -41,63 +54,34 @@ if Code.ensure_loaded?(Igniter) do use Igniter.Mix.Task - @impl Igniter.Mix.Task - def info(argv, composing_task) do - {opts, _positional, _other} = OptionParser.parse(argv, strict: [mode: :string]) - - adds_deps = - case Keyword.fetch(opts, :mode) do - {:ok, "embedded"} -> - [{:electric, required_electric_version()}] - - {:ok, "http"} -> - [] - - {:ok, _other} -> - # errors will come later - [] - end + require Igniter.Code.Function + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do %Igniter.Mix.Task.Info{ - # Groups allow for overlapping arguments for tasks by the same author - # See the generators guide for more. group: :phoenix_sync, - # *other* dependencies to add - # i.e `{:foo, "~> 2.0"}` - adds_deps: adds_deps, - # *other* dependencies to add and call their associated installers, if they exist - # i.e `{:foo, "~> 2.0"}` + adds_deps: [], installs: [], - # An example invocation example: __MODULE__.Docs.example(), - # A list of environments that this should be installed in. only: nil, - # a list of positional arguments, i.e `[:file]` positional: [], - # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv - # This ensures your option schema includes options from nested tasks composes: [], - # `OptionParser` schema schema: [ - mode: :string, - repo: :string, - url: :string, - sandbox: :boolean + sync_mode: :string, + sync_url: :string, + sync_sandbox: :boolean ], - # Default values for the options in the `schema` defaults: [], - # CLI aliases aliases: [], - # A list of options in the schema that are required - required: [:mode] + required: [:sync_mode] } end - @valid_modes ~w(embedded http) + @valid_modes ~w[embedded http] @impl Igniter.Mix.Task def igniter(igniter) do - {:ok, mode} = Keyword.fetch(igniter.args.options, :mode) + {:ok, mode} = Keyword.fetch(igniter.args.options, :sync_mode) if mode not in @valid_modes do Igniter.add_issue( @@ -110,7 +94,7 @@ if Code.ensure_loaded?(Igniter) do end defp add_dependencies(igniter, "http") do - case Keyword.fetch(igniter.args.options, :url) do + case Keyword.fetch(igniter.args.options, :sync_url) do {:ok, url} -> igniter |> base_configuration(:http) @@ -120,23 +104,97 @@ if Code.ensure_loaded?(Igniter) do [:url], url ) + |> Igniter.Project.Config.configure_new( + "config.exs", + :phoenix_sync, + [:credentials], + secret: "MY_SECRET", + source_id: "00000000-0000-0000-0000-000000000000" + ) + |> configure_endpoint() :error -> - Igniter.add_issue(igniter, "`--url` is required for :http mode") + Igniter.add_issue(igniter, "`--sync-url` is required for :http mode") end end defp add_dependencies(igniter, "embedded") do - # we should maybe confirm all this stuff before doing it - igniter + |> Igniter.Project.Deps.add_dep({:electric, required_electric_version()}, error?: true) |> base_configuration(:embedded) |> find_repo() |> configure_repo() + |> configure_endpoint() + end + + defp configure_endpoint(igniter) do + application = Igniter.Project.Application.app_module(igniter) + + case Igniter.Libs.Phoenix.select_endpoint(igniter) do + {igniter, nil} -> + configure_plug_app(igniter, application) + + {igniter, endpoint} -> + configure_phoenix_endpoint(igniter, application, endpoint) + end + end + + defp configure_plug_app(igniter, application) do + # find plug module in application children and add config there + set_plug_opts(igniter, application, [Plug.Cowboy, Bandit], fn zipper -> + with {:ok, zipper} <- Igniter.Code.Tuple.tuple_elem(zipper, 1) do + Igniter.Code.Keyword.set_keyword_key( + zipper, + :plug, + nil, + fn zipper -> + if Igniter.Code.Tuple.tuple?(zipper) do + with {:ok, zipper} <- Igniter.Code.Tuple.tuple_elem(zipper, 1) do + Igniter.Code.Keyword.set_keyword_key( + zipper, + :phoenix_sync, + quote(do: Phoenix.Sync.plug_opts()) + ) + end + else + with {:ok, plug} <- Igniter.Code.Common.expand_literal(zipper) do + {:ok, + zipper + |> Sourceror.Zipper.search_pattern("#{inspect(plug)}") + |> Igniter.Code.Common.replace_code( + "{#{inspect(plug)}, phoenix_sync: Phoenix.Sync.plug_opts()}" + )} + end + end + end + ) + end + end) + end - # update the repo source with the sandbox adapter - # check if this is phoenix or plain plug - # add the plug_opts + defp configure_phoenix_endpoint(igniter, application, endpoint) do + set_plug_opts(igniter, application, [endpoint], fn zipper -> + # gets called with a zipper on the endpoint module in the list of children + + if Igniter.Code.Tuple.tuple?(zipper) do + with {:ok, zipper} <- Igniter.Code.Tuple.tuple_elem(zipper, 1) do + Igniter.Code.Keyword.set_keyword_key( + zipper, + :phoenix_sync, + quote(do: Phoenix.Sync.plug_opts()) + ) + end + else + # the search pattern call results in replacing the module name with + # the configuration whilst preserving the preceding comments + {:ok, + zipper + |> Sourceror.Zipper.search_pattern("#{inspect(endpoint)}") + |> Igniter.Code.Common.replace_code( + "{#{inspect(endpoint)}, phoenix_sync: Phoenix.Sync.plug_opts()}" + )} + end + end) end defp configure_repo(%{issues: [_ | _] = _issues} = igniter) do @@ -144,34 +202,72 @@ if Code.ensure_loaded?(Igniter) do end defp configure_repo(igniter) do - enable_sandbox? = Keyword.get(igniter.args.options, :sandbox, true) - - igniter = - igniter - |> Igniter.Project.Config.configure_new( - "config.exs", - :phoenix_sync, - [:repo], - igniter.assigns.repo - ) - - if enable_sandbox? do - igniter - |> Igniter.Project.Module.find_and_update_module!( - igniter.assigns.repo |> dbg, - fn zipper -> - with {:ok, zipper} <- - Igniter.Code.Module.move_to_use(zipper, Ecto.Repo) |> dbg, - zipper <- - Igniter.Code.Common.add_code(zipper, "use Phoenix.Sync.Sandbox.Postgres", - placement: :before - ) do - {:ok, zipper} - end + case igniter.assigns do + %{repo: repo} when is_atom(repo) -> + igniter = + igniter + |> Igniter.Project.Config.configure_new("config.exs", :phoenix_sync, [:repo], repo) + |> Igniter.Project.Config.configure_new("test.exs", :phoenix_sync, [:mode], :sandbox) + |> Igniter.Project.Config.configure_new( + "test.exs", + :phoenix_sync, + [:env], + {:code, quote(do: config_env())} + ) + + enable_sandbox? = Keyword.get(igniter.args.options, :sync_sandbox, true) + + if enable_sandbox? do + igniter + |> Igniter.Project.Module.find_and_update_module!( + repo, + fn zipper -> + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call(zipper, :use, [2]), + {:ok, zipper} <- + Igniter.Code.Function.update_nth_argument(zipper, 1, fn zipper -> + adapter = quote(do: Phoenix.Sync.Sandbox.Postgres.adapter()) + + Igniter.Code.Keyword.set_keyword_key( + zipper, + :adapter, + adapter, + fn z -> {:ok, Igniter.Code.Common.replace_code(z, adapter)} end + ) + end), + {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ecto.Repo), + zipper <- + Igniter.Code.Common.add_code(zipper, "use Phoenix.Sync.Sandbox.Postgres", + placement: :before + ) do + {:ok, zipper} + end + end + ) + else + igniter end - ) - else - igniter + + _ -> + igniter + |> Igniter.add_notice("No Ecto.Repo found, adding example `connection_opts` to config") + |> Igniter.Project.Config.configure_new( + "config.exs", + :phoenix_sync, + [:connection_opts], + {:code, + Sourceror.parse_string!(""" + # add your real database connection details + [ + username: "your_username", + password: "your_password", + hostname: "localhost", + database: "your_database", + port: 5432, + sslmode: :disable + ] + """)} + ) end end @@ -199,17 +295,91 @@ if Code.ensure_loaded?(Igniter) do end defp find_repo(igniter) do - app_name = Igniter.Project.Application.app_name(igniter) |> dbg + case Igniter.Libs.Ecto.select_repo(igniter) do + {igniter, nil} -> + Igniter.add_notice( + igniter, + """ + No Ecto.Repo found in application environment. + + To use `embedded` mode you must add `connection_opts` to your config, e.g. + + config :phoenix_sync, + env: config_env(), + mode: :embedded, + connection_opts: [ + username: "your_username", + password: "your_password", + hostname: "localhost", + database: "your_database", + port: 5432 + ] + """ + ) - case Application.fetch_env(app_name, :ecto_repos) do - {:ok, [repo | _]} -> + {igniter, repo} -> Igniter.assign(igniter, :repo, repo) + end + end - :error -> - Igniter.add_issue( - igniter, - "No Ecto.Repo found in application environment, please specify one using `--repo` option" - ) + defp set_plug_opts(igniter, application, modify_modules, updater) + when is_list(modify_modules) do + Igniter.Project.Module.find_and_update_module!(igniter, application, fn zipper -> + with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :start, 2), + {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :=, + [2], + fn call -> + Igniter.Code.Function.argument_matches_pattern?( + call, + 0, + {:children, _, context} when is_atom(context) + ) && + Igniter.Code.Function.argument_matches_pattern?(call, 1, v when is_list(v)) + end + ), + {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1) do + case Igniter.Code.List.move_to_list_item(zipper, fn item -> + case extract_child_module(item) do + {:ok, child_module} -> + Enum.any?(modify_modules, fn modify_module -> + Igniter.Code.Common.nodes_equal?(child_module, modify_module) + end) + + :error -> + false + end + end) do + {:ok, zipper} -> + updater.(zipper) + + :error -> + {:warning, + """ + Could not find a suitable `children = [...]` assignment in the `start` function of the `#{inspect(application)}` module. + Please add `phoenix_sync: Phoenix.Sync.plug_opts()` to your Phoenix endpoint or Plug module configuration + """} + end + else + _ -> + {:warning, + """ + Could not find a `children = [...]` assignment in the `start` function of the `#{inspect(application)}` module. + Please add `phoenix_sync: Phoenix.Sync.plug_opts()` to your Phoenix endpoint or Plug module configuration + """} + end + end) + end + + defp extract_child_module(zipper) do + if Igniter.Code.Tuple.tuple?(zipper) do + with {:ok, elem} <- Igniter.Code.Tuple.tuple_elem(zipper, 0) do + {:ok, Igniter.Code.Common.expand_alias(elem)} + end + else + {:ok, Igniter.Code.Common.expand_alias(zipper)} end end end diff --git a/mix.exs b/mix.exs index d311b8c..8b4aa3c 100644 --- a/mix.exs +++ b/mix.exs @@ -3,6 +3,7 @@ defmodule Phoenix.Sync.MixProject do # Remember to update the README when you change the version @version "0.5.1" + @electric_version "~> 1.1.2" def project do [ @@ -35,6 +36,8 @@ defmodule Phoenix.Sync.MixProject do [preferred_envs: ["test.all": :test, "test.apps": :test]] end + def electric_version, do: @electric_version + defp deps do [ {:nimble_options, "~> 1.1"}, @@ -42,7 +45,7 @@ defmodule Phoenix.Sync.MixProject do {:plug, "~> 1.0"}, {:jason, "~> 1.0"}, {:ecto_sql, "~> 3.10", optional: true}, - {:electric, "~> 1.1.2", optional: true}, + {:electric, @electric_version, optional: true}, {:electric_client, "~> 0.7"}, {:igniter, "~> 0.6", optional: true} ] ++ deps_for_env(Mix.env()) ++ json_deps() diff --git a/test/mix/tasks/phoenix_sync.install_test.exs b/test/mix/tasks/phoenix_sync.install_test.exs index b430cf4..102a078 100644 --- a/test/mix/tasks/phoenix_sync.install_test.exs +++ b/test/mix/tasks/phoenix_sync.install_test.exs @@ -1,13 +1,579 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do use ExUnit.Case, async: true + import Igniter.Test - test "it warns when run" do - # generate a test project - test_project() - # run our task - |> Igniter.compose_task("phoenix_sync.install", []) - # see tools in `Igniter.Test` for available assertions & helpers - |> assert_has_warning("mix phoenix_sync.install is not yet implemented") + defp run_install_task(igniter, ctx) do + install_args = Map.fetch!(ctx, :install_args) + + igniter + |> Igniter.compose_task("phoenix_sync.install", install_args) + end + + describe "phoenix: embedded" do + @describetag install_args: ["--sync-mode", "embedded"] + + setup(ctx) do + files = Map.get(ctx, :files, %{}) + + [igniter: run_install_task(phx_test_project(files: files), ctx)] + end + + test "adds electric dependency", ctx do + ctx.igniter + |> assert_has_patch( + "mix.exs", + """ + + | {:electric, "#{Phoenix.Sync.MixProject.electric_version()}"} + """ + ) + end + + test "updates existing config files", ctx do + ctx.igniter + |> assert_has_patch( + "config/config.exs", + """ + + |config :phoenix_sync, mode: :embedded, env: config_env(), repo: Test.Repo + """ + ) + |> assert_has_patch( + "config/test.exs", + """ + + |config :phoenix_sync, mode: :sandbox, env: config_env() + """ + ) + end + + test "configures the endpoint", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test/application.ex", + """ + - | TestWeb.Endpoint + + | {TestWeb.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} + """ + ) + end + + test "appends sync config to any existing params", ctx do + phx_test_project() + |> Igniter.update_file("lib/test/application.ex", fn source -> + Rewrite.Source.update(source, :content, fn content -> + String.replace( + content, + ~r/TestWeb\.Endpoint/, + "{TestWeb.Endpoint, config: [here: true]}" + ) + end) + end) + |> run_install_task(ctx) + |> assert_has_patch( + "lib/test/application.ex", + # the final patch is my source update above plus the effect of the install + """ + - | TestWeb.Endpoint + + | {TestWeb.Endpoint, config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()} + """ + ) + end + + test "adds the sandbox config to the repo", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test/repo.ex", + """ + + | use Phoenix.Sync.Sandbox.Postgres + + | + """ + ) + |> assert_has_patch( + "lib/test/repo.ex", + """ + - | adapter: Ecto.Adapters.Postgres + + | adapter: Phoenix.Sync.Sandbox.Postgres.adapter() + """ + ) + end + + @tag install_args: ["--sync-mode", "embedded", "--no-sync-sandbox"] + test "doesn't add the sandbox config if passed --no-sync-sandbox", ctx do + ctx.igniter + |> assert_unchanged("lib/test/repo.ex") + end + + test "adds connection_opts to repo-less application", ctx do + phx_test_project() + |> Igniter.rm("lib/test/repo.ex") + |> run_install_task(ctx) + |> assert_has_notice(fn notice -> + assert notice =~ "No Ecto.Repo found" + end) + |> assert_has_patch( + "config/config.exs", + """ + + |# add your real database connection details + + |config :phoenix_sync, + + | mode: :embedded, + + | env: config_env(), + + | connection_opts: [ + + | username: "your_username", + + | password: "your_password", + + | hostname: "localhost", + + | database: "your_database", + + | port: 5432, + + | sslmode: :disable + + | ] + """ + ) + end + end + + describe "phoenix: http" do + @describetag install_args: [ + "--sync-mode", + "http", + "--sync-url", + "https://electric-sql.cloud/" + ] + + setup(ctx) do + files = Map.get(ctx, :files, %{}) + + [igniter: run_install_task(phx_test_project(files: files), ctx)] + end + + @tag install_args: ["--sync-mode", "http"] + test "returns issue if url is not provided", ctx do + ctx.igniter + |> assert_has_issue(fn issue -> + assert issue =~ "`--sync-url` is required for :http mode" + end) + end + + test "adds url and empty credentials to config.exs", ctx do + ctx.igniter + |> assert_has_patch( + "config/config.exs", + """ + + |config :phoenix_sync, + + | mode: :http, + + | env: config_env(), + + | url: "https://electric-sql.cloud/", + + | credentials: [secret: "MY_SECRET", source_id: "00000000-0000-0000-0000-000000000000"] + + | + """ + ) + end + + test "configures the endpoint", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test/application.ex", + """ + - | TestWeb.Endpoint + + | {TestWeb.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} + """ + ) + end + end + + plug_application = fn server_spec -> + """ + defmodule TestPlug.Application do + use Application + + def start(_type, _args) do + children = [ + #{server_spec} + ] + + opts = [strategy: :one_for_one, name: TestPlug.Supervisor] + Supervisor.start_link(children, opts) + end + end + """ + end + + @plug_files %{ + "mix.exs" => """ + defmodule TestPlug.MixProject do + use Mix.Project + + def project do + [ + app: :test_plug, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {TestPlug.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [] + end + end + """, + "lib/test_plug/application.ex" => + plug_application.( + "{Plug.Cowboy, scheme: :http, plug: PlugTest.Router, options: [port: 4040]}" + ), + "lib/test_plug/repo.ex" => """ + defmodule TestPlug.Repo do + use Ecto.Repo, + otp_app: :test_plug, + adapter: Ecto.Adapters.Postgres + end + """ + } + describe "Plug: embedded" do + @describetag install_args: ["--sync-mode", "embedded"] + + setup(ctx) do + extra_files = Map.get(ctx, :files, %{}) + + files = Map.merge(@plug_files, extra_files) + + [igniter: run_install_task(test_project(files: files), ctx)] + end + + test "adds electric dependency", ctx do + ctx.igniter + |> assert_has_patch( + "mix.exs", + """ + - | [] + + | [{:electric, "#{Phoenix.Sync.MixProject.electric_version()}"}] + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.( + "{Plug.Cowboy, scheme: :http, plug: TestPlug.Router, options: [port: 4040]}" + ) + } + test "configures Plug.Cowboy app", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Plug.Cowboy, scheme: :http, plug: TestPlug.Router, options: [port: 4040]} + + | {Plug.Cowboy, + + | scheme: :http, + + | plug: {TestPlug.Router, phoenix_sync: Phoenix.Sync.plug_opts()}, + + | options: [port: 4040]} + + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.( + "{Plug.Cowboy, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, options: [port: 4040]}" + ) + } + test "configures Plug.Cowboy app which already has config", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Plug.Cowboy, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, options: [port: 4040]} + + | {Plug.Cowboy, + + | scheme: :http, + + | plug: {TestPlug.Router, [my_config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()]}, + + | options: [port: 4040]} + + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.("{Bandit, scheme: :http, plug: TestPlug.Router, port: 4040}") + } + test "configures Bandit app", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Bandit, scheme: :http, plug: TestPlug.Router, port: 4040} + + | {Bandit, + + | scheme: :http, plug: {TestPlug.Router, phoenix_sync: Phoenix.Sync.plug_opts()}, port: 4040} + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.( + "{Bandit, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, port: 4040}" + ) + } + test "configures Bandit app with existing config", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Bandit, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, port: 4040} + + | {Bandit, + + | scheme: :http, + + | plug: {TestPlug.Router, [my_config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()]}, + + | port: 4040} + + """ + ) + end + + @tag files: %{ + "config/config.exs" => """ + import Config + + import_config "\#{config_env()}.exs" + """, + "config/test.exs" => """ + import Config + """ + } + test "updates existing config files", ctx do + ctx.igniter + |> assert_has_patch( + "config/config.exs", + """ + + |config :phoenix_sync, mode: :embedded, env: config_env(), repo: TestPlug.Repo + """ + ) + |> assert_has_patch( + "config/test.exs", + """ + + |config :phoenix_sync, mode: :sandbox, env: config_env() + """ + ) + end + + test "creates config files", ctx do + ctx.igniter + |> assert_creates( + "config/config.exs", + """ + import Config + config :phoenix_sync, mode: :embedded, env: config_env(), repo: TestPlug.Repo + import_config "\#{config_env()}.exs" + """ + ) + |> assert_creates( + "config/test.exs", + """ + import Config + config :phoenix_sync, mode: :sandbox, env: config_env() + """ + ) + end + + test "adds the sandbox config to the repo", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/repo.ex", + """ + + | use Phoenix.Sync.Sandbox.Postgres + + | + """ + ) + |> assert_has_patch( + "lib/test_plug/repo.ex", + """ + - | adapter: Ecto.Adapters.Postgres + + | adapter: Phoenix.Sync.Sandbox.Postgres.adapter() + """ + ) + end + + @tag install_args: ["--sync-mode", "embedded", "--no-sync-sandbox"] + test "honours --no-sync-sandbox", ctx do + ctx.igniter + |> assert_unchanged("lib/test_plug/repo.ex") + end + + @tag files: %{"lib/test_plug/repo.ex" => ""} + test "handles a repo-less application", ctx do + ctx.igniter + |> assert_unchanged("lib/test_plug/repo.ex") + |> assert_creates( + "config/config.exs", + """ + import Config + + config :phoenix_sync, + mode: :embedded, + env: config_env(), + # add your real database connection details + connection_opts: [ + username: "your_username", + password: "your_password", + hostname: "localhost", + database: "your_database", + port: 5432, + sslmode: :disable + ] + """ + ) + |> refute_creates("config/test.exs") + end + end + + describe "Plug: http" do + @describetag install_args: [ + "--sync-mode", + "http", + "--sync-url", + "https://electric-sql.cloud/" + ] + + setup(ctx) do + extra_files = Map.get(ctx, :files, %{}) + + files = Map.put(Map.merge(@plug_files, extra_files), "lib/test_plug/repo.ex", "") + + [igniter: run_install_task(test_project(files: files), ctx)] + end + + test "adds electric dependency", ctx do + ctx.igniter + |> assert_unchanged("mix.exs") + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.( + "{Plug.Cowboy, scheme: :http, plug: TestPlug.Router, options: [port: 4040]}" + ) + } + test "configures Plug.Cowboy app", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Plug.Cowboy, scheme: :http, plug: TestPlug.Router, options: [port: 4040]} + + | {Plug.Cowboy, + + | scheme: :http, + + | plug: {TestPlug.Router, phoenix_sync: Phoenix.Sync.plug_opts()}, + + | options: [port: 4040]} + + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.( + "{Plug.Cowboy, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, options: [port: 4040]}" + ) + } + test "configures Plug.Cowboy app which already has config", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Plug.Cowboy, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, options: [port: 4040]} + + | {Plug.Cowboy, + + | scheme: :http, + + | plug: {TestPlug.Router, [my_config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()]}, + + | options: [port: 4040]} + + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.("{Bandit, scheme: :http, plug: TestPlug.Router, port: 4040}") + } + test "configures Bandit app", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Bandit, scheme: :http, plug: TestPlug.Router, port: 4040} + + | {Bandit, + + | scheme: :http, plug: {TestPlug.Router, phoenix_sync: Phoenix.Sync.plug_opts()}, port: 4040} + """ + ) + end + + @tag files: %{ + "lib/test_plug/application.ex" => + plug_application.( + "{Bandit, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, port: 4040}" + ) + } + test "configures Bandit app with existing config", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Bandit, scheme: :http, plug: {TestPlug.Router, my_config: [here: true]}, port: 4040} + + | {Bandit, + + | scheme: :http, + + | plug: {TestPlug.Router, [my_config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()]}, + + | port: 4040} + + """ + ) + end + + @tag files: %{ + "config/config.exs" => """ + import Config + + import_config "\#{config_env()}.exs" + """, + "config/test.exs" => """ + import Config + """ + } + test "updates existing config files", ctx do + ctx.igniter + |> assert_has_patch( + "config/config.exs", + """ + + |config :phoenix_sync, + + | mode: :http, + + | env: config_env(), + + | url: "https://electric-sql.cloud/", + + | credentials: [secret: "MY_SECRET", source_id: "00000000-0000-0000-0000-000000000000"] + + | + """ + ) + |> assert_unchanged("config/test.exs") + end + + test "creates config files", ctx do + ctx.igniter + |> assert_creates( + "config/config.exs", + """ + import Config + + config :phoenix_sync, + mode: :http, + env: config_env(), + url: "https://electric-sql.cloud/", + credentials: [secret: "MY_SECRET", source_id: "00000000-0000-0000-0000-000000000000"] + """ + ) + |> refute_creates("config/test.exs") + end end end From 1c840bf3c99d98c6fd869b985e5af4e5b367c440 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Tue, 19 Aug 2025 15:46:50 +0100 Subject: [PATCH 3/7] add required phx.new task to ci --- .github/workflows/elixir_tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/elixir_tests.yml b/.github/workflows/elixir_tests.yml index b2af713..4e94b52 100644 --- a/.github/workflows/elixir_tests.yml +++ b/.github/workflows/elixir_tests.yml @@ -71,6 +71,10 @@ jobs: - name: Install dependencies run: mix deps.get && mix deps.compile + # needed for the igniter tests + - name: Install phx.new task + run: mix archive.install hex phx_new --force + - name: Compiles without warnings run: mix compile --force --all-warnings --warnings-as-errors From 6de63c23c9a5d072f5d7461e9c787872c934f521 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Tue, 19 Aug 2025 15:50:46 +0100 Subject: [PATCH 4/7] install phx_new before compiling deps --- .github/workflows/elixir_tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/elixir_tests.yml b/.github/workflows/elixir_tests.yml index 4e94b52..80899b2 100644 --- a/.github/workflows/elixir_tests.yml +++ b/.github/workflows/elixir_tests.yml @@ -68,13 +68,13 @@ jobs: ${{ runner.os }}-build-${{ env.MIX_ENV }}- ${{ runner.os }}-build- - - name: Install dependencies - run: mix deps.get && mix deps.compile - # needed for the igniter tests - name: Install phx.new task run: mix archive.install hex phx_new --force + - name: Install dependencies + run: mix deps.get && mix deps.compile + - name: Compiles without warnings run: mix compile --force --all-warnings --warnings-as-errors From 5a99cd2b2ffbc7ada1aa5bc37e4dbd15e6dfdf45 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Tue, 19 Aug 2025 16:07:03 +0100 Subject: [PATCH 5/7] add test case for aliased modules --- test/mix/tasks/phoenix_sync.install_test.exs | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/mix/tasks/phoenix_sync.install_test.exs b/test/mix/tasks/phoenix_sync.install_test.exs index 102a078..bb3ebae 100644 --- a/test/mix/tasks/phoenix_sync.install_test.exs +++ b/test/mix/tasks/phoenix_sync.install_test.exs @@ -496,6 +496,39 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do ) end + @tag files: %{ + "lib/test_plug/application.ex" => """ + defmodule TestPlug.Application do + use Application + + alias TestPlug.Router + + def start(_type, _args) do + children = [ + {Plug.Cowboy, scheme: :http, plug: {Router, my_config: [here: true]}, options: [port: 4040]} + ] + + opts = [strategy: :one_for_one, name: TestPlug.Supervisor] + Supervisor.start_link(children, opts) + end + end + """ + } + test "supports aliased plug modules", ctx do + ctx.igniter + |> assert_has_patch( + "lib/test_plug/application.ex", + """ + - | {Plug.Cowboy, scheme: :http, plug: {Router, my_config: [here: true]}, options: [port: 4040]} + + | {Plug.Cowboy, + + | scheme: :http, + + | plug: {Router, [my_config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()]}, + + | options: [port: 4040]} + + """ + ) + end + @tag files: %{ "lib/test_plug/application.ex" => plug_application.("{Bandit, scheme: :http, plug: TestPlug.Router, port: 4040}") From 205b689e0bba9a2f31d75d30c8217f422a60829c Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Mon, 25 Aug 2025 11:26:16 +0100 Subject: [PATCH 6/7] don't add sandbox adapter if it's already added --- lib/mix/tasks/phoenix_sync.install.ex | 11 +++++++- test/mix/tasks/phoenix_sync.install_test.exs | 29 +++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/mix/tasks/phoenix_sync.install.ex b/lib/mix/tasks/phoenix_sync.install.ex index fc35f4d..d6867dc 100644 --- a/lib/mix/tasks/phoenix_sync.install.ex +++ b/lib/mix/tasks/phoenix_sync.install.ex @@ -121,6 +121,13 @@ if Code.ensure_loaded?(Igniter) do defp add_dependencies(igniter, "embedded") do igniter |> Igniter.Project.Deps.add_dep({:electric, required_electric_version()}, error?: true) + |> then(fn igniter -> + if igniter.assigns[:test_mode?] do + igniter + else + Igniter.apply_and_fetch_dependencies(igniter) + end + end) |> base_configuration(:embedded) |> find_repo() |> configure_repo() @@ -222,7 +229,9 @@ if Code.ensure_loaded?(Igniter) do |> Igniter.Project.Module.find_and_update_module!( repo, fn zipper -> - with {:ok, zipper} <- + with :error <- + Igniter.Code.Module.move_to_use(zipper, Phoenix.Sync.Sandbox.Postgres), + {:ok, zipper} <- Igniter.Code.Function.move_to_function_call(zipper, :use, [2]), {:ok, zipper} <- Igniter.Code.Function.update_nth_argument(zipper, 1, fn zipper -> diff --git a/test/mix/tasks/phoenix_sync.install_test.exs b/test/mix/tasks/phoenix_sync.install_test.exs index bb3ebae..47aef25 100644 --- a/test/mix/tasks/phoenix_sync.install_test.exs +++ b/test/mix/tasks/phoenix_sync.install_test.exs @@ -5,8 +5,15 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do defp run_install_task(igniter, ctx) do install_args = Map.fetch!(ctx, :install_args) + files = Map.get(ctx, :files, %{}) + # put the files after the base install -- needed otherwise the phx install + # task overwrites any sources we define here that conflict igniter + |> Map.update!(:assigns, fn assigns -> + Map.update!(assigns, :test_files, &Map.merge(&1, Map.new(files))) + end) + |> apply_igniter!() |> Igniter.compose_task("phoenix_sync.install", install_args) end @@ -14,9 +21,7 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do @describetag install_args: ["--sync-mode", "embedded"] setup(ctx) do - files = Map.get(ctx, :files, %{}) - - [igniter: run_install_task(phx_test_project(files: files), ctx)] + [igniter: run_install_task(phx_test_project(), ctx)] end test "adds electric dependency", ctx do @@ -72,7 +77,7 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do "lib/test/application.ex", # the final patch is my source update above plus the effect of the install """ - - | TestWeb.Endpoint + - | {TestWeb.Endpoint, config: [here: true]} + | {TestWeb.Endpoint, config: [here: true], phoenix_sync: Phoenix.Sync.plug_opts()} """ ) @@ -96,6 +101,22 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do ) end + @tag files: %{ + "lib/test/repo.ex" => """ + defmodule Test.Repo do + use Phoenix.Sync.Sandbox.Postgres + + use Ecto.Repo, + otp_app: :test, + adapter: Phoenix.Sync.Sandbox.Postgres.adapter() + end + """ + } + test "doesn't add the sandbox adapter if its already present", ctx do + ctx.igniter + |> assert_unchanged("lib/test/repo.ex") + end + @tag install_args: ["--sync-mode", "embedded", "--no-sync-sandbox"] test "doesn't add the sandbox config if passed --no-sync-sandbox", ctx do ctx.igniter From d27c64903e6fe32b0cb858b06ee8248fa85d0a93 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Mon, 25 Aug 2025 12:20:33 +0100 Subject: [PATCH 7/7] document sslmode option --- lib/mix/tasks/phoenix_sync.install.ex | 3 ++- test/mix/tasks/phoenix_sync.install_test.exs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/mix/tasks/phoenix_sync.install.ex b/lib/mix/tasks/phoenix_sync.install.ex index d6867dc..34e6214 100644 --- a/lib/mix/tasks/phoenix_sync.install.ex +++ b/lib/mix/tasks/phoenix_sync.install.ex @@ -273,7 +273,8 @@ if Code.ensure_loaded?(Igniter) do hostname: "localhost", database: "your_database", port: 5432, - sslmode: :disable + # sslmode can be: :disable, :allow, :prefer or :require + sslmode: :prefer ] """)} ) diff --git a/test/mix/tasks/phoenix_sync.install_test.exs b/test/mix/tasks/phoenix_sync.install_test.exs index 47aef25..e5b4ea2 100644 --- a/test/mix/tasks/phoenix_sync.install_test.exs +++ b/test/mix/tasks/phoenix_sync.install_test.exs @@ -143,7 +143,8 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do + | hostname: "localhost", + | database: "your_database", + | port: 5432, - + | sslmode: :disable + + | # sslmode can be: :disable, :allow, :prefer or :require + + | sslmode: :prefer + | ] """ ) @@ -446,7 +447,8 @@ defmodule Mix.Tasks.PhoenixSync.InstallTest do hostname: "localhost", database: "your_database", port: 5432, - sslmode: :disable + # sslmode can be: :disable, :allow, :prefer or :require + sslmode: :prefer ] """ )