├── config ├── dev.exs ├── config.exs └── test.exs ├── lib ├── mix │ └── tasks │ │ ├── live_query.migrate.ex │ │ └── live_query.gen.migration.ex ├── live_query │ ├── test_repo.ex │ ├── test_endpoint.ex │ ├── application.ex │ ├── notifications_test.exs │ ├── notifications.ex │ └── listener.ex └── live_query.ex ├── test ├── test_helper.exs └── live_query_test.exs ├── .tool-versions ├── .formatter.exs ├── .gitignore ├── mix.exs ├── README.md └── mix.lock /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /lib/mix/tasks/live_query.migrate.ex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.13.2 2 | erlang 24.2 3 | 4 | -------------------------------------------------------------------------------- /lib/live_query/test_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.TestRepo do 2 | end 3 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/live_query/test_endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.TestEndpoint do 2 | use Phoenix.Endpoint, otp_app: :live_query 3 | end 4 | -------------------------------------------------------------------------------- /test/live_query_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveQueryTest do 2 | use ExUnit.Case 3 | doctest LiveQuery 4 | 5 | test "greets the world" do 6 | assert LiveQuery.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :live_query, repo: LiveQuery.TestRepo 4 | 5 | config :live_query, LiveQuery.TestEndpoint, 6 | secret_key_base: "kjoy3o1zeidquwy1398juxzldjlksahdk3", 7 | live_view: [signing_salt: "FwuDKzc6D9-jxgIG"] 8 | -------------------------------------------------------------------------------- /lib/live_query.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery do 2 | @moduledoc """ 3 | Documentation for `LiveQuery`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> LiveQuery.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_query-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :live_query, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {LiveQuery.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:phoenix_live_view, "~> 0.17.11"}, 26 | {:floki, ">= 0.30.0", only: :test}, 27 | {:jason, "~> 1.0", only: :test}, 28 | {:phoenix_pubsub, "~> 2.1.1"}, 29 | {:ecto_sql, "~> 3.0"}, 30 | {:postgrex, "~> 0.16"} 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/live_query/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = children(Mix.env()) 11 | 12 | # See https://hexdocs.pm/elixir/Supervisor.html 13 | # for other strategies and supported options 14 | opts = [strategy: :one_for_one, name: LiveQuery.Supervisor] 15 | Supervisor.start_link(children, opts) 16 | end 17 | 18 | defp children(:test), 19 | do: [ 20 | LiveQuery.TestEndpoint, 21 | {Phoenix.PubSub, name: LiveQuery.PubSub} 22 | ] 23 | 24 | defp children(:prod), 25 | do: [ 26 | {Postgrex.Notifications, name: LiveQuery.Notifications}, 27 | {Phoenix.PubSub, name: LiveQuery.PubSub} 28 | ] 29 | end 30 | -------------------------------------------------------------------------------- /lib/live_query/notifications_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.NotificationsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.ConnTest 5 | import Phoenix.LiveViewTest 6 | 7 | @endpoint LiveQuery.TestEndpoint 8 | 9 | defmodule TestLive do 10 | use Phoenix.LiveView 11 | 12 | use LiveQuery.Notifications, for: ["test_table"] 13 | 14 | def render(assigns) do 15 | ~H""" 16 |

TestLive: <%= @status %>

