├── 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 |
--------------------------------------------------------------------------------