Elixir ATProtocol ingestion and sync library.

feat: add Tap integration

ovyerus.com 7aa87458 c2deb557

verified
+790 -31
+2 -18
.gitignore
··· 1 - # The directory Mix will write compiled artifacts to. 2 1 /_build/ 3 - 4 - # If you run "mix test --cover", coverage assets end up here. 5 2 /cover/ 6 - 7 - # The directory Mix downloads your dependencies sources to. 8 3 /deps/ 9 - 10 - # Where third-party dependencies like ExDoc output generated docs. 11 4 /doc/ 12 - 13 - # If the VM crashes, it generates a dump, let's ignore it too. 14 5 erl_crash.dump 15 - 16 - # Also ignore archive artifacts (built via "mix archive.build"). 17 6 *.ez 18 - 19 - # Ignore package tarball (built via "mix hex.build"). 20 7 drinkup-*.tar 21 - 22 - # Temporary files, for example, from tests. 23 8 /tmp/ 24 - 25 - # Nix 26 9 .envrc 27 10 .direnv 28 - result 11 + result 12 + priv/dets/
+6
CHANGELOG.md
··· 13 13 - Existing behaviour moved to `Drinkup.Firehose` namespace, to make way for 14 14 alternate sync systems. 15 15 16 + ### Added 17 + 18 + - Support for the 19 + [Tap](https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md) 20 + sync and backfill utility service, via `Drinkup.Tap`. 21 + 16 22 ### Changed 17 23 18 24 - Refactor core connection logic for websockets into `Drinkup.Socket` to make it
+14
compose.yml
··· 1 + services: 2 + tap: 3 + image: "ghcr.io/bluesky-social/indigo/tap" 4 + restart: "unless-stopped" 5 + ports: 6 + - "127.0.0.1:2480:2480" 7 + volumes: 8 + - "tap_data:/data" 9 + environment: 10 + TAP_SIGNAL_COLLECTION: "sh.weaver.actor.profile" 11 + TAP_COLLECTION_FILTERS: "sh.weaver.*" 12 + 13 + volumes: 14 + tap_data:
+33
examples/tap_consumer.ex
··· 1 + defmodule TapConsumer do 2 + @behaviour Drinkup.Tap.Consumer 3 + 4 + def handle_event(%Drinkup.Tap.Event.Record{} = record) do 5 + IO.inspect(record, label: "Tap record event") 6 + end 7 + 8 + def handle_event(%Drinkup.Tap.Event.Identity{} = identity) do 9 + IO.inspect(identity, label: "Tap identity event") 10 + end 11 + end 12 + 13 + defmodule TapExampleSupervisor do 14 + use Supervisor 15 + 16 + def start_link(arg \\ []) do 17 + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 18 + end 19 + 20 + @impl true 21 + def init(_) do 22 + children = [ 23 + {Drinkup.Tap, 24 + %{ 25 + consumer: TapConsumer, 26 + name: MyTap, 27 + host: "http://localhost:2480" 28 + }} 29 + ] 30 + 31 + Supervisor.init(children, strategy: :one_for_one) 32 + end 33 + end
+1 -1
lib/firehose/socket.ex
··· 39 39 end 40 40 41 41 @impl true 42 - def handle_frame({:binary, frame}, %{seq: seq, options: options} = data) do 42 + def handle_frame({:binary, frame}, {%{seq: seq, options: options} = data, _conn, _stream}) do 43 43 with {:ok, header, next} <- CAR.DagCbor.decode(frame), 44 44 {:ok, payload, _} <- CAR.DagCbor.decode(next), 45 45 {%{"op" => @op_regular, "t" => type}, _} <- {header, payload},
+23 -11
lib/socket.ex
··· 32 32 33 33 @callback build_path(data :: user_data()) :: String.t() 34 34 35 - @callback handle_frame(frame :: frame(), data :: user_data()) :: 35 + @callback handle_frame( 36 + frame :: frame(), 37 + data :: {user_data(), conn :: pid() | nil, stream :: :gun.stream_ref() | nil} 38 + ) :: 36 39 {:ok, new_data :: user_data()} | :noop | nil | {:error, reason :: term()} 37 40 38 - @callback handle_connected(data :: user_data()) :: {:ok, new_data :: user_data()} 41 + @callback handle_connected(data :: {user_data(), conn :: pid(), stream :: :gun.stream_ref()}) :: 42 + {:ok, new_data :: user_data()} 39 43 40 - @callback handle_disconnected(reason :: term(), data :: user_data()) :: 44 + @callback handle_disconnected( 45 + reason :: term(), 46 + data :: {user_data(), conn :: pid() | nil, stream :: :gun.stream_ref() | nil} 47 + ) :: 41 48 {:ok, new_data :: user_data()} 42 49 43 50 @optional_callbacks handle_connected: 1, handle_disconnected: 2 ··· 76 83 defoverridable child_spec: 1 77 84 78 85 @impl true 79 - def handle_connected(data), do: {:ok, data} 86 + def handle_connected({user_data, _conn, _stream}), do: {:ok, user_data} 80 87 81 88 @impl true 82 - def handle_disconnected(_reason, data), do: {:ok, data} 89 + def handle_disconnected(_reason, {user_data, _conn, _stream}), do: {:ok, user_data} 83 90 84 91 defoverridable handle_connected: 1, handle_disconnected: 2 85 92 end ··· 211 218 212 219 # :connected state - active WebSocket connection 213 220 214 - def connected(:enter, _from, %{module: module, user_data: user_data} = data) do 221 + def connected( 222 + :enter, 223 + _from, 224 + %{module: module, user_data: user_data, conn: conn, stream: stream} = data 225 + ) do 215 226 Logger.debug("[Drinkup.Socket] WebSocket connected") 216 227 217 - case module.handle_connected(user_data) do 228 + case module.handle_connected({user_data, conn, stream}) do 218 229 {:ok, new_user_data} -> 219 230 {:keep_state, %{data | user_data: new_user_data, reconnect_attempts: 0}} 220 231 ··· 226 237 def connected( 227 238 :info, 228 239 {:gun_ws, conn, _stream, frame}, 229 - %{module: module, user_data: user_data, options: options} = data 240 + %{module: module, user_data: user_data, options: options, conn: conn, stream: stream} = 241 + data 230 242 ) do 231 - result = module.handle_frame(frame, user_data) 243 + result = module.handle_frame(frame, {user_data, conn, stream}) 232 244 233 245 :ok = :gun.update_flow(conn, frame, options.flow) 234 246 ··· 286 298 # Helper functions 287 299 288 300 defp trigger_reconnect(data, reason \\ :unknown) do 289 - %{module: module, user_data: user_data} = data 301 + %{module: module, user_data: user_data, conn: conn, stream: stream} = data 290 302 291 - case module.handle_disconnected(reason, user_data) do 303 + case module.handle_disconnected(reason, {user_data, conn, stream}) do 292 304 {:ok, new_user_data} -> 293 305 {:keep_state, %{data | user_data: new_user_data}, [{:next_event, :internal, :reconnect}]} 294 306
+249
lib/tap.ex
··· 1 + defmodule Drinkup.Tap do 2 + @moduledoc """ 3 + Supervisor and HTTP API for Tap indexer/backfill service. 4 + 5 + Tap simplifies AT sync by handling the firehose connection, verification, 6 + backfill, and filtering. Your application connects to a Tap service and 7 + receives simple JSON events for only the repos and collections you care about. 8 + 9 + ## Usage 10 + 11 + Add Tap to your supervision tree: 12 + 13 + children = [ 14 + {Drinkup.Tap, %{ 15 + consumer: MyTapConsumer, 16 + name: MyTap, 17 + host: "http://localhost:2480", 18 + admin_password: "secret" # optional 19 + }} 20 + ] 21 + 22 + Then interact with the Tap HTTP API: 23 + 24 + # Add repos to track (triggers backfill) 25 + Drinkup.Tap.add_repos(MyTap, ["did:plc:abc123"]) 26 + 27 + # Get stats 28 + {:ok, count} = Drinkup.Tap.get_repo_count(MyTap) 29 + 30 + ## Configuration 31 + 32 + Tap itself is configured via environment variables. See the Tap documentation 33 + for details on configuring collection filters, signal collections, and other 34 + operational settings: 35 + https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md 36 + """ 37 + 38 + use Supervisor 39 + alias Drinkup.Tap.Options 40 + 41 + @dialyzer nowarn_function: {:init, 1} 42 + @impl true 43 + def init({%Options{name: name} = drinkup_options, supervisor_options}) do 44 + # Register options in Registry for HTTP API access 45 + Registry.register(Drinkup.Registry, {name, TapOptions}, drinkup_options) 46 + 47 + children = [ 48 + {Task.Supervisor, name: {:via, Registry, {Drinkup.Registry, {name, TapTasks}}}}, 49 + {Drinkup.Tap.Socket, drinkup_options} 50 + ] 51 + 52 + Supervisor.start_link( 53 + children, 54 + supervisor_options ++ [name: {:via, Registry, {Drinkup.Registry, {name, TapSupervisor}}}] 55 + ) 56 + end 57 + 58 + @spec child_spec(Options.options()) :: Supervisor.child_spec() 59 + def child_spec(%{} = options), do: child_spec({options, [strategy: :one_for_one]}) 60 + 61 + @spec child_spec({Options.options(), Keyword.t()}) :: Supervisor.child_spec() 62 + def child_spec({drinkup_options, supervisor_options}) do 63 + %{ 64 + id: Map.get(drinkup_options, :name, __MODULE__), 65 + start: {__MODULE__, :init, [{Options.from(drinkup_options), supervisor_options}]}, 66 + type: :supervisor, 67 + restart: :permanent, 68 + shutdown: 500 69 + } 70 + end 71 + 72 + # HTTP API Functions 73 + 74 + @doc """ 75 + Add DIDs to track. 76 + 77 + Triggers backfill for the specified DIDs. Historical events will be fetched 78 + from each repo's PDS, followed by live events from the firehose. 79 + """ 80 + @spec add_repos(atom(), [String.t()]) :: {:ok, term()} | {:error, term()} 81 + def add_repos(name \\ Drinkup.Tap, dids) when is_list(dids) do 82 + with {:ok, options} <- get_options(name), 83 + {:ok, response} <- make_request(options, :post, "/repos/add", %{dids: dids}) do 84 + {:ok, response} 85 + end 86 + end 87 + 88 + @doc """ 89 + Remove DIDs from tracking. 90 + 91 + Stops syncing the specified repos and deletes tracked repo metadata. Does not 92 + delete buffered events in the outbox. 93 + """ 94 + @spec remove_repos(atom(), [String.t()]) :: {:ok, term()} | {:error, term()} 95 + def remove_repos(name \\ Drinkup.Tap, dids) when is_list(dids) do 96 + with {:ok, options} <- get_options(name), 97 + {:ok, response} <- make_request(options, :post, "/repos/remove", %{dids: dids}) do 98 + {:ok, response} 99 + end 100 + end 101 + 102 + @doc """ 103 + Resolve a DID to its DID document. 104 + """ 105 + @spec resolve_did(atom(), String.t()) :: {:ok, term()} | {:error, term()} 106 + def resolve_did(name \\ Drinkup.Tap, did) when is_binary(did) do 107 + with {:ok, options} <- get_options(name), 108 + {:ok, response} <- make_request(options, :get, "/resolve/#{did}") do 109 + {:ok, response} 110 + end 111 + end 112 + 113 + @doc """ 114 + Get info about a tracked repo. 115 + 116 + Returns repo state, repo rev, record count, error info, and retry count. 117 + """ 118 + @spec get_repo_info(atom(), String.t()) :: {:ok, term()} | {:error, term()} 119 + def get_repo_info(name \\ Drinkup.Tap, did) when is_binary(did) do 120 + with {:ok, options} <- get_options(name), 121 + {:ok, response} <- make_request(options, :get, "/info/#{did}") do 122 + {:ok, response} 123 + end 124 + end 125 + 126 + @doc """ 127 + Get the total number of tracked repos. 128 + """ 129 + @spec get_repo_count(atom()) :: {:ok, integer()} | {:error, term()} 130 + def get_repo_count(name \\ Drinkup.Tap) do 131 + with {:ok, options} <- get_options(name), 132 + {:ok, response} <- make_request(options, :get, "/stats/repo-count") do 133 + {:ok, response} 134 + end 135 + end 136 + 137 + @doc """ 138 + Get the total number of tracked records. 139 + """ 140 + @spec get_record_count(atom()) :: {:ok, integer()} | {:error, term()} 141 + def get_record_count(name \\ Drinkup.Tap) do 142 + with {:ok, options} <- get_options(name), 143 + {:ok, response} <- make_request(options, :get, "/stats/record-count") do 144 + {:ok, response} 145 + end 146 + end 147 + 148 + @doc """ 149 + Get the number of events in the outbox buffer. 150 + """ 151 + @spec get_outbox_buffer(atom()) :: {:ok, integer()} | {:error, term()} 152 + def get_outbox_buffer(name \\ Drinkup.Tap) do 153 + with {:ok, options} <- get_options(name), 154 + {:ok, response} <- make_request(options, :get, "/stats/outbox-buffer") do 155 + {:ok, response} 156 + end 157 + end 158 + 159 + @doc """ 160 + Get the number of events in the resync buffer. 161 + """ 162 + @spec get_resync_buffer(atom()) :: {:ok, integer()} | {:error, term()} 163 + def get_resync_buffer(name \\ Drinkup.Tap) do 164 + with {:ok, options} <- get_options(name), 165 + {:ok, response} <- make_request(options, :get, "/stats/resync-buffer") do 166 + {:ok, response} 167 + end 168 + end 169 + 170 + @doc """ 171 + Get current firehose and list repos cursors. 172 + """ 173 + @spec get_cursors(atom()) :: {:ok, map()} | {:error, term()} 174 + def get_cursors(name \\ Drinkup.Tap) do 175 + with {:ok, options} <- get_options(name), 176 + {:ok, response} <- make_request(options, :get, "/stats/cursors") do 177 + {:ok, response} 178 + end 179 + end 180 + 181 + @doc """ 182 + Check Tap health status. 183 + 184 + Returns `{:ok, %{"status" => "ok"}}` if healthy. 185 + """ 186 + @spec health(atom()) :: {:ok, map()} | {:error, term()} 187 + def health(name \\ Drinkup.Tap) do 188 + with {:ok, options} <- get_options(name), 189 + {:ok, response} <- make_request(options, :get, "/health") do 190 + {:ok, response} 191 + end 192 + end 193 + 194 + # Private Functions 195 + 196 + @spec get_options(atom()) :: {:ok, Options.t()} | {:error, :not_found} 197 + defp get_options(name) do 198 + case Registry.lookup(Drinkup.Registry, {name, TapOptions}) do 199 + [{_pid, options}] -> {:ok, options} 200 + [] -> {:error, :not_found} 201 + end 202 + end 203 + 204 + @spec make_request(Options.t(), atom(), String.t(), map() | nil) :: 205 + {:ok, term()} | {:error, term()} 206 + defp make_request(options, method, path, body \\ nil) do 207 + url = build_url(options.host, path) 208 + headers = build_headers(options.admin_password) 209 + 210 + request_opts = [ 211 + method: method, 212 + url: url, 213 + headers: headers 214 + ] 215 + 216 + request_opts = 217 + if body do 218 + Keyword.merge(request_opts, json: body) 219 + else 220 + request_opts 221 + end 222 + 223 + case Req.request(request_opts) do 224 + {:ok, %{status: status, body: body}} when status in 200..299 -> 225 + {:ok, body} 226 + 227 + {:ok, %{status: status, body: body}} -> 228 + {:error, {:http_error, status, body}} 229 + 230 + {:error, reason} -> 231 + {:error, reason} 232 + end 233 + end 234 + 235 + @spec build_url(String.t(), String.t()) :: String.t() 236 + defp build_url(host, path) do 237 + host = String.trim_trailing(host, "/") 238 + "#{host}#{path}" 239 + end 240 + 241 + @spec build_headers(String.t() | nil) :: list() 242 + defp build_headers(nil), do: [] 243 + 244 + defp build_headers(admin_password) do 245 + credentials = "admin:#{admin_password}" 246 + auth_header = "Basic #{Base.encode64(credentials)}" 247 + [{"authorization", auth_header}] 248 + end 249 + end
+46
lib/tap/consumer.ex
··· 1 + defmodule Drinkup.Tap.Consumer do 2 + @moduledoc """ 3 + Consumer behaviour for handling Tap events. 4 + 5 + Implement this behaviour to process events from a Tap indexer/backfill service. 6 + Events are dispatched asynchronously via `Task.Supervisor` and acknowledged 7 + to Tap based on the return value of `handle_event/1`. 8 + 9 + ## Event Acknowledgment 10 + 11 + By default, events are acknowledged to Tap based on your return value: 12 + 13 + - `:ok`, `{:ok, any()}`, or `nil` → Success, event is acked to Tap 14 + - `{:error, reason}` → Failure, event is NOT acked (Tap will retry after timeout) 15 + - Exception raised → Failure, event is NOT acked (Tap will retry after timeout) 16 + 17 + Any other value will log a warning and acknowledge the event anyway. 18 + 19 + If you set `disable_acks: true` in your Tap options, no acks are sent regardless 20 + of the return value. This matches Tap's `TAP_DISABLE_ACKS` environment variable. 21 + 22 + ## Example 23 + 24 + defmodule MyTapConsumer do 25 + @behaviour Drinkup.Tap.Consumer 26 + 27 + def handle_event(%Drinkup.Tap.Event.Record{action: :create} = record) do 28 + # Handle new record creation 29 + case save_to_database(record) do 30 + :ok -> :ok # Success - event will be acked 31 + {:error, reason} -> {:error, reason} # Failure - Tap will retry 32 + end 33 + end 34 + 35 + def handle_event(%Drinkup.Tap.Event.Identity{} = identity) do 36 + # Handle identity changes 37 + update_identity(identity) 38 + :ok # Success - event will be acked 39 + end 40 + end 41 + """ 42 + 43 + alias Drinkup.Tap.Event 44 + 45 + @callback handle_event(Event.Record.t() | Event.Identity.t()) :: any() 46 + end
+105
lib/tap/event.ex
··· 1 + defmodule Drinkup.Tap.Event do 2 + @moduledoc """ 3 + Event handling and dispatch for Tap events. 4 + 5 + Parses incoming JSON events from Tap and dispatches them to the configured 6 + consumer via Task.Supervisor. After successful processing, sends an ack 7 + message back to the socket. 8 + """ 9 + 10 + require Logger 11 + alias Drinkup.Tap.{Event, Options} 12 + 13 + @type t() :: Event.Record.t() | Event.Identity.t() 14 + 15 + @doc """ 16 + Parse a JSON map into an event struct. 17 + 18 + Returns the appropriate event struct based on the "type" field. 19 + """ 20 + @spec from(map()) :: t() | nil 21 + def from(%{"type" => "record"} = payload), do: Event.Record.from(payload) 22 + def from(%{"type" => "identity"} = payload), do: Event.Identity.from(payload) 23 + def from(_payload), do: nil 24 + 25 + @doc """ 26 + Dispatch an event to the consumer via Task.Supervisor. 27 + 28 + Spawns a task that: 29 + 1. Processes the event via the consumer's handle_event/1 callback 30 + 2. Sends an ack to Tap if acks are enabled and the consumer returns :ok, {:ok, _}, or nil 31 + 3. Does not ack if the consumer returns an error-like value or raises an exception 32 + 33 + Consumer return value semantics (when acks are enabled): 34 + - `:ok` or `{:ok, any()}` or `nil` -> Success, send ack 35 + - `{:error, _}` or any error-like tuple -> Failure, don't ack (Tap will retry) 36 + - Exception raised -> Failure, don't ack (Tap will retry) 37 + 38 + If `disable_acks: true` is set in options, no acks are sent regardless of 39 + consumer return value. 40 + """ 41 + @spec dispatch(t(), Options.t(), pid(), :gun.stream_ref()) :: :ok 42 + def dispatch( 43 + event, 44 + %Options{consumer: consumer, name: name, disable_acks: disable_acks}, 45 + conn, 46 + stream 47 + ) do 48 + supervisor_name = {:via, Registry, {Drinkup.Registry, {name, TapTasks}}} 49 + event_id = get_event_id(event) 50 + 51 + {:ok, _pid} = 52 + Task.Supervisor.start_child(supervisor_name, fn -> 53 + try do 54 + result = consumer.handle_event(event) 55 + 56 + unless disable_acks do 57 + case result do 58 + :ok -> 59 + send_ack(conn, stream, event_id) 60 + 61 + {:ok, _} -> 62 + send_ack(conn, stream, event_id) 63 + 64 + nil -> 65 + send_ack(conn, stream, event_id) 66 + 67 + :error -> 68 + Logger.error("Consumer returned error for event #{event_id}, not acking.") 69 + 70 + {:error, reason} -> 71 + Logger.error( 72 + "Consumer returned error for event #{event_id}, not acking: #{inspect(reason)}" 73 + ) 74 + 75 + _ -> 76 + Logger.warning( 77 + "Consumer returned unexpected value for event #{event_id}, acking anyway: #{inspect(result)}" 78 + ) 79 + 80 + send_ack(conn, stream, event_id) 81 + end 82 + end 83 + rescue 84 + e -> 85 + Logger.error( 86 + "Error in Tap event handler (event #{event_id}), not acking: #{Exception.format(:error, e, __STACKTRACE__)}" 87 + ) 88 + end 89 + end) 90 + 91 + :ok 92 + end 93 + 94 + @spec send_ack(pid(), :gun.stream_ref(), integer()) :: :ok 95 + defp send_ack(conn, stream, event_id) do 96 + ack_message = Jason.encode!(%{type: "ack", id: event_id}) 97 + 98 + :ok = :gun.ws_send(conn, stream, {:text, ack_message}) 99 + Logger.debug("[Drinkup.Tap] Acked event #{event_id}") 100 + end 101 + 102 + @spec get_event_id(t()) :: integer() 103 + defp get_event_id(%Event.Record{id: id}), do: id 104 + defp get_event_id(%Event.Identity{id: id}), do: id 105 + end
+39
lib/tap/event/identity.ex
··· 1 + defmodule Drinkup.Tap.Event.Identity do 2 + @moduledoc """ 3 + Struct for identity events from Tap. 4 + 5 + Represents handle or status changes for a DID. 6 + """ 7 + 8 + use TypedStruct 9 + 10 + typedstruct enforce: true do 11 + field :id, integer() 12 + field :did, String.t() 13 + field :handle, String.t() | nil 14 + field :is_active, boolean() 15 + field :status, String.t() 16 + end 17 + 18 + @spec from(map()) :: t() 19 + def from(%{ 20 + "id" => id, 21 + "type" => "identity", 22 + "identity" => 23 + %{ 24 + "did" => did, 25 + "is_active" => is_active, 26 + "status" => status 27 + } = identity_data 28 + }) do 29 + handle = Map.get(identity_data, "handle") 30 + 31 + %__MODULE__{ 32 + id: id, 33 + did: did, 34 + handle: handle, 35 + is_active: is_active, 36 + status: status 37 + } 38 + end 39 + end
+58
lib/tap/event/record.ex
··· 1 + defmodule Drinkup.Tap.Event.Record do 2 + @moduledoc """ 3 + Struct for record events from Tap. 4 + 5 + Represents create, update, or delete operations on records in the repository. 6 + """ 7 + 8 + use TypedStruct 9 + 10 + typedstruct enforce: true do 11 + @type action() :: :create | :update | :delete 12 + 13 + field :id, integer() 14 + field :live, boolean() 15 + field :rev, String.t() 16 + field :did, String.t() 17 + field :collection, String.t() 18 + field :rkey, String.t() 19 + field :action, action() 20 + field :cid, String.t() | nil 21 + field :record, map() | nil 22 + end 23 + 24 + @spec from(map()) :: t() 25 + def from(%{ 26 + "id" => id, 27 + "type" => "record", 28 + "record" => 29 + %{ 30 + "live" => live, 31 + "rev" => rev, 32 + "did" => did, 33 + "collection" => collection, 34 + "rkey" => rkey, 35 + "action" => action 36 + } = record_data 37 + }) do 38 + cid = Map.get(record_data, "cid") 39 + record = Map.get(record_data, "record") 40 + 41 + %__MODULE__{ 42 + id: id, 43 + live: live, 44 + rev: rev, 45 + did: did, 46 + collection: collection, 47 + rkey: rkey, 48 + action: parse_action(action), 49 + cid: cid, 50 + record: record 51 + } 52 + end 53 + 54 + @spec parse_action(String.t()) :: action() 55 + defp parse_action("create"), do: :create 56 + defp parse_action("update"), do: :update 57 + defp parse_action("delete"), do: :delete 58 + end
+90
lib/tap/options.ex
··· 1 + defmodule Drinkup.Tap.Options do 2 + @moduledoc """ 3 + Configuration options for Tap indexer/backfill service connection. 4 + 5 + This module defines the configuration structure for connecting to and 6 + interacting with a Tap service. Tap simplifies AT Protocol sync by handling 7 + firehose connections, verification, backfill, and filtering server-side. 8 + 9 + ## Options 10 + 11 + - `:consumer` (required) - Module implementing `Drinkup.Tap.Consumer` behaviour 12 + - `:name` - Unique name for this Tap instance in the supervision tree (default: `Drinkup.Tap`) 13 + - `:host` - Tap service URL (default: `"http://localhost:2480"`) 14 + - `:admin_password` - Optional password for authenticated Tap instances 15 + - `:disable_acks` - Disable event acknowledgments (default: `false`) 16 + 17 + ## Example 18 + 19 + %{ 20 + consumer: MyTapConsumer, 21 + name: MyTap, 22 + host: "http://localhost:2480", 23 + admin_password: "secret", 24 + disable_acks: false 25 + } 26 + """ 27 + 28 + use TypedStruct 29 + 30 + @default_host "http://localhost:2480" 31 + 32 + @typedoc """ 33 + Map of configuration options accepted by `Drinkup.Tap.child_spec/1`. 34 + """ 35 + @type options() :: %{ 36 + required(:consumer) => consumer(), 37 + optional(:name) => name(), 38 + optional(:host) => host(), 39 + optional(:admin_password) => admin_password(), 40 + optional(:disable_acks) => disable_acks() 41 + } 42 + 43 + @typedoc """ 44 + Module implementing the `Drinkup.Tap.Consumer` behaviour. 45 + """ 46 + @type consumer() :: module() 47 + 48 + @typedoc """ 49 + Unique identifier for this Tap instance in the supervision tree. 50 + 51 + Used for Registry lookups and naming child processes. 52 + """ 53 + @type name() :: atom() 54 + 55 + @typedoc """ 56 + HTTP/HTTPS URL of the Tap service. 57 + 58 + Defaults to `"http://localhost:2480"` which is Tap's default bind address. 59 + """ 60 + @type host() :: String.t() 61 + 62 + @typedoc """ 63 + Optional password for HTTP Basic authentication. 64 + 65 + Required when connecting to a Tap service configured with `TAP_ADMIN_PASSWORD`. 66 + The password is sent as `Basic admin:<password>` in the Authorization header. 67 + """ 68 + @type admin_password() :: String.t() | nil 69 + 70 + @typedoc """ 71 + Whether to disable event acknowledgments. 72 + 73 + When `true`, events are not acknowledged to Tap regardless of consumer 74 + return values. This matches Tap's `TAP_DISABLE_ACKS` environment variable. 75 + 76 + Defaults to `false` (acknowledgments enabled). 77 + """ 78 + @type disable_acks() :: boolean() 79 + 80 + typedstruct do 81 + field :consumer, consumer(), enforce: true 82 + field :name, name(), default: Drinkup.Tap 83 + field :host, host(), default: @default_host 84 + field :admin_password, admin_password() 85 + field :disable_acks, disable_acks(), default: false 86 + end 87 + 88 + @spec from(options()) :: t() 89 + def from(%{consumer: _} = options), do: struct(__MODULE__, options) 90 + end
+100
lib/tap/socket.ex
··· 1 + defmodule Drinkup.Tap.Socket do 2 + @moduledoc """ 3 + WebSocket connection handler for Tap indexer/backfill service. 4 + 5 + Implements the Drinkup.Socket behaviour to manage connections to a Tap service, 6 + handling JSON-encoded events and dispatching them to the configured consumer. 7 + 8 + Events are acknowledged after successful processing based on the consumer's 9 + return value: 10 + - `:ok`, `{:ok, any()}`, or `nil` → Success, ack sent to Tap 11 + - `{:error, reason}` → Failure, no ack (Tap will retry after timeout) 12 + - Exception raised → Failure, no ack (Tap will retry after timeout) 13 + """ 14 + 15 + use Drinkup.Socket 16 + 17 + require Logger 18 + alias Drinkup.Tap.{Event, Options} 19 + 20 + @impl true 21 + def init(opts) do 22 + options = Keyword.fetch!(opts, :options) 23 + {:ok, %{options: options, host: options.host}} 24 + end 25 + 26 + def start_link(%Options{} = options, statem_opts) do 27 + socket_opts = build_socket_opts(options) 28 + Drinkup.Socket.start_link(__MODULE__, socket_opts, statem_opts) 29 + end 30 + 31 + @impl true 32 + def build_path(_data) do 33 + "/channel" 34 + end 35 + 36 + @impl true 37 + def handle_frame({:text, json}, {%{options: options} = data, conn, stream}) do 38 + case Jason.decode(json) do 39 + {:ok, payload} -> 40 + case Event.from(payload) do 41 + nil -> 42 + Logger.warning("Received unrecognized event from Tap: #{inspect(payload)}") 43 + :noop 44 + 45 + event -> 46 + Event.dispatch(event, options, conn, stream) 47 + {:ok, data} 48 + end 49 + 50 + {:error, reason} -> 51 + Logger.error("Failed to decode JSON from Tap: #{inspect(reason)}") 52 + :noop 53 + end 54 + end 55 + 56 + @impl true 57 + def handle_frame({:binary, _binary}, _data) do 58 + Logger.warning("Received unexpected binary frame from Tap") 59 + :noop 60 + end 61 + 62 + @impl true 63 + def handle_frame(:close, _data) do 64 + Logger.info("Websocket closed, reason unknown") 65 + nil 66 + end 67 + 68 + @impl true 69 + def handle_frame({:close, errno, reason}, _data) do 70 + Logger.info("Websocket closed, errno: #{errno}, reason: #{inspect(reason)}") 71 + nil 72 + end 73 + 74 + defp build_socket_opts(%Options{host: host, admin_password: admin_password} = options) do 75 + base_opts = [ 76 + host: host, 77 + options: options 78 + ] 79 + 80 + if admin_password do 81 + auth_header = build_auth_header(admin_password) 82 + 83 + gun_opts = %{ 84 + ws_opts: %{ 85 + headers: [{"authorization", auth_header}] 86 + } 87 + } 88 + 89 + Keyword.put(base_opts, :gun_opts, gun_opts) 90 + else 91 + base_opts 92 + end 93 + end 94 + 95 + @spec build_auth_header(String.t()) :: String.t() 96 + defp build_auth_header(password) do 97 + credentials = "admin:#{password}" 98 + "Basic #{Base.encode64(credentials)}" 99 + end 100 + end
+4 -1
mix.exs
··· 35 35 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 36 36 {:ex_doc, "~> 0.34", only: :dev, runtime: false}, 37 37 {:gun, "~> 2.2"}, 38 - {:typedstruct, "~> 0.5"} 38 + {:typedstruct, "~> 0.5"}, 39 + {:jason, "~> 1.4"}, 40 + {:req, "~> 0.5.0"}, 41 + {:atex, "~> 0.7"} 39 42 ] 40 43 end 41 44
+20
mix.lock
··· 1 1 %{ 2 + "atex": {:hex, :atex, "0.7.0", "23baa616d584ef2cdd2c444b838b672d4472cdae6894fc98dbcb8e1d4b3dd210", [:mix], [{:con_cache, "~> 1.1", [hex: :con_cache, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.42", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:multiformats_ex, "~> 0.2", [hex: :multiformats_ex, repo: "hexpm", optional: false]}, {:mutex, "~> 3.0", [hex: :mutex, repo: "hexpm", optional: false]}, {:peri, "~> 0.6", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:recase, "~> 0.5", [hex: :recase, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}], "hexpm", "dfb5ced5259658ed6881add0e304b726cd281d7ae030813f7c4fe1e5fa8b35ef"}, 2 3 "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 4 "car": {:hex, :car, "0.1.1", "a5bc4c5c1be96eab437634b3c0ccad1fe17b5e3d68c22a4031241ae1345aebd4", [:mix], [{:cbor, "~> 1.0.0", [hex: :cbor, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}, {:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "f895dda8123d04dd336db5a2bf0d0b47f4559cd5383f83fcca0700c1b45bfb6a"}, 4 5 "cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"}, 5 6 "certifi": {:hex, :certifi, "2.16.0", "a4edfc1d2da3424d478a3271133bf28e0ec5e6fd8c009aab5a4ae980cb165ce9", [:rebar3], [], "hexpm", "8a64f6669d85e9cc0e5086fcf29a5b13de57a13efa23d3582874b9a19303f184"}, 7 + "cldr_utils": {:hex, :cldr_utils, "2.29.1", "11ff0a50a36a7e5f3bd9fc2fb8486a4c1bcca3081d9c080bf9e48fe0e6742e2d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3844a0a0ed7f42e6590ddd8bd37eb4b1556b112898f67dea3ba068c29aabd6c2"}, 8 + "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, 6 9 "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, 7 10 "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, 11 + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 8 12 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 13 + "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, 9 14 "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 10 15 "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 16 + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 11 17 "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, 18 + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 12 19 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 20 + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, 13 21 "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 14 22 "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 15 23 "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 24 + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 25 + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 26 + "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, 27 + "mutex": {:hex, :mutex, "3.0.2", "528877fd0dbc09fc93ad667e10ea0d35a2126fa85205822f9dca85e87d732245", [:mix], [], "hexpm", "0a8f2ed3618160dca6a1e3520b293dc3c2ae53116265e71b4a732d35d29aa3c6"}, 28 + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 16 29 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 30 + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 31 + "peri": {:hex, :peri, "0.6.2", "3c043bfb6aa18eb1ea41d80981d19294c5e943937b1311e8e958da3581139061", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "5e0d8e0bd9de93d0f8e3ad6b9a5bd143f7349c025196ef4a3591af93ce6ecad9"}, 32 + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, 33 + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 34 + "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, 35 + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [: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", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, 36 + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 17 37 "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, 18 38 "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, 19 39 }