···10101111### Added
12121313+- `Atex.Repo` module for building, mutating, signing, serialising, and loading
1414+ AT Protocol repositories. Also supports lazily streaming from a CAR binary for
1515+ efficient processing of large repository exports.
1316- `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on
1417 public APIs or PDSes.
1518- `Atex.NSID.authority_domain/1` for deriving the `_lexicon.<authority>` DNS
+5-4
README.md
···88 - [x] `at://` links
99 - [x] TIDs
1010 - [ ] NSIDs
1111- - [ ] CIDs
1211- [x] Identity resolution with bi-directional validation and caching.
1313-- [x] Macro and codegen for converting Lexicon definitions to runtime schemas and structs.
1212+- [x] Macro and codegen for converting Lexicon definitions to runtime schemas
1313+ and structs.
1414- [x] OAuth client
1515- [x] XRPC client
1616 - With integration for generated Lexicon structs!
1717-- [ ] Repository reading and manipulation (MST & CAR)
1717+- [x] Repository reading and manipulation
1818- [x] Service auth
1919- [x] PLC client
2020- [x] XRPC server router
21212222-Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup].
2222+Looking to use a data subscription service like the Firehose, [Jetstream], or
2323+[Tap]? Check out [Drinkup].
23242425[Jetstream]: https://docs.bsky.app/blog/jetstream
2526[Tap]: https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md
+198
bench/repo.exs
···11+##
22+## Atex.Repo benchmarks
33+##
44+## Run with:
55+## mix run bench/repo.exs
66+##
77+## Uses the real-world CAR fixtures in test/fixtures/ and the larger repo at
88+## tmp/ovyerus.car (39 MB, ~90k records) when present.
99+##
1010+## Each suite section measures a distinct subsystem. Memory measurements are
1111+## enabled with memory_time: 2 (seconds of sampling).
1212+##
1313+1414+alias Atex.Repo
1515+1616+fixture = fn name ->
1717+ File.read!(Path.join("test/fixtures", name))
1818+end
1919+2020+fixture_stream = fn name ->
2121+ File.stream!(Path.join("test/fixtures", name), 65_536, [:raw, :binary])
2222+end
2323+2424+large_path = "tmp/ovyerus.car"
2525+has_large = File.exists?(large_path)
2626+2727+if has_large do
2828+ IO.puts("Large fixture (#{large_path}) found - including in streaming benchmarks.\n")
2929+else
3030+ IO.puts("Large fixture (#{large_path}) not found - skipping large-file benchmarks.\n")
3131+end
3232+3333+# ---------------------------------------------------------------------------
3434+# Pre-load repos used as inputs to export / access benchmarks
3535+# ---------------------------------------------------------------------------
3636+3737+# ~22 KB, 62 records
3838+small_bin = fixture.("alt.car")
3939+# ~46 KB, 123 records
4040+medium_bin = fixture.("comet.car")
4141+4242+{:ok, small_repo} = Repo.from_car(small_bin)
4343+{:ok, medium_repo} = Repo.from_car(medium_bin)
4444+4545+# Pre-fetch one path from each for the get_record benchmark
4646+{:ok, small_pairs} = MST.to_list(small_repo.tree)
4747+{:ok, medium_pairs} = MST.to_list(medium_repo.tree)
4848+4949+small_path = small_pairs |> Enum.at(div(length(small_pairs), 2)) |> elem(0)
5050+medium_path = medium_pairs |> Enum.at(div(length(medium_pairs), 2)) |> elem(0)
5151+5252+small_collection =
5353+ small_pairs |> hd() |> elem(0) |> String.split("/") |> hd()
5454+5555+medium_collection =
5656+ medium_pairs |> hd() |> elem(0) |> String.split("/") |> hd()
5757+5858+# Repos need a signed commit to be exportable via to_car.
5959+jwk = JOSE.JWK.generate_key({:ec, "P-256"})
6060+{:ok, small_repo_committed} = Repo.commit(small_repo, small_repo.commit.did, jwk)
6161+{:ok, medium_repo_committed} = Repo.commit(medium_repo, medium_repo.commit.did, jwk)
6262+6363+IO.puts("=== CAR import ===\n")
6464+6565+Benchee.run(
6666+ %{
6767+ "from_car - small (62 records, ~22 KB)" => fn -> Repo.from_car(small_bin) end,
6868+ "from_car - medium (123 records, ~46 KB)" => fn -> Repo.from_car(medium_bin) end
6969+ },
7070+ time: 5,
7171+ memory_time: 2,
7272+ print: [fast_warning: false]
7373+)
7474+7575+IO.puts("\n=== CAR export (to_car) ===\n")
7676+7777+Benchee.run(
7878+ %{
7979+ "to_car - small (62 records)" => fn -> Repo.to_car(small_repo_committed) end,
8080+ "to_car - medium (123 records)" => fn -> Repo.to_car(medium_repo_committed) end
8181+ },
8282+ time: 5,
8383+ memory_time: 2,
8484+ print: [fast_warning: false]
8585+)
8686+8787+IO.puts("\n=== CAR streaming - small fixtures ===\n")
8888+8989+Benchee.run(
9090+ %{
9191+ "stream_car full - small (62 records)" => fn ->
9292+ fixture_stream.("alt.car")
9393+ |> Repo.stream_car()
9494+ |> Stream.run()
9595+ end,
9696+ "stream_car full - medium (123 records)" => fn ->
9797+ fixture_stream.("comet.car")
9898+ |> Repo.stream_car()
9999+ |> Stream.run()
100100+ end,
101101+ "stream_car take 10 - small" => fn ->
102102+ fixture_stream.("alt.car")
103103+ |> Repo.stream_car()
104104+ |> Stream.filter(&match?({:record, _, _}, &1))
105105+ |> Stream.take(10)
106106+ |> Stream.run()
107107+ end,
108108+ "stream_car take 10 - medium" => fn ->
109109+ fixture_stream.("comet.car")
110110+ |> Repo.stream_car()
111111+ |> Stream.filter(&match?({:record, _, _}, &1))
112112+ |> Stream.take(10)
113113+ |> Stream.run()
114114+ end
115115+ },
116116+ time: 5,
117117+ memory_time: 2,
118118+ print: [fast_warning: false]
119119+)
120120+121121+if has_large do
122122+ IO.puts("\n=== CAR streaming - large fixture (39 MB, ~90k records) ===\n")
123123+124124+ Benchee.run(
125125+ %{
126126+ "stream_car full - large (~90k records)" => fn ->
127127+ File.stream!(large_path, 65_536, [:raw, :binary])
128128+ |> Repo.stream_car()
129129+ |> Stream.run()
130130+ end,
131131+ "stream_car take 100 - large" => fn ->
132132+ File.stream!(large_path, 65_536, [:raw, :binary])
133133+ |> Repo.stream_car()
134134+ |> Stream.filter(&match?({:record, _, _}, &1))
135135+ |> Stream.take(100)
136136+ |> Stream.run()
137137+ end
138138+ },
139139+ time: 10,
140140+ memory_time: 3,
141141+ warmup: 2,
142142+ print: [fast_warning: false]
143143+ )
144144+end
145145+146146+IO.puts("\n=== Record access ===\n")
147147+148148+Benchee.run(
149149+ %{
150150+ "get_record - small repo" => fn -> Repo.get_record(small_repo, small_path) end,
151151+ "get_record - medium repo" => fn -> Repo.get_record(medium_repo, medium_path) end,
152152+ "list_collections - small (#{length(small_pairs)} records)" => fn ->
153153+ Repo.list_collections(small_repo)
154154+ end,
155155+ "list_collections - medium (#{length(medium_pairs)} records)" => fn ->
156156+ Repo.list_collections(medium_repo)
157157+ end,
158158+ "list_record_keys - small, 1 collection" => fn ->
159159+ Repo.list_record_keys(small_repo, small_collection)
160160+ end,
161161+ "list_record_keys - medium, 1 collection" => fn ->
162162+ Repo.list_record_keys(medium_repo, medium_collection)
163163+ end,
164164+ "list_records - small, 1 collection" => fn ->
165165+ Repo.list_records(small_repo, small_collection)
166166+ end,
167167+ "list_records - medium, 1 collection" => fn ->
168168+ Repo.list_records(medium_repo, medium_collection)
169169+ end
170170+ },
171171+ time: 5,
172172+ memory_time: 2,
173173+ print: [fast_warning: false]
174174+)
175175+176176+IO.puts("\n=== Record mutation ===\n")
177177+178178+Benchee.run(
179179+ %{
180180+ "put_record - small repo" => fn ->
181181+ Repo.put_record(small_repo, "app.bsky.feed.post/bench#{System.unique_integer()}", %{
182182+ "text" => "bench"
183183+ })
184184+ end,
185185+ "put_record - medium repo" => fn ->
186186+ Repo.put_record(
187187+ medium_repo,
188188+ "app.bsky.feed.post/bench#{System.unique_integer()}",
189189+ %{"text" => "bench"}
190190+ )
191191+ end,
192192+ "delete_record - small repo" => fn -> Repo.delete_record(small_repo, small_path) end,
193193+ "delete_record - medium repo" => fn -> Repo.delete_record(medium_repo, medium_path) end
194194+ },
195195+ time: 5,
196196+ memory_time: 2,
197197+ print: [fast_warning: false]
198198+)
+850
lib/atex/repo.ex
···11+defmodule Atex.Repo do
22+ @moduledoc """
33+ AT Protocol repository - a signed, content-addressed store of records.
44+55+ A repository is a key/value mapping of repo paths (`collection/rkey`) to
66+ records (CBOR objects), backed by a Merkle Search Tree (MST). Each published
77+ version of the tree is captured in a signed `Atex.Repo.Commit`.
88+99+ ## Quick start
1010+1111+ # Create a new empty repository
1212+ repo = Atex.Repo.new()
1313+1414+ # Insert records (string path or Atex.Repo.Path struct)
1515+ {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"})
1616+1717+ # Commit (sign) the current tree state
1818+ jwk = JOSE.JWK.generate_key({:ec, "P-256"})
1919+ {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk)
2020+2121+ # Export to a CAR file
2222+ {:ok, car_binary} = Atex.Repo.to_car(repo)
2323+2424+ # Round-trip import
2525+ {:ok, repo2} = Atex.Repo.from_car(car_binary)
2626+2727+ # Verify the commit signature
2828+ :ok = Atex.Repo.verify_commit(repo2, JOSE.JWK.to_public(jwk))
2929+3030+ ## Paths
3131+3232+ Record paths can be passed as plain strings (`"collection/rkey"`) or as
3333+ `Atex.Repo.Path` structs. Both are accepted by all path-taking functions.
3434+ See `Atex.Repo.Path` for validation rules and struct API.
3535+3636+ ## Record storage
3737+3838+ Records are DRISL CBOR-encoded. Their CIDs (`:drisl` codec) are stored as
3939+ leaf values in the MST. The raw record bytes are tracked in a separate
4040+ `blocks` map inside the struct so they are available for CAR export without
4141+ re-encoding.
4242+4343+ ## CAR serialization
4444+4545+ `to_car/1` produces a CARv1 file in the streamable block order described in
4646+ the spec: commit first, then MST nodes in depth-first pre-order, interleaved
4747+ with their record blocks.
4848+4949+ `from_car/1` decodes a CAR file, extracts the signed commit from the first
5050+ root CID, loads the MST, and collects all record blocks. It does **not**
5151+ verify the commit signature - call `verify_commit/2` explicitly.
5252+5353+ `stream_car/1` provides a lazy stream over a CAR binary, emitting
5454+ `{:commit, commit}` then `{:record, path, record}` tuples without loading
5555+ the full repository into memory. Requires a streamable-order CAR (commit
5656+ first, MST nodes in pre-order before their records).
5757+5858+ ATProto spec: https://atproto.com/specs/repository
5959+ """
6060+6161+ use TypedStruct
6262+ alias Atex.{Repo.Commit, Repo.Path, TID}
6363+ alias DASL.{CAR, CID, DRISL}
6464+ alias MST.{Node, Store, Tree}
6565+6666+ typedstruct enforce: true do
6767+ @typedoc "An AT Protocol repository."
6868+6969+ field :tree, Tree.t()
7070+ field :commit, Commit.t() | nil
7171+ field :blocks, %{CID.t() => binary()}, default: %{}
7272+ end
7373+7474+ @doc """
7575+ Returns a new empty repository with no records and no commit.
7676+7777+ ## Examples
7878+7979+ iex> repo = Atex.Repo.new()
8080+ iex> repo.commit
8181+ nil
8282+8383+ """
8484+ @spec new() :: t()
8585+ def new do
8686+ %__MODULE__{
8787+ tree: MST.new(),
8888+ commit: nil,
8989+ blocks: %{}
9090+ }
9191+ end
9292+9393+ @doc """
9494+ Retrieves the record at `path`, returning the decoded map.
9595+9696+ `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct.
9797+9898+ Returns `{:error, :not_found}` if the path does not exist.
9999+100100+ ## Examples
101101+102102+ iex> repo = Atex.Repo.new()
103103+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"})
104104+ iex> {:ok, record} = Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
105105+ iex> record["text"]
106106+ "hi"
107107+108108+ """
109109+ @spec get_record(t(), String.t() | Path.t()) ::
110110+ {:ok, map()}
111111+ | {:error, :not_found | :invalid_path | :invalid_collection | :invalid_rkey | atom()}
112112+ def get_record(%__MODULE__{} = repo, path) do
113113+ with {:ok, path_str} <- coerce_path(path),
114114+ {:ok, cid} <- MST.get(repo.tree, path_str),
115115+ {:ok, bytes} <- fetch_block(repo.blocks, cid),
116116+ {:ok, record, _rest} <- DRISL.decode(bytes) do
117117+ {:ok, record}
118118+ end
119119+ end
120120+121121+ @doc """
122122+ Inserts or replaces the record at `path`.
123123+124124+ `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct.
125125+126126+ The record is DRISL CBOR-encoded and its CID computed. The CID is inserted
127127+ into the MST as a leaf value. The commit is **not** updated - call
128128+ `commit/3` to sign the new tree state.
129129+130130+ ## Examples
131131+132132+ iex> repo = Atex.Repo.new()
133133+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"})
134134+ iex> {:ok, record} = Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
135135+ iex> record["text"]
136136+ "hi"
137137+138138+ """
139139+ @spec put_record(t(), String.t() | Path.t(), map()) ::
140140+ {:ok, t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey | atom()}
141141+ def put_record(%__MODULE__{} = repo, path, record) when is_map(record) do
142142+ with {:ok, path_str} <- coerce_path(path),
143143+ {:ok, bytes} <- DRISL.encode(record),
144144+ cid = CID.compute(bytes, :drisl),
145145+ {:ok, tree} <- MST.put(repo.tree, path_str, cid) do
146146+ {:ok, %{repo | tree: tree, blocks: Map.put(repo.blocks, cid, bytes)}}
147147+ end
148148+ end
149149+150150+ @doc """
151151+ Removes the record at `path`.
152152+153153+ `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct.
154154+155155+ Returns `{:error, :not_found}` if the path does not exist.
156156+157157+ ## Examples
158158+159159+ iex> repo = Atex.Repo.new()
160160+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"})
161161+ iex> {:ok, repo} = Atex.Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
162162+ iex> Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
163163+ {:error, :not_found}
164164+165165+ """
166166+ @spec delete_record(t(), String.t() | Path.t()) ::
167167+ {:ok, t()}
168168+ | {:error, :not_found | :invalid_path | :invalid_collection | :invalid_rkey | atom()}
169169+ def delete_record(%__MODULE__{} = repo, path) do
170170+ with {:ok, path_str} <- coerce_path(path),
171171+ {:ok, tree} <- MST.delete(repo.tree, path_str) do
172172+ {:ok, %{repo | tree: tree}}
173173+ end
174174+ end
175175+176176+ @doc """
177177+ Signs the current tree state and stores the result as the repository commit.
178178+179179+ Builds an `Atex.Repo.Commit` for `did` referencing the current MST root,
180180+ signs it with `signing_key`, and updates `repo.commit`. The `rev` is set to
181181+ the current timestamp as a TID string, guaranteed to be monotonically
182182+ increasing relative to any previous commit in this process.
183183+184184+ ## Examples
185185+186186+ iex> repo = Atex.Repo.new()
187187+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
188188+ iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk)
189189+ iex> repo.commit.did
190190+ "did:plc:example"
191191+ iex> repo.commit.version
192192+ 3
193193+194194+ """
195195+ @spec commit(t(), String.t(), JOSE.JWK.t()) :: {:ok, t()} | {:error, atom()}
196196+ def commit(%__MODULE__{} = repo, did, signing_key) do
197197+ data_cid = mst_root_cid(repo.tree)
198198+ rev = TID.now() |> TID.encode()
199199+200200+ unsigned =
201201+ Commit.new(
202202+ did: did,
203203+ data: data_cid,
204204+ rev: rev,
205205+ prev: nil
206206+ )
207207+208208+ with {:ok, signed} <- Commit.sign(unsigned, signing_key) do
209209+ {:ok, %{repo | commit: signed}}
210210+ end
211211+ end
212212+213213+ @doc """
214214+ Returns a deduplicated list of all collection names in the repository.
215215+216216+ Collections are returned in MST key order (bytewise-lexicographic on the
217217+ full `collection/rkey` path string). This is generally close to but not
218218+ identical to alphabetical order - for example, `"foo.bar"` sorts after
219219+ `"foo.bar.baz"` because `/` (0x2F) > `.` (0x2E).
220220+221221+ ## Examples
222222+223223+ iex> repo = Atex.Repo.new()
224224+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{})
225225+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.like/bbbb", %{})
226226+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{})
227227+ iex> {:ok, cols} = Atex.Repo.list_collections(repo)
228228+ iex> cols
229229+ ["app.bsky.feed.like", "app.bsky.feed.post"]
230230+231231+ """
232232+ @spec list_collections(t()) :: {:ok, [String.t()]} | {:error, atom()}
233233+ def list_collections(%__MODULE__{tree: tree}) do
234234+ result =
235235+ tree
236236+ |> MST.stream()
237237+ |> Stream.map(fn {key, _cid} -> collection_from_key(key) end)
238238+ |> Stream.dedup()
239239+ |> Enum.to_list()
240240+241241+ {:ok, result}
242242+ rescue
243243+ e -> {:error, {:stream_error, e}}
244244+ end
245245+246246+ @doc """
247247+ Returns a sorted list of all record keys within `collection`.
248248+249249+ The list is in MST key order, which for TID-keyed records is chronological.
250250+ Returns an empty list (not an error) when the collection exists in the repo
251251+ but has no records, or does not exist at all.
252252+253253+ ## Examples
254254+255255+ iex> repo = Atex.Repo.new()
256256+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{})
257257+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{})
258258+ iex> {:ok, keys} = Atex.Repo.list_record_keys(repo, "app.bsky.feed.post")
259259+ iex> keys
260260+ ["aaaa", "bbbb"]
261261+262262+ """
263263+ @spec list_record_keys(t(), String.t()) :: {:ok, [String.t()]} | {:error, atom()}
264264+ def list_record_keys(%__MODULE__{tree: tree}, collection) when is_binary(collection) do
265265+ prefix = collection <> "/"
266266+267267+ result =
268268+ tree
269269+ |> MST.stream()
270270+ |> stream_collection(prefix)
271271+ |> Stream.map(fn {key, _cid} -> String.slice(key, byte_size(prefix)..-1//1) end)
272272+ |> Enum.to_list()
273273+274274+ {:ok, result}
275275+ rescue
276276+ e -> {:error, {:stream_error, e}}
277277+ end
278278+279279+ @doc """
280280+ Returns a sorted list of `{rkey, record_map}` pairs for all records in
281281+ `collection`.
282282+283283+ The list is in MST key order. Returns an empty list when the collection does
284284+ not exist or has no records.
285285+286286+ ## Examples
287287+288288+ iex> repo = Atex.Repo.new()
289289+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1})
290290+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2})
291291+ iex> {:ok, records} = Atex.Repo.list_records(repo, "app.bsky.feed.post")
292292+ iex> Enum.map(records, fn {rkey, _} -> rkey end)
293293+ ["aaaa", "bbbb"]
294294+295295+ """
296296+ @spec list_records(t(), String.t()) ::
297297+ {:ok, [{String.t(), map()}]} | {:error, atom()}
298298+ def list_records(%__MODULE__{tree: tree, blocks: blocks}, collection)
299299+ when is_binary(collection) do
300300+ prefix = collection <> "/"
301301+302302+ result =
303303+ tree
304304+ |> MST.stream()
305305+ |> stream_collection(prefix)
306306+ |> Enum.reduce_while([], fn {key, cid}, acc ->
307307+ rkey = String.slice(key, byte_size(prefix)..-1//1)
308308+309309+ case decode_record(blocks, cid) do
310310+ {:ok, record} -> {:cont, [{rkey, record} | acc]}
311311+ {:error, _} = err -> {:halt, err}
312312+ end
313313+ end)
314314+315315+ case result do
316316+ {:error, _} = err -> err
317317+ pairs -> {:ok, Enum.reverse(pairs)}
318318+ end
319319+ rescue
320320+ e -> {:error, {:stream_error, e}}
321321+ end
322322+323323+ @doc """
324324+ Exports the repository as a CARv1 binary.
325325+326326+ Block ordering follows the streamable convention from the spec:
327327+328328+ 1. The signed commit block.
329329+ 2. The MST root node, then MST nodes in depth-first pre-order, with each
330330+ record block immediately following the MST entry that references it.
331331+332332+ Returns `{:error, :no_commit}` if `commit/3` has not been called.
333333+334334+ ## Examples
335335+336336+ iex> repo = Atex.Repo.new()
337337+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"})
338338+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
339339+ iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk)
340340+ iex> {:ok, bin} = Atex.Repo.to_car(repo)
341341+ iex> is_binary(bin)
342342+ true
343343+344344+ """
345345+ @spec to_car(t()) :: {:ok, binary()} | {:error, :no_commit | atom()}
346346+ def to_car(%__MODULE__{commit: nil}), do: {:error, :no_commit}
347347+348348+ def to_car(%__MODULE__{commit: commit, tree: tree, blocks: record_blocks}) do
349349+ with {:ok, commit_cid} <- Commit.cid(commit),
350350+ {:ok, commit_bytes} <- Commit.encode(commit),
351351+ {:ok, ordered_blocks} <- collect_ordered_blocks(tree, record_blocks) do
352352+ # Encode with explicit ordering: commit block must be first so that
353353+ # stream_car/1 can emit {:commit, _} before any {:record, _, _} items.
354354+ encode_car_ordered(commit_cid, commit_bytes, ordered_blocks)
355355+ end
356356+ end
357357+358358+ @doc """
359359+ Decodes a CARv1 binary into a repository struct.
360360+361361+ The first root CID in the CAR header must point to a valid signed commit
362362+ block. The MST is reconstructed from the remaining `:drisl` codec blocks.
363363+ Record blocks are collected into `repo.blocks`.
364364+365365+ The commit signature is **not** verified. Call `verify_commit/2` explicitly
366366+ if you need to authenticate the repository.
367367+368368+ ## Examples
369369+370370+ iex> repo = Atex.Repo.new()
371371+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"})
372372+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
373373+ iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk)
374374+ iex> {:ok, bin} = Atex.Repo.to_car(repo)
375375+ iex> {:ok, repo2} = Atex.Repo.from_car(bin)
376376+ iex> repo2.commit.did
377377+ "did:plc:example"
378378+379379+ """
380380+ @spec from_car(binary()) :: {:ok, t()} | {:error, atom()}
381381+ def from_car(binary) when is_binary(binary) do
382382+ with {:ok, car} <- CAR.decode(binary),
383383+ {:ok, commit_cid} <- car_root_cid(car),
384384+ {:ok, commit} <- decode_commit_block(car.blocks, commit_cid),
385385+ {:ok, tree, record_blocks} <- build_tree_from_car(car.blocks, commit_cid, commit.data) do
386386+ {:ok, %__MODULE__{tree: tree, commit: commit, blocks: record_blocks}}
387387+ end
388388+ end
389389+390390+ @doc """
391391+ Returns a lazy stream over a CARv1 chunk stream, emitting decoded items
392392+ without loading the full repository into memory.
393393+394394+ `chunk_stream` must be an `Enumerable` that yields binary chunks of any
395395+ size - for example `File.stream!("repo.car", [], 65_536)` or a chunked
396396+ HTTP response body. Passing a plain binary also works but is equivalent to
397397+ loading it into memory first; prefer `from_car/1` in that case.
398398+399399+ The stream emits:
400400+401401+ - `{:commit, Atex.Repo.Commit.t()}` - the first item, decoded from the CAR
402402+ root block
403403+ - `{:record, Atex.Repo.Path.t(), map()}` - one per record, decoded in the
404404+ order they appear in the CAR
405405+406406+ The CAR must be in streamable pre-order: commit block first, then MST nodes
407407+ before their child nodes and records. This is the format produced by
408408+ `to_car/1` and by spec-compliant PDS exports. For CARs with arbitrary block
409409+ ordering use `from_car/1` instead.
410410+411411+ If a record block is encountered before its parent MST node has been seen
412412+ (i.e. the path cannot be resolved from already-decoded nodes), the stream
413413+ emits `{:error, :unresolvable_record, cid}` and halts. Parse errors raise a
414414+ `RuntimeError` (consistent with `DASL.CAR.stream_decode/2` semantics).
415415+416416+ ## Examples
417417+418418+ From a file without loading it fully into memory:
419419+420420+ File.stream!("repo.car", 65_536, [:raw, :binary])
421421+ |> Atex.Repo.stream_car()
422422+ |> Enum.each(fn
423423+ {:commit, commit} -> IO.puts(commit.did)
424424+ {:record, path, record} -> IO.inspect({to_string(path), record})
425425+ end)
426426+427427+ From a binary (e.g. in tests):
428428+429429+ iex> repo = Atex.Repo.new()
430430+ iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1})
431431+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
432432+ iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk)
433433+ iex> {:ok, bin} = Atex.Repo.to_car(repo)
434434+ iex> items = Atex.Repo.stream_car([bin]) |> Enum.to_list()
435435+ iex> match?([{:commit, _} | _], items)
436436+ true
437437+ iex> Enum.any?(items, &match?({:record, _, _}, &1))
438438+ true
439439+440440+ Partial consumption with `Stream.take/2` works without raising:
441441+442442+ File.stream!("repo.car", 65_536, [:raw, :binary])
443443+ |> Atex.Repo.stream_car()
444444+ |> Stream.filter(&match?({:record, _, _}, &1))
445445+ |> Stream.take(10)
446446+ |> Enum.to_list()
447447+448448+ """
449449+ @spec stream_car(Enumerable.t()) :: Enumerable.t()
450450+ def stream_car(chunk_stream) do
451451+ # safe_car_decode/1 wraps CAR.stream_decode so that halting the stream
452452+ # early (Stream.take, Enum.reduce_while with :halt, etc.) does not raise.
453453+ # Items are emitted as each incoming chunk is processed - no buffering.
454454+ chunk_stream
455455+ |> safe_car_decode()
456456+ |> Stream.transform(
457457+ fn -> %{commit_cid: nil, cid_to_path: %{}, halted: false} end,
458458+ &reduce_car_item/2,
459459+ fn _ -> :ok end
460460+ )
461461+ end
462462+463463+ @doc """
464464+ Verifies the commit signature against the given public key.
465465+466466+ Delegates to `Atex.Repo.Commit.verify/2`.
467467+468468+ ## Examples
469469+470470+ iex> repo = Atex.Repo.new()
471471+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
472472+ iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk)
473473+ iex> Atex.Repo.verify_commit(repo, JOSE.JWK.to_public(jwk))
474474+ :ok
475475+476476+ """
477477+ @spec verify_commit(t(), JOSE.JWK.t()) :: :ok | {:error, :no_commit | atom()}
478478+ def verify_commit(%__MODULE__{commit: nil}, _jwk), do: {:error, :no_commit}
479479+480480+ def verify_commit(%__MODULE__{commit: commit}, jwk) do
481481+ Commit.verify(commit, jwk)
482482+ end
483483+484484+ # ---------------------------------------------------------------------------
485485+ # Private - path coercion
486486+ # ---------------------------------------------------------------------------
487487+488488+ @spec coerce_path(String.t() | Path.t()) ::
489489+ {:ok, String.t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey}
490490+ defp coerce_path(%Path{} = path), do: {:ok, Path.to_string(path)}
491491+492492+ defp coerce_path(string) when is_binary(string) do
493493+ case Path.from_string(string) do
494494+ {:ok, _} -> {:ok, string}
495495+ {:error, _} = err -> err
496496+ end
497497+ end
498498+499499+ # ---------------------------------------------------------------------------
500500+ # Private - collection streaming helpers
501501+ # ---------------------------------------------------------------------------
502502+503503+ @spec collection_from_key(String.t()) :: String.t()
504504+ defp collection_from_key(key) do
505505+ key |> String.split("/", parts: 2) |> hd()
506506+ end
507507+508508+ # Filters an MST key stream to only those belonging to `prefix`, halting
509509+ # once the first key past the prefix is encountered (exploiting sort order).
510510+ @spec stream_collection(Enumerable.t(), String.t()) :: Enumerable.t()
511511+ defp stream_collection(stream, prefix) do
512512+ stream
513513+ |> Stream.transform(:before, fn {key, cid}, state ->
514514+ cond do
515515+ String.starts_with?(key, prefix) -> {[{key, cid}], :in}
516516+ state == :in -> {:halt, :done}
517517+ true -> {[], :before}
518518+ end
519519+ end)
520520+ end
521521+522522+ # ---------------------------------------------------------------------------
523523+ # Private - record block decoding
524524+ # ---------------------------------------------------------------------------
525525+526526+ @spec decode_record(%{CID.t() => binary()}, CID.t()) :: {:ok, map()} | {:error, atom()}
527527+ defp decode_record(blocks, cid) do
528528+ with {:ok, bytes} <- fetch_block(blocks, cid),
529529+ {:ok, record, _rest} <- DRISL.decode(bytes) do
530530+ {:ok, record}
531531+ end
532532+ end
533533+534534+ # ---------------------------------------------------------------------------
535535+ # Private - safe CAR stream wrapper
536536+ # ---------------------------------------------------------------------------
537537+538538+ # Wraps CAR.stream_decode/1 in a Stream.resource that manually drives the
539539+ # inner enumerable one item at a time via its suspension continuation.
540540+ #
541541+ # The key property: when the downstream halts early (Stream.take, etc.),
542542+ # the cleanup function calls the continuation with {:halt, nil}, which
543543+ # triggers DASL.CAR.StreamDecoder.finish/1. That function raises a
544544+ # RuntimeError if its internal buffer is non-empty (as it will be mid-stream).
545545+ # We catch that specific raise here so callers never see it.
546546+ #
547547+ # Genuine parse errors (truncated file, CID mismatch) still propagate because
548548+ # they originate in next_fun, not in the cleanup path.
549549+ @spec safe_car_decode(Enumerable.t()) :: Enumerable.t()
550550+ defp safe_car_decode(chunk_stream) do
551551+ # The step function suspends after every item, giving us a continuation
552552+ # we can call directly: cont.({:cont, nil}) to advance, cont.({:halt, nil})
553553+ # to clean up. The continuation already has the reducer baked in from the
554554+ # initial Enumerable.reduce call so subsequent steps just call it directly.
555555+ step = fn item, _ -> {:suspend, item} end
556556+557557+ Stream.resource(
558558+ fn ->
559559+ case Enumerable.reduce(CAR.stream_decode(chunk_stream), {:cont, nil}, step) do
560560+ {:suspended, item, cont} -> {item, cont}
561561+ _ -> :done
562562+ end
563563+ end,
564564+ fn
565565+ :done ->
566566+ {:halt, :done}
567567+568568+ {item, cont} ->
569569+ next =
570570+ case cont.({:cont, nil}) do
571571+ {:suspended, next_item, next_cont} -> {next_item, next_cont}
572572+ _ -> :done
573573+ end
574574+575575+ {[item], next}
576576+ end,
577577+ fn
578578+ :done ->
579579+ :ok
580580+581581+ {_item, cont} ->
582582+ try do
583583+ cont.({:halt, nil})
584584+ rescue
585585+ RuntimeError -> :ok
586586+ end
587587+ end
588588+ )
589589+ end
590590+591591+ # ---------------------------------------------------------------------------
592592+ # Private - stream_car incremental reducer
593593+ # ---------------------------------------------------------------------------
594594+595595+ # State fields:
596596+ # commit_cid - the root CID from the CAR header (first root)
597597+ # cid_to_path - %{record_value_CID => "collection/rkey"}, built as MST
598598+ # node blocks arrive in pre-order
599599+ # halted - true after an unrecoverable error; blocks are skipped but
600600+ # the source stream is always allowed to finish naturally
601601+602602+ @spec reduce_car_item(DASL.CAR.StreamDecoder.stream_item(), map()) :: {list(), map()}
603603+ defp reduce_car_item(_item, %{halted: true} = state), do: {[], state}
604604+605605+ defp reduce_car_item({:header, _version, [root | _]}, state) do
606606+ {[], %{state | commit_cid: root}}
607607+ end
608608+609609+ defp reduce_car_item({:header, _version, []}, state) do
610610+ {[{:error, :no_root}], %{state | halted: true}}
611611+ end
612612+613613+ defp reduce_car_item({:block, cid, data}, %{commit_cid: commit_cid} = state) do
614614+ cond do
615615+ cid == commit_cid ->
616616+ case Commit.decode(data) do
617617+ {:ok, commit, _} -> {[{:commit, commit}], state}
618618+ {:error, reason} -> {[{:error, reason}], %{state | halted: true}}
619619+ end
620620+621621+ cid.codec == :drisl ->
622622+ case Node.decode(data) do
623623+ {:ok, node} ->
624624+ full_keys = MST.Node.keys(node)
625625+626626+ cid_to_path =
627627+ node.entries
628628+ |> Enum.zip(full_keys)
629629+ |> Enum.reduce(state.cid_to_path, fn {entry, key}, acc ->
630630+ Map.put(acc, entry.value.bytes, key)
631631+ end)
632632+633633+ {[], %{state | cid_to_path: cid_to_path}}
634634+635635+ {:error, :decode, _} ->
636636+ emit_record_block(cid, data, state)
637637+ end
638638+639639+ true ->
640640+ emit_record_block(cid, data, state)
641641+ end
642642+ end
643643+644644+ @spec emit_record_block(CID.t(), binary(), map()) :: {list(), map()}
645645+ defp emit_record_block(cid, data, state) do
646646+ case Map.fetch(state.cid_to_path, cid.bytes) do
647647+ :error ->
648648+ {[{:error, :unresolvable_record, cid}], %{state | halted: true}}
649649+650650+ {:ok, key} ->
651651+ case DRISL.decode(data) do
652652+ {:error, _} ->
653653+ {[], state}
654654+655655+ {:ok, record, _} ->
656656+ case String.split(key, "/", parts: 2) do
657657+ [collection, rkey] ->
658658+ {[{:record, %Path{collection: collection, rkey: rkey}, record}], state}
659659+660660+ _ ->
661661+ {[], state}
662662+ end
663663+ end
664664+ end
665665+ end
666666+667667+ # ---------------------------------------------------------------------------
668668+ # Private - CAR export helpers
669669+ # ---------------------------------------------------------------------------
670670+671671+ # Encodes a CARv1 binary with the commit block guaranteed to be first,
672672+ # followed by the MST and record blocks in pre-order. This ensures the output
673673+ # is in streamable order per the spec and is correctly processed by stream_car/1.
674674+ @spec encode_car_ordered(CID.t(), binary(), ordered_acc()) ::
675675+ {:ok, binary()} | {:error, atom()}
676676+ defp encode_car_ordered(commit_cid, commit_bytes, {blocks_map, rev_order}) do
677677+ alias Varint.LEB128
678678+679679+ # Build each block as an iolist: [leb128_length, cid_bytes, data].
680680+ # Accumulating iolists avoids binary copying at each step; a single
681681+ # :erlang.iolist_to_binary at the end does one allocation.
682682+ encode_block_io = fn %CID{bytes: cid_bytes}, data ->
683683+ [LEB128.encode(byte_size(cid_bytes) + byte_size(data)), cid_bytes, data]
684684+ end
685685+686686+ with {:ok, header_bin} <-
687687+ DRISL.encode(%{"version" => 1, "roots" => [commit_cid]}) do
688688+ header_io = [LEB128.encode(byte_size(header_bin)), header_bin]
689689+ commit_io = encode_block_io.(commit_cid, commit_bytes)
690690+691691+ # rev_order was built by prepending, so reverse to get pre-order sequence.
692692+ rest_io =
693693+ rev_order
694694+ |> Enum.reverse()
695695+ |> Enum.map(fn cid -> encode_block_io.(cid, Map.fetch!(blocks_map, cid)) end)
696696+697697+ {:ok, :erlang.iolist_to_binary([header_io, commit_io, rest_io])}
698698+ end
699699+ end
700700+701701+ # Returns {blocks_map, ordered_cids} where ordered_cids preserves pre-order
702702+ # insertion sequence. This is necessary because Elixir maps do not preserve
703703+ # insertion order - iterating a map in encode_car_ordered/3 would lose the
704704+ # pre-order block sequencing required for streamable CARs.
705705+ @type ordered_acc() :: {%{CID.t() => binary()}, [CID.t()]}
706706+707707+ @spec collect_ordered_blocks(Tree.t(), %{CID.t() => binary()}) ::
708708+ {:ok, ordered_acc()} | {:error, atom()}
709709+ defp collect_ordered_blocks(%Tree{root: nil}, _record_blocks) do
710710+ empty = Node.empty()
711711+ {:ok, bytes} = Node.encode(empty)
712712+ cid = CID.compute(bytes, :drisl)
713713+ {:ok, {%{cid => bytes}, [cid]}}
714714+ end
715715+716716+ defp collect_ordered_blocks(%Tree{root: root, store: store}, record_blocks) do
717717+ collect_node_blocks(store, root, record_blocks, {%{}, []})
718718+ end
719719+720720+ @spec collect_node_blocks(Store.t(), CID.t(), %{CID.t() => binary()}, ordered_acc()) ::
721721+ {:ok, ordered_acc()} | {:error, atom()}
722722+ defp collect_node_blocks(store, cid, record_blocks, {map, order}) do
723723+ with {:ok, node} <- Store.get(store, cid),
724724+ {:ok, node_bytes} <- Node.encode(node) do
725725+ acc = {Map.put(map, cid, node_bytes), [cid | order]}
726726+727727+ Enum.reduce_while(build_preorder_steps(node), {:ok, acc}, fn step, {:ok, {map, order}} ->
728728+ case step do
729729+ {:node, child_cid} ->
730730+ case collect_node_blocks(store, child_cid, record_blocks, {map, order}) do
731731+ {:ok, acc} -> {:cont, {:ok, acc}}
732732+ err -> {:halt, err}
733733+ end
734734+735735+ {:record, record_cid} ->
736736+ case Map.fetch(record_blocks, record_cid) do
737737+ {:ok, bytes} ->
738738+ {:cont, {:ok, {Map.put(map, record_cid, bytes), [record_cid | order]}}}
739739+740740+ :error ->
741741+ {:cont, {:ok, {map, order}}}
742742+ end
743743+ end
744744+ end)
745745+ else
746746+ {:error, :not_found} -> {:error, :missing_node}
747747+ {:error, :encode, reason} -> {:error, reason}
748748+ end
749749+ end
750750+751751+ @spec build_preorder_steps(Node.t()) :: list()
752752+ defp build_preorder_steps(node) do
753753+ left_steps = if node.left, do: [{:node, node.left}], else: []
754754+755755+ entry_steps =
756756+ Enum.flat_map(node.entries, fn entry ->
757757+ right_steps = if entry.right, do: [{:node, entry.right}], else: []
758758+ [{:record, entry.value} | right_steps]
759759+ end)
760760+761761+ left_steps ++ entry_steps
762762+ end
763763+764764+ # ---------------------------------------------------------------------------
765765+ # Private - CAR import helpers
766766+ # ---------------------------------------------------------------------------
767767+768768+ @spec car_root_cid(CAR.t()) :: {:ok, CID.t()} | {:error, :no_root}
769769+ defp car_root_cid(%CAR{roots: [cid | _]}), do: {:ok, cid}
770770+ defp car_root_cid(%CAR{roots: []}), do: {:error, :no_root}
771771+772772+ @spec decode_commit_block(%{CID.t() => binary()}, CID.t()) ::
773773+ {:ok, Commit.t()} | {:error, atom()}
774774+ defp decode_commit_block(blocks, cid) do
775775+ with {:ok, bytes} <- fetch_block(blocks, cid),
776776+ {:ok, commit, _rest} <- Commit.decode(bytes) do
777777+ {:ok, commit}
778778+ end
779779+ end
780780+781781+ @spec build_tree_from_car(%{CID.t() => binary()}, CID.t(), CID.t() | nil) ::
782782+ {:ok, Tree.t(), %{CID.t() => binary()}} | {:error, atom()}
783783+ defp build_tree_from_car(blocks, commit_cid, mst_root) do
784784+ result =
785785+ Enum.reduce_while(blocks, {:ok, Store.Memory.new(), %{}}, fn {cid, data},
786786+ {:ok, store, rec_blocks} ->
787787+ cond do
788788+ cid == commit_cid ->
789789+ {:cont, {:ok, store, rec_blocks}}
790790+791791+ cid.codec == :drisl ->
792792+ case Node.decode(data) do
793793+ {:ok, node} ->
794794+ {:cont, {:ok, Store.put(store, cid, node), rec_blocks}}
795795+796796+ {:error, :decode, _reason} ->
797797+ {:cont, {:ok, store, Map.put(rec_blocks, cid, data)}}
798798+ end
799799+800800+ true ->
801801+ {:cont, {:ok, store, Map.put(rec_blocks, cid, data)}}
802802+ end
803803+ end)
804804+805805+ with {:ok, store, record_blocks} <- result do
806806+ {:ok, Tree.from_root(mst_root, store), record_blocks}
807807+ end
808808+ end
809809+810810+ # ---------------------------------------------------------------------------
811811+ # Private - misc
812812+ # ---------------------------------------------------------------------------
813813+814814+ @spec mst_root_cid(Tree.t()) :: CID.t()
815815+ defp mst_root_cid(%Tree{root: nil}) do
816816+ empty = Node.empty()
817817+ {:ok, bytes} = Node.encode(empty)
818818+ CID.compute(bytes, :drisl)
819819+ end
820820+821821+ defp mst_root_cid(%Tree{root: cid}), do: cid
822822+823823+ @spec fetch_block(%{CID.t() => binary()}, CID.t()) :: {:ok, binary()} | {:error, :not_found}
824824+ defp fetch_block(blocks, cid) do
825825+ case Map.fetch(blocks, cid) do
826826+ {:ok, bytes} -> {:ok, bytes}
827827+ :error -> {:error, :not_found}
828828+ end
829829+ end
830830+end
831831+832832+defimpl Inspect, for: Atex.Repo do
833833+ import Inspect.Algebra
834834+835835+ def inspect(%Atex.Repo{commit: nil, blocks: blocks}, _opts) do
836836+ concat(["#Atex.Repo<uncommitted records=", Integer.to_string(map_size(blocks)), ">"])
837837+ end
838838+839839+ def inspect(%Atex.Repo{commit: commit, blocks: blocks}, _opts) do
840840+ concat([
841841+ "#Atex.Repo<",
842842+ commit.did,
843843+ " rev=",
844844+ commit.rev,
845845+ " records=",
846846+ Integer.to_string(map_size(blocks)),
847847+ ">"
848848+ ])
849849+ end
850850+end
+334
lib/atex/repo/commit.ex
···11+defmodule Atex.Repo.Commit do
22+ @moduledoc """
33+ The signed commit object at the top of an AT Protocol repository.
44+55+ A commit binds together:
66+77+ - The account DID that owns the repository.
88+ - A CID link (`data`) to the root of the MST that holds all records.
99+ - A monotonically-increasing revision (`rev`) in TID string format, used as
1010+ a logical clock.
1111+ - A `prev` link to the previous commit (virtually always `nil` in v3 repos,
1212+ but the field must be present in the CBOR object).
1313+ - A cryptographic `sig` over the DRISL CBOR encoding of the unsigned commit.
1414+1515+ ## Signing a commit
1616+1717+ The signing convention follows the AT Protocol repository spec:
1818+1919+ 1. Build an unsigned commit (all fields except `sig`).
2020+ 2. Encode it with `encode_unsigned/1` to get the DRISL CBOR bytes.
2121+ 3. SHA-256 hash the bytes, then ECDSA-sign the *hash* with the account's
2222+ signing key.
2323+ 4. Store the raw (DER-encoded) signature bytes in `sig`.
2424+2525+ `sign/2` performs steps 2–4 in one call. Verification with `verify/2`
2626+ reverses the process using a public key.
2727+2828+ ## CID computation
2929+3030+ The CID for a commit is computed from the DRISL CBOR encoding of the **signed**
3131+ commit object (with `sig` present), using the `:drisl` codec.
3232+3333+ ## Wire format
3434+3535+ Map keys follow the AT Protocol specification field names:
3636+3737+ - `"did"` - account DID string
3838+ - `"version"` - integer `3`
3939+ - `"data"` - CID link to MST root
4040+ - `"rev"` - TID string
4141+ - `"prev"` - CID link or `nil`
4242+ - `"sig"` - raw ECDSA signature bytes (absent from the unsigned map)
4343+4444+ ATProto spec: https://atproto.com/specs/repository#commit-objects
4545+ """
4646+4747+ use TypedStruct
4848+ alias Atex.Crypto
4949+ alias DASL.{CID, DRISL}
5050+5151+ @version 3
5252+5353+ typedstruct enforce: true do
5454+ @typedoc "A v3 AT Protocol repository commit."
5555+5656+ field :did, String.t()
5757+ field :version, pos_integer(), default: @version
5858+ field :data, CID.t()
5959+ field :rev, String.t()
6060+ field :prev, CID.t() | nil
6161+ field :sig, binary() | nil
6262+ end
6363+6464+ @doc """
6565+ Builds an unsigned commit struct from the given fields.
6666+6767+ `sig` is set to `nil`.
6868+6969+ ## Options
7070+7171+ - `:did` (required) - the account DID string
7272+ - `:data` (required) - `DASL.CID` pointing to the MST root
7373+ - `:rev` (required) - TID string used as the logical clock
7474+ - `:prev` - `DASL.CID` pointing to the previous commit, or `nil` (default)
7575+7676+ ## Examples
7777+7878+ iex> data_cid = DASL.CID.compute("data", :drisl)
7979+ iex> commit = Atex.Repo.Commit.new(
8080+ ...> did: "did:plc:example",
8181+ ...> data: data_cid,
8282+ ...> rev: "3jzfcijpj2z2a"
8383+ ...> )
8484+ iex> commit.version
8585+ 3
8686+ iex> commit.sig
8787+ nil
8888+8989+ """
9090+ @spec new(keyword()) :: t()
9191+ def new(fields) do
9292+ %__MODULE__{
9393+ did: Keyword.fetch!(fields, :did),
9494+ version: @version,
9595+ data: Keyword.fetch!(fields, :data),
9696+ rev: Keyword.fetch!(fields, :rev),
9797+ prev: Keyword.get(fields, :prev, nil),
9898+ sig: nil
9999+ }
100100+ end
101101+102102+ @doc """
103103+ Serializes the commit **without** the `sig` field as DRISL CBOR.
104104+105105+ This is the payload that is hashed and signed. The `sig` field is omitted
106106+ entirely from the map, as required by the spec.
107107+108108+ ## Examples
109109+110110+ iex> data_cid = DASL.CID.compute("data", :drisl)
111111+ iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a")
112112+ iex> {:ok, bin} = Atex.Repo.Commit.encode_unsigned(commit)
113113+ iex> is_binary(bin)
114114+ true
115115+116116+ """
117117+ @spec encode_unsigned(t()) :: {:ok, binary()} | {:error, atom()}
118118+ def encode_unsigned(%__MODULE__{} = commit) do
119119+ commit |> to_unsigned_map() |> DRISL.encode()
120120+ end
121121+122122+ @doc """
123123+ Serializes a signed commit (including `sig`) as DRISL CBOR.
124124+125125+ Returns `{:error, :unsigned}` if `sig` is `nil`.
126126+127127+ ## Examples
128128+129129+ iex> data_cid = DASL.CID.compute("data", :drisl)
130130+ iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a")
131131+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
132132+ iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk)
133133+ iex> {:ok, bin} = Atex.Repo.Commit.encode(signed)
134134+ iex> is_binary(bin)
135135+ true
136136+137137+ """
138138+ @spec encode(t()) :: {:ok, binary()} | {:error, :unsigned | atom()}
139139+ def encode(%__MODULE__{sig: nil}), do: {:error, :unsigned}
140140+141141+ def encode(%__MODULE__{} = commit) do
142142+ commit |> to_signed_map() |> DRISL.encode()
143143+ end
144144+145145+ @doc """
146146+ Decodes a DRISL CBOR binary into a `%Atex.Repo.Commit{}`.
147147+148148+ Accepts both signed (with `"sig"`) and unsigned (without `"sig"`) payloads.
149149+150150+ ## Examples
151151+152152+ iex> data_cid = DASL.CID.compute("data", :drisl)
153153+ iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a")
154154+ iex> {:ok, bin} = Atex.Repo.Commit.encode_unsigned(commit)
155155+ iex> {:ok, decoded, ""} = Atex.Repo.Commit.decode(bin)
156156+ iex> decoded.did
157157+ "did:plc:e"
158158+159159+ """
160160+ @spec decode(binary()) :: {:ok, t(), binary()} | {:error, atom()}
161161+ def decode(binary) when is_binary(binary) do
162162+ with {:ok, map, rest} <- DRISL.decode(binary),
163163+ {:ok, commit} <- from_map(map) do
164164+ {:ok, commit, rest}
165165+ end
166166+ end
167167+168168+ @doc """
169169+ Signs an unsigned commit with the given private key.
170170+171171+ Encodes the unsigned commit as DRISL CBOR and signs the bytes using
172172+ `Atex.Crypto.sign/2` (SHA-256 ECDSA, low-S normalized DER output).
173173+174174+ Returns `{:error, :already_signed}` if `sig` is already present.
175175+176176+ ## Examples
177177+178178+ iex> data_cid = DASL.CID.compute("data", :drisl)
179179+ iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a")
180180+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
181181+ iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk)
182182+ iex> is_binary(signed.sig)
183183+ true
184184+185185+ """
186186+ @spec sign(t(), JOSE.JWK.t()) :: {:ok, t()} | {:error, :already_signed | atom()}
187187+ def sign(%__MODULE__{sig: sig}, _jwk) when not is_nil(sig), do: {:error, :already_signed}
188188+189189+ def sign(%__MODULE__{} = commit, jwk) do
190190+ with {:ok, payload} <- encode_unsigned(commit),
191191+ {:ok, sig} <- Crypto.sign(payload, jwk) do
192192+ {:ok, %{commit | sig: sig}}
193193+ end
194194+ end
195195+196196+ @doc """
197197+ Verifies the signature of a signed commit against the given public key.
198198+199199+ Returns `:ok` or `{:error, reason}`.
200200+201201+ ## Examples
202202+203203+ iex> data_cid = DASL.CID.compute("data", :drisl)
204204+ iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a")
205205+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
206206+ iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk)
207207+ iex> Atex.Repo.Commit.verify(signed, JOSE.JWK.to_public(jwk))
208208+ :ok
209209+210210+ """
211211+ @spec verify(t(), JOSE.JWK.t()) :: :ok | {:error, :unsigned | atom()}
212212+ def verify(%__MODULE__{sig: nil}, _jwk), do: {:error, :unsigned}
213213+214214+ def verify(%__MODULE__{sig: sig} = commit, jwk) do
215215+ with {:ok, payload} <- encode_unsigned(commit) do
216216+ Crypto.verify(payload, sig, jwk)
217217+ end
218218+ end
219219+220220+ @doc """
221221+ Computes the CID of a signed commit.
222222+223223+ The CID is derived from the DRISL CBOR encoding of the **signed** commit
224224+ object, using the `:drisl` codec (blessed CID format).
225225+226226+ Returns `{:error, :unsigned}` if `sig` is `nil`.
227227+228228+ ## Examples
229229+230230+ iex> data_cid = DASL.CID.compute("data", :drisl)
231231+ iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a")
232232+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
233233+ iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk)
234234+ iex> {:ok, cid} = Atex.Repo.Commit.cid(signed)
235235+ iex> cid.codec
236236+ :drisl
237237+238238+ """
239239+ @spec cid(t()) :: {:ok, CID.t()} | {:error, :unsigned | atom()}
240240+ def cid(%__MODULE__{sig: nil}), do: {:error, :unsigned}
241241+242242+ def cid(%__MODULE__{} = commit) do
243243+ with {:ok, bytes} <- encode(commit) do
244244+ {:ok, CID.compute(bytes, :drisl)}
245245+ end
246246+ end
247247+248248+ # ---------------------------------------------------------------------------
249249+ # Private helpers
250250+ # ---------------------------------------------------------------------------
251251+252252+ @spec to_unsigned_map(t()) :: map()
253253+ defp to_unsigned_map(%__MODULE__{} = c) do
254254+ %{
255255+ "did" => c.did,
256256+ "version" => c.version,
257257+ "data" => c.data,
258258+ "rev" => c.rev,
259259+ "prev" => c.prev
260260+ }
261261+ end
262262+263263+ @spec to_signed_map(t()) :: map()
264264+ defp to_signed_map(%__MODULE__{} = c) do
265265+ c
266266+ |> to_unsigned_map()
267267+ |> Map.put("sig", %CBOR.Tag{tag: :bytes, value: c.sig})
268268+ end
269269+270270+ @spec from_map(map()) :: {:ok, t()} | {:error, atom()}
271271+ defp from_map(map) when is_map(map) do
272272+ with {:ok, did} <- fetch_string(map, "did"),
273273+ {:ok, version} <- fetch_integer(map, "version"),
274274+ {:ok, data} <- fetch_cid(map, "data"),
275275+ {:ok, rev} <- fetch_string(map, "rev"),
276276+ {:ok, prev} <- fetch_nullable_cid(map, "prev") do
277277+ sig = extract_sig(Map.get(map, "sig"))
278278+279279+ {:ok,
280280+ %__MODULE__{
281281+ did: did,
282282+ version: version,
283283+ data: data,
284284+ rev: rev,
285285+ prev: prev,
286286+ sig: sig
287287+ }}
288288+ end
289289+ end
290290+291291+ defp from_map(_), do: {:error, :invalid_commit}
292292+293293+ @spec extract_sig(any()) :: binary() | nil
294294+ defp extract_sig(%CBOR.Tag{tag: :bytes, value: bytes}) when is_binary(bytes), do: bytes
295295+ defp extract_sig(bytes) when is_binary(bytes), do: bytes
296296+ defp extract_sig(_), do: nil
297297+298298+ @spec fetch_string(map(), String.t()) :: {:ok, String.t()} | {:error, atom()}
299299+ defp fetch_string(map, key) do
300300+ case Map.fetch(map, key) do
301301+ {:ok, val} when is_binary(val) -> {:ok, val}
302302+ {:ok, _} -> {:error, :invalid_commit}
303303+ :error -> {:error, :missing_field}
304304+ end
305305+ end
306306+307307+ @spec fetch_integer(map(), String.t()) :: {:ok, integer()} | {:error, atom()}
308308+ defp fetch_integer(map, key) do
309309+ case Map.fetch(map, key) do
310310+ {:ok, val} when is_integer(val) -> {:ok, val}
311311+ {:ok, _} -> {:error, :invalid_commit}
312312+ :error -> {:error, :missing_field}
313313+ end
314314+ end
315315+316316+ @spec fetch_cid(map(), String.t()) :: {:ok, CID.t()} | {:error, atom()}
317317+ defp fetch_cid(map, key) do
318318+ case Map.fetch(map, key) do
319319+ {:ok, %CID{} = cid} -> {:ok, cid}
320320+ {:ok, _} -> {:error, :invalid_commit}
321321+ :error -> {:error, :missing_field}
322322+ end
323323+ end
324324+325325+ @spec fetch_nullable_cid(map(), String.t()) :: {:ok, CID.t() | nil} | {:error, atom()}
326326+ defp fetch_nullable_cid(map, key) do
327327+ case Map.fetch(map, key) do
328328+ {:ok, %CID{} = cid} -> {:ok, cid}
329329+ {:ok, nil} -> {:ok, nil}
330330+ {:ok, _} -> {:error, :invalid_commit}
331331+ :error -> {:ok, nil}
332332+ end
333333+ end
334334+end
+219
lib/atex/repo/path.ex
···11+defmodule Atex.Repo.Path do
22+ @moduledoc """
33+ A validated AT Protocol repository path - a `collection/rkey` pair.
44+55+ Repo paths identify individual records within a repository. They always have
66+ exactly two segments separated by a single `/`:
77+88+ - **collection** - a valid NSID string (e.g. `"app.bsky.feed.post"`)
99+ - **rkey** - a record key string (e.g. `"3jzfcijpj2z2a"`, `"self"`,
1010+ `"example.com"`)
1111+1212+ ## Character constraints
1313+1414+ Collection segments follow NSID syntax: alphanumeric characters and periods
1515+ (`A-Za-z0-9.`), at least two period-separated components.
1616+1717+ Record keys allow: `A-Za-z0-9 . - _ : ~` (per
1818+ [spec](https://atproto.com/specs/record-key)), with a minimum length of 1
1919+ and the values `"."` and `".."` disallowed.
2020+2121+ ## Usage
2222+2323+ iex> {:ok, path} = Atex.Repo.Path.new("app.bsky.feed.post", "3jzfcijpj2z2a")
2424+ iex> to_string(path)
2525+ "app.bsky.feed.post/3jzfcijpj2z2a"
2626+2727+ iex> {:ok, path} = Atex.Repo.Path.from_string("app.bsky.actor.profile/self")
2828+ iex> path.collection
2929+ "app.bsky.actor.profile"
3030+ iex> path.rkey
3131+ "self"
3232+3333+ ## `String.Chars` and interpolation
3434+3535+ `Atex.Repo.Path` implements `String.Chars`, so paths can be used directly
3636+ in string interpolation and anywhere a string path is expected:
3737+3838+ iex> path = Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
3939+ iex> "Record at \#{path}"
4040+ "Record at app.bsky.feed.post/3jzfcijpj2z2a"
4141+4242+ ATProto spec: https://atproto.com/specs/repository#repository-paths
4343+ """
4444+4545+ use TypedStruct
4646+4747+ # Collection: NSID - only A-Za-z0-9 and periods, must have at least one dot
4848+ # (i.e. at least two components). Case-sensitive, no leading/trailing dots.
4949+ @collection_re ~r/^[a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+$/
5050+5151+ # Record key: A-Za-z0-9 .-_:~ only, min 1 char.
5252+ @rkey_re ~r/^[A-Za-z0-9.\-_:~]+$/
5353+5454+ @reserved_rkeys [~c".", ~c".."]
5555+5656+ typedstruct enforce: true do
5757+ @typedoc "A validated AT Protocol repository path (collection + rkey)."
5858+ field :collection, String.t()
5959+ field :rkey, String.t()
6060+ end
6161+6262+ @doc """
6363+ Builds a validated `%Atex.Repo.Path{}` from a collection and record key.
6464+6565+ Returns `{:error, :invalid_collection}` if the collection is not a valid
6666+ NSID, or `{:error, :invalid_rkey}` if the record key contains disallowed
6767+ characters or is a reserved value (`.` or `..`).
6868+6969+ ## Examples
7070+7171+ iex> Atex.Repo.Path.new("app.bsky.feed.post", "3jzfcijpj2z2a")
7272+ {:ok, %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}}
7373+7474+ iex> Atex.Repo.Path.new("not-an-nsid", "self")
7575+ {:error, :invalid_collection}
7676+7777+ iex> Atex.Repo.Path.new("app.bsky.feed.post", "..")
7878+ {:error, :invalid_rkey}
7979+8080+ iex> Atex.Repo.Path.new("app.bsky.feed.post", "bad key!")
8181+ {:error, :invalid_rkey}
8282+8383+ """
8484+ @spec new(String.t(), String.t()) :: {:ok, t()} | {:error, :invalid_collection | :invalid_rkey}
8585+ def new(collection, rkey) when is_binary(collection) and is_binary(rkey) do
8686+ with :ok <- validate_collection(collection),
8787+ :ok <- validate_rkey(rkey) do
8888+ {:ok, %__MODULE__{collection: collection, rkey: rkey}}
8989+ end
9090+ end
9191+9292+ @doc """
9393+ Builds a validated `%Atex.Repo.Path{}`, raising on invalid input.
9494+9595+ ## Examples
9696+9797+ iex> Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
9898+ %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}
9999+100100+ """
101101+ @spec new!(String.t(), String.t()) :: t()
102102+ def new!(collection, rkey) do
103103+ case new(collection, rkey) do
104104+ {:ok, path} -> path
105105+ {:error, reason} -> raise ArgumentError, "invalid repo path: #{reason}"
106106+ end
107107+ end
108108+109109+ @doc """
110110+ Parses a `"collection/rkey"` string into a validated `%Atex.Repo.Path{}`.
111111+112112+ Returns `{:error, :invalid_path}` if the string does not contain exactly one
113113+ `/`, or if either segment is invalid.
114114+115115+ ## Examples
116116+117117+ iex> Atex.Repo.Path.from_string("app.bsky.feed.post/3jzfcijpj2z2a")
118118+ {:ok, %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}}
119119+120120+ iex> Atex.Repo.Path.from_string("no-slash")
121121+ {:error, :invalid_path}
122122+123123+ iex> Atex.Repo.Path.from_string("a/b/c")
124124+ {:error, :invalid_path}
125125+126126+ """
127127+ @spec from_string(String.t()) ::
128128+ {:ok, t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey}
129129+ def from_string(string) when is_binary(string) do
130130+ case String.split(string, "/") do
131131+ [collection, rkey] when collection != "" and rkey != "" ->
132132+ case new(collection, rkey) do
133133+ {:ok, _} = ok -> ok
134134+ {:error, _} = err -> err
135135+ end
136136+137137+ _ ->
138138+ {:error, :invalid_path}
139139+ end
140140+ end
141141+142142+ @doc """
143143+ Parses a `"collection/rkey"` string into a validated `%Atex.Repo.Path{}`,
144144+ raising on invalid input.
145145+146146+ ## Examples
147147+148148+ iex> Atex.Repo.Path.from_string!("app.bsky.feed.post/3jzfcijpj2z2a")
149149+ %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}
150150+151151+ """
152152+ @spec from_string!(String.t()) :: t()
153153+ def from_string!(string) when is_binary(string) do
154154+ case from_string(string) do
155155+ {:ok, path} -> path
156156+ {:error, reason} -> raise ArgumentError, "invalid repo path: #{reason}"
157157+ end
158158+ end
159159+160160+ @doc """
161161+ Converts the path to its canonical `"collection/rkey"` string form.
162162+163163+ ## Examples
164164+165165+ iex> path = Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
166166+ iex> Atex.Repo.Path.to_string(path)
167167+ "app.bsky.feed.post/3jzfcijpj2z2a"
168168+169169+ """
170170+ @spec to_string(t()) :: String.t()
171171+ def to_string(%__MODULE__{collection: collection, rkey: rkey}), do: "#{collection}/#{rkey}"
172172+173173+ @doc """
174174+ Sigil for constructing a validated `%Atex.Repo.Path{}` from a literal string.
175175+176176+ Raises `ArgumentError` if the string is not a valid `"collection/rkey"` path.
177177+ To use this sigil, import `Atex.Repo.Path`.
178178+179179+ ## Examples
180180+181181+ iex> import Atex.Repo.Path
182182+ iex> ~PATH"app.bsky.feed.post/3jzfcijpj2z2a"
183183+ %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}
184184+185185+ """
186186+ @spec sigil_PATH(String.t(), list()) :: t()
187187+ def sigil_PATH(string, _) when is_binary(string), do: from_string!(string)
188188+189189+ # ---------------------------------------------------------------------------
190190+ # Private validators
191191+ # ---------------------------------------------------------------------------
192192+193193+ @spec validate_collection(String.t()) :: :ok | {:error, :invalid_collection}
194194+ defp validate_collection(collection) do
195195+ if Regex.match?(@collection_re, collection),
196196+ do: :ok,
197197+ else: {:error, :invalid_collection}
198198+ end
199199+200200+ @spec validate_rkey(String.t()) :: :ok | {:error, :invalid_rkey}
201201+ defp validate_rkey(rkey) do
202202+ cond do
203203+ rkey == "" -> {:error, :invalid_rkey}
204204+ String.to_charlist(rkey) in @reserved_rkeys -> {:error, :invalid_rkey}
205205+ not Regex.match?(@rkey_re, rkey) -> {:error, :invalid_rkey}
206206+ true -> :ok
207207+ end
208208+ end
209209+end
210210+211211+defimpl String.Chars, for: Atex.Repo.Path do
212212+ def to_string(path), do: Atex.Repo.Path.to_string(path)
213213+end
214214+215215+defimpl Inspect, for: Atex.Repo.Path do
216216+ def inspect(path, _opts) do
217217+ ~s'~PATH"#{path}"'
218218+ end
219219+end
···11+defmodule Atex.Repo.CommitTest do
22+ use ExUnit.Case, async: true
33+44+ alias Atex.Repo.Commit
55+ alias DASL.CID
66+77+ @did "did:plc:example"
88+ @rev "3jzfcijpj2z2a"
99+1010+ defp data_cid, do: CID.compute("mst root", :drisl)
1111+1212+ defp unsigned_commit do
1313+ Commit.new(did: @did, data: data_cid(), rev: @rev)
1414+ end
1515+1616+ defp p256_jwk, do: JOSE.JWK.generate_key({:ec, "P-256"})
1717+ defp k256_jwk, do: JOSE.JWK.generate_key({:ec, "secp256k1"})
1818+1919+ # ---------------------------------------------------------------------------
2020+ # new/1
2121+ # ---------------------------------------------------------------------------
2222+2323+ describe "new/1" do
2424+ test "sets version to 3" do
2525+ assert unsigned_commit().version == 3
2626+ end
2727+2828+ test "sets sig to nil" do
2929+ assert unsigned_commit().sig == nil
3030+ end
3131+3232+ test "sets prev to nil by default" do
3333+ assert unsigned_commit().prev == nil
3434+ end
3535+3636+ test "accepts an explicit prev CID" do
3737+ prev = CID.compute("prev commit", :drisl)
3838+ commit = Commit.new(did: @did, data: data_cid(), rev: @rev, prev: prev)
3939+ assert commit.prev == prev
4040+ end
4141+ end
4242+4343+ # ---------------------------------------------------------------------------
4444+ # encode_unsigned/1 + decode/1 round-trip
4545+ # ---------------------------------------------------------------------------
4646+4747+ describe "encode_unsigned/1 and decode/1" do
4848+ test "produces valid DRISL bytes" do
4949+ assert {:ok, bin} = Commit.encode_unsigned(unsigned_commit())
5050+ assert is_binary(bin)
5151+ end
5252+5353+ test "round-trips unsigned commit" do
5454+ commit = unsigned_commit()
5555+ {:ok, bin} = Commit.encode_unsigned(commit)
5656+ {:ok, decoded, rest} = Commit.decode(bin)
5757+5858+ assert rest == ""
5959+ assert decoded.did == commit.did
6060+ assert decoded.version == commit.version
6161+ assert decoded.data == commit.data
6262+ assert decoded.rev == commit.rev
6363+ assert decoded.prev == commit.prev
6464+ assert decoded.sig == nil
6565+ end
6666+6767+ test "does not include sig in unsigned bytes" do
6868+ jwk = p256_jwk()
6969+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
7070+7171+ {:ok, unsigned_bytes} = Commit.encode_unsigned(signed)
7272+ {:ok, decoded, _} = Commit.decode(unsigned_bytes)
7373+7474+ assert decoded.sig == nil
7575+ end
7676+ end
7777+7878+ # ---------------------------------------------------------------------------
7979+ # sign/2
8080+ # ---------------------------------------------------------------------------
8181+8282+ describe "sign/2" do
8383+ test "produces a binary signature (P-256)" do
8484+ {:ok, signed} = Commit.sign(unsigned_commit(), p256_jwk())
8585+ assert is_binary(signed.sig)
8686+ assert byte_size(signed.sig) > 0
8787+ end
8888+8989+ test "produces a binary signature (secp256k1)" do
9090+ {:ok, signed} = Commit.sign(unsigned_commit(), k256_jwk())
9191+ assert is_binary(signed.sig)
9292+ end
9393+9494+ test "returns error when already signed" do
9595+ {:ok, signed} = Commit.sign(unsigned_commit(), p256_jwk())
9696+ assert {:error, :already_signed} = Commit.sign(signed, p256_jwk())
9797+ end
9898+ end
9999+100100+ # ---------------------------------------------------------------------------
101101+ # verify/2
102102+ # ---------------------------------------------------------------------------
103103+104104+ describe "verify/2" do
105105+ test "accepts a valid signature (P-256)" do
106106+ jwk = p256_jwk()
107107+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
108108+ assert :ok = Commit.verify(signed, JOSE.JWK.to_public(jwk))
109109+ end
110110+111111+ test "accepts a valid signature (secp256k1)" do
112112+ jwk = k256_jwk()
113113+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
114114+ assert :ok = Commit.verify(signed, JOSE.JWK.to_public(jwk))
115115+ end
116116+117117+ test "rejects signature from a different key" do
118118+ jwk_a = p256_jwk()
119119+ jwk_b = p256_jwk()
120120+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk_a)
121121+ assert {:error, _} = Commit.verify(signed, JOSE.JWK.to_public(jwk_b))
122122+ end
123123+124124+ test "rejects unsigned commit" do
125125+ assert {:error, :unsigned} = Commit.verify(unsigned_commit(), p256_jwk())
126126+ end
127127+128128+ test "rejects tampered data field" do
129129+ jwk = p256_jwk()
130130+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
131131+ tampered = %{signed | data: CID.compute("tampered", :drisl)}
132132+ assert {:error, _} = Commit.verify(tampered, JOSE.JWK.to_public(jwk))
133133+ end
134134+ end
135135+136136+ # ---------------------------------------------------------------------------
137137+ # encode/1 (signed)
138138+ # ---------------------------------------------------------------------------
139139+140140+ describe "encode/1" do
141141+ test "returns error for unsigned commit" do
142142+ assert {:error, :unsigned} = Commit.encode(unsigned_commit())
143143+ end
144144+145145+ test "produces DRISL bytes for a signed commit" do
146146+ jwk = p256_jwk()
147147+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
148148+ assert {:ok, bin} = Commit.encode(signed)
149149+ assert is_binary(bin)
150150+ end
151151+152152+ test "round-trips signed commit" do
153153+ jwk = p256_jwk()
154154+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
155155+ {:ok, bin} = Commit.encode(signed)
156156+ {:ok, decoded, _} = Commit.decode(bin)
157157+158158+ assert decoded.did == signed.did
159159+ assert decoded.sig == signed.sig
160160+ assert decoded.data == signed.data
161161+ end
162162+ end
163163+164164+ # ---------------------------------------------------------------------------
165165+ # cid/1
166166+ # ---------------------------------------------------------------------------
167167+168168+ describe "cid/1" do
169169+ test "returns error for unsigned commit" do
170170+ assert {:error, :unsigned} = Commit.cid(unsigned_commit())
171171+ end
172172+173173+ test "returns a CID with :drisl codec" do
174174+ jwk = p256_jwk()
175175+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
176176+ {:ok, cid} = Commit.cid(signed)
177177+ assert cid.codec == :drisl
178178+ end
179179+180180+ test "is stable - same commit produces the same CID" do
181181+ jwk = p256_jwk()
182182+ {:ok, signed} = Commit.sign(unsigned_commit(), jwk)
183183+ {:ok, cid1} = Commit.cid(signed)
184184+ {:ok, cid2} = Commit.cid(signed)
185185+ assert cid1 == cid2
186186+ end
187187+188188+ test "changes when the data field changes" do
189189+ jwk = p256_jwk()
190190+ {:ok, signed_a} = Commit.sign(unsigned_commit(), jwk)
191191+192192+ commit_b = Commit.new(did: @did, data: CID.compute("other mst", :drisl), rev: @rev)
193193+ {:ok, signed_b} = Commit.sign(commit_b, jwk)
194194+195195+ {:ok, cid_a} = Commit.cid(signed_a)
196196+ {:ok, cid_b} = Commit.cid(signed_b)
197197+ assert cid_a != cid_b
198198+ end
199199+ end
200200+end
+280
test/atex/repo/fixtures_test.exs
···11+defmodule Atex.Repo.FixturesTest do
22+ use ExUnit.Case, async: true
33+44+ @moduledoc """
55+ Parses real-world AT Protocol repository CAR exports from test/fixtures/.
66+77+ These verify that `Atex.Repo.from_car/1` correctly handles actual PDS-
88+ exported repositories, including commit decoding, MST reconstruction, and
99+ individual record retrieval.
1010+ """
1111+1212+ alias Atex.Repo
1313+ alias Atex.Repo.Path, as: RepoPath
1414+1515+ defp fixture_path(name), do: Elixir.Path.join([__DIR__, "../../fixtures", name])
1616+ defp fixture(name), do: File.read!(fixture_path(name))
1717+ defp fixture_stream(name), do: File.stream!(fixture_path(name), 65_536, [:raw, :binary])
1818+1919+ # ---------------------------------------------------------------------------
2020+ # comet.car - did:web:comet.sh
2121+ # ---------------------------------------------------------------------------
2222+2323+ describe "comet.car (did:web:comet.sh)" do
2424+ setup do
2525+ {:ok, repo} = fixture("comet.car") |> Repo.from_car()
2626+ {:ok, pairs} = MST.to_list(repo.tree)
2727+ %{repo: repo, pairs: pairs}
2828+ end
2929+3030+ test "decodes the commit", %{repo: repo} do
3131+ assert repo.commit.did == "did:web:comet.sh"
3232+ assert repo.commit.version == 3
3333+ assert repo.commit.rev == "3mi3cqkyzsv22"
3434+ assert is_binary(repo.commit.sig)
3535+ end
3636+3737+ test "commit CID matches CAR root" do
3838+ bin = fixture("comet.car")
3939+ {:ok, car} = DASL.CAR.decode(bin)
4040+ {:ok, repo} = Repo.from_car(bin)
4141+ {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit)
4242+ assert [^commit_cid] = car.roots
4343+ end
4444+4545+ test "reconstructs the correct number of records", %{pairs: pairs} do
4646+ assert length(pairs) == 123
4747+ end
4848+4949+ test "contains the expected collections", %{pairs: pairs} do
5050+ collections =
5151+ pairs
5252+ |> Enum.map(fn {k, _} -> k |> String.split("/") |> hd() end)
5353+ |> Enum.uniq()
5454+ |> Enum.sort()
5555+5656+ assert "app.bsky.actor.profile" in collections
5757+ assert "app.bsky.feed.post" in collections
5858+ assert "app.bsky.feed.like" in collections
5959+ assert "app.bsky.graph.follow" in collections
6060+ assert "sh.tangled.actor.profile" in collections
6161+ assert "sh.tangled.repo" in collections
6262+ end
6363+6464+ test "retrieves the Bluesky profile record", %{repo: repo} do
6565+ {:ok, profile} = Repo.get_record(repo, "app.bsky.actor.profile/self")
6666+ assert profile["displayName"] == "comet.sh"
6767+ assert profile["$type"] == "app.bsky.actor.profile"
6868+ end
6969+7070+ test "retrieves a Tangled profile record", %{repo: repo} do
7171+ {:ok, profile} = Repo.get_record(repo, "sh.tangled.actor.profile/self")
7272+ assert is_map(profile)
7373+ end
7474+7575+ test "returns not_found for a non-existent path", %{repo: repo} do
7676+ assert {:error, :not_found} =
7777+ Repo.get_record(repo, "app.bsky.feed.post/doesnotexist")
7878+ end
7979+8080+ test "all MST leaf CIDs match their record blocks", %{repo: repo, pairs: pairs} do
8181+ for {path, cid} <- pairs do
8282+ assert {:ok, _record} = Repo.get_record(repo, path),
8383+ "expected to decode record at #{path}"
8484+8585+ assert Map.has_key?(repo.blocks, cid),
8686+ "expected block for #{path} (#{DASL.CID.encode(cid)}) to be present"
8787+ end
8888+ end
8989+9090+ test "MST root CID matches commit data field", %{repo: repo} do
9191+ assert repo.tree.root == repo.commit.data
9292+ end
9393+9494+ test "list_collections returns expected collections", %{repo: repo} do
9595+ {:ok, cols} = Repo.list_collections(repo)
9696+ assert "app.bsky.feed.post" in cols
9797+ assert "app.bsky.feed.like" in cols
9898+ assert "sh.tangled.actor.profile" in cols
9999+ assert "sh.tangled.repo" in cols
100100+ # Collections are in MST byte order, not necessarily lexicographic order.
101101+ assert length(cols) == length(Enum.uniq(cols))
102102+ end
103103+104104+ test "list_record_keys returns rkeys for a collection", %{repo: repo} do
105105+ {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post")
106106+ assert length(keys) > 0
107107+ assert Enum.all?(keys, &is_binary/1)
108108+ assert keys == Enum.sort(keys)
109109+ end
110110+111111+ test "list_records round-trips record content", %{repo: repo} do
112112+ {:ok, records} = Repo.list_records(repo, "app.bsky.actor.profile")
113113+ assert length(records) == 1
114114+ {"self", profile} = hd(records)
115115+ assert profile["displayName"] == "comet.sh"
116116+ end
117117+118118+ test "stream_car emits commit then all records (via File.stream!)" do
119119+ items = fixture_stream("comet.car") |> Repo.stream_car() |> Enum.to_list()
120120+ [{:commit, commit} | rest] = items
121121+ assert commit.did == "did:web:comet.sh"
122122+ record_items = Enum.filter(rest, &match?({:record, _, _}, &1))
123123+ assert length(record_items) == 123
124124+ end
125125+126126+ test "stream_car record paths are Atex.Repo.Path structs" do
127127+ fixture_stream("comet.car")
128128+ |> Repo.stream_car()
129129+ |> Stream.filter(&match?({:record, _, _}, &1))
130130+ |> Enum.each(fn {:record, path, _} ->
131131+ assert %RepoPath{} = path
132132+ end)
133133+ end
134134+135135+ test "stream_car and from_car agree on all record content", %{repo: repo} do
136136+ streamed =
137137+ fixture_stream("comet.car")
138138+ |> Repo.stream_car()
139139+ |> Stream.filter(&match?({:record, _, _}, &1))
140140+ |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end)
141141+ |> Map.new()
142142+143143+ {:ok, pairs} = MST.to_list(repo.tree)
144144+145145+ for {path_str, _cid} <- pairs do
146146+ {:ok, from_car_rec} = Repo.get_record(repo, path_str)
147147+148148+ assert Map.get(streamed, path_str) == from_car_rec,
149149+ "mismatch at #{path_str}"
150150+ end
151151+ end
152152+ end
153153+154154+ # ---------------------------------------------------------------------------
155155+ # alt.car - did:plc:xl2n6atcb6vz3ajmf6bnbrmw
156156+ # ---------------------------------------------------------------------------
157157+158158+ describe "alt.car (did:plc:xl2n6atcb6vz3ajmf6bnbrmw)" do
159159+ setup do
160160+ {:ok, repo} = fixture("alt.car") |> Repo.from_car()
161161+ {:ok, pairs} = MST.to_list(repo.tree)
162162+ %{repo: repo, pairs: pairs}
163163+ end
164164+165165+ test "decodes the commit", %{repo: repo} do
166166+ assert repo.commit.did == "did:plc:xl2n6atcb6vz3ajmf6bnbrmw"
167167+ assert repo.commit.version == 3
168168+ assert repo.commit.rev == "3mgbwezwku722"
169169+ assert is_binary(repo.commit.sig)
170170+ end
171171+172172+ test "commit CID matches CAR root" do
173173+ bin = fixture("alt.car")
174174+ {:ok, car} = DASL.CAR.decode(bin)
175175+ {:ok, repo} = Repo.from_car(bin)
176176+ {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit)
177177+ assert [^commit_cid] = car.roots
178178+ end
179179+180180+ test "reconstructs the correct number of records", %{pairs: pairs} do
181181+ assert length(pairs) == 62
182182+ end
183183+184184+ test "contains the expected collections", %{pairs: pairs} do
185185+ collections =
186186+ pairs
187187+ |> Enum.map(fn {k, _} -> k |> String.split("/") |> hd() end)
188188+ |> Enum.uniq()
189189+ |> Enum.sort()
190190+191191+ assert "app.bsky.actor.profile" in collections
192192+ assert "app.bsky.feed.post" in collections
193193+ assert "app.bsky.feed.like" in collections
194194+ assert "sh.tangled.knot" in collections
195195+ assert "xyz.statusphere.status" in collections
196196+ end
197197+198198+ test "retrieves the Bluesky profile record", %{repo: repo} do
199199+ {:ok, profile} = Repo.get_record(repo, "app.bsky.actor.profile/self")
200200+ assert profile["displayName"] == "ovyerus alt"
201201+ assert profile["$type"] == "app.bsky.actor.profile"
202202+ end
203203+204204+ test "returns not_found for a non-existent path", %{repo: repo} do
205205+ assert {:error, :not_found} =
206206+ Repo.get_record(repo, "app.bsky.feed.post/doesnotexist")
207207+ end
208208+209209+ test "all MST leaf CIDs match their record blocks", %{repo: repo, pairs: pairs} do
210210+ for {path, cid} <- pairs do
211211+ assert {:ok, _record} = Repo.get_record(repo, path),
212212+ "expected to decode record at #{path}"
213213+214214+ assert Map.has_key?(repo.blocks, cid),
215215+ "expected block for #{path} (#{DASL.CID.encode(cid)}) to be present"
216216+ end
217217+ end
218218+219219+ test "MST root CID matches commit data field", %{repo: repo} do
220220+ assert repo.tree.root == repo.commit.data
221221+ end
222222+223223+ test "list_collections returns expected collections", %{repo: repo} do
224224+ {:ok, cols} = Repo.list_collections(repo)
225225+ assert "app.bsky.feed.post" in cols
226226+ assert "sh.tangled.knot" in cols
227227+ assert "xyz.statusphere.status" in cols
228228+ # Collections are in MST byte order, not necessarily lexicographic order.
229229+ assert length(cols) == length(Enum.uniq(cols))
230230+ end
231231+232232+ test "list_record_keys returns rkeys including colon rkeys", %{repo: repo} do
233233+ {:ok, keys} = Repo.list_record_keys(repo, "sh.tangled.knot")
234234+ assert "localhost:6000" in keys
235235+ end
236236+237237+ test "list_records round-trips record content", %{repo: repo} do
238238+ {:ok, records} = Repo.list_records(repo, "app.bsky.actor.profile")
239239+ assert length(records) == 1
240240+ {"self", profile} = hd(records)
241241+ assert profile["displayName"] == "ovyerus alt"
242242+ end
243243+244244+ test "stream_car emits commit then all records (via File.stream!)" do
245245+ items = fixture_stream("alt.car") |> Repo.stream_car() |> Enum.to_list()
246246+ [{:commit, commit} | rest] = items
247247+ assert commit.did == "did:plc:xl2n6atcb6vz3ajmf6bnbrmw"
248248+ record_items = Enum.filter(rest, &match?({:record, _, _}, &1))
249249+ assert length(record_items) == 62
250250+ end
251251+252252+ test "stream_car handles colon rkey paths" do
253253+ paths =
254254+ fixture_stream("alt.car")
255255+ |> Repo.stream_car()
256256+ |> Stream.filter(&match?({:record, _, _}, &1))
257257+ |> Enum.map(fn {:record, path, _} -> to_string(path) end)
258258+259259+ assert "sh.tangled.knot/localhost:6000" in paths
260260+ end
261261+262262+ test "stream_car and from_car agree on all record content", %{repo: repo} do
263263+ streamed =
264264+ fixture_stream("alt.car")
265265+ |> Repo.stream_car()
266266+ |> Stream.filter(&match?({:record, _, _}, &1))
267267+ |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end)
268268+ |> Map.new()
269269+270270+ {:ok, pairs} = MST.to_list(repo.tree)
271271+272272+ for {path_str, _cid} <- pairs do
273273+ {:ok, from_car_rec} = Repo.get_record(repo, path_str)
274274+275275+ assert Map.get(streamed, path_str) == from_car_rec,
276276+ "mismatch at #{path_str}"
277277+ end
278278+ end
279279+ end
280280+end
+242
test/atex/repo/path_test.exs
···11+defmodule Atex.Repo.PathTest do
22+ use ExUnit.Case, async: true
33+44+ alias Atex.Repo.Path
55+66+ # ---------------------------------------------------------------------------
77+ # new/2
88+ # ---------------------------------------------------------------------------
99+1010+ describe "new/2" do
1111+ test "accepts a standard NSID collection and TID rkey" do
1212+ assert {:ok, path} = Path.new("app.bsky.feed.post", "3jzfcijpj2z2a")
1313+ assert path.collection == "app.bsky.feed.post"
1414+ assert path.rkey == "3jzfcijpj2z2a"
1515+ end
1616+1717+ test "accepts 'self' literal rkey" do
1818+ assert {:ok, path} = Path.new("app.bsky.actor.profile", "self")
1919+ assert path.rkey == "self"
2020+ end
2121+2222+ test "accepts rkey with colon (e.g. domain name)" do
2323+ assert {:ok, _} = Path.new("sh.tangled.knot", "localhost:6000")
2424+ end
2525+2626+ test "accepts rkey with tilde" do
2727+ assert {:ok, _} = Path.new("com.example.thing", "~1.2-3_")
2828+ end
2929+3030+ test "accepts rkey with all allowed special chars" do
3131+ assert {:ok, _} = Path.new("com.example.thing", "aZ0.-_:~")
3232+ end
3333+3434+ test "accepts multi-segment deep NSID" do
3535+ assert {:ok, _} = Path.new("codes.advent.challenge.day", "3jzfcijpj2z2a")
3636+ end
3737+3838+ test "rejects collection without a dot (single segment)" do
3939+ assert {:error, :invalid_collection} = Path.new("noperiod", "self")
4040+ end
4141+4242+ test "rejects collection with leading dot" do
4343+ assert {:error, :invalid_collection} = Path.new(".app.bsky", "self")
4444+ end
4545+4646+ test "rejects collection with trailing dot" do
4747+ assert {:error, :invalid_collection} = Path.new("app.bsky.", "self")
4848+ end
4949+5050+ test "rejects collection with consecutive dots" do
5151+ assert {:error, :invalid_collection} = Path.new("app..bsky", "self")
5252+ end
5353+5454+ test "rejects collection with hyphen" do
5555+ assert {:error, :invalid_collection} = Path.new("app-bsky.feed.post", "self")
5656+ end
5757+5858+ test "rejects collection with uppercase segment starting char" do
5959+ # NSIDs are lowercase-only at the authority level; uppercase disallowed in collection
6060+ # The spec says NSID segments must start with a letter - uppercase is allowed per NSID spec
6161+ # but our regex allows [a-zA-Z][a-zA-Z0-9]* - so let's just verify the regex works
6262+ assert {:ok, _} = Path.new("App.Bsky.Feed", "self")
6363+ end
6464+6565+ test "rejects empty rkey" do
6666+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "")
6767+ end
6868+6969+ test "rejects '.' rkey" do
7070+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", ".")
7171+ end
7272+7373+ test "rejects '..' rkey" do
7474+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "..")
7575+ end
7676+7777+ test "rejects rkey with slash" do
7878+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "a/b")
7979+ end
8080+8181+ test "rejects rkey with space" do
8282+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "bad key")
8383+ end
8484+8585+ test "rejects rkey with @" do
8686+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "@handle")
8787+ end
8888+8989+ test "rejects rkey with #" do
9090+ assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "#extra")
9191+ end
9292+ end
9393+9494+ # ---------------------------------------------------------------------------
9595+ # new!/2
9696+ # ---------------------------------------------------------------------------
9797+9898+ describe "new!/2" do
9999+ test "returns the struct on valid input" do
100100+ path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
101101+ assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = path
102102+ end
103103+104104+ test "raises ArgumentError on invalid collection" do
105105+ assert_raise ArgumentError, fn -> Path.new!("noslash", "self") end
106106+ end
107107+108108+ test "raises ArgumentError on invalid rkey" do
109109+ assert_raise ArgumentError, fn -> Path.new!("app.bsky.feed.post", "..") end
110110+ end
111111+ end
112112+113113+ # ---------------------------------------------------------------------------
114114+ # from_string/1
115115+ # ---------------------------------------------------------------------------
116116+117117+ # ---------------------------------------------------------------------------
118118+ # from_string!/1
119119+ # ---------------------------------------------------------------------------
120120+121121+ describe "from_string!/1" do
122122+ test "returns the struct on a valid path string" do
123123+ assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} =
124124+ Path.from_string!("app.bsky.feed.post/3jzfcijpj2z2a")
125125+ end
126126+127127+ test "raises ArgumentError for a string with no slash" do
128128+ assert_raise ArgumentError, fn -> Path.from_string!("no-slash") end
129129+ end
130130+131131+ test "raises ArgumentError for a string with two slashes" do
132132+ assert_raise ArgumentError, fn -> Path.from_string!("a/b/c") end
133133+ end
134134+135135+ test "raises ArgumentError for an invalid collection segment" do
136136+ assert_raise ArgumentError, fn -> Path.from_string!("bad/self") end
137137+ end
138138+139139+ test "raises ArgumentError for a reserved rkey" do
140140+ assert_raise ArgumentError, fn -> Path.from_string!("app.bsky.feed.post/..") end
141141+ end
142142+ end
143143+144144+ # ---------------------------------------------------------------------------
145145+ # sigil_PATH
146146+ # ---------------------------------------------------------------------------
147147+148148+ describe "sigil_PATH" do
149149+ import Path, only: [sigil_PATH: 2]
150150+151151+ test "constructs a valid path from a literal string" do
152152+ assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} =
153153+ ~PATH"app.bsky.feed.post/3jzfcijpj2z2a"
154154+ end
155155+156156+ test "works with alternative rkey formats" do
157157+ assert %Path{collection: "sh.tangled.knot", rkey: "localhost:6000"} =
158158+ ~PATH"sh.tangled.knot/localhost:6000"
159159+ end
160160+161161+ test "raises ArgumentError for an invalid path string" do
162162+ assert_raise ArgumentError, fn -> ~PATH"not-a-valid-path" end
163163+ end
164164+ end
165165+166166+ describe "from_string/1" do
167167+ test "parses a valid path string" do
168168+ assert {:ok, path} = Path.from_string("app.bsky.feed.post/3jzfcijpj2z2a")
169169+ assert path.collection == "app.bsky.feed.post"
170170+ assert path.rkey == "3jzfcijpj2z2a"
171171+ end
172172+173173+ test "parses a path with colon rkey" do
174174+ assert {:ok, path} = Path.from_string("sh.tangled.knot/localhost:6000")
175175+ assert path.rkey == "localhost:6000"
176176+ end
177177+178178+ test "returns invalid_path for string with no slash" do
179179+ assert {:error, :invalid_path} = Path.from_string("no-slash")
180180+ end
181181+182182+ test "returns invalid_path for string with two slashes" do
183183+ assert {:error, :invalid_path} = Path.from_string("a/b/c")
184184+ end
185185+186186+ test "returns invalid_path for empty string" do
187187+ assert {:error, :invalid_path} = Path.from_string("")
188188+ end
189189+190190+ test "returns invalid_collection for bad collection segment" do
191191+ assert {:error, :invalid_collection} = Path.from_string("bad/self")
192192+ end
193193+194194+ test "returns invalid_rkey for reserved rkey" do
195195+ assert {:error, :invalid_rkey} = Path.from_string("app.bsky.feed.post/..")
196196+ end
197197+ end
198198+199199+ # ---------------------------------------------------------------------------
200200+ # to_string/1 and String.Chars
201201+ # ---------------------------------------------------------------------------
202202+203203+ describe "to_string/1" do
204204+ test "produces collection/rkey format" do
205205+ path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
206206+ assert Path.to_string(path) == "app.bsky.feed.post/3jzfcijpj2z2a"
207207+ end
208208+209209+ test "String.Chars protocol works in interpolation" do
210210+ path = Path.new!("app.bsky.actor.profile", "self")
211211+ assert "#{path}" == "app.bsky.actor.profile/self"
212212+ end
213213+214214+ test "Kernel.to_string/1 works" do
215215+ path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
216216+ assert Kernel.to_string(path) == "app.bsky.feed.post/3jzfcijpj2z2a"
217217+ end
218218+ end
219219+220220+ # ---------------------------------------------------------------------------
221221+ # Inspect protocol
222222+ # ---------------------------------------------------------------------------
223223+224224+ describe "Inspect" do
225225+ test "renders as sigil form" do
226226+ path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
227227+ assert inspect(path) == ~s(~PATH"app.bsky.feed.post/3jzfcijpj2z2a")
228228+ end
229229+ end
230230+231231+ # ---------------------------------------------------------------------------
232232+ # Round-trip
233233+ # ---------------------------------------------------------------------------
234234+235235+ describe "round-trip" do
236236+ test "from_string |> to_string is identity" do
237237+ str = "app.bsky.feed.post/3jzfcijpj2z2a"
238238+ {:ok, path} = Path.from_string(str)
239239+ assert Path.to_string(path) == str
240240+ end
241241+ end
242242+end
+507
test/atex/repo_test.exs
···11+defmodule Atex.RepoTest do
22+ use ExUnit.Case, async: true
33+44+ alias Atex.Repo
55+ alias Atex.Repo.Path
66+77+ @did "did:plc:example"
88+99+ defp jwk, do: JOSE.JWK.generate_key({:ec, "P-256"})
1010+1111+ defp committed_repo(key \\ nil) do
1212+ key = key || jwk()
1313+ repo = Repo.new()
1414+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"})
1515+ {:ok, repo} = Repo.commit(repo, @did, key)
1616+ {repo, key}
1717+ end
1818+1919+ # ---------------------------------------------------------------------------
2020+ # new/0
2121+ # ---------------------------------------------------------------------------
2222+2323+ describe "new/0" do
2424+ test "returns an empty repo with no commit" do
2525+ repo = Repo.new()
2626+ assert repo.commit == nil
2727+ assert repo.blocks == %{}
2828+ end
2929+ end
3030+3131+ # ---------------------------------------------------------------------------
3232+ # put_record/3 and get_record/2
3333+ # ---------------------------------------------------------------------------
3434+3535+ describe "put_record/3 and get_record/2" do
3636+ test "round-trips a record" do
3737+ repo = Repo.new()
3838+ record = %{"text" => "hello world", "createdAt" => "2024-01-01T00:00:00Z"}
3939+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", record)
4040+ {:ok, fetched} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
4141+ assert fetched == record
4242+ end
4343+4444+ test "replaces an existing record" do
4545+ repo = Repo.new()
4646+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"v" => 1})
4747+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"v" => 2})
4848+ {:ok, fetched} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
4949+ assert fetched["v"] == 2
5050+ end
5151+5252+ test "returns not_found for missing path" do
5353+ repo = Repo.new()
5454+ assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
5555+ end
5656+5757+ test "stores multiple records independently" do
5858+ repo = Repo.new()
5959+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1})
6060+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2})
6161+ {:ok, r1} = Repo.get_record(repo, "app.bsky.feed.post/aaaa")
6262+ {:ok, r2} = Repo.get_record(repo, "app.bsky.feed.post/bbbb")
6363+ assert r1["n"] == 1
6464+ assert r2["n"] == 2
6565+ end
6666+6767+ test "rejects an invalid path string" do
6868+ repo = Repo.new()
6969+ assert {:error, :invalid_path} = Repo.put_record(repo, "no-slash", %{})
7070+ assert {:error, :invalid_path} = Repo.put_record(repo, "/leading", %{})
7171+ assert {:error, :invalid_path} = Repo.put_record(repo, "a/b/c", %{})
7272+ assert {:error, :invalid_path} = Repo.put_record(repo, "", %{})
7373+ end
7474+7575+ test "accepts an Atex.Repo.Path struct" do
7676+ repo = Repo.new()
7777+ path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
7878+ {:ok, repo} = Repo.put_record(repo, path, %{"text" => "via struct"})
7979+ {:ok, record} = Repo.get_record(repo, path)
8080+ assert record["text"] == "via struct"
8181+ end
8282+8383+ test "Path struct and equivalent string retrieve the same record" do
8484+ repo = Repo.new()
8585+ path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a")
8686+ {:ok, repo} = Repo.put_record(repo, path, %{"text" => "hi"})
8787+ {:ok, r1} = Repo.get_record(repo, path)
8888+ {:ok, r2} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
8989+ assert r1 == r2
9090+ end
9191+ end
9292+9393+ # ---------------------------------------------------------------------------
9494+ # delete_record/2
9595+ # ---------------------------------------------------------------------------
9696+9797+ describe "delete_record/2" do
9898+ test "removes an existing record" do
9999+ repo = Repo.new()
100100+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"x" => 1})
101101+ {:ok, repo} = Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
102102+ assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
103103+ end
104104+105105+ test "returns not_found for missing path" do
106106+ repo = Repo.new()
107107+ assert {:error, :not_found} = Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a")
108108+ end
109109+110110+ test "does not affect other records" do
111111+ repo = Repo.new()
112112+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1})
113113+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2})
114114+ {:ok, repo} = Repo.delete_record(repo, "app.bsky.feed.post/aaaa")
115115+ assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/aaaa")
116116+ assert {:ok, %{"n" => 2}} = Repo.get_record(repo, "app.bsky.feed.post/bbbb")
117117+ end
118118+119119+ test "rejects invalid path" do
120120+ repo = Repo.new()
121121+ assert {:error, :invalid_path} = Repo.delete_record(repo, "bad")
122122+ end
123123+124124+ test "accepts an Atex.Repo.Path struct" do
125125+ repo = Repo.new()
126126+ path = Path.new!("app.bsky.feed.post", "aaaa")
127127+ {:ok, repo} = Repo.put_record(repo, path, %{"n" => 1})
128128+ {:ok, repo} = Repo.delete_record(repo, path)
129129+ assert {:error, :not_found} = Repo.get_record(repo, path)
130130+ end
131131+ end
132132+133133+ # ---------------------------------------------------------------------------
134134+ # commit/3
135135+ # ---------------------------------------------------------------------------
136136+137137+ describe "commit/3" do
138138+ test "sets the commit DID" do
139139+ {repo, _key} = committed_repo()
140140+ assert repo.commit.did == @did
141141+ end
142142+143143+ test "sets version to 3" do
144144+ {repo, _key} = committed_repo()
145145+ assert repo.commit.version == 3
146146+ end
147147+148148+ test "sets prev to nil" do
149149+ {repo, _key} = committed_repo()
150150+ assert repo.commit.prev == nil
151151+ end
152152+153153+ test "rev is a valid TID string" do
154154+ {repo, _key} = committed_repo()
155155+ assert Atex.TID.match?(repo.commit.rev)
156156+ end
157157+158158+ test "produces a non-nil sig" do
159159+ {repo, _key} = committed_repo()
160160+ assert is_binary(repo.commit.sig)
161161+ end
162162+163163+ test "data CID matches the MST root" do
164164+ key = jwk()
165165+ repo = Repo.new()
166166+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"x" => 1})
167167+ {:ok, repo} = Repo.commit(repo, @did, key)
168168+169169+ assert repo.commit.data == repo.tree.root
170170+ end
171171+172172+ test "rev increases monotonically across sequential commits" do
173173+ key = jwk()
174174+ repo = Repo.new()
175175+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1})
176176+ {:ok, repo} = Repo.commit(repo, @did, key)
177177+ rev1 = repo.commit.rev
178178+179179+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2})
180180+ {:ok, repo} = Repo.commit(repo, @did, key)
181181+ rev2 = repo.commit.rev
182182+183183+ assert rev2 > rev1
184184+ end
185185+ end
186186+187187+ # ---------------------------------------------------------------------------
188188+ # verify_commit/2
189189+ # ---------------------------------------------------------------------------
190190+191191+ describe "verify_commit/2" do
192192+ test "passes with the correct public key" do
193193+ key = jwk()
194194+ {repo, _} = committed_repo(key)
195195+ assert :ok = Repo.verify_commit(repo, JOSE.JWK.to_public(key))
196196+ end
197197+198198+ test "fails with a different key" do
199199+ {repo, _key} = committed_repo()
200200+ other_key = JOSE.JWK.to_public(jwk())
201201+ assert {:error, _} = Repo.verify_commit(repo, other_key)
202202+ end
203203+204204+ test "returns error when no commit exists" do
205205+ repo = Repo.new()
206206+ assert {:error, :no_commit} = Repo.verify_commit(repo, jwk())
207207+ end
208208+ end
209209+210210+ # ---------------------------------------------------------------------------
211211+ # to_car/1
212212+ # ---------------------------------------------------------------------------
213213+214214+ describe "to_car/1" do
215215+ test "returns error when no commit exists" do
216216+ repo = Repo.new()
217217+ assert {:error, :no_commit} = Repo.to_car(repo)
218218+ end
219219+220220+ test "returns a binary" do
221221+ {repo, _key} = committed_repo()
222222+ assert {:ok, bin} = Repo.to_car(repo)
223223+ assert is_binary(bin)
224224+ end
225225+226226+ test "CAR root is the commit CID" do
227227+ {repo, _key} = committed_repo()
228228+ {:ok, bin} = Repo.to_car(repo)
229229+ {:ok, car} = DASL.CAR.decode(bin)
230230+ {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit)
231231+ assert [^commit_cid] = car.roots
232232+ end
233233+234234+ test "empty repo produces a valid CAR" do
235235+ key = jwk()
236236+ repo = Repo.new()
237237+ {:ok, repo} = Repo.commit(repo, @did, key)
238238+ assert {:ok, bin} = Repo.to_car(repo)
239239+ assert is_binary(bin)
240240+ end
241241+ end
242242+243243+ # ---------------------------------------------------------------------------
244244+ # from_car/1
245245+ # ---------------------------------------------------------------------------
246246+247247+ describe "from_car/1" do
248248+ test "round-trips a single-record repo" do
249249+ key = jwk()
250250+ repo = Repo.new()
251251+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"})
252252+ {:ok, repo} = Repo.commit(repo, @did, key)
253253+ {:ok, bin} = Repo.to_car(repo)
254254+255255+ {:ok, repo2} = Repo.from_car(bin)
256256+ assert repo2.commit.did == @did
257257+ assert {:ok, %{"text" => "hi"}} = Repo.get_record(repo2, "app.bsky.feed.post/3jzfcijpj2z2a")
258258+ end
259259+260260+ test "round-trips a multi-record repo" do
261261+ key = jwk()
262262+ repo = Repo.new()
263263+264264+ records = [
265265+ {"app.bsky.feed.post/aaaa", %{"n" => 1}},
266266+ {"app.bsky.feed.post/bbbb", %{"n" => 2}},
267267+ {"app.bsky.actor.profile/self", %{"displayName" => "Test"}}
268268+ ]
269269+270270+ repo =
271271+ Enum.reduce(records, repo, fn {path, rec}, acc ->
272272+ {:ok, acc} = Repo.put_record(acc, path, rec)
273273+ acc
274274+ end)
275275+276276+ {:ok, repo} = Repo.commit(repo, @did, key)
277277+ {:ok, bin} = Repo.to_car(repo)
278278+ {:ok, repo2} = Repo.from_car(bin)
279279+280280+ for {path, record} <- records do
281281+ assert {:ok, ^record} = Repo.get_record(repo2, path)
282282+ end
283283+ end
284284+285285+ test "commit signature survives round-trip" do
286286+ key = jwk()
287287+ {repo, _} = committed_repo(key)
288288+ {:ok, bin} = Repo.to_car(repo)
289289+ {:ok, repo2} = Repo.from_car(bin)
290290+ assert :ok = Repo.verify_commit(repo2, JOSE.JWK.to_public(key))
291291+ end
292292+293293+ test "returns error for invalid binary" do
294294+ assert match?({:error, _, _}, Repo.from_car("not a car")) or
295295+ match?({:error, _}, Repo.from_car("not a car"))
296296+ end
297297+298298+ test "round-trips an empty repo" do
299299+ key = jwk()
300300+ repo = Repo.new()
301301+ {:ok, repo} = Repo.commit(repo, @did, key)
302302+ {:ok, bin} = Repo.to_car(repo)
303303+ {:ok, repo2} = Repo.from_car(bin)
304304+ assert repo2.commit.did == @did
305305+ end
306306+ end
307307+308308+ # ---------------------------------------------------------------------------
309309+ # list_collections/1
310310+ # ---------------------------------------------------------------------------
311311+312312+ describe "list_collections/1" do
313313+ test "returns empty list for empty repo" do
314314+ assert {:ok, []} = Repo.list_collections(Repo.new())
315315+ end
316316+317317+ test "returns deduplicated collection names in MST order" do
318318+ repo = Repo.new()
319319+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{})
320320+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/bbbb", %{})
321321+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/cccc", %{})
322322+ {:ok, cols} = Repo.list_collections(repo)
323323+ # MST key order: "app.bsky.feed.like/..." < "app.bsky.feed.post/..."
324324+ assert "app.bsky.feed.like" in cols
325325+ assert "app.bsky.feed.post" in cols
326326+ assert length(cols) == 2
327327+ end
328328+329329+ test "each collection appears exactly once" do
330330+ repo = Repo.new()
331331+332332+ repo =
333333+ Enum.reduce(1..5, repo, fn i, acc ->
334334+ {:ok, acc} = Repo.put_record(acc, "app.bsky.feed.post/key#{i}", %{"n" => i})
335335+ acc
336336+ end)
337337+338338+ {:ok, cols} = Repo.list_collections(repo)
339339+ assert cols == ["app.bsky.feed.post"]
340340+ end
341341+ end
342342+343343+ # ---------------------------------------------------------------------------
344344+ # list_record_keys/2
345345+ # ---------------------------------------------------------------------------
346346+347347+ describe "list_record_keys/2" do
348348+ test "returns empty list for empty repo" do
349349+ assert {:ok, []} = Repo.list_record_keys(Repo.new(), "app.bsky.feed.post")
350350+ end
351351+352352+ test "returns empty list for non-existent collection" do
353353+ repo = Repo.new()
354354+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{})
355355+ assert {:ok, []} = Repo.list_record_keys(repo, "app.bsky.feed.post")
356356+ end
357357+358358+ test "returns rkeys in sorted order" do
359359+ repo = Repo.new()
360360+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/cccc", %{})
361361+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{})
362362+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{})
363363+ {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post")
364364+ assert keys == ["aaaa", "bbbb", "cccc"]
365365+ end
366366+367367+ test "does not bleed into adjacent collections" do
368368+ repo = Repo.new()
369369+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{})
370370+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{})
371371+ {:ok, repo} = Repo.put_record(repo, "app.bsky.graph.follow/cccc", %{})
372372+ {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post")
373373+ assert keys == ["bbbb"]
374374+ end
375375+ end
376376+377377+ # ---------------------------------------------------------------------------
378378+ # list_records/2
379379+ # ---------------------------------------------------------------------------
380380+381381+ describe "list_records/2" do
382382+ test "returns empty list for empty repo" do
383383+ assert {:ok, []} = Repo.list_records(Repo.new(), "app.bsky.feed.post")
384384+ end
385385+386386+ test "returns {rkey, record} pairs in sorted order" do
387387+ repo = Repo.new()
388388+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1})
389389+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2})
390390+ {:ok, records} = Repo.list_records(repo, "app.bsky.feed.post")
391391+ assert Enum.map(records, &elem(&1, 0)) == ["aaaa", "bbbb"]
392392+ assert Enum.map(records, fn {_, r} -> r["n"] end) == [1, 2]
393393+ end
394394+395395+ test "only returns records for the specified collection" do
396396+ repo = Repo.new()
397397+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{"liked" => true})
398398+ {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"text" => "hi"})
399399+ {:ok, records} = Repo.list_records(repo, "app.bsky.feed.post")
400400+ assert length(records) == 1
401401+ assert {"bbbb", %{"text" => "hi"}} = hd(records)
402402+ end
403403+ end
404404+405405+ # ---------------------------------------------------------------------------
406406+ # stream_car/1
407407+ # ---------------------------------------------------------------------------
408408+409409+ describe "stream_car/1" do
410410+ defp build_committed_repo(records) do
411411+ key = jwk()
412412+413413+ repo =
414414+ Enum.reduce(records, Repo.new(), fn {path, rec}, acc ->
415415+ {:ok, acc} = Repo.put_record(acc, path, rec)
416416+ acc
417417+ end)
418418+419419+ {:ok, repo} = Repo.commit(repo, @did, key)
420420+ {:ok, bin} = Repo.to_car(repo)
421421+ {repo, bin, key}
422422+ end
423423+424424+ test "first item is {:commit, commit}" do
425425+ {_repo, bin, _key} =
426426+ build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}])
427427+428428+ [first | _] = Repo.stream_car([bin]) |> Enum.to_list()
429429+ assert match?({:commit, %Atex.Repo.Commit{}}, first)
430430+ end
431431+432432+ test "commit in stream has correct DID" do
433433+ {_repo, bin, _key} =
434434+ build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}])
435435+436436+ [{:commit, commit} | _] = Repo.stream_car([bin]) |> Enum.to_list()
437437+ assert commit.did == @did
438438+ end
439439+440440+ test "emits a {:record, path, map} for each record" do
441441+ records = [
442442+ {"app.bsky.feed.post/aaaa", %{"n" => 1}},
443443+ {"app.bsky.feed.post/bbbb", %{"n" => 2}}
444444+ ]
445445+446446+ {_repo, bin, _key} = build_committed_repo(records)
447447+ items = Repo.stream_car([bin]) |> Enum.to_list()
448448+ record_items = Enum.filter(items, &match?({:record, _, _}, &1))
449449+ assert length(record_items) == 2
450450+451451+ paths = Enum.map(record_items, fn {:record, path, _} -> to_string(path) end) |> Enum.sort()
452452+ assert paths == ["app.bsky.feed.post/aaaa", "app.bsky.feed.post/bbbb"]
453453+ end
454454+455455+ test "record content is correct" do
456456+ {_repo, bin, _key} =
457457+ build_committed_repo([{"app.bsky.feed.post/aaaa", %{"text" => "hello stream"}}])
458458+459459+ items = Repo.stream_car([bin]) |> Enum.to_list()
460460+ [{:record, path, record}] = Enum.filter(items, &match?({:record, _, _}, &1))
461461+ assert path.collection == "app.bsky.feed.post"
462462+ assert path.rkey == "aaaa"
463463+ assert record["text"] == "hello stream"
464464+ end
465465+466466+ test "path items are Atex.Repo.Path structs" do
467467+ {_repo, bin, _key} =
468468+ build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}])
469469+470470+ items = Repo.stream_car([bin]) |> Enum.to_list()
471471+ [{:record, path, _}] = Enum.filter(items, &match?({:record, _, _}, &1))
472472+ assert %Atex.Repo.Path{} = path
473473+ end
474474+475475+ test "empty repo stream has only commit item" do
476476+ key = jwk()
477477+ repo = Repo.new()
478478+ {:ok, repo} = Repo.commit(repo, @did, key)
479479+ {:ok, bin} = Repo.to_car(repo)
480480+ items = Repo.stream_car([bin]) |> Enum.to_list()
481481+ assert length(items) == 1
482482+ assert match?([{:commit, _}], items)
483483+ end
484484+485485+ test "stream and from_car agree on record content" do
486486+ records = [
487487+ {"app.bsky.feed.post/aaaa", %{"n" => 1}},
488488+ {"app.bsky.actor.profile/self", %{"displayName" => "Test"}}
489489+ ]
490490+491491+ {_repo, bin, _key} = build_committed_repo(records)
492492+493493+ {:ok, repo} = Repo.from_car(bin)
494494+495495+ streamed =
496496+ Repo.stream_car([bin])
497497+ |> Stream.filter(&match?({:record, _, _}, &1))
498498+ |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end)
499499+ |> Map.new()
500500+501501+ for {path_str, _record} <- records do
502502+ {:ok, from_car_rec} = Repo.get_record(repo, path_str)
503503+ assert Map.get(streamed, path_str) == from_car_rec
504504+ end
505505+ end
506506+ end
507507+end