17 | """ 18 | end 19 | 20 | def mount(_params, _conn, socket) do 21 | socket = assign(socket, status: :fresh) 22 | {:ok, socket} 23 | end 24 | 25 | def handle_info({:live_query_notification, %{table: "test_table"}}, socket) do 26 | socket = assign(socket, status: :notified) 27 | {:noreply, socket} 28 | end 29 | end 30 | 31 | test "LiveView mounts" do 32 | {:ok, view, html} = live_isolated(build_conn(), TestLive) 33 | 34 | assert html =~ "TestLive: fresh" 35 | 36 | broadcast_live_query_message("test_table") 37 | 38 | assert render(view) =~ "TestLive: notified" 39 | end 40 | 41 | defp broadcast_live_query_message(channel) do 42 | Phoenix.PubSub.broadcast( 43 | LiveQuery.PubSub, 44 | "live_query:#{channel}", 45 | {:live_query_notification, %{table: channel}} 46 | ) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/live_query/notifications.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.Notifications do 2 | @moduledoc """ 3 | Notification mechanism for changes in PostgreSQL database tables. 4 | 5 | Notifications work via the 6 | """ 7 | 8 | require Logger 9 | 10 | defmacro __using__(opts) do 11 | tables = 12 | case opts[:for] do 13 | tables when is_list(tables) -> 14 | tables 15 | 16 | other -> 17 | raise ArgumentError, 18 | ":for expects a list of table names of the from [\"my_table\", \"my_other_table\"]" <> 19 | "got: #{inspect(other)}" 20 | end 21 | 22 | quote bind_quoted: [tables: tables] do 23 | Module.register_attribute(__MODULE__, :live_query_notifications_for, persist: true) 24 | Module.put_attribute(__MODULE__, :live_query_notifications_for, tables) 25 | 26 | on_mount({LiveQuery.Notifications, tables}) 27 | end 28 | end 29 | 30 | def on_mount(tables, _params, _session, socket) when is_list(tables) do 31 | if Phoenix.LiveView.connected?(socket) do 32 | Logger.info("LiveQuery: LiveView #{inspect(self())} is subscribing to #{inspect(tables)}") 33 | receive_notifications_for(tables) 34 | end 35 | 36 | {:cont, socket} 37 | end 38 | 39 | defp receive_notifications_for(tables) do 40 | tables 41 | |> Enum.map(fn table -> 42 | {table, Phoenix.PubSub.subscribe(LiveQuery.PubSub, "live_query:#{table}")} 43 | end) 44 | |> Enum.each(fn result -> 45 | case result do 46 | {table, :ok} -> 47 | {:ok, table} 48 | 49 | {table, {:error, error}} -> 50 | Logger.warn( 51 | "LiveQuery: LiveView #{inspect(self())} failed to subscribe to #{table} with #{inspect(error)}" 52 | ) 53 | end 54 | end) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/mix/tasks/live_query.gen.migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.LiveQuery.Gen.Migration do 2 | @moduledoc """ 3 | Generates a migration that creates all PostgreSQL triggers and trigger functions for all tables that occur in any `use LiveQuery.Notifications, for: [...]` list. 4 | """ 5 | use Mix.Task 6 | 7 | @shortdoc "Generate migration to create PostgreSQL triggers and trigger functions for LiveQuery." 8 | def run(_) do 9 | Mix.Task.run("ecto.gen.migration", ["--change", postgresql_trigger_function()]) 10 | 11 | modules_with_live_query = 12 | modules_with_persistent_module_attribute(:live_query_notifications_for) 13 | 14 | tables_in_live_queries = 15 | modules_with_live_query 16 | |> IO.inspect() 17 | |> Enum.flat_map(fn {_, attribute_values} -> attribute_values end) 18 | |> Enum.uniq() 19 | |> IO.inspect() 20 | end 21 | 22 | defp modules_with_persistent_module_attribute(attribute) when is_atom(attribute) do 23 | # Ensure the current projects code path is loaded 24 | Mix.Task.run("loadpaths", []) 25 | # Fetch all .beam files 26 | Path.wildcard(Path.join([Mix.Project.build_path(), "**/ebin/**/*.beam"])) 27 | # Parse the BEAM for behaviour implementations 28 | |> Stream.map(fn path -> 29 | {:ok, {mod, chunks}} = :beam_lib.chunks('#{path}', [:attributes]) 30 | {mod, get_in(chunks, [:attributes, attribute])} 31 | end) 32 | # Filter modules with given attribute 33 | |> Stream.filter(fn {_mod, attribute_values} -> is_list(attribute_values) end) 34 | |> Enum.into([]) 35 | end 36 | 37 | defp postgresql_trigger_function() do 38 | """ 39 | CREATE OR REPLACE FUNCTION live_query.notify_about_changes_in_table() RETURNS trigger AS $live_query.notify_about_changes_in_table$ 40 | BEGIN 41 | SELECT pg_notify(TG_TABLE_NAME, NULL); 42 | 43 | RETURN NULL; 44 | END; 45 | $live_query.notify_about_changes_in_table$ LANGUAGE plpgsql; 46 | """ 47 | end 48 | 49 | defp postgres_trigger_for_table(table) do 50 | """ 51 | CREATE OR REPLACE TRIGGER live_query__#{table}_changed 52 | AFTER INSERT OR UPDATE OR DELETE ON #{table} 53 | FOR EACH STATEMENT EXECUTE FUNCTION live_query.notify_about_changes_in_table(); 54 | """ 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/live_query/listener.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveQuery.Listener do 2 | use GenServer 3 | 4 | defstruct [:listen_ref, :notifyee_refs] 5 | 6 | ## CLIENT ## 7 | 8 | def start_link(opts) do 9 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 10 | end 11 | 12 | @spec listen_for(list(binary()), pid()) :: :ok 13 | def listen_for(tables, notifyee \\ self()) when is_list(tables) do 14 | GenServer.cast(__MODULE__, {:listen_for, tables: tables, notifyee: notifyee}) 15 | end 16 | 17 | ## CALLBACKS ## 18 | 19 | def init(_opts) do 20 | {:ok, %{}} 21 | end 22 | 23 | @doc """ 24 | Called when a LiveView with `use LiveQuery.Notifications` mounts. 25 | 26 | Adds that LiveView process to the list of notifyees and starts listening for any table that LiveView is the first on to be notified about. 27 | """ 28 | def handle_cast({:listen_for, tables: tables, notifyee: notifyee}, state) do 29 | notifyee_ref = Process.monitor(notifyee) 30 | 31 | state = 32 | tables 33 | |> Enum.map(fn table -> 34 | table_atom = table |> String.to_atom() 35 | 36 | listener = 37 | case state[table_atom] do 38 | %__MODULE__{notifyee_refs: notifyee_refs} -> 39 | %__MODULE__{state[table_atom] | notifyee_refs: [notifyee_ref | notifyee_refs]} 40 | 41 | nil -> 42 | {:ok, listen_ref} = start_listen_for_table(table) 43 | %__MODULE__{listen_ref: listen_ref, notifyee_refs: [notifyee_ref]} 44 | end 45 | 46 | {table_atom, listener} 47 | end) 48 | |> Enum.into(state) 49 | 50 | {:noreply, state} 51 | end 52 | 53 | @doc """ 54 | Called when a LiveView process ends. 55 | 56 | Unlistens from any tables when that LiveView was the last process interested in notifications about those tables. 57 | """ 58 | def handle_info({:DOWN, ref, :process, _object, _reason}, state) do 59 | state = 60 | state 61 | |> Enum.map(fn {table, listener} -> 62 | case listener do 63 | %__MODULE__{listen_ref: listen_ref, notifyee_refs: [^ref]} -> 64 | stop_listen_for_table(listen_ref) 65 | {table, nil} 66 | 67 | %__MODULE__{notifyee_refs: notifyee_refs} -> 68 | %__MODULE__{listener | notifyee_refs: notifyee_refs |> Enum.filter(&(&1 != ref))} 69 | end 70 | end) 71 | |> Enum.into(state) 72 | 73 | {:noreply, state} 74 | end 75 | 76 | @doc """ 77 | Called when any listened channel receives a notification. 78 | 79 | Send from Postgrex.Notifications. 80 | """ 81 | def handle_info({:notification, _connection_pid, _listen_ref, channel, _payload}) do 82 | Phoenix.PubSub.broadcast( 83 | LiveQuery.PubSub, 84 | "live_query:#{channel}", 85 | {:live_query_notification, %{table: channel}} 86 | ) 87 | end 88 | 89 | defp start_listen_for_table(table) do 90 | if table_trigger_exists?(table) do 91 | Postgrex.Notifications.listen(LiveQuery.Notifications, table) 92 | else 93 | # warn or error about missing trigger 94 | end 95 | end 96 | 97 | defp stop_listen_for_table(listen_ref) do 98 | :ok = Postgrex.Notifications.unlisten(LiveQuery.Notifications, listen_ref) 99 | end 100 | 101 | defp table_trigger_exists?(_table) do 102 | true 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LiveQuery 2 | 3 | LiveQuery shall bring the power of GQL-Subscriptions to Phoenix via the combination of the PostgreSQL TRIGGER/NOTIFY/LISTEN features. 4 | 5 | It enables to show the user fresh database state without manual wiring by passing down fresh query results to the UI once they are available. 6 | 7 | ## What options do I know of to build reactive UI in Phoenix? 8 | 9 | Well, Phoenix.LiveView is reactive and re-renders when state on the socket changes. 10 | But it lacks the information if data in the database has changed. 11 | 12 | Phoenix.PubSub enables reacting to even global live state changes. 13 | Still it is not connected to the database layer. 14 | 15 | ## What do we need to do? 16 | 17 | We need to automatically listen to relevant topics in the correct places. 18 | 19 | We need to setup TRIGGER/NOTIFY calls with appropriate topics ande messages. 20 | 21 | ## How do we do it? 22 | 23 | ### Sketch #1 24 | 25 | We could build a dependency graph for all live queries in the application. 26 | We could then hash each query to directly use the query hash at the notification topic. 27 | We could then build the triggers with notifications to each query hash directly. 28 | 29 | This would be a very static, upfront setup. It could not easily be changed after application startup. 30 | This would leverage the build-in notification deduplication for transactions in postgres. 31 | 32 | ### Sketch #2 33 | 34 | We could collect all tables that are part of live queries. 35 | We could then setup triggers for updates on the topic of the table name via migrations. 36 | That would be static setup. 37 | 38 | If every live query would listen to notifications of every table it depends on it is possible that it receives several notifications after transactions are commited. 39 | Either the cost for multiple queries occurs of which only the first is useful (is it costly when is is exactly the same query?) or debouncing/deduplication has to be handled by LiveQuery. 40 | 41 | 42 | ## MVP 43 | 44 | MVP is to state tables a LiveView uses to fetch its data and receive notifications about changes once the data in taht tables was touched. 45 | 46 | Create [TRIGGER](https://www.postgresql.org/docs/current/sql-createtrigger.html)/[NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) and according [trigger function](https://www.postgresql.org/docs/current/plpgsql-trigger.html) via [Ecto.Migration.execute/2](https://hexdocs.pm/ecto_sql/Ecto.Migration.html#execute/2) 47 | 48 | Have a process manage all LISTEN via [Postgrex.Notifications.listen/2](https://hexdocs.pm/postgrex/Postgrex.Notifications.html#listen/3) 49 | 50 | Distribute notifications from PostgreSQL via [Phoenix.PubSub.broadcast/3](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html#broadcast/4) 51 | 52 | PostgreSQL-Channel-Name is table name. 53 | Phoenix.PubSub topic is table name. 54 | 55 | Requires explicitly listing all tables relevant to a LiveView's data. 56 | 57 | ```elixir 58 | use LiveQuery.Notifications, for: ["users", "posts"] 59 | ``` 60 | 61 | Create mix task to generate migration from listed tables. 62 | 63 | Create `LiveQuery.Listener` module to handle subscriptions 64 | 65 | - checks for existence of triggers, errors/warns otherwise 66 | - monitors LiveViews to automatically issue unlisten 67 | - publishes notifications via PubSub 68 | 69 | ### Architecture 70 | 71 | Before runtime: 72 | Mix task => Migration 73 | 74 | At runtime: 75 | +--------------Elixir/Phoenix----------------------------------------------------------------+ 76 | | LiveViews mount +=> ask to register LISTEN => warning/error when table listener not set up | 77 | | | | 78 | | +=> subscribe to PubSub | 79 | | | 80 | +--------------Database--------------+ | | 81 | | tables change => TRIGGER => NOTIFY | => | LISTEN => (debounce) => PubSub => LiveViews get notification | 82 | +------------------------------------+ +--------------------------------------------------------------------------------------------+ 83 | 84 | ### Stuff 85 | 86 | ```shell 87 | mix do live_query.gen.migration, ecto.migrate 88 | ``` 89 | aliased as 90 | ```shell 91 | mix live_query.migrate 92 | ``` 93 | 94 | How to track when new tables are listed in any `tables` list? 95 | 96 | How to rollback outdated triggers? 97 | 98 | How to only create new triggers? 99 | 100 | => DELETE all old triggers in every migration 101 | 102 | Possible solution: Never write out migrations, only generate and apply them at application startup. 103 | 104 | Initial solution: Create all triggers for currently listed tables on every live_query.migrate call. 105 | 106 | How to remind devs to run live_query.migrate when tables have changed? 107 | 108 | => when listen call is issued, check if DB contains corresponding trigger 109 | 110 | 111 | ```elixir 112 | def handle_info({live_query: update}, socket) do 113 | # react to changes in database 114 | 115 | {:noreply, socket} 116 | end 117 | ``` 118 | 119 | ## Questions 120 | 121 | What would it cost to blantly setup TRIGGER/NOTIFY for every database table? 122 | => disregarded 123 | 124 | What is the cost of having many listeners for one NOTIFY call? 125 | => Postgrex.Notifications only sets up one LISTEN per channels, then broadcasts to any listening process 126 | 127 | 128 | ## What do we build on? 129 | 130 | Postgrex.Notifications gives us one part of the puzzle. (LISTEN) 131 | 132 | 133 | ## FollowUp: LiveForm 134 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, 4 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 5 | "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, 7 | "floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"}, 8 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 9 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 10 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 11 | "phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"}, 12 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 13 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.11", "205f6aa5405648c76f2abcd57716f42fc07d8f21dd8ea7b262dd12b324b50c95", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7177791944b7f90ed18f5935a6a5f07f760b36f7b3bdfb9d28c57440a3c43f99"}, 14 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 15 | "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, 16 | "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, 17 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 18 | "postgrex": {:hex, :postgrex, "0.16.4", "26d998467b4a22252285e728a29d341e08403d084e44674784975bb1cd00d2cb", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "3234d1a70cb7b1e0c95d2e242785ec2a7a94a092bbcef4472320b950cfd64c5f"}, 19 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 20 | } 21 | --------------------------------------------------------------------------------