├── config ├── dev.exs ├── prod.exs ├── config.exs └── test.exs ├── test ├── test_helper.exs ├── support │ └── repo.ex └── pow_postgres_store_test.exs ├── .formatter.exs ├── lib ├── templates │ ├── schema.ex.eex │ └── migrations │ │ └── 20200115134156_create_pow_store_table.exs.eex ├── auto_delete_expired.ex ├── mix │ └── gen_schema.ex └── pow_postgres_store.ex ├── .gitignore ├── README.md ├── mix.exs └── mix.lock /config/dev.exs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Pow.Postgres.Repo.start_link() 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Pow.Postgres.Repo do 2 | use Ecto.Repo, otp_app: :pow_postgres_store, adapter: Ecto.Adapters.Postgres 3 | end 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | # Import environment specific config. This must remain at the bottom 3 | # of this file so it overrides the configuration defined above. 4 | import_config "#{Mix.env()}.exs" 5 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :pow_postgres_store, 4 | ecto_repos: [Pow.Postgres.Repo] 5 | 6 | config :pow_postgres_store, Pow.Postgres.Repo, 7 | username: "postgres", 8 | password: "postgres", 9 | database: "pow_postgres_store_test", 10 | hostname: "localhost", 11 | pool: Ecto.Adapters.SQL.Sandbox 12 | -------------------------------------------------------------------------------- /lib/templates/schema.ex.eex: -------------------------------------------------------------------------------- 1 | defmodule <%= @module_prefix %>.<%= @schema_module_name %> do 2 | use Ecto.Schema 3 | 4 | @primary_key false 5 | schema "<%= @schema_name %>" do 6 | field :namespace, :string 7 | field :key, {:array, :binary} 8 | field :original_key, :binary 9 | field :value, :binary 10 | field :expires_at, <%= @datetime_type %> 11 | timestamps(type: <%= @datetime_type %>) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/templates/migrations/20200115134156_create_pow_store_table.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule <%= @module_prefix %>.Repo.Migrations.CreateTablePowStore do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("<%= @schema_name %>", primary_key: false) do 6 | add :namespace, :text 7 | add :key, {:array, :bytea} 8 | add :original_key, :bytea 9 | add :value, :bytea 10 | add :expires_at, <%= @datetime_type %> 11 | timestamps(type: <%= @datetime_type %>) 12 | end 13 | 14 | create unique_index("<%= @schema_name %>", [:namespace, :original_key]) 15 | end 16 | end -------------------------------------------------------------------------------- /.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 | pow_postgres_store-*.tar 24 | 25 | # ignore generated files from mix test 26 | test/support/schema.ex 27 | priv/repo/migrations 28 | 29 | # ignore elixir LS cache 30 | .elixir_ls -------------------------------------------------------------------------------- /lib/auto_delete_expired.ex: -------------------------------------------------------------------------------- 1 | defmodule Pow.Postgres.Store.AutoDeleteExpired do 2 | use GenServer 3 | require Logger 4 | 5 | @moduledoc """ 6 | When started, this server will regularly clean up the table from expired records. 7 | 8 | It is not necessary to do this very often, since expired records won't be returned 9 | by get/2 or all/2. But it will keep the table size smaller. 10 | """ 11 | 12 | @doc """ 13 | Starts the server. 14 | 15 | **Options**: 16 | 17 | * `interval` - interval in milliseconds how often expired records will be cleaned from the database. Defaults to 1 hour. 18 | """ 19 | def start_link(opts \\ []) do 20 | GenServer.start_link(__MODULE__, opts) 21 | end 22 | 23 | def init(opts) do 24 | interval = Keyword.get(opts, :interval, :timer.hours(1)) 25 | :timer.send_interval(interval, :delete) 26 | {:ok, []} 27 | end 28 | 29 | def handle_info(:delete, state) do 30 | Logger.debug("deleting expired records from pow store") 31 | Pow.Postgres.Store.delete_expired() 32 | {:noreply, state} 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PowPostgresStore 2 | 3 | Implements Pow's `Pow.Backend.Store.Base` behaviour using a Postgres table. 4 | 5 | ## Installation 6 | 7 | First, add the dependency: 8 | 9 | ```elixir 10 | def deps do 11 | [ 12 | {:pow_postgres_store, "~> 1.0"} 13 | # or 14 | {:pow_postgres_store, github: "ZennerIoT/pow_postgres_store} 15 | ] 16 | end 17 | ``` 18 | 19 | Run: 20 | 21 | ```sh 22 | $ mix pow.postgres.gen.schema lib/my_app/util/pow_store.ex 23 | ``` 24 | 25 | to generate the ecto schema as well as the necessary migrations. 26 | 27 | Tell `pow_postgres_store` where to find the ecto repository: 28 | 29 | ```elixir 30 | config :pow, Pow.Postgres.Store, 31 | repo: MyApp.Repo 32 | # schema: Pow.Postgres.Schema 33 | # you can use a different name for the schema if you've modified the generated file 34 | ``` 35 | 36 | and tell `pow` to use this library as the store: 37 | 38 | ```elixir 39 | config :my_app, Pow, 40 | cache_store_backend: Pow.Postgres.Store 41 | ``` 42 | 43 | To automatically delete expired records from the database table, add this somewhere in your supervision tree: 44 | 45 | ```elixir 46 | children = [ 47 | #... 48 | {Pow.Postgres.Store.AutoDeleteExpired, [interval: :timer.hours(1)]}, 49 | #... 50 | ] 51 | ``` 52 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PowPostgresStore.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :pow_postgres_store, 7 | version: "1.0.0", 8 | elixir: "~> 1.9", 9 | start_permanent: Mix.env() == :prod, 10 | elixirc_paths: elixirc_paths(Mix.env), 11 | deps: deps(), 12 | package: package(), 13 | description: "Postgres-backed cache backend for pow", 14 | aliases: [ 15 | test: [ 16 | # generate the schema to test if schema generation works 17 | "pow.postgres.gen.schema --overwrite test/support/schema.ex", 18 | "ecto.drop", 19 | "ecto.create", 20 | "ecto.migrate", 21 | # finally, call the tests 22 | "test" 23 | ] 24 | ] 25 | ] 26 | end 27 | 28 | # Run "mix help compile.app" to learn about applications. 29 | def application do 30 | [ 31 | extra_applications: [:logger] 32 | ] 33 | end 34 | 35 | # Run "mix help deps" to learn about dependencies. 36 | defp deps do 37 | [ 38 | {:pow, ">= 1.0.0"}, 39 | {:ecto_sql, ">= 3.0.0"}, 40 | {:postgrex, "~> 0.15.3", only: :test}, 41 | {:ex_doc, "> 0.0.0", only: :dev, runtime: false} 42 | ] 43 | end 44 | 45 | defp elixirc_paths(:test), do: ["lib","test/support"] 46 | defp elixirc_paths(_), do: ["lib"] 47 | 48 | defp package() do 49 | [ 50 | licenses: ["MIT"], 51 | links: %{ 52 | "GitHub" => "https://github.com/ZennerIoT/pow_postgres_store" 53 | }, 54 | ] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/pow_postgres_store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pow.Postgres.StoreTest do 2 | use ExUnit.Case 3 | alias Pow.Postgres.Store 4 | 5 | test "inserts records" do 6 | config = [] 7 | assert :ok = Store.put(config, {"abc", 13}) 8 | assert :ok = Store.put(config, [ 9 | {[:numbers, 1], 1}, 10 | {[:numbers, 2], 2}, 11 | {[:numbers, 3], 3}, 12 | ]) 13 | 14 | assert 13 = Store.get(config, "abc") 15 | records = Store.all(config, [:numbers, :_]) 16 | assert 3 = length(records) 17 | assert 2 = Store.get(config, [:numbers, 2]) 18 | end 19 | 20 | test "deletes records" do 21 | config = [] 22 | assert :ok = Store.put(config, {"def", 20}) 23 | assert :ok = Store.delete(config, "def") 24 | assert :not_found = Store.get(config, "def") 25 | end 26 | 27 | test "overwrites existing records" do 28 | config = [] 29 | assert :ok = Store.put(config, {"overwrite", 20}) 30 | assert 20 = Store.get(config, "overwrite") 31 | assert :ok = Store.put(config, {"overwrite", :abc}) 32 | assert :abc = Store.get(config, "overwrite") 33 | end 34 | 35 | test "records expire if ttl option is set" do 36 | config = [ttl: -5000] # negative ttl will immediately expire 37 | assert :ok = Store.put(config, {"key", 10}) 38 | assert :not_found = Store.get(config, "key") 39 | config = [ttl: 50_000] 40 | assert :ok = Store.put(config, {"key", 20}) 41 | assert 20 = Store.get(config, "key") 42 | end 43 | 44 | test "integer keys" do 45 | config = [] 46 | assert :ok = Store.put(config, {["users", 20, "struct"], %{name: "boo"}}) 47 | assert %{name: "boo"} = Store.get(config, ["users", 20, "struct"]) 48 | 49 | result = Store.all(config, ["users", 20, :_]) 50 | assert [{_, %{name: "boo"}}] = result 51 | 52 | end 53 | 54 | test "deletes only expired records" do 55 | assert :ok = Store.put([ttl: -5000], {:expired, 1}) 56 | assert :ok = Store.put([ttl: 5000], {:existing, 1}) 57 | assert :ok = Store.delete_expired() 58 | assert 1 = Store.get([], :existing) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/mix/gen_schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Pow.Postgres.Gen.Schema do 2 | use Mix.Task 3 | 4 | @flags [ 5 | datetime_type: :string, 6 | schema_name: :string, 7 | schema_module_name: :string, 8 | module_prefix: :string, 9 | overwrite: :boolean 10 | ] 11 | 12 | @defaults [ 13 | datetime_type: ":utc_datetime", 14 | schema_name: "pow_store", 15 | schema_module_name: "Schema", 16 | module_prefix: "Pow.Postgres", 17 | overwrite: false 18 | ] 19 | 20 | require EEx 21 | template_file = Path.join([__DIR__, "../templates/schema.ex.eex"]) 22 | EEx.function_from_file(:defp, :render_schema, template_file, [:assigns]) 23 | 24 | migrations = "../templates/migrations/" 25 | 26 | @migrations Path.join([__DIR__, migrations]) |> File.ls!() |> Enum.map(fn file -> %{ 27 | source: Path.join([__DIR__, migrations, file]), 28 | target: "priv/repo/migrations/" <> String.replace_trailing(file, ".eex", ""), 29 | fun_name: ("render_migration_" <> String.replace_trailing(file, ".exs.eex", "")) |> String.to_atom() 30 | } end) 31 | 32 | for migration <- @migrations do 33 | EEx.function_from_file(:def, migration.fun_name, migration.source, [:assigns]) 34 | end 35 | 36 | def run(args) do 37 | {options, [filename], _errors} = OptionParser.parse(args, strict: @flags) 38 | assigns = 39 | Keyword.merge(@defaults, options) 40 | |> Keyword.take([:datetime_type, :schema_name, :schema_module_name, :module_prefix]) 41 | |> Enum.into(%{}) 42 | 43 | create_schema_file(assigns, filename, options) 44 | 45 | create_migrations(assigns, options) 46 | end 47 | 48 | def create_schema_file(assigns, filename, options) do 49 | code = render_schema(assigns) 50 | write(filename, code, options) 51 | end 52 | 53 | def create_migrations(assigns, options) do 54 | File.mkdir_p!("priv/repo/migrations") 55 | for migration <- @migrations do 56 | code = apply(__MODULE__, migration.fun_name, [assigns]) 57 | write(migration.target, code, options) 58 | end 59 | end 60 | 61 | def write(filename, code, opts) do 62 | if not File.exists?(filename) or Keyword.get(opts, :overwrite, false) do 63 | File.write!(filename, code) 64 | IO.puts [ 65 | IO.ANSI.green(), 66 | " * Generated ", 67 | IO.ANSI.reset(), 68 | filename 69 | ] 70 | else 71 | IO.puts [ 72 | IO.ANSI.red(), 73 | " * Failed to generate ", 74 | IO.ANSI.reset(), 75 | filename, 76 | IO.ANSI.red(), 77 | " - this file already exists. Pass ", 78 | IO.ANSI.reset(), 79 | "--overwrite", 80 | IO.ANSI.red(), 81 | " to generate this file anyway.", 82 | IO.ANSI.reset() 83 | ] 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/pow_postgres_store.ex: -------------------------------------------------------------------------------- 1 | defmodule Pow.Postgres.Store do 2 | @behaviour Pow.Store.Backend.Base 3 | 4 | alias Pow.Config 5 | import Ecto.Query 6 | 7 | @type key() :: [any()] | binary() 8 | @type record() :: {key(), any()} 9 | @type key_match() :: [atom() | binary()] 10 | 11 | @spec put(Config.t(), record() | [record()]) :: :ok 12 | def put(config, record) do 13 | schema = schema() 14 | namespace = namespace(config) 15 | now = DateTime.utc_now() |> DateTime.truncate(:second) 16 | expires_at = case Config.get(config, :ttl) do 17 | nil -> 18 | nil 19 | 20 | ttl when is_integer(ttl) -> 21 | now 22 | |> DateTime.add(ttl, :millisecond) 23 | |> DateTime.truncate(:second) 24 | end 25 | 26 | records = 27 | List.wrap(record) 28 | |> Enum.map(fn {original_key, value} -> 29 | key = List.wrap(original_key) 30 | [ 31 | namespace: namespace, 32 | key: Enum.map(key, &:erlang.term_to_binary/1), 33 | original_key: :erlang.term_to_binary(original_key), 34 | value: :erlang.term_to_binary(value), 35 | expires_at: expires_at, 36 | inserted_at: now, 37 | updated_at: now, 38 | ] 39 | end) 40 | 41 | case repo().insert_all( 42 | schema, 43 | records, 44 | on_conflict: {:replace, [:value, :expires_at, :updated_at]}, 45 | conflict_target: [:namespace, :original_key] 46 | ) do 47 | {_rows, _entries} -> :ok 48 | end 49 | end 50 | 51 | @spec delete(Config.t(), key()) :: :ok 52 | def delete(config, key) do 53 | query = 54 | schema() 55 | |> filter_key(key) 56 | |> filter_namespace(config) 57 | 58 | case repo().delete_all(query) do 59 | {_rows, _} -> 60 | :ok 61 | end 62 | end 63 | 64 | @spec delete_expired() :: :ok 65 | def delete_expired() do 66 | query = 67 | schema() 68 | |> filter_expired() 69 | 70 | case repo().delete_all(query) do 71 | {_rows, _} -> 72 | :ok 73 | end 74 | end 75 | 76 | @spec get(Config.t(), key()) :: any() | :not_found 77 | def get(config, key) do 78 | query = 79 | schema() 80 | |> filter_key(key) 81 | |> filter_namespace(config) 82 | |> reject_expired() 83 | |> select_value() 84 | 85 | case repo().one(query) do 86 | nil -> 87 | :not_found 88 | 89 | value -> 90 | decode_value(value) 91 | end 92 | end 93 | 94 | @spec all(Config.t(), key_match()) :: [record()] 95 | def all(config, key_match) do 96 | query = 97 | schema() 98 | |> filter_key_match(key_match) 99 | |> filter_namespace(config) 100 | |> reject_expired() 101 | |> select_record() 102 | 103 | repo().all(query) 104 | |> Enum.map(&decode_record/1) 105 | end 106 | 107 | defp namespace(config) do 108 | Config.get(config, :namespace, "cache") 109 | end 110 | 111 | defp config() do 112 | Application.get_env(:pow, __MODULE__, []) 113 | end 114 | 115 | defp repo() do 116 | Keyword.get(config(), :repo, Pow.Postgres.Repo) 117 | end 118 | 119 | defp schema() do 120 | Keyword.get(config(), :schema, Pow.Postgres.Schema) 121 | end 122 | 123 | def filter_namespace(query, config) do 124 | where(query, [s], s.namespace == ^namespace(config)) 125 | end 126 | 127 | def select_record(query) do 128 | select(query, [s], {s.original_key, s.value}) 129 | end 130 | 131 | def select_value(query) do 132 | select(query, [s], s.value) 133 | end 134 | 135 | def filter_key_match(query, key_match) do 136 | query = where(query, [s], fragment("array_length(?, 1) = ?", s.key, ^length(key_match))) 137 | 138 | key_match 139 | |> Enum.with_index(1) # postgres index begins at 1 140 | |> Enum.reduce(query, fn {match, index}, query -> 141 | case match do 142 | :_ -> 143 | query 144 | 145 | key -> 146 | from s in query, where: fragment("?[?] = ?", s.key, ^index, ^:erlang.term_to_binary(key)) 147 | end 148 | end) 149 | end 150 | 151 | def filter_key(query, key) do 152 | where(query, [s], s.original_key == ^:erlang.term_to_binary(key)) 153 | end 154 | 155 | def reject_expired(query) do 156 | where(query, [s], is_nil(s.expires_at) or s.expires_at > ^DateTime.utc_now()) 157 | end 158 | 159 | def filter_expired(query) do 160 | where(query, [s], not is_nil(s.expires_at) and s.expires_at <= ^DateTime.utc_now()) 161 | end 162 | 163 | def decode_record({key, value}) do 164 | { 165 | :erlang.binary_to_term(key), 166 | :erlang.binary_to_term(value) 167 | } 168 | end 169 | 170 | def decode_value(value) do 171 | :erlang.binary_to_term(value) 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 3 | "db_connection": {:hex, :db_connection, "2.2.0", "e923e88887cd60f9891fd324ac5e0290954511d090553c415fbf54be4c57ee63", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "bdf196feedfa6b83071e808b2b086fb113f8a1c4c7761f6eff6fe4b96aba0086"}, 4 | "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, 5 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 7 | "ecto": {:hex, :ecto, "3.3.1", "82ab74298065bf0c64ca299f6c6785e68ea5d6b980883ee80b044499df35aba1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "e6c614dfe3bcff2d575ce16d815dbd43f4ee1844599a83de1eea81976a31c174"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.3.2", "92804e0de69bb63e621273c3492252cb08a29475c05d40eeb6f41ad2d483cfd3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b82d89d4e6a9f7f7f04783b07e8b0af968e0be2f01ee4b39047fe727c5c07471"}, 9 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 10 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 13 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 15 | "phoenix": {:hex, :phoenix, "1.4.11", "d112c862f6959f98e6e915c3b76c7a87ca3efd075850c8daa7c3c7a609014b0d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef19d737ca23b66f7333eaa873cbfc5e6fa6427ef5a0ffd358de1ba8e1a4b2f4"}, 16 | "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8b01b3d6d39731ab18aa548d928b5796166d2500755f553725cfe967bafba7d9"}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, 18 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "164baaeb382d19beee0ec484492aa82a9c8685770aee33b24ec727a0971b34d0"}, 19 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, 20 | "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, 21 | "pow": {:hex, :pow, "1.0.16", "f4d3de2f423962f08740b80d57fee193fa1f4ed2bbe3e94ba4355cb066314d70", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3.0 or ~> 1.4.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and <= 3.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "95d5c57a4037b2ec893ee1bc63eeb303b28a4b5447c5321d73bae48f240bd1a1"}, 22 | "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, 23 | } 24 | --------------------------------------------------------------------------------