···11-# AGENTS.md
22-33-This file provides guidance for agentic coding assistants working with the
44-Drinkup codebase.
55-66-## Project Overview
77-88-Drinkup is an Elixir library for consuming events from an ATProtocol relay
99-(firehose/`com.atproto.sync.subscribeRepos`). It uses OTP principles with
1010-GenStatem for managing WebSocket connections and Task.Supervisor for concurrent
1111-event processing.
1212-1313-## Build, Lint, and Test Commands
1414-1515-### Running Tests
1616-1717-```bash
1818-# Run all tests
1919-mix test
2020-2121-# Run a single test file
2222-mix test test/drinkup_test.exs
2323-2424-# Run a specific test by line number
2525-mix test test/drinkup_test.exs:5
2626-2727-# Run tests with coverage
2828-mix test --cover
2929-3030-# Run tests matching a pattern
3131-mix test --only [tag_name]
3232-```
3333-3434-### Formatting and Linting
3535-3636-```bash
3737-# Format code (uses .formatter.exs config)
3838-mix format
3939-4040-# Check if code is formatted
4141-mix format --check-formatted
4242-4343-# Run Credo for static code analysis
4444-mix credo
4545-4646-# Run Credo strictly
4747-mix credo --strict
4848-```
4949-5050-### Compilation and Documentation
5151-5252-```bash
5353-# Compile the project
5454-mix compile
5555-5656-# Clean build artifacts
5757-mix clean
5858-5959-# Generate documentation
6060-mix docs
6161-6262-# Run Dialyzer for type checking (if configured)
6363-mix dialyzer
6464-```
6565-6666-## Code Style Guidelines
6767-6868-### Module Structure
6969-7070-- Use `defmodule` with clear, descriptive names following `Drinkup.<Component>`
7171- namespace
7272-- Place module documentation (`@moduledoc`) immediately after `defmodule`
7373-- Group related functionality within nested modules (e.g.,
7474- `Drinkup.Event.Commit.RepoOp`)
7575-- Order module contents: module attributes, types, public functions, private
7676- functions
7777-7878-### Imports and Aliases
7979-8080-- Use `require` for macros (e.g., `require Logger`)
8181-- Use `alias` to shorten module names, prefer explicit aliases over `import`
8282-- Group in order: `require`, `alias`, `import`
8383-- Example:
8484- ```elixir
8585- require Logger
8686- alias Drinkup.{Event, Options}
8787- ```
8888-8989-### Type Specifications
9090-9191-- Use TypedStruct for structs with typed fields (dependency:
9292- `{:typedstruct, "~> 0.5"}`)
9393-- Define `@type` specs for complex types, unions, and public APIs
9494-- Use `@spec` for all public functions
9595-- Use `enforce: true` for required TypedStruct fields
9696-- Example:
9797-9898- ```elixir
9999- use TypedStruct
100100-101101- typedstruct enforce: true do
102102- field :consumer, module()
103103- field :name, atom(), default: Drinkup
104104- field :cursor, pos_integer() | nil, enforce: false
105105- end
106106- ```
107107-108108-### Naming Conventions
109109-110110-- Modules: PascalCase (`Drinkup.Event.Commit`)
111111-- Functions: snake_case (`handle_event/1`, `from/1`)
112112-- Variables: snake_case (`repo_op`, `last_seq`)
113113-- Private functions: prefix with `defp`, mark with `@spec` if complex
114114-- Atoms: lowercase with underscores (`:ok`, `:connect_timeout`)
115115-- Behaviours: use `@behaviour` (not `@behavior`)
116116-117117-### Function Definitions
118118-119119-- Pattern match in function heads when possible
120120-- Use guard clauses for simple type/value checks
121121-- Prefer multiple function heads over large case statements
122122-- Example:
123123- ```elixir
124124- def valid_seq?(nil, seq) when is_integer(seq), do: true
125125- def valid_seq?(last_seq, nil) when is_integer(last_seq), do: true
126126- def valid_seq?(last_seq, seq) when is_integer(last_seq) and is_integer(seq),
127127- do: seq > last_seq
128128- def valid_seq?(_last_seq, _seq), do: false
129129- ```
130130-131131-### Error Handling
132132-133133-- Use `try/rescue` for expected errors, catch and log appropriately
134134-- Use Logger for errors:
135135- `Logger.error("Message: #{Exception.format(:error, e, __STACKTRACE__)}")`
136136-- Return tagged tuples: `{:ok, result}` or `{:error, reason}`
137137-- Use `with` for chaining operations that may fail
138138-- Example from Socket module:
139139- ```elixir
140140- with {:ok, header, next} <- CAR.DagCbor.decode(frame),
141141- {:ok, payload, _} <- CAR.DagCbor.decode(next),
142142- {%{"op" => @op_regular}, _} <- {header, payload} do
143143- # happy path
144144- else
145145- {:error, reason} -> Logger.warning("Failed to decode: #{inspect(reason)}")
146146- end
147147- ```
148148-149149-### OTP and Concurrency Patterns
150150-151151-- Use `child_spec/1` for custom supervisor specifications
152152-- Prefer `GenServer` for stateful processes, `:gen_statem` for state machines
153153-- Use `Task.Supervisor` for concurrent, fire-and-forget work (see
154154- `Event.dispatch/2`)
155155-- Register processes via Registry for named lookups
156156-- Define proper restart strategies (`:permanent`, `:transient`, `:temporary`)
157157-158158-### Comments
159159-160160-- Avoid obvious comments; prefer self-documenting code
161161-- Use `# TODO:` for future improvements (see existing TODOs in codebase)
162162-- Use `# DEPRECATED` for deprecated fields (see Commit struct)
163163-- Document complex algorithms or non-obvious business logic
164164-- Use module-level `@moduledoc` and function-level `@doc` for public APIs
165165-166166-### Formatting
167167-168168-- Use `mix format` (configured in `.formatter.exs`)
169169-- Import deps for formatting: `import_deps: [:typedstruct]`
170170-- Line length: default Elixir formatter settings
171171-- Use 2-space indentation (enforced by formatter)
172172-173173-### Testing
174174-175175-- Use ExUnit for tests (files in `test/` with `_test.exs` suffix)
176176-- Use `use ExUnit.Case` in test modules
177177-- Use `doctest Module` for testing documentation examples
178178-- Tag tests for selective running: `@tag :integration`
179179-- Use descriptive test names: `test "validates sequence numbers correctly"`
180180-181181-## Project-Specific Patterns
182182-183183-### Consumer Behaviour Pattern
184184-185185-- Implement `@behaviour Drinkup.Consumer` with `handle_event/1` callback
186186-- Use pattern matching to handle different event types
187187-- Return any value; errors are caught by Task.Supervisor wrapper
188188-189189-### RecordConsumer Macro Pattern
190190-191191-- Use `use Drinkup.RecordConsumer` with `collections:` opt for filtering
192192-- Override `handle_create/1`, `handle_update/1`, `handle_delete/1` as needed
193193-- Collections can be exact strings or Regex patterns: `~r/app\.bsky\.graph\..+/`
194194-195195-### WebSocket State Machine
196196-197197-- Socket module uses `:gen_statem` with states: `:disconnected`,
198198- `:connecting_http`, `:connecting_ws`, `:connected`
199199-- State functions match on events: `state_name(:enter, from, data)` or
200200- `state_name(:info, msg, data)`
201201-- Use `{:next_event, :internal, event}` for internal state transitions
202202-203203-## Dependencies
204204-205205-- `{:gun, "~> 2.2"}` - HTTP/WebSocket client
206206-- `{:car, "~> 0.1.0"}` - CAR (Content Addressable aRchive) format
207207-- `{:cbor, "~> 1.0.0"}` - CBOR encoding/decoding
208208-- `{:typedstruct, "~> 0.5"}` - Typed structs
209209-- `{:credo, "~> 1.7"}` - Static analysis (dev/test only)
210210-211211-## Common Tasks
212212-213213-### Adding a New Event Type
11+# Agent Guidelines for Drinkup
2142215215-1. Create `lib/event/your_event.ex` with TypedStruct definition
216216-2. Add `from/1` function to parse payload
217217-3. Add pattern match in `Drinkup.Event.from/2`
218218-4. Add to `@type t()` union in `Drinkup.Event`
219219-5. Update `CHANGELOG.md` under `[Unreleased]` section with the new feature
33+## Commands
2204221221-### Debugging Connection Issues
55+- **Test**: `mix test` (all), `mix test test/path/to/file_test.exs` (single file), `mix test test/path/to/file_test.exs:42` (single test at line)
66+- **Format**: `mix format` (auto-formats all code)
77+- **Lint**: `mix credo` (static analysis), `mix credo --strict` (strict mode)
88+- **Compile**: `mix compile`
99+- **Docs**: `mix docs`
1010+- **Type Check**: `mix dialyzer` (if configured)
22211223223-- Check `:gun` connection logs in Socket module
224224-- Verify sequence tracking with `Event.valid_seq?/2`
225225-- Monitor state transitions: `:disconnected` → `:connecting_http` →
226226- `:connecting_ws` → `:connected`
1212+## Code Style
22713228228-## Changelog Management
1414+- **Imports**: Use `alias` for modules (e.g., `alias Drinkup.Firehose.{Event, Options}`), `require` for macros (e.g., `require Logger`)
1515+- **Formatting**: Elixir 1.18+, auto-formatted via `.formatter.exs` with `import_deps: [:typedstruct]`
1616+- **Naming**: snake_case for functions/variables, PascalCase for modules, `:lowercase_atoms` for atoms, `@behaviour` (not `@behavior`)
1717+- **Types**: Use `@type` and `@spec` for all functions; use TypedStruct for structs with `enforce: true` for required fields
1818+- **Moduledocs**: Public modules need `@moduledoc`, public functions need `@doc` with examples
1919+- **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use `with` for chaining operations; log errors with `Logger.error("#{Exception.format(:error, e, __STACKTRACE__)}")`
2020+- **Pattern Matching**: Prefer pattern matching in function heads over conditionals; use guard clauses when appropriate
2121+- **OTP**: Use `child_spec/1` for custom supervisor specs; `:gen_statem` for state machines; `Task.Supervisor` for concurrent tasks; Registry for named lookups
2222+- **Tests**: Use ExUnit with `use ExUnit.Case`; use `doctest Module` for documentation examples
2323+- **Dependencies**: Core deps include gun (WebSocket), car (CAR format), cbor (encoding), TypedStruct (typed structs), Credo (linting)
22924230230-**IMPORTANT**: After completing any feature or fixing a bug from a previous
231231-release, you MUST update `CHANGELOG.md`.
2525+## Project Structure
23226233233-### Changelog Format
234234-235235-- Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
236236-- Uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
237237-- Group changes under appropriate sections: `Added`, `Changed`, `Deprecated`,
238238- `Removed`, `Fixed`, `Security`
2727+- **Namespace**: All firehose functionality under `Drinkup.Firehose.*`
2828+ - `Drinkup.Firehose` - Main supervisor
2929+ - `Drinkup.Firehose.Consumer` - Behaviour for handling all events
3030+ - `Drinkup.Firehose.RecordConsumer` - Macro for handling commit record events with filtering
3131+ - `Drinkup.Firehose.Event` - Event types (`Commit`, `Sync`, `Identity`, `Account`, `Info`)
3232+ - `Drinkup.Firehose.Socket` - `:gen_statem` WebSocket connection manager
3333+- **Consumer Pattern**: Implement `@behaviour Drinkup.Firehose.Consumer` with `handle_event/1`
3434+- **RecordConsumer Pattern**: `use Drinkup.Firehose.RecordConsumer, collections: [~r/app\.bsky\.graph\..+/, "app.bsky.feed.post"]` with `handle_create/1`, `handle_update/1`, `handle_delete/1` overrides
23935240240-### When to Update
3636+## Important Notes
24137242242-- **New features**: Add under `## [Unreleased]` → `### Added`
243243-- **Bug fixes**: Add under `## [Unreleased]` → `### Fixed`
244244-- **Breaking changes**: Add under `## [Unreleased]` → `### Breaking Changes`
245245-- **Deprecations**: Add under `## [Unreleased]` → `### Deprecated`
246246-- **Security fixes**: Add under `## [Unreleased]` → `### Security`
247247-248248-### Example Entry
249249-250250-```markdown
251251-## [Unreleased]
252252-253253-### Added
254254-255255-- Support for `#handle` event type in firehose consumer
256256-257257-### Fixed
258258-259259-- Sequence validation now correctly handles nil cursor on initial connection
260260-```
3838+- **Update CHANGELOG.md** when adding features, changes, or fixes under `## [Unreleased]` with appropriate sections (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`)
3939+- **WebSocket States**: Socket uses `:disconnected` → `:connecting_http` → `:connecting_ws` → `:connected` flow
4040+- **Sequence Tracking**: Use `Event.valid_seq?/2` to validate sequence numbers from firehose
+7
CHANGELOG.md
···66and this project adheres to
77[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8899+## [Unreleased]
1010+1111+### Breaking Change
1212+1313+- Existing behaviour moved to `Drinkup.Firehose` namespace, to make way for
1414+ alternate sync systems.
1515+916## [0.1.0] - 2025-05-26
10171118Initial release.
+3-3
examples/basic_consumer.ex
···11defmodule BasicConsumer do
22- @behaviour Drinkup.Consumer
22+ @behaviour Drinkup.Firehose.Consumer
3344- def handle_event(%Drinkup.Event.Commit{} = event) do
44+ def handle_event(%Drinkup.Firehose.Event.Commit{} = event) do
55 IO.inspect(event, label: "Got commit event")
66 end
77···1818 @impl true
1919 def init(_) do
2020 children = [
2121- {Drinkup, %{consumer: BasicConsumer}}
2121+ {Drinkup.Firehose, %{consumer: BasicConsumer}}
2222 ]
23232424 Supervisor.init(children, strategy: :one_for_one)
+5-5
examples/multiple_consumers.ex
···11defmodule PostDeleteConsumer do
22- use Drinkup.RecordConsumer, collections: ["app.bsky.feed.post"]
22+ use Drinkup.Firehose.RecordConsumer, collections: ["app.bsky.feed.post"]
3344 def handle_delete(record) do
55 IO.inspect(record, label: "update")
···77end
8899defmodule IdentityConsumer do
1010- @behaviour Drinkup.Consumer
1010+ @behaviour Drinkup.Firehose.Consumer
11111212- def handle_event(%Drinkup.Event.Identity{} = event) do
1212+ def handle_event(%Drinkup.Firehose.Event.Identity{} = event) do
1313 IO.inspect(event, label: "identity event")
1414 end
1515···2626 @impl true
2727 def init(_) do
2828 children = [
2929- {Drinkup, %{consumer: PostDeleteConsumer}},
3030- {Drinkup, %{consumer: IdentityConsumer, name: :identities}}
2929+ {Drinkup.Firehose, %{consumer: PostDeleteConsumer}},
3030+ {Drinkup.Firehose, %{consumer: IdentityConsumer, name: :identities}}
3131 ]
32323333 Supervisor.init(children, strategy: :one_for_one)
+3-2
examples/record_consumer.ex
···11defmodule ExampleRecordConsumer do
22- use Drinkup.RecordConsumer, collections: [~r/app\.bsky\.graph\..+/, "app.bsky.feed.post"]
22+ use Drinkup.Firehose.RecordConsumer,
33+ collections: [~r/app\.bsky\.graph\..+/, "app.bsky.feed.post"]
3445 def handle_create(record) do
56 IO.inspect(record, label: "create")
···2425 @impl true
2526 def init(_) do
2627 children = [
2727- {Drinkup, %{consumer: ExampleRecordConsumer}}
2828+ {Drinkup.Firehose, %{consumer: ExampleRecordConsumer}}
2829 ]
29303031 Supervisor.init(children, strategy: :one_for_one)
+2-2
lib/consumer.ex
lib/firehose/consumer.ex
···11-defmodule Drinkup.Consumer do
11+defmodule Drinkup.Firehose.Consumer do
22 @moduledoc """
33 An unopinionated consumer of the Firehose. Will receive all events, not just commits.
44 """
5566- alias Drinkup.Event
66+ alias Drinkup.Firehose.Event
7788 @callback handle_event(Event.t()) :: any()
99end
+3-3
lib/drinkup.ex
lib/firehose.ex
···11-defmodule Drinkup do
11+defmodule Drinkup.Firehose do
22 use Supervisor
33- alias Drinkup.Options
33+ alias Drinkup.Firehose.Options
4455 @dialyzer nowarn_function: {:init, 1}
66 @impl true
77 def init({%Options{name: name} = drinkup_options, supervisor_options}) do
88 children = [
99 {Task.Supervisor, name: {:via, Registry, {Drinkup.Registry, {name, Tasks}}}},
1010- {Drinkup.Socket, drinkup_options}
1010+ {Drinkup.Firehose.Socket, drinkup_options}
1111 ]
12121313 Supervisor.start_link(
+2-2
lib/event.ex
lib/firehose/event.ex
···11-defmodule Drinkup.Event do
11+defmodule Drinkup.Firehose.Event do
22 require Logger
33- alias Drinkup.{Event, Options}
33+ alias Drinkup.Firehose.{Event, Options}
4455 @type t() ::
66 Event.Commit.t()
···11-defmodule Drinkup.Event.Account do
11+defmodule Drinkup.Firehose.Event.Account do
22 @moduledoc """
33 Struct for account events from the ATProto Firehose.
44 """
+1-1
lib/event/commit.ex
lib/firehose/event/commit.ex
···11-defmodule Drinkup.Event.Commit do
11+defmodule Drinkup.Firehose.Event.Commit do
22 @moduledoc """
33 Struct for commit events from the ATProto Firehose.
44 """
···11-defmodule Drinkup.Event.Identity do
11+defmodule Drinkup.Firehose.Event.Identity do
22 @moduledoc """
33 Struct for identity events from the ATProto Firehose.
44 """
+1-1
lib/event/info.ex
lib/firehose/event/info.ex
···11-defmodule Drinkup.Event.Info do
11+defmodule Drinkup.Firehose.Event.Info do
22 @moduledoc """
33 Struct for info events from the ATProto Firehose.
44 """
+1-1
lib/event/sync.ex
lib/firehose/event/sync.ex
···11-defmodule Drinkup.Event.Sync do
11+defmodule Drinkup.Firehose.Event.Sync do
22 @moduledoc """
33 Struct for sync events from the ATProto Firehose.
44 """
+1-1
lib/options.ex
lib/firehose/options.ex
···11-defmodule Drinkup.Options do
11+defmodule Drinkup.Firehose.Options do
22 use TypedStruct
3344 @default_host "https://bsky.network"
···11-defmodule Drinkup.RecordConsumer do
11+defmodule Drinkup.Firehose.RecordConsumer do
22 @moduledoc """
33 An opinionated consumer of the Firehose that eats consumers
44 """
···1111 {collections, _opts} = Keyword.pop(opts, :collections, [])
12121313 quote location: :keep do
1414- @behaviour Drinkup.Consumer
1515- @behaviour Drinkup.RecordConsumer
1414+ @behaviour Drinkup.Firehose.Consumer
1515+ @behaviour Drinkup.Firehose.RecordConsumer
16161717- def handle_event(%Drinkup.Event.Commit{} = event) do
1717+ def handle_event(%Drinkup.Firehose.Event.Commit{} = event) do
1818 event.ops
1919 |> Enum.filter(fn %{path: path} ->
2020 path |> String.split("/") |> Enum.at(0) |> matches_collections?()
2121 end)
2222- |> Enum.map(&Drinkup.RecordConsumer.Record.from(&1, event.repo))
2222+ |> Enum.map(&Drinkup.Firehose.RecordConsumer.Record.from(&1, event.repo))
2323 |> Enum.each(&apply(__MODULE__, :"handle_#{&1.action}", [&1]))
2424 end
2525···5656 end
57575858 defmodule Record do
5959- alias Drinkup.Event.Commit.RepoOp
5959+ alias Drinkup.Firehose.Event.Commit.RepoOp
6060 use TypedStruct
61616262 typedstruct do
+2-2
lib/socket.ex
lib/firehose/socket.ex
···11-defmodule Drinkup.Socket do
11+defmodule Drinkup.Firehose.Socket do
22 @moduledoc """
33 gen_statem process for managing the websocket connection to an ATProto relay.
44 """
5566 require Logger
77- alias Drinkup.{Event, Options}
77+ alias Drinkup.Firehose.{Event, Options}
8899 @behaviour :gen_statem
1010 @timeout :timer.seconds(5)