From a2a657f20cf0adfa4940fce6abd715508bae696d Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Fri, 10 Apr 2026 21:22:01 +1000 Subject: [PATCH] feat: repository support! --- CHANGELOG.md | 3 + README.md | 9 +- bench/repo.exs | 198 +++++++ lib/atex/repo.ex | 850 +++++++++++++++++++++++++++++++ lib/atex/repo/commit.ex | 334 ++++++++++++ lib/atex/repo/path.ex | 219 ++++++++ mix.exs | 8 +- mix.lock | 26 +- test/atex/repo/commit_test.exs | 200 ++++++++ test/atex/repo/fixtures_test.exs | 280 ++++++++++ test/atex/repo/path_test.exs | 242 +++++++++ test/atex/repo_test.exs | 507 ++++++++++++++++++ test/fixtures/alt.car | Bin 0 -> 22427 bytes test/fixtures/comet.car | Bin 0 -> 47039 bytes 14 files changed, 2860 insertions(+), 16 deletions(-) create mode 100644 bench/repo.exs create mode 100644 lib/atex/repo.ex create mode 100644 lib/atex/repo/commit.ex create mode 100644 lib/atex/repo/path.ex create mode 100644 test/atex/repo/commit_test.exs create mode 100644 test/atex/repo/fixtures_test.exs create mode 100644 test/atex/repo/path_test.exs create mode 100644 test/atex/repo_test.exs create mode 100644 test/fixtures/alt.car create mode 100644 test/fixtures/comet.car diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e0807d..d45aeeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to ### Added +- `Atex.Repo` module for building, mutating, signing, serialising, and loading + AT Protocol repositories. Also supports lazily streaming from a CAR binary for + efficient processing of large repository exports. - `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on public APIs or PDSes. - `Atex.NSID.authority_domain/1` for deriving the `_lexicon.` DNS diff --git a/README.md b/README.md index 5697966..6a88812 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,19 @@ An Elixir toolkit for the [AT Protocol](https://atproto.com). - [x] `at://` links - [x] TIDs - [ ] NSIDs - - [ ] CIDs - [x] Identity resolution with bi-directional validation and caching. -- [x] Macro and codegen for converting Lexicon definitions to runtime schemas and structs. +- [x] Macro and codegen for converting Lexicon definitions to runtime schemas + and structs. - [x] OAuth client - [x] XRPC client - With integration for generated Lexicon structs! -- [ ] Repository reading and manipulation (MST & CAR) +- [x] Repository reading and manipulation - [x] Service auth - [x] PLC client - [x] XRPC server router -Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup]. +Looking to use a data subscription service like the Firehose, [Jetstream], or +[Tap]? Check out [Drinkup]. [Jetstream]: https://docs.bsky.app/blog/jetstream [Tap]: https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md diff --git a/bench/repo.exs b/bench/repo.exs new file mode 100644 index 0000000..381bf39 --- /dev/null +++ b/bench/repo.exs @@ -0,0 +1,198 @@ +## +## Atex.Repo benchmarks +## +## Run with: +## mix run bench/repo.exs +## +## Uses the real-world CAR fixtures in test/fixtures/ and the larger repo at +## tmp/ovyerus.car (39 MB, ~90k records) when present. +## +## Each suite section measures a distinct subsystem. Memory measurements are +## enabled with memory_time: 2 (seconds of sampling). +## + +alias Atex.Repo + +fixture = fn name -> + File.read!(Path.join("test/fixtures", name)) +end + +fixture_stream = fn name -> + File.stream!(Path.join("test/fixtures", name), 65_536, [:raw, :binary]) +end + +large_path = "tmp/ovyerus.car" +has_large = File.exists?(large_path) + +if has_large do + IO.puts("Large fixture (#{large_path}) found - including in streaming benchmarks.\n") +else + IO.puts("Large fixture (#{large_path}) not found - skipping large-file benchmarks.\n") +end + +# --------------------------------------------------------------------------- +# Pre-load repos used as inputs to export / access benchmarks +# --------------------------------------------------------------------------- + +# ~22 KB, 62 records +small_bin = fixture.("alt.car") +# ~46 KB, 123 records +medium_bin = fixture.("comet.car") + +{:ok, small_repo} = Repo.from_car(small_bin) +{:ok, medium_repo} = Repo.from_car(medium_bin) + +# Pre-fetch one path from each for the get_record benchmark +{:ok, small_pairs} = MST.to_list(small_repo.tree) +{:ok, medium_pairs} = MST.to_list(medium_repo.tree) + +small_path = small_pairs |> Enum.at(div(length(small_pairs), 2)) |> elem(0) +medium_path = medium_pairs |> Enum.at(div(length(medium_pairs), 2)) |> elem(0) + +small_collection = + small_pairs |> hd() |> elem(0) |> String.split("/") |> hd() + +medium_collection = + medium_pairs |> hd() |> elem(0) |> String.split("/") |> hd() + +# Repos need a signed commit to be exportable via to_car. +jwk = JOSE.JWK.generate_key({:ec, "P-256"}) +{:ok, small_repo_committed} = Repo.commit(small_repo, small_repo.commit.did, jwk) +{:ok, medium_repo_committed} = Repo.commit(medium_repo, medium_repo.commit.did, jwk) + +IO.puts("=== CAR import ===\n") + +Benchee.run( + %{ + "from_car - small (62 records, ~22 KB)" => fn -> Repo.from_car(small_bin) end, + "from_car - medium (123 records, ~46 KB)" => fn -> Repo.from_car(medium_bin) end + }, + time: 5, + memory_time: 2, + print: [fast_warning: false] +) + +IO.puts("\n=== CAR export (to_car) ===\n") + +Benchee.run( + %{ + "to_car - small (62 records)" => fn -> Repo.to_car(small_repo_committed) end, + "to_car - medium (123 records)" => fn -> Repo.to_car(medium_repo_committed) end + }, + time: 5, + memory_time: 2, + print: [fast_warning: false] +) + +IO.puts("\n=== CAR streaming - small fixtures ===\n") + +Benchee.run( + %{ + "stream_car full - small (62 records)" => fn -> + fixture_stream.("alt.car") + |> Repo.stream_car() + |> Stream.run() + end, + "stream_car full - medium (123 records)" => fn -> + fixture_stream.("comet.car") + |> Repo.stream_car() + |> Stream.run() + end, + "stream_car take 10 - small" => fn -> + fixture_stream.("alt.car") + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Stream.take(10) + |> Stream.run() + end, + "stream_car take 10 - medium" => fn -> + fixture_stream.("comet.car") + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Stream.take(10) + |> Stream.run() + end + }, + time: 5, + memory_time: 2, + print: [fast_warning: false] +) + +if has_large do + IO.puts("\n=== CAR streaming - large fixture (39 MB, ~90k records) ===\n") + + Benchee.run( + %{ + "stream_car full - large (~90k records)" => fn -> + File.stream!(large_path, 65_536, [:raw, :binary]) + |> Repo.stream_car() + |> Stream.run() + end, + "stream_car take 100 - large" => fn -> + File.stream!(large_path, 65_536, [:raw, :binary]) + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Stream.take(100) + |> Stream.run() + end + }, + time: 10, + memory_time: 3, + warmup: 2, + print: [fast_warning: false] + ) +end + +IO.puts("\n=== Record access ===\n") + +Benchee.run( + %{ + "get_record - small repo" => fn -> Repo.get_record(small_repo, small_path) end, + "get_record - medium repo" => fn -> Repo.get_record(medium_repo, medium_path) end, + "list_collections - small (#{length(small_pairs)} records)" => fn -> + Repo.list_collections(small_repo) + end, + "list_collections - medium (#{length(medium_pairs)} records)" => fn -> + Repo.list_collections(medium_repo) + end, + "list_record_keys - small, 1 collection" => fn -> + Repo.list_record_keys(small_repo, small_collection) + end, + "list_record_keys - medium, 1 collection" => fn -> + Repo.list_record_keys(medium_repo, medium_collection) + end, + "list_records - small, 1 collection" => fn -> + Repo.list_records(small_repo, small_collection) + end, + "list_records - medium, 1 collection" => fn -> + Repo.list_records(medium_repo, medium_collection) + end + }, + time: 5, + memory_time: 2, + print: [fast_warning: false] +) + +IO.puts("\n=== Record mutation ===\n") + +Benchee.run( + %{ + "put_record - small repo" => fn -> + Repo.put_record(small_repo, "app.bsky.feed.post/bench#{System.unique_integer()}", %{ + "text" => "bench" + }) + end, + "put_record - medium repo" => fn -> + Repo.put_record( + medium_repo, + "app.bsky.feed.post/bench#{System.unique_integer()}", + %{"text" => "bench"} + ) + end, + "delete_record - small repo" => fn -> Repo.delete_record(small_repo, small_path) end, + "delete_record - medium repo" => fn -> Repo.delete_record(medium_repo, medium_path) end + }, + time: 5, + memory_time: 2, + print: [fast_warning: false] +) diff --git a/lib/atex/repo.ex b/lib/atex/repo.ex new file mode 100644 index 0000000..58046c8 --- /dev/null +++ b/lib/atex/repo.ex @@ -0,0 +1,850 @@ +defmodule Atex.Repo do + @moduledoc """ + AT Protocol repository - a signed, content-addressed store of records. + + A repository is a key/value mapping of repo paths (`collection/rkey`) to + records (CBOR objects), backed by a Merkle Search Tree (MST). Each published + version of the tree is captured in a signed `Atex.Repo.Commit`. + + ## Quick start + + # Create a new empty repository + repo = Atex.Repo.new() + + # Insert records (string path or Atex.Repo.Path struct) + {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) + + # Commit (sign) the current tree state + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) + + # Export to a CAR file + {:ok, car_binary} = Atex.Repo.to_car(repo) + + # Round-trip import + {:ok, repo2} = Atex.Repo.from_car(car_binary) + + # Verify the commit signature + :ok = Atex.Repo.verify_commit(repo2, JOSE.JWK.to_public(jwk)) + + ## Paths + + Record paths can be passed as plain strings (`"collection/rkey"`) or as + `Atex.Repo.Path` structs. Both are accepted by all path-taking functions. + See `Atex.Repo.Path` for validation rules and struct API. + + ## Record storage + + Records are DRISL CBOR-encoded. Their CIDs (`:drisl` codec) are stored as + leaf values in the MST. The raw record bytes are tracked in a separate + `blocks` map inside the struct so they are available for CAR export without + re-encoding. + + ## CAR serialization + + `to_car/1` produces a CARv1 file in the streamable block order described in + the spec: commit first, then MST nodes in depth-first pre-order, interleaved + with their record blocks. + + `from_car/1` decodes a CAR file, extracts the signed commit from the first + root CID, loads the MST, and collects all record blocks. It does **not** + verify the commit signature - call `verify_commit/2` explicitly. + + `stream_car/1` provides a lazy stream over a CAR binary, emitting + `{:commit, commit}` then `{:record, path, record}` tuples without loading + the full repository into memory. Requires a streamable-order CAR (commit + first, MST nodes in pre-order before their records). + + ATProto spec: https://atproto.com/specs/repository + """ + + use TypedStruct + alias Atex.{Repo.Commit, Repo.Path, TID} + alias DASL.{CAR, CID, DRISL} + alias MST.{Node, Store, Tree} + + typedstruct enforce: true do + @typedoc "An AT Protocol repository." + + field :tree, Tree.t() + field :commit, Commit.t() | nil + field :blocks, %{CID.t() => binary()}, default: %{} + end + + @doc """ + Returns a new empty repository with no records and no commit. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> repo.commit + nil + + """ + @spec new() :: t() + def new do + %__MODULE__{ + tree: MST.new(), + commit: nil, + blocks: %{} + } + end + + @doc """ + Retrieves the record at `path`, returning the decoded map. + + `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct. + + Returns `{:error, :not_found}` if the path does not exist. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) + iex> {:ok, record} = Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + iex> record["text"] + "hi" + + """ + @spec get_record(t(), String.t() | Path.t()) :: + {:ok, map()} + | {:error, :not_found | :invalid_path | :invalid_collection | :invalid_rkey | atom()} + def get_record(%__MODULE__{} = repo, path) do + with {:ok, path_str} <- coerce_path(path), + {:ok, cid} <- MST.get(repo.tree, path_str), + {:ok, bytes} <- fetch_block(repo.blocks, cid), + {:ok, record, _rest} <- DRISL.decode(bytes) do + {:ok, record} + end + end + + @doc """ + Inserts or replaces the record at `path`. + + `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct. + + The record is DRISL CBOR-encoded and its CID computed. The CID is inserted + into the MST as a leaf value. The commit is **not** updated - call + `commit/3` to sign the new tree state. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) + iex> {:ok, record} = Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + iex> record["text"] + "hi" + + """ + @spec put_record(t(), String.t() | Path.t(), map()) :: + {:ok, t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey | atom()} + def put_record(%__MODULE__{} = repo, path, record) when is_map(record) do + with {:ok, path_str} <- coerce_path(path), + {:ok, bytes} <- DRISL.encode(record), + cid = CID.compute(bytes, :drisl), + {:ok, tree} <- MST.put(repo.tree, path_str, cid) do + {:ok, %{repo | tree: tree, blocks: Map.put(repo.blocks, cid, bytes)}} + end + end + + @doc """ + Removes the record at `path`. + + `path` may be a `"collection/rkey"` string or an `Atex.Repo.Path` struct. + + Returns `{:error, :not_found}` if the path does not exist. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) + iex> {:ok, repo} = Atex.Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + iex> Atex.Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + {:error, :not_found} + + """ + @spec delete_record(t(), String.t() | Path.t()) :: + {:ok, t()} + | {:error, :not_found | :invalid_path | :invalid_collection | :invalid_rkey | atom()} + def delete_record(%__MODULE__{} = repo, path) do + with {:ok, path_str} <- coerce_path(path), + {:ok, tree} <- MST.delete(repo.tree, path_str) do + {:ok, %{repo | tree: tree}} + end + end + + @doc """ + Signs the current tree state and stores the result as the repository commit. + + Builds an `Atex.Repo.Commit` for `did` referencing the current MST root, + signs it with `signing_key`, and updates `repo.commit`. The `rev` is set to + the current timestamp as a TID string, guaranteed to be monotonically + increasing relative to any previous commit in this process. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) + iex> repo.commit.did + "did:plc:example" + iex> repo.commit.version + 3 + + """ + @spec commit(t(), String.t(), JOSE.JWK.t()) :: {:ok, t()} | {:error, atom()} + def commit(%__MODULE__{} = repo, did, signing_key) do + data_cid = mst_root_cid(repo.tree) + rev = TID.now() |> TID.encode() + + unsigned = + Commit.new( + did: did, + data: data_cid, + rev: rev, + prev: nil + ) + + with {:ok, signed} <- Commit.sign(unsigned, signing_key) do + {:ok, %{repo | commit: signed}} + end + end + + @doc """ + Returns a deduplicated list of all collection names in the repository. + + Collections are returned in MST key order (bytewise-lexicographic on the + full `collection/rkey` path string). This is generally close to but not + identical to alphabetical order - for example, `"foo.bar"` sorts after + `"foo.bar.baz"` because `/` (0x2F) > `.` (0x2E). + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.like/bbbb", %{}) + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) + iex> {:ok, cols} = Atex.Repo.list_collections(repo) + iex> cols + ["app.bsky.feed.like", "app.bsky.feed.post"] + + """ + @spec list_collections(t()) :: {:ok, [String.t()]} | {:error, atom()} + def list_collections(%__MODULE__{tree: tree}) do + result = + tree + |> MST.stream() + |> Stream.map(fn {key, _cid} -> collection_from_key(key) end) + |> Stream.dedup() + |> Enum.to_list() + + {:ok, result} + rescue + e -> {:error, {:stream_error, e}} + end + + @doc """ + Returns a sorted list of all record keys within `collection`. + + The list is in MST key order, which for TID-keyed records is chronological. + Returns an empty list (not an error) when the collection exists in the repo + but has no records, or does not exist at all. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) + iex> {:ok, keys} = Atex.Repo.list_record_keys(repo, "app.bsky.feed.post") + iex> keys + ["aaaa", "bbbb"] + + """ + @spec list_record_keys(t(), String.t()) :: {:ok, [String.t()]} | {:error, atom()} + def list_record_keys(%__MODULE__{tree: tree}, collection) when is_binary(collection) do + prefix = collection <> "/" + + result = + tree + |> MST.stream() + |> stream_collection(prefix) + |> Stream.map(fn {key, _cid} -> String.slice(key, byte_size(prefix)..-1//1) end) + |> Enum.to_list() + + {:ok, result} + rescue + e -> {:error, {:stream_error, e}} + end + + @doc """ + Returns a sorted list of `{rkey, record_map}` pairs for all records in + `collection`. + + The list is in MST key order. Returns an empty list when the collection does + not exist or has no records. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) + iex> {:ok, records} = Atex.Repo.list_records(repo, "app.bsky.feed.post") + iex> Enum.map(records, fn {rkey, _} -> rkey end) + ["aaaa", "bbbb"] + + """ + @spec list_records(t(), String.t()) :: + {:ok, [{String.t(), map()}]} | {:error, atom()} + def list_records(%__MODULE__{tree: tree, blocks: blocks}, collection) + when is_binary(collection) do + prefix = collection <> "/" + + result = + tree + |> MST.stream() + |> stream_collection(prefix) + |> Enum.reduce_while([], fn {key, cid}, acc -> + rkey = String.slice(key, byte_size(prefix)..-1//1) + + case decode_record(blocks, cid) do + {:ok, record} -> {:cont, [{rkey, record} | acc]} + {:error, _} = err -> {:halt, err} + end + end) + + case result do + {:error, _} = err -> err + pairs -> {:ok, Enum.reverse(pairs)} + end + rescue + e -> {:error, {:stream_error, e}} + end + + @doc """ + Exports the repository as a CARv1 binary. + + Block ordering follows the streamable convention from the spec: + + 1. The signed commit block. + 2. The MST root node, then MST nodes in depth-first pre-order, with each + record block immediately following the MST entry that references it. + + Returns `{:error, :no_commit}` if `commit/3` has not been called. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) + iex> {:ok, bin} = Atex.Repo.to_car(repo) + iex> is_binary(bin) + true + + """ + @spec to_car(t()) :: {:ok, binary()} | {:error, :no_commit | atom()} + def to_car(%__MODULE__{commit: nil}), do: {:error, :no_commit} + + def to_car(%__MODULE__{commit: commit, tree: tree, blocks: record_blocks}) do + with {:ok, commit_cid} <- Commit.cid(commit), + {:ok, commit_bytes} <- Commit.encode(commit), + {:ok, ordered_blocks} <- collect_ordered_blocks(tree, record_blocks) do + # Encode with explicit ordering: commit block must be first so that + # stream_car/1 can emit {:commit, _} before any {:record, _, _} items. + encode_car_ordered(commit_cid, commit_bytes, ordered_blocks) + end + end + + @doc """ + Decodes a CARv1 binary into a repository struct. + + The first root CID in the CAR header must point to a valid signed commit + block. The MST is reconstructed from the remaining `:drisl` codec blocks. + Record blocks are collected into `repo.blocks`. + + The commit signature is **not** verified. Call `verify_commit/2` explicitly + if you need to authenticate the repository. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) + iex> {:ok, bin} = Atex.Repo.to_car(repo) + iex> {:ok, repo2} = Atex.Repo.from_car(bin) + iex> repo2.commit.did + "did:plc:example" + + """ + @spec from_car(binary()) :: {:ok, t()} | {:error, atom()} + def from_car(binary) when is_binary(binary) do + with {:ok, car} <- CAR.decode(binary), + {:ok, commit_cid} <- car_root_cid(car), + {:ok, commit} <- decode_commit_block(car.blocks, commit_cid), + {:ok, tree, record_blocks} <- build_tree_from_car(car.blocks, commit_cid, commit.data) do + {:ok, %__MODULE__{tree: tree, commit: commit, blocks: record_blocks}} + end + end + + @doc """ + Returns a lazy stream over a CARv1 chunk stream, emitting decoded items + without loading the full repository into memory. + + `chunk_stream` must be an `Enumerable` that yields binary chunks of any + size - for example `File.stream!("repo.car", [], 65_536)` or a chunked + HTTP response body. Passing a plain binary also works but is equivalent to + loading it into memory first; prefer `from_car/1` in that case. + + The stream emits: + + - `{:commit, Atex.Repo.Commit.t()}` - the first item, decoded from the CAR + root block + - `{:record, Atex.Repo.Path.t(), map()}` - one per record, decoded in the + order they appear in the CAR + + The CAR must be in streamable pre-order: commit block first, then MST nodes + before their child nodes and records. This is the format produced by + `to_car/1` and by spec-compliant PDS exports. For CARs with arbitrary block + ordering use `from_car/1` instead. + + If a record block is encountered before its parent MST node has been seen + (i.e. the path cannot be resolved from already-decoded nodes), the stream + emits `{:error, :unresolvable_record, cid}` and halts. Parse errors raise a + `RuntimeError` (consistent with `DASL.CAR.stream_decode/2` semantics). + + ## Examples + + From a file without loading it fully into memory: + + File.stream!("repo.car", 65_536, [:raw, :binary]) + |> Atex.Repo.stream_car() + |> Enum.each(fn + {:commit, commit} -> IO.puts(commit.did) + {:record, path, record} -> IO.inspect({to_string(path), record}) + end) + + From a binary (e.g. in tests): + + iex> repo = Atex.Repo.new() + iex> {:ok, repo} = Atex.Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) + iex> {:ok, bin} = Atex.Repo.to_car(repo) + iex> items = Atex.Repo.stream_car([bin]) |> Enum.to_list() + iex> match?([{:commit, _} | _], items) + true + iex> Enum.any?(items, &match?({:record, _, _}, &1)) + true + + Partial consumption with `Stream.take/2` works without raising: + + File.stream!("repo.car", 65_536, [:raw, :binary]) + |> Atex.Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Stream.take(10) + |> Enum.to_list() + + """ + @spec stream_car(Enumerable.t()) :: Enumerable.t() + def stream_car(chunk_stream) do + # safe_car_decode/1 wraps CAR.stream_decode so that halting the stream + # early (Stream.take, Enum.reduce_while with :halt, etc.) does not raise. + # Items are emitted as each incoming chunk is processed - no buffering. + chunk_stream + |> safe_car_decode() + |> Stream.transform( + fn -> %{commit_cid: nil, cid_to_path: %{}, halted: false} end, + &reduce_car_item/2, + fn _ -> :ok end + ) + end + + @doc """ + Verifies the commit signature against the given public key. + + Delegates to `Atex.Repo.Commit.verify/2`. + + ## Examples + + iex> repo = Atex.Repo.new() + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, repo} = Atex.Repo.commit(repo, "did:plc:example", jwk) + iex> Atex.Repo.verify_commit(repo, JOSE.JWK.to_public(jwk)) + :ok + + """ + @spec verify_commit(t(), JOSE.JWK.t()) :: :ok | {:error, :no_commit | atom()} + def verify_commit(%__MODULE__{commit: nil}, _jwk), do: {:error, :no_commit} + + def verify_commit(%__MODULE__{commit: commit}, jwk) do + Commit.verify(commit, jwk) + end + + # --------------------------------------------------------------------------- + # Private - path coercion + # --------------------------------------------------------------------------- + + @spec coerce_path(String.t() | Path.t()) :: + {:ok, String.t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey} + defp coerce_path(%Path{} = path), do: {:ok, Path.to_string(path)} + + defp coerce_path(string) when is_binary(string) do + case Path.from_string(string) do + {:ok, _} -> {:ok, string} + {:error, _} = err -> err + end + end + + # --------------------------------------------------------------------------- + # Private - collection streaming helpers + # --------------------------------------------------------------------------- + + @spec collection_from_key(String.t()) :: String.t() + defp collection_from_key(key) do + key |> String.split("/", parts: 2) |> hd() + end + + # Filters an MST key stream to only those belonging to `prefix`, halting + # once the first key past the prefix is encountered (exploiting sort order). + @spec stream_collection(Enumerable.t(), String.t()) :: Enumerable.t() + defp stream_collection(stream, prefix) do + stream + |> Stream.transform(:before, fn {key, cid}, state -> + cond do + String.starts_with?(key, prefix) -> {[{key, cid}], :in} + state == :in -> {:halt, :done} + true -> {[], :before} + end + end) + end + + # --------------------------------------------------------------------------- + # Private - record block decoding + # --------------------------------------------------------------------------- + + @spec decode_record(%{CID.t() => binary()}, CID.t()) :: {:ok, map()} | {:error, atom()} + defp decode_record(blocks, cid) do + with {:ok, bytes} <- fetch_block(blocks, cid), + {:ok, record, _rest} <- DRISL.decode(bytes) do + {:ok, record} + end + end + + # --------------------------------------------------------------------------- + # Private - safe CAR stream wrapper + # --------------------------------------------------------------------------- + + # Wraps CAR.stream_decode/1 in a Stream.resource that manually drives the + # inner enumerable one item at a time via its suspension continuation. + # + # The key property: when the downstream halts early (Stream.take, etc.), + # the cleanup function calls the continuation with {:halt, nil}, which + # triggers DASL.CAR.StreamDecoder.finish/1. That function raises a + # RuntimeError if its internal buffer is non-empty (as it will be mid-stream). + # We catch that specific raise here so callers never see it. + # + # Genuine parse errors (truncated file, CID mismatch) still propagate because + # they originate in next_fun, not in the cleanup path. + @spec safe_car_decode(Enumerable.t()) :: Enumerable.t() + defp safe_car_decode(chunk_stream) do + # The step function suspends after every item, giving us a continuation + # we can call directly: cont.({:cont, nil}) to advance, cont.({:halt, nil}) + # to clean up. The continuation already has the reducer baked in from the + # initial Enumerable.reduce call so subsequent steps just call it directly. + step = fn item, _ -> {:suspend, item} end + + Stream.resource( + fn -> + case Enumerable.reduce(CAR.stream_decode(chunk_stream), {:cont, nil}, step) do + {:suspended, item, cont} -> {item, cont} + _ -> :done + end + end, + fn + :done -> + {:halt, :done} + + {item, cont} -> + next = + case cont.({:cont, nil}) do + {:suspended, next_item, next_cont} -> {next_item, next_cont} + _ -> :done + end + + {[item], next} + end, + fn + :done -> + :ok + + {_item, cont} -> + try do + cont.({:halt, nil}) + rescue + RuntimeError -> :ok + end + end + ) + end + + # --------------------------------------------------------------------------- + # Private - stream_car incremental reducer + # --------------------------------------------------------------------------- + + # State fields: + # commit_cid - the root CID from the CAR header (first root) + # cid_to_path - %{record_value_CID => "collection/rkey"}, built as MST + # node blocks arrive in pre-order + # halted - true after an unrecoverable error; blocks are skipped but + # the source stream is always allowed to finish naturally + + @spec reduce_car_item(DASL.CAR.StreamDecoder.stream_item(), map()) :: {list(), map()} + defp reduce_car_item(_item, %{halted: true} = state), do: {[], state} + + defp reduce_car_item({:header, _version, [root | _]}, state) do + {[], %{state | commit_cid: root}} + end + + defp reduce_car_item({:header, _version, []}, state) do + {[{:error, :no_root}], %{state | halted: true}} + end + + defp reduce_car_item({:block, cid, data}, %{commit_cid: commit_cid} = state) do + cond do + cid == commit_cid -> + case Commit.decode(data) do + {:ok, commit, _} -> {[{:commit, commit}], state} + {:error, reason} -> {[{:error, reason}], %{state | halted: true}} + end + + cid.codec == :drisl -> + case Node.decode(data) do + {:ok, node} -> + full_keys = MST.Node.keys(node) + + cid_to_path = + node.entries + |> Enum.zip(full_keys) + |> Enum.reduce(state.cid_to_path, fn {entry, key}, acc -> + Map.put(acc, entry.value.bytes, key) + end) + + {[], %{state | cid_to_path: cid_to_path}} + + {:error, :decode, _} -> + emit_record_block(cid, data, state) + end + + true -> + emit_record_block(cid, data, state) + end + end + + @spec emit_record_block(CID.t(), binary(), map()) :: {list(), map()} + defp emit_record_block(cid, data, state) do + case Map.fetch(state.cid_to_path, cid.bytes) do + :error -> + {[{:error, :unresolvable_record, cid}], %{state | halted: true}} + + {:ok, key} -> + case DRISL.decode(data) do + {:error, _} -> + {[], state} + + {:ok, record, _} -> + case String.split(key, "/", parts: 2) do + [collection, rkey] -> + {[{:record, %Path{collection: collection, rkey: rkey}, record}], state} + + _ -> + {[], state} + end + end + end + end + + # --------------------------------------------------------------------------- + # Private - CAR export helpers + # --------------------------------------------------------------------------- + + # Encodes a CARv1 binary with the commit block guaranteed to be first, + # followed by the MST and record blocks in pre-order. This ensures the output + # is in streamable order per the spec and is correctly processed by stream_car/1. + @spec encode_car_ordered(CID.t(), binary(), ordered_acc()) :: + {:ok, binary()} | {:error, atom()} + defp encode_car_ordered(commit_cid, commit_bytes, {blocks_map, rev_order}) do + alias Varint.LEB128 + + # Build each block as an iolist: [leb128_length, cid_bytes, data]. + # Accumulating iolists avoids binary copying at each step; a single + # :erlang.iolist_to_binary at the end does one allocation. + encode_block_io = fn %CID{bytes: cid_bytes}, data -> + [LEB128.encode(byte_size(cid_bytes) + byte_size(data)), cid_bytes, data] + end + + with {:ok, header_bin} <- + DRISL.encode(%{"version" => 1, "roots" => [commit_cid]}) do + header_io = [LEB128.encode(byte_size(header_bin)), header_bin] + commit_io = encode_block_io.(commit_cid, commit_bytes) + + # rev_order was built by prepending, so reverse to get pre-order sequence. + rest_io = + rev_order + |> Enum.reverse() + |> Enum.map(fn cid -> encode_block_io.(cid, Map.fetch!(blocks_map, cid)) end) + + {:ok, :erlang.iolist_to_binary([header_io, commit_io, rest_io])} + end + end + + # Returns {blocks_map, ordered_cids} where ordered_cids preserves pre-order + # insertion sequence. This is necessary because Elixir maps do not preserve + # insertion order - iterating a map in encode_car_ordered/3 would lose the + # pre-order block sequencing required for streamable CARs. + @type ordered_acc() :: {%{CID.t() => binary()}, [CID.t()]} + + @spec collect_ordered_blocks(Tree.t(), %{CID.t() => binary()}) :: + {:ok, ordered_acc()} | {:error, atom()} + defp collect_ordered_blocks(%Tree{root: nil}, _record_blocks) do + empty = Node.empty() + {:ok, bytes} = Node.encode(empty) + cid = CID.compute(bytes, :drisl) + {:ok, {%{cid => bytes}, [cid]}} + end + + defp collect_ordered_blocks(%Tree{root: root, store: store}, record_blocks) do + collect_node_blocks(store, root, record_blocks, {%{}, []}) + end + + @spec collect_node_blocks(Store.t(), CID.t(), %{CID.t() => binary()}, ordered_acc()) :: + {:ok, ordered_acc()} | {:error, atom()} + defp collect_node_blocks(store, cid, record_blocks, {map, order}) do + with {:ok, node} <- Store.get(store, cid), + {:ok, node_bytes} <- Node.encode(node) do + acc = {Map.put(map, cid, node_bytes), [cid | order]} + + Enum.reduce_while(build_preorder_steps(node), {:ok, acc}, fn step, {:ok, {map, order}} -> + case step do + {:node, child_cid} -> + case collect_node_blocks(store, child_cid, record_blocks, {map, order}) do + {:ok, acc} -> {:cont, {:ok, acc}} + err -> {:halt, err} + end + + {:record, record_cid} -> + case Map.fetch(record_blocks, record_cid) do + {:ok, bytes} -> + {:cont, {:ok, {Map.put(map, record_cid, bytes), [record_cid | order]}}} + + :error -> + {:cont, {:ok, {map, order}}} + end + end + end) + else + {:error, :not_found} -> {:error, :missing_node} + {:error, :encode, reason} -> {:error, reason} + end + end + + @spec build_preorder_steps(Node.t()) :: list() + defp build_preorder_steps(node) do + left_steps = if node.left, do: [{:node, node.left}], else: [] + + entry_steps = + Enum.flat_map(node.entries, fn entry -> + right_steps = if entry.right, do: [{:node, entry.right}], else: [] + [{:record, entry.value} | right_steps] + end) + + left_steps ++ entry_steps + end + + # --------------------------------------------------------------------------- + # Private - CAR import helpers + # --------------------------------------------------------------------------- + + @spec car_root_cid(CAR.t()) :: {:ok, CID.t()} | {:error, :no_root} + defp car_root_cid(%CAR{roots: [cid | _]}), do: {:ok, cid} + defp car_root_cid(%CAR{roots: []}), do: {:error, :no_root} + + @spec decode_commit_block(%{CID.t() => binary()}, CID.t()) :: + {:ok, Commit.t()} | {:error, atom()} + defp decode_commit_block(blocks, cid) do + with {:ok, bytes} <- fetch_block(blocks, cid), + {:ok, commit, _rest} <- Commit.decode(bytes) do + {:ok, commit} + end + end + + @spec build_tree_from_car(%{CID.t() => binary()}, CID.t(), CID.t() | nil) :: + {:ok, Tree.t(), %{CID.t() => binary()}} | {:error, atom()} + defp build_tree_from_car(blocks, commit_cid, mst_root) do + result = + Enum.reduce_while(blocks, {:ok, Store.Memory.new(), %{}}, fn {cid, data}, + {:ok, store, rec_blocks} -> + cond do + cid == commit_cid -> + {:cont, {:ok, store, rec_blocks}} + + cid.codec == :drisl -> + case Node.decode(data) do + {:ok, node} -> + {:cont, {:ok, Store.put(store, cid, node), rec_blocks}} + + {:error, :decode, _reason} -> + {:cont, {:ok, store, Map.put(rec_blocks, cid, data)}} + end + + true -> + {:cont, {:ok, store, Map.put(rec_blocks, cid, data)}} + end + end) + + with {:ok, store, record_blocks} <- result do + {:ok, Tree.from_root(mst_root, store), record_blocks} + end + end + + # --------------------------------------------------------------------------- + # Private - misc + # --------------------------------------------------------------------------- + + @spec mst_root_cid(Tree.t()) :: CID.t() + defp mst_root_cid(%Tree{root: nil}) do + empty = Node.empty() + {:ok, bytes} = Node.encode(empty) + CID.compute(bytes, :drisl) + end + + defp mst_root_cid(%Tree{root: cid}), do: cid + + @spec fetch_block(%{CID.t() => binary()}, CID.t()) :: {:ok, binary()} | {:error, :not_found} + defp fetch_block(blocks, cid) do + case Map.fetch(blocks, cid) do + {:ok, bytes} -> {:ok, bytes} + :error -> {:error, :not_found} + end + end +end + +defimpl Inspect, for: Atex.Repo do + import Inspect.Algebra + + def inspect(%Atex.Repo{commit: nil, blocks: blocks}, _opts) do + concat(["#Atex.Repo"]) + end + + def inspect(%Atex.Repo{commit: commit, blocks: blocks}, _opts) do + concat([ + "#Atex.Repo<", + commit.did, + " rev=", + commit.rev, + " records=", + Integer.to_string(map_size(blocks)), + ">" + ]) + end +end diff --git a/lib/atex/repo/commit.ex b/lib/atex/repo/commit.ex new file mode 100644 index 0000000..4cf0eae --- /dev/null +++ b/lib/atex/repo/commit.ex @@ -0,0 +1,334 @@ +defmodule Atex.Repo.Commit do + @moduledoc """ + The signed commit object at the top of an AT Protocol repository. + + A commit binds together: + + - The account DID that owns the repository. + - A CID link (`data`) to the root of the MST that holds all records. + - A monotonically-increasing revision (`rev`) in TID string format, used as + a logical clock. + - A `prev` link to the previous commit (virtually always `nil` in v3 repos, + but the field must be present in the CBOR object). + - A cryptographic `sig` over the DRISL CBOR encoding of the unsigned commit. + + ## Signing a commit + + The signing convention follows the AT Protocol repository spec: + + 1. Build an unsigned commit (all fields except `sig`). + 2. Encode it with `encode_unsigned/1` to get the DRISL CBOR bytes. + 3. SHA-256 hash the bytes, then ECDSA-sign the *hash* with the account's + signing key. + 4. Store the raw (DER-encoded) signature bytes in `sig`. + + `sign/2` performs steps 2–4 in one call. Verification with `verify/2` + reverses the process using a public key. + + ## CID computation + + The CID for a commit is computed from the DRISL CBOR encoding of the **signed** + commit object (with `sig` present), using the `:drisl` codec. + + ## Wire format + + Map keys follow the AT Protocol specification field names: + + - `"did"` - account DID string + - `"version"` - integer `3` + - `"data"` - CID link to MST root + - `"rev"` - TID string + - `"prev"` - CID link or `nil` + - `"sig"` - raw ECDSA signature bytes (absent from the unsigned map) + + ATProto spec: https://atproto.com/specs/repository#commit-objects + """ + + use TypedStruct + alias Atex.Crypto + alias DASL.{CID, DRISL} + + @version 3 + + typedstruct enforce: true do + @typedoc "A v3 AT Protocol repository commit." + + field :did, String.t() + field :version, pos_integer(), default: @version + field :data, CID.t() + field :rev, String.t() + field :prev, CID.t() | nil + field :sig, binary() | nil + end + + @doc """ + Builds an unsigned commit struct from the given fields. + + `sig` is set to `nil`. + + ## Options + + - `:did` (required) - the account DID string + - `:data` (required) - `DASL.CID` pointing to the MST root + - `:rev` (required) - TID string used as the logical clock + - `:prev` - `DASL.CID` pointing to the previous commit, or `nil` (default) + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new( + ...> did: "did:plc:example", + ...> data: data_cid, + ...> rev: "3jzfcijpj2z2a" + ...> ) + iex> commit.version + 3 + iex> commit.sig + nil + + """ + @spec new(keyword()) :: t() + def new(fields) do + %__MODULE__{ + did: Keyword.fetch!(fields, :did), + version: @version, + data: Keyword.fetch!(fields, :data), + rev: Keyword.fetch!(fields, :rev), + prev: Keyword.get(fields, :prev, nil), + sig: nil + } + end + + @doc """ + Serializes the commit **without** the `sig` field as DRISL CBOR. + + This is the payload that is hashed and signed. The `sig` field is omitted + entirely from the map, as required by the spec. + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") + iex> {:ok, bin} = Atex.Repo.Commit.encode_unsigned(commit) + iex> is_binary(bin) + true + + """ + @spec encode_unsigned(t()) :: {:ok, binary()} | {:error, atom()} + def encode_unsigned(%__MODULE__{} = commit) do + commit |> to_unsigned_map() |> DRISL.encode() + end + + @doc """ + Serializes a signed commit (including `sig`) as DRISL CBOR. + + Returns `{:error, :unsigned}` if `sig` is `nil`. + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) + iex> {:ok, bin} = Atex.Repo.Commit.encode(signed) + iex> is_binary(bin) + true + + """ + @spec encode(t()) :: {:ok, binary()} | {:error, :unsigned | atom()} + def encode(%__MODULE__{sig: nil}), do: {:error, :unsigned} + + def encode(%__MODULE__{} = commit) do + commit |> to_signed_map() |> DRISL.encode() + end + + @doc """ + Decodes a DRISL CBOR binary into a `%Atex.Repo.Commit{}`. + + Accepts both signed (with `"sig"`) and unsigned (without `"sig"`) payloads. + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") + iex> {:ok, bin} = Atex.Repo.Commit.encode_unsigned(commit) + iex> {:ok, decoded, ""} = Atex.Repo.Commit.decode(bin) + iex> decoded.did + "did:plc:e" + + """ + @spec decode(binary()) :: {:ok, t(), binary()} | {:error, atom()} + def decode(binary) when is_binary(binary) do + with {:ok, map, rest} <- DRISL.decode(binary), + {:ok, commit} <- from_map(map) do + {:ok, commit, rest} + end + end + + @doc """ + Signs an unsigned commit with the given private key. + + Encodes the unsigned commit as DRISL CBOR and signs the bytes using + `Atex.Crypto.sign/2` (SHA-256 ECDSA, low-S normalized DER output). + + Returns `{:error, :already_signed}` if `sig` is already present. + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) + iex> is_binary(signed.sig) + true + + """ + @spec sign(t(), JOSE.JWK.t()) :: {:ok, t()} | {:error, :already_signed | atom()} + def sign(%__MODULE__{sig: sig}, _jwk) when not is_nil(sig), do: {:error, :already_signed} + + def sign(%__MODULE__{} = commit, jwk) do + with {:ok, payload} <- encode_unsigned(commit), + {:ok, sig} <- Crypto.sign(payload, jwk) do + {:ok, %{commit | sig: sig}} + end + end + + @doc """ + Verifies the signature of a signed commit against the given public key. + + Returns `:ok` or `{:error, reason}`. + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) + iex> Atex.Repo.Commit.verify(signed, JOSE.JWK.to_public(jwk)) + :ok + + """ + @spec verify(t(), JOSE.JWK.t()) :: :ok | {:error, :unsigned | atom()} + def verify(%__MODULE__{sig: nil}, _jwk), do: {:error, :unsigned} + + def verify(%__MODULE__{sig: sig} = commit, jwk) do + with {:ok, payload} <- encode_unsigned(commit) do + Crypto.verify(payload, sig, jwk) + end + end + + @doc """ + Computes the CID of a signed commit. + + The CID is derived from the DRISL CBOR encoding of the **signed** commit + object, using the `:drisl` codec (blessed CID format). + + Returns `{:error, :unsigned}` if `sig` is `nil`. + + ## Examples + + iex> data_cid = DASL.CID.compute("data", :drisl) + iex> commit = Atex.Repo.Commit.new(did: "did:plc:e", data: data_cid, rev: "3jzfcijpj2z2a") + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + iex> {:ok, signed} = Atex.Repo.Commit.sign(commit, jwk) + iex> {:ok, cid} = Atex.Repo.Commit.cid(signed) + iex> cid.codec + :drisl + + """ + @spec cid(t()) :: {:ok, CID.t()} | {:error, :unsigned | atom()} + def cid(%__MODULE__{sig: nil}), do: {:error, :unsigned} + + def cid(%__MODULE__{} = commit) do + with {:ok, bytes} <- encode(commit) do + {:ok, CID.compute(bytes, :drisl)} + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + @spec to_unsigned_map(t()) :: map() + defp to_unsigned_map(%__MODULE__{} = c) do + %{ + "did" => c.did, + "version" => c.version, + "data" => c.data, + "rev" => c.rev, + "prev" => c.prev + } + end + + @spec to_signed_map(t()) :: map() + defp to_signed_map(%__MODULE__{} = c) do + c + |> to_unsigned_map() + |> Map.put("sig", %CBOR.Tag{tag: :bytes, value: c.sig}) + end + + @spec from_map(map()) :: {:ok, t()} | {:error, atom()} + defp from_map(map) when is_map(map) do + with {:ok, did} <- fetch_string(map, "did"), + {:ok, version} <- fetch_integer(map, "version"), + {:ok, data} <- fetch_cid(map, "data"), + {:ok, rev} <- fetch_string(map, "rev"), + {:ok, prev} <- fetch_nullable_cid(map, "prev") do + sig = extract_sig(Map.get(map, "sig")) + + {:ok, + %__MODULE__{ + did: did, + version: version, + data: data, + rev: rev, + prev: prev, + sig: sig + }} + end + end + + defp from_map(_), do: {:error, :invalid_commit} + + @spec extract_sig(any()) :: binary() | nil + defp extract_sig(%CBOR.Tag{tag: :bytes, value: bytes}) when is_binary(bytes), do: bytes + defp extract_sig(bytes) when is_binary(bytes), do: bytes + defp extract_sig(_), do: nil + + @spec fetch_string(map(), String.t()) :: {:ok, String.t()} | {:error, atom()} + defp fetch_string(map, key) do + case Map.fetch(map, key) do + {:ok, val} when is_binary(val) -> {:ok, val} + {:ok, _} -> {:error, :invalid_commit} + :error -> {:error, :missing_field} + end + end + + @spec fetch_integer(map(), String.t()) :: {:ok, integer()} | {:error, atom()} + defp fetch_integer(map, key) do + case Map.fetch(map, key) do + {:ok, val} when is_integer(val) -> {:ok, val} + {:ok, _} -> {:error, :invalid_commit} + :error -> {:error, :missing_field} + end + end + + @spec fetch_cid(map(), String.t()) :: {:ok, CID.t()} | {:error, atom()} + defp fetch_cid(map, key) do + case Map.fetch(map, key) do + {:ok, %CID{} = cid} -> {:ok, cid} + {:ok, _} -> {:error, :invalid_commit} + :error -> {:error, :missing_field} + end + end + + @spec fetch_nullable_cid(map(), String.t()) :: {:ok, CID.t() | nil} | {:error, atom()} + defp fetch_nullable_cid(map, key) do + case Map.fetch(map, key) do + {:ok, %CID{} = cid} -> {:ok, cid} + {:ok, nil} -> {:ok, nil} + {:ok, _} -> {:error, :invalid_commit} + :error -> {:ok, nil} + end + end +end diff --git a/lib/atex/repo/path.ex b/lib/atex/repo/path.ex new file mode 100644 index 0000000..1c35a53 --- /dev/null +++ b/lib/atex/repo/path.ex @@ -0,0 +1,219 @@ +defmodule Atex.Repo.Path do + @moduledoc """ + A validated AT Protocol repository path - a `collection/rkey` pair. + + Repo paths identify individual records within a repository. They always have + exactly two segments separated by a single `/`: + + - **collection** - a valid NSID string (e.g. `"app.bsky.feed.post"`) + - **rkey** - a record key string (e.g. `"3jzfcijpj2z2a"`, `"self"`, + `"example.com"`) + + ## Character constraints + + Collection segments follow NSID syntax: alphanumeric characters and periods + (`A-Za-z0-9.`), at least two period-separated components. + + Record keys allow: `A-Za-z0-9 . - _ : ~` (per + [spec](https://atproto.com/specs/record-key)), with a minimum length of 1 + and the values `"."` and `".."` disallowed. + + ## Usage + + iex> {:ok, path} = Atex.Repo.Path.new("app.bsky.feed.post", "3jzfcijpj2z2a") + iex> to_string(path) + "app.bsky.feed.post/3jzfcijpj2z2a" + + iex> {:ok, path} = Atex.Repo.Path.from_string("app.bsky.actor.profile/self") + iex> path.collection + "app.bsky.actor.profile" + iex> path.rkey + "self" + + ## `String.Chars` and interpolation + + `Atex.Repo.Path` implements `String.Chars`, so paths can be used directly + in string interpolation and anywhere a string path is expected: + + iex> path = Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + iex> "Record at \#{path}" + "Record at app.bsky.feed.post/3jzfcijpj2z2a" + + ATProto spec: https://atproto.com/specs/repository#repository-paths + """ + + use TypedStruct + + # Collection: NSID - only A-Za-z0-9 and periods, must have at least one dot + # (i.e. at least two components). Case-sensitive, no leading/trailing dots. + @collection_re ~r/^[a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+$/ + + # Record key: A-Za-z0-9 .-_:~ only, min 1 char. + @rkey_re ~r/^[A-Za-z0-9.\-_:~]+$/ + + @reserved_rkeys [~c".", ~c".."] + + typedstruct enforce: true do + @typedoc "A validated AT Protocol repository path (collection + rkey)." + field :collection, String.t() + field :rkey, String.t() + end + + @doc """ + Builds a validated `%Atex.Repo.Path{}` from a collection and record key. + + Returns `{:error, :invalid_collection}` if the collection is not a valid + NSID, or `{:error, :invalid_rkey}` if the record key contains disallowed + characters or is a reserved value (`.` or `..`). + + ## Examples + + iex> Atex.Repo.Path.new("app.bsky.feed.post", "3jzfcijpj2z2a") + {:ok, %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}} + + iex> Atex.Repo.Path.new("not-an-nsid", "self") + {:error, :invalid_collection} + + iex> Atex.Repo.Path.new("app.bsky.feed.post", "..") + {:error, :invalid_rkey} + + iex> Atex.Repo.Path.new("app.bsky.feed.post", "bad key!") + {:error, :invalid_rkey} + + """ + @spec new(String.t(), String.t()) :: {:ok, t()} | {:error, :invalid_collection | :invalid_rkey} + def new(collection, rkey) when is_binary(collection) and is_binary(rkey) do + with :ok <- validate_collection(collection), + :ok <- validate_rkey(rkey) do + {:ok, %__MODULE__{collection: collection, rkey: rkey}} + end + end + + @doc """ + Builds a validated `%Atex.Repo.Path{}`, raising on invalid input. + + ## Examples + + iex> Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} + + """ + @spec new!(String.t(), String.t()) :: t() + def new!(collection, rkey) do + case new(collection, rkey) do + {:ok, path} -> path + {:error, reason} -> raise ArgumentError, "invalid repo path: #{reason}" + end + end + + @doc """ + Parses a `"collection/rkey"` string into a validated `%Atex.Repo.Path{}`. + + Returns `{:error, :invalid_path}` if the string does not contain exactly one + `/`, or if either segment is invalid. + + ## Examples + + iex> Atex.Repo.Path.from_string("app.bsky.feed.post/3jzfcijpj2z2a") + {:ok, %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"}} + + iex> Atex.Repo.Path.from_string("no-slash") + {:error, :invalid_path} + + iex> Atex.Repo.Path.from_string("a/b/c") + {:error, :invalid_path} + + """ + @spec from_string(String.t()) :: + {:ok, t()} | {:error, :invalid_path | :invalid_collection | :invalid_rkey} + def from_string(string) when is_binary(string) do + case String.split(string, "/") do + [collection, rkey] when collection != "" and rkey != "" -> + case new(collection, rkey) do + {:ok, _} = ok -> ok + {:error, _} = err -> err + end + + _ -> + {:error, :invalid_path} + end + end + + @doc """ + Parses a `"collection/rkey"` string into a validated `%Atex.Repo.Path{}`, + raising on invalid input. + + ## Examples + + iex> Atex.Repo.Path.from_string!("app.bsky.feed.post/3jzfcijpj2z2a") + %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} + + """ + @spec from_string!(String.t()) :: t() + def from_string!(string) when is_binary(string) do + case from_string(string) do + {:ok, path} -> path + {:error, reason} -> raise ArgumentError, "invalid repo path: #{reason}" + end + end + + @doc """ + Converts the path to its canonical `"collection/rkey"` string form. + + ## Examples + + iex> path = Atex.Repo.Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + iex> Atex.Repo.Path.to_string(path) + "app.bsky.feed.post/3jzfcijpj2z2a" + + """ + @spec to_string(t()) :: String.t() + def to_string(%__MODULE__{collection: collection, rkey: rkey}), do: "#{collection}/#{rkey}" + + @doc """ + Sigil for constructing a validated `%Atex.Repo.Path{}` from a literal string. + + Raises `ArgumentError` if the string is not a valid `"collection/rkey"` path. + To use this sigil, import `Atex.Repo.Path`. + + ## Examples + + iex> import Atex.Repo.Path + iex> ~PATH"app.bsky.feed.post/3jzfcijpj2z2a" + %Atex.Repo.Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} + + """ + @spec sigil_PATH(String.t(), list()) :: t() + def sigil_PATH(string, _) when is_binary(string), do: from_string!(string) + + # --------------------------------------------------------------------------- + # Private validators + # --------------------------------------------------------------------------- + + @spec validate_collection(String.t()) :: :ok | {:error, :invalid_collection} + defp validate_collection(collection) do + if Regex.match?(@collection_re, collection), + do: :ok, + else: {:error, :invalid_collection} + end + + @spec validate_rkey(String.t()) :: :ok | {:error, :invalid_rkey} + defp validate_rkey(rkey) do + cond do + rkey == "" -> {:error, :invalid_rkey} + String.to_charlist(rkey) in @reserved_rkeys -> {:error, :invalid_rkey} + not Regex.match?(@rkey_re, rkey) -> {:error, :invalid_rkey} + true -> :ok + end + end +end + +defimpl String.Chars, for: Atex.Repo.Path do + def to_string(path), do: Atex.Repo.Path.to_string(path) +end + +defimpl Inspect, for: Atex.Repo.Path do + def inspect(path, _opts) do + ~s'~PATH"#{path}"' + end +end diff --git a/mix.exs b/mix.exs index c53ba6a..dca909e 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Atex.MixProject do @version "0.8.0" @github "https://github.com/cometsh/atex" - @tangled "https://tangled.sh/@comet.sh/atex" + @tangled "https://tangled.org/@comet.sh/atex" def project do [ @@ -46,7 +46,10 @@ defmodule Atex.MixProject do {:bandit, "~> 1.0", only: [:dev, :test]}, {:con_cache, "~> 1.1"}, {:mutex, "~> 3.0"}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + {:dasl, "~> 0.1"}, + {:mst, "~> 0.1"}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:benchee, "~> 1.3", only: :dev} ] end @@ -70,6 +73,7 @@ defmodule Atex.MixProject do formatters: ["html"], groups_for_modules: [ "Data types": [Atex.AtURI, ~r/^Atex\.DID/, Atex.Handle, Atex.NSID, Atex.TID], + Repository: ~r/^Atex\.Repo/, XRPC: ~r/^Atex\.XRPC/, PLC: [Atex.PLC], OAuth: [Atex.Config.OAuth, ~r/^Atex\.OAuth/], diff --git a/mix.lock b/mix.lock index 41485eb..d2981ff 100644 --- a/mix.lock +++ b/mix.lock @@ -1,27 +1,32 @@ %{ - "bandit": {:hex, :bandit, "1.10.0", "f8293b4a4e6c06b31655ae10bd3462f59d8c5dbd1df59028a4984f10c5961147", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "43ebceb7060a4d8273e47d83e703d01b112198624ba0826980caa3f5091243c4"}, + "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, + "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "cldr_utils": {:hex, :cldr_utils, "2.29.1", "11ff0a50a36a7e5f3bd9fc2fb8486a4c1bcca3081d9c080bf9e48fe0e6742e2d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3844a0a0ed7f42e6590ddd8bd37eb4b1556b112898f67dea3ba068c29aabd6c2"}, + "cbor": {:hex, :cbor, "1.0.2", "9b0af85af291a556e10a0ffd48ba9a21a75e711828fafd3af193d56d95f0907f", [:mix], [], "hexpm", "edbc9b4a16eb93a582437b9b249c340a75af03958e338fb43d8c1be9fc65b864"}, + "cldr_utils": {:hex, :cldr_utils, "2.29.5", "f43161e04acb4016f5841b2320d69120d51827f5346babb2227893a2c5916dc8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "962d3a2028b232ee0a5373941dc411028a9442f53444a4d5d2c354f687db1835"}, "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, - "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, + "dasl": {:hex, :dasl, "0.1.1", "18ee2d4faa8320406cb444fa6d8e6f45c41871e6898bf1844572f83627509f72", [:mix], [{:cbor, "~> 1.0.0", [hex: :cbor, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}, {:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "ba06404cfb343ea92f17b466eaf4efc57f08958d8b3110464bd7d368fc4c4013"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, - "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, + "ex_cldr": {:hex, :ex_cldr, "2.47.2", "c866f4b45523abd25eea3e5252eb91364296dd15bddf970db1c78cd38f25df9a", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "4a7cef380a1c2546166b45d6ee5e8e2f707ea695b12ae6dadd250201588b4f16"}, + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mst": {:hex, :mst, "0.1.0", "407b9a36e7e9ccfeaaee28ce5489d32d3f9c263bc7aaff16650110a3f6db796f", [:mix], [{:dasl, "~> 0.1.0", [hex: :dasl, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}], "hexpm", "80a1f0768122534c1b592d5c77408169e7fee59247275cec4e03243bfb8a3ed5"}, "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, - "mutex": {:hex, :mutex, "3.0.2", "528877fd0dbc09fc93ad667e10ea0d35a2126fa85205822f9dca85e87d732245", [:mix], [], "hexpm", "0a8f2ed3618160dca6a1e3520b293dc3c2ae53116265e71b4a732d35d29aa3c6"}, + "mutex": {:hex, :mutex, "3.0.3", "26408c7c518b10da5c37bc4a95511b8ac1d4841f86780e947fb683eede682952", [:mix], [], "hexpm", "fb2d7d5fc1174f6c812fa0289c907cfae10793d7bd02eadd46faea2cb1516eb5"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, @@ -29,8 +34,9 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, - "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, diff --git a/test/atex/repo/commit_test.exs b/test/atex/repo/commit_test.exs new file mode 100644 index 0000000..6d83be4 --- /dev/null +++ b/test/atex/repo/commit_test.exs @@ -0,0 +1,200 @@ +defmodule Atex.Repo.CommitTest do + use ExUnit.Case, async: true + + alias Atex.Repo.Commit + alias DASL.CID + + @did "did:plc:example" + @rev "3jzfcijpj2z2a" + + defp data_cid, do: CID.compute("mst root", :drisl) + + defp unsigned_commit do + Commit.new(did: @did, data: data_cid(), rev: @rev) + end + + defp p256_jwk, do: JOSE.JWK.generate_key({:ec, "P-256"}) + defp k256_jwk, do: JOSE.JWK.generate_key({:ec, "secp256k1"}) + + # --------------------------------------------------------------------------- + # new/1 + # --------------------------------------------------------------------------- + + describe "new/1" do + test "sets version to 3" do + assert unsigned_commit().version == 3 + end + + test "sets sig to nil" do + assert unsigned_commit().sig == nil + end + + test "sets prev to nil by default" do + assert unsigned_commit().prev == nil + end + + test "accepts an explicit prev CID" do + prev = CID.compute("prev commit", :drisl) + commit = Commit.new(did: @did, data: data_cid(), rev: @rev, prev: prev) + assert commit.prev == prev + end + end + + # --------------------------------------------------------------------------- + # encode_unsigned/1 + decode/1 round-trip + # --------------------------------------------------------------------------- + + describe "encode_unsigned/1 and decode/1" do + test "produces valid DRISL bytes" do + assert {:ok, bin} = Commit.encode_unsigned(unsigned_commit()) + assert is_binary(bin) + end + + test "round-trips unsigned commit" do + commit = unsigned_commit() + {:ok, bin} = Commit.encode_unsigned(commit) + {:ok, decoded, rest} = Commit.decode(bin) + + assert rest == "" + assert decoded.did == commit.did + assert decoded.version == commit.version + assert decoded.data == commit.data + assert decoded.rev == commit.rev + assert decoded.prev == commit.prev + assert decoded.sig == nil + end + + test "does not include sig in unsigned bytes" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + + {:ok, unsigned_bytes} = Commit.encode_unsigned(signed) + {:ok, decoded, _} = Commit.decode(unsigned_bytes) + + assert decoded.sig == nil + end + end + + # --------------------------------------------------------------------------- + # sign/2 + # --------------------------------------------------------------------------- + + describe "sign/2" do + test "produces a binary signature (P-256)" do + {:ok, signed} = Commit.sign(unsigned_commit(), p256_jwk()) + assert is_binary(signed.sig) + assert byte_size(signed.sig) > 0 + end + + test "produces a binary signature (secp256k1)" do + {:ok, signed} = Commit.sign(unsigned_commit(), k256_jwk()) + assert is_binary(signed.sig) + end + + test "returns error when already signed" do + {:ok, signed} = Commit.sign(unsigned_commit(), p256_jwk()) + assert {:error, :already_signed} = Commit.sign(signed, p256_jwk()) + end + end + + # --------------------------------------------------------------------------- + # verify/2 + # --------------------------------------------------------------------------- + + describe "verify/2" do + test "accepts a valid signature (P-256)" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + assert :ok = Commit.verify(signed, JOSE.JWK.to_public(jwk)) + end + + test "accepts a valid signature (secp256k1)" do + jwk = k256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + assert :ok = Commit.verify(signed, JOSE.JWK.to_public(jwk)) + end + + test "rejects signature from a different key" do + jwk_a = p256_jwk() + jwk_b = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk_a) + assert {:error, _} = Commit.verify(signed, JOSE.JWK.to_public(jwk_b)) + end + + test "rejects unsigned commit" do + assert {:error, :unsigned} = Commit.verify(unsigned_commit(), p256_jwk()) + end + + test "rejects tampered data field" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + tampered = %{signed | data: CID.compute("tampered", :drisl)} + assert {:error, _} = Commit.verify(tampered, JOSE.JWK.to_public(jwk)) + end + end + + # --------------------------------------------------------------------------- + # encode/1 (signed) + # --------------------------------------------------------------------------- + + describe "encode/1" do + test "returns error for unsigned commit" do + assert {:error, :unsigned} = Commit.encode(unsigned_commit()) + end + + test "produces DRISL bytes for a signed commit" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + assert {:ok, bin} = Commit.encode(signed) + assert is_binary(bin) + end + + test "round-trips signed commit" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + {:ok, bin} = Commit.encode(signed) + {:ok, decoded, _} = Commit.decode(bin) + + assert decoded.did == signed.did + assert decoded.sig == signed.sig + assert decoded.data == signed.data + end + end + + # --------------------------------------------------------------------------- + # cid/1 + # --------------------------------------------------------------------------- + + describe "cid/1" do + test "returns error for unsigned commit" do + assert {:error, :unsigned} = Commit.cid(unsigned_commit()) + end + + test "returns a CID with :drisl codec" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + {:ok, cid} = Commit.cid(signed) + assert cid.codec == :drisl + end + + test "is stable - same commit produces the same CID" do + jwk = p256_jwk() + {:ok, signed} = Commit.sign(unsigned_commit(), jwk) + {:ok, cid1} = Commit.cid(signed) + {:ok, cid2} = Commit.cid(signed) + assert cid1 == cid2 + end + + test "changes when the data field changes" do + jwk = p256_jwk() + {:ok, signed_a} = Commit.sign(unsigned_commit(), jwk) + + commit_b = Commit.new(did: @did, data: CID.compute("other mst", :drisl), rev: @rev) + {:ok, signed_b} = Commit.sign(commit_b, jwk) + + {:ok, cid_a} = Commit.cid(signed_a) + {:ok, cid_b} = Commit.cid(signed_b) + assert cid_a != cid_b + end + end +end diff --git a/test/atex/repo/fixtures_test.exs b/test/atex/repo/fixtures_test.exs new file mode 100644 index 0000000..d1f1b10 --- /dev/null +++ b/test/atex/repo/fixtures_test.exs @@ -0,0 +1,280 @@ +defmodule Atex.Repo.FixturesTest do + use ExUnit.Case, async: true + + @moduledoc """ + Parses real-world AT Protocol repository CAR exports from test/fixtures/. + + These verify that `Atex.Repo.from_car/1` correctly handles actual PDS- + exported repositories, including commit decoding, MST reconstruction, and + individual record retrieval. + """ + + alias Atex.Repo + alias Atex.Repo.Path, as: RepoPath + + defp fixture_path(name), do: Elixir.Path.join([__DIR__, "../../fixtures", name]) + defp fixture(name), do: File.read!(fixture_path(name)) + defp fixture_stream(name), do: File.stream!(fixture_path(name), 65_536, [:raw, :binary]) + + # --------------------------------------------------------------------------- + # comet.car - did:web:comet.sh + # --------------------------------------------------------------------------- + + describe "comet.car (did:web:comet.sh)" do + setup do + {:ok, repo} = fixture("comet.car") |> Repo.from_car() + {:ok, pairs} = MST.to_list(repo.tree) + %{repo: repo, pairs: pairs} + end + + test "decodes the commit", %{repo: repo} do + assert repo.commit.did == "did:web:comet.sh" + assert repo.commit.version == 3 + assert repo.commit.rev == "3mi3cqkyzsv22" + assert is_binary(repo.commit.sig) + end + + test "commit CID matches CAR root" do + bin = fixture("comet.car") + {:ok, car} = DASL.CAR.decode(bin) + {:ok, repo} = Repo.from_car(bin) + {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit) + assert [^commit_cid] = car.roots + end + + test "reconstructs the correct number of records", %{pairs: pairs} do + assert length(pairs) == 123 + end + + test "contains the expected collections", %{pairs: pairs} do + collections = + pairs + |> Enum.map(fn {k, _} -> k |> String.split("/") |> hd() end) + |> Enum.uniq() + |> Enum.sort() + + assert "app.bsky.actor.profile" in collections + assert "app.bsky.feed.post" in collections + assert "app.bsky.feed.like" in collections + assert "app.bsky.graph.follow" in collections + assert "sh.tangled.actor.profile" in collections + assert "sh.tangled.repo" in collections + end + + test "retrieves the Bluesky profile record", %{repo: repo} do + {:ok, profile} = Repo.get_record(repo, "app.bsky.actor.profile/self") + assert profile["displayName"] == "comet.sh" + assert profile["$type"] == "app.bsky.actor.profile" + end + + test "retrieves a Tangled profile record", %{repo: repo} do + {:ok, profile} = Repo.get_record(repo, "sh.tangled.actor.profile/self") + assert is_map(profile) + end + + test "returns not_found for a non-existent path", %{repo: repo} do + assert {:error, :not_found} = + Repo.get_record(repo, "app.bsky.feed.post/doesnotexist") + end + + test "all MST leaf CIDs match their record blocks", %{repo: repo, pairs: pairs} do + for {path, cid} <- pairs do + assert {:ok, _record} = Repo.get_record(repo, path), + "expected to decode record at #{path}" + + assert Map.has_key?(repo.blocks, cid), + "expected block for #{path} (#{DASL.CID.encode(cid)}) to be present" + end + end + + test "MST root CID matches commit data field", %{repo: repo} do + assert repo.tree.root == repo.commit.data + end + + test "list_collections returns expected collections", %{repo: repo} do + {:ok, cols} = Repo.list_collections(repo) + assert "app.bsky.feed.post" in cols + assert "app.bsky.feed.like" in cols + assert "sh.tangled.actor.profile" in cols + assert "sh.tangled.repo" in cols + # Collections are in MST byte order, not necessarily lexicographic order. + assert length(cols) == length(Enum.uniq(cols)) + end + + test "list_record_keys returns rkeys for a collection", %{repo: repo} do + {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post") + assert length(keys) > 0 + assert Enum.all?(keys, &is_binary/1) + assert keys == Enum.sort(keys) + end + + test "list_records round-trips record content", %{repo: repo} do + {:ok, records} = Repo.list_records(repo, "app.bsky.actor.profile") + assert length(records) == 1 + {"self", profile} = hd(records) + assert profile["displayName"] == "comet.sh" + end + + test "stream_car emits commit then all records (via File.stream!)" do + items = fixture_stream("comet.car") |> Repo.stream_car() |> Enum.to_list() + [{:commit, commit} | rest] = items + assert commit.did == "did:web:comet.sh" + record_items = Enum.filter(rest, &match?({:record, _, _}, &1)) + assert length(record_items) == 123 + end + + test "stream_car record paths are Atex.Repo.Path structs" do + fixture_stream("comet.car") + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Enum.each(fn {:record, path, _} -> + assert %RepoPath{} = path + end) + end + + test "stream_car and from_car agree on all record content", %{repo: repo} do + streamed = + fixture_stream("comet.car") + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end) + |> Map.new() + + {:ok, pairs} = MST.to_list(repo.tree) + + for {path_str, _cid} <- pairs do + {:ok, from_car_rec} = Repo.get_record(repo, path_str) + + assert Map.get(streamed, path_str) == from_car_rec, + "mismatch at #{path_str}" + end + end + end + + # --------------------------------------------------------------------------- + # alt.car - did:plc:xl2n6atcb6vz3ajmf6bnbrmw + # --------------------------------------------------------------------------- + + describe "alt.car (did:plc:xl2n6atcb6vz3ajmf6bnbrmw)" do + setup do + {:ok, repo} = fixture("alt.car") |> Repo.from_car() + {:ok, pairs} = MST.to_list(repo.tree) + %{repo: repo, pairs: pairs} + end + + test "decodes the commit", %{repo: repo} do + assert repo.commit.did == "did:plc:xl2n6atcb6vz3ajmf6bnbrmw" + assert repo.commit.version == 3 + assert repo.commit.rev == "3mgbwezwku722" + assert is_binary(repo.commit.sig) + end + + test "commit CID matches CAR root" do + bin = fixture("alt.car") + {:ok, car} = DASL.CAR.decode(bin) + {:ok, repo} = Repo.from_car(bin) + {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit) + assert [^commit_cid] = car.roots + end + + test "reconstructs the correct number of records", %{pairs: pairs} do + assert length(pairs) == 62 + end + + test "contains the expected collections", %{pairs: pairs} do + collections = + pairs + |> Enum.map(fn {k, _} -> k |> String.split("/") |> hd() end) + |> Enum.uniq() + |> Enum.sort() + + assert "app.bsky.actor.profile" in collections + assert "app.bsky.feed.post" in collections + assert "app.bsky.feed.like" in collections + assert "sh.tangled.knot" in collections + assert "xyz.statusphere.status" in collections + end + + test "retrieves the Bluesky profile record", %{repo: repo} do + {:ok, profile} = Repo.get_record(repo, "app.bsky.actor.profile/self") + assert profile["displayName"] == "ovyerus alt" + assert profile["$type"] == "app.bsky.actor.profile" + end + + test "returns not_found for a non-existent path", %{repo: repo} do + assert {:error, :not_found} = + Repo.get_record(repo, "app.bsky.feed.post/doesnotexist") + end + + test "all MST leaf CIDs match their record blocks", %{repo: repo, pairs: pairs} do + for {path, cid} <- pairs do + assert {:ok, _record} = Repo.get_record(repo, path), + "expected to decode record at #{path}" + + assert Map.has_key?(repo.blocks, cid), + "expected block for #{path} (#{DASL.CID.encode(cid)}) to be present" + end + end + + test "MST root CID matches commit data field", %{repo: repo} do + assert repo.tree.root == repo.commit.data + end + + test "list_collections returns expected collections", %{repo: repo} do + {:ok, cols} = Repo.list_collections(repo) + assert "app.bsky.feed.post" in cols + assert "sh.tangled.knot" in cols + assert "xyz.statusphere.status" in cols + # Collections are in MST byte order, not necessarily lexicographic order. + assert length(cols) == length(Enum.uniq(cols)) + end + + test "list_record_keys returns rkeys including colon rkeys", %{repo: repo} do + {:ok, keys} = Repo.list_record_keys(repo, "sh.tangled.knot") + assert "localhost:6000" in keys + end + + test "list_records round-trips record content", %{repo: repo} do + {:ok, records} = Repo.list_records(repo, "app.bsky.actor.profile") + assert length(records) == 1 + {"self", profile} = hd(records) + assert profile["displayName"] == "ovyerus alt" + end + + test "stream_car emits commit then all records (via File.stream!)" do + items = fixture_stream("alt.car") |> Repo.stream_car() |> Enum.to_list() + [{:commit, commit} | rest] = items + assert commit.did == "did:plc:xl2n6atcb6vz3ajmf6bnbrmw" + record_items = Enum.filter(rest, &match?({:record, _, _}, &1)) + assert length(record_items) == 62 + end + + test "stream_car handles colon rkey paths" do + paths = + fixture_stream("alt.car") + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Enum.map(fn {:record, path, _} -> to_string(path) end) + + assert "sh.tangled.knot/localhost:6000" in paths + end + + test "stream_car and from_car agree on all record content", %{repo: repo} do + streamed = + fixture_stream("alt.car") + |> Repo.stream_car() + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end) + |> Map.new() + + {:ok, pairs} = MST.to_list(repo.tree) + + for {path_str, _cid} <- pairs do + {:ok, from_car_rec} = Repo.get_record(repo, path_str) + + assert Map.get(streamed, path_str) == from_car_rec, + "mismatch at #{path_str}" + end + end + end +end diff --git a/test/atex/repo/path_test.exs b/test/atex/repo/path_test.exs new file mode 100644 index 0000000..0c735b4 --- /dev/null +++ b/test/atex/repo/path_test.exs @@ -0,0 +1,242 @@ +defmodule Atex.Repo.PathTest do + use ExUnit.Case, async: true + + alias Atex.Repo.Path + + # --------------------------------------------------------------------------- + # new/2 + # --------------------------------------------------------------------------- + + describe "new/2" do + test "accepts a standard NSID collection and TID rkey" do + assert {:ok, path} = Path.new("app.bsky.feed.post", "3jzfcijpj2z2a") + assert path.collection == "app.bsky.feed.post" + assert path.rkey == "3jzfcijpj2z2a" + end + + test "accepts 'self' literal rkey" do + assert {:ok, path} = Path.new("app.bsky.actor.profile", "self") + assert path.rkey == "self" + end + + test "accepts rkey with colon (e.g. domain name)" do + assert {:ok, _} = Path.new("sh.tangled.knot", "localhost:6000") + end + + test "accepts rkey with tilde" do + assert {:ok, _} = Path.new("com.example.thing", "~1.2-3_") + end + + test "accepts rkey with all allowed special chars" do + assert {:ok, _} = Path.new("com.example.thing", "aZ0.-_:~") + end + + test "accepts multi-segment deep NSID" do + assert {:ok, _} = Path.new("codes.advent.challenge.day", "3jzfcijpj2z2a") + end + + test "rejects collection without a dot (single segment)" do + assert {:error, :invalid_collection} = Path.new("noperiod", "self") + end + + test "rejects collection with leading dot" do + assert {:error, :invalid_collection} = Path.new(".app.bsky", "self") + end + + test "rejects collection with trailing dot" do + assert {:error, :invalid_collection} = Path.new("app.bsky.", "self") + end + + test "rejects collection with consecutive dots" do + assert {:error, :invalid_collection} = Path.new("app..bsky", "self") + end + + test "rejects collection with hyphen" do + assert {:error, :invalid_collection} = Path.new("app-bsky.feed.post", "self") + end + + test "rejects collection with uppercase segment starting char" do + # NSIDs are lowercase-only at the authority level; uppercase disallowed in collection + # The spec says NSID segments must start with a letter - uppercase is allowed per NSID spec + # but our regex allows [a-zA-Z][a-zA-Z0-9]* - so let's just verify the regex works + assert {:ok, _} = Path.new("App.Bsky.Feed", "self") + end + + test "rejects empty rkey" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "") + end + + test "rejects '.' rkey" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", ".") + end + + test "rejects '..' rkey" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "..") + end + + test "rejects rkey with slash" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "a/b") + end + + test "rejects rkey with space" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "bad key") + end + + test "rejects rkey with @" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "@handle") + end + + test "rejects rkey with #" do + assert {:error, :invalid_rkey} = Path.new("app.bsky.feed.post", "#extra") + end + end + + # --------------------------------------------------------------------------- + # new!/2 + # --------------------------------------------------------------------------- + + describe "new!/2" do + test "returns the struct on valid input" do + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = path + end + + test "raises ArgumentError on invalid collection" do + assert_raise ArgumentError, fn -> Path.new!("noslash", "self") end + end + + test "raises ArgumentError on invalid rkey" do + assert_raise ArgumentError, fn -> Path.new!("app.bsky.feed.post", "..") end + end + end + + # --------------------------------------------------------------------------- + # from_string/1 + # --------------------------------------------------------------------------- + + # --------------------------------------------------------------------------- + # from_string!/1 + # --------------------------------------------------------------------------- + + describe "from_string!/1" do + test "returns the struct on a valid path string" do + assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = + Path.from_string!("app.bsky.feed.post/3jzfcijpj2z2a") + end + + test "raises ArgumentError for a string with no slash" do + assert_raise ArgumentError, fn -> Path.from_string!("no-slash") end + end + + test "raises ArgumentError for a string with two slashes" do + assert_raise ArgumentError, fn -> Path.from_string!("a/b/c") end + end + + test "raises ArgumentError for an invalid collection segment" do + assert_raise ArgumentError, fn -> Path.from_string!("bad/self") end + end + + test "raises ArgumentError for a reserved rkey" do + assert_raise ArgumentError, fn -> Path.from_string!("app.bsky.feed.post/..") end + end + end + + # --------------------------------------------------------------------------- + # sigil_PATH + # --------------------------------------------------------------------------- + + describe "sigil_PATH" do + import Path, only: [sigil_PATH: 2] + + test "constructs a valid path from a literal string" do + assert %Path{collection: "app.bsky.feed.post", rkey: "3jzfcijpj2z2a"} = + ~PATH"app.bsky.feed.post/3jzfcijpj2z2a" + end + + test "works with alternative rkey formats" do + assert %Path{collection: "sh.tangled.knot", rkey: "localhost:6000"} = + ~PATH"sh.tangled.knot/localhost:6000" + end + + test "raises ArgumentError for an invalid path string" do + assert_raise ArgumentError, fn -> ~PATH"not-a-valid-path" end + end + end + + describe "from_string/1" do + test "parses a valid path string" do + assert {:ok, path} = Path.from_string("app.bsky.feed.post/3jzfcijpj2z2a") + assert path.collection == "app.bsky.feed.post" + assert path.rkey == "3jzfcijpj2z2a" + end + + test "parses a path with colon rkey" do + assert {:ok, path} = Path.from_string("sh.tangled.knot/localhost:6000") + assert path.rkey == "localhost:6000" + end + + test "returns invalid_path for string with no slash" do + assert {:error, :invalid_path} = Path.from_string("no-slash") + end + + test "returns invalid_path for string with two slashes" do + assert {:error, :invalid_path} = Path.from_string("a/b/c") + end + + test "returns invalid_path for empty string" do + assert {:error, :invalid_path} = Path.from_string("") + end + + test "returns invalid_collection for bad collection segment" do + assert {:error, :invalid_collection} = Path.from_string("bad/self") + end + + test "returns invalid_rkey for reserved rkey" do + assert {:error, :invalid_rkey} = Path.from_string("app.bsky.feed.post/..") + end + end + + # --------------------------------------------------------------------------- + # to_string/1 and String.Chars + # --------------------------------------------------------------------------- + + describe "to_string/1" do + test "produces collection/rkey format" do + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + assert Path.to_string(path) == "app.bsky.feed.post/3jzfcijpj2z2a" + end + + test "String.Chars protocol works in interpolation" do + path = Path.new!("app.bsky.actor.profile", "self") + assert "#{path}" == "app.bsky.actor.profile/self" + end + + test "Kernel.to_string/1 works" do + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + assert Kernel.to_string(path) == "app.bsky.feed.post/3jzfcijpj2z2a" + end + end + + # --------------------------------------------------------------------------- + # Inspect protocol + # --------------------------------------------------------------------------- + + describe "Inspect" do + test "renders as sigil form" do + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + assert inspect(path) == ~s(~PATH"app.bsky.feed.post/3jzfcijpj2z2a") + end + end + + # --------------------------------------------------------------------------- + # Round-trip + # --------------------------------------------------------------------------- + + describe "round-trip" do + test "from_string |> to_string is identity" do + str = "app.bsky.feed.post/3jzfcijpj2z2a" + {:ok, path} = Path.from_string(str) + assert Path.to_string(path) == str + end + end +end diff --git a/test/atex/repo_test.exs b/test/atex/repo_test.exs new file mode 100644 index 0000000..54b5f66 --- /dev/null +++ b/test/atex/repo_test.exs @@ -0,0 +1,507 @@ +defmodule Atex.RepoTest do + use ExUnit.Case, async: true + + alias Atex.Repo + alias Atex.Repo.Path + + @did "did:plc:example" + + defp jwk, do: JOSE.JWK.generate_key({:ec, "P-256"}) + + defp committed_repo(key \\ nil) do + key = key || jwk() + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hello"}) + {:ok, repo} = Repo.commit(repo, @did, key) + {repo, key} + end + + # --------------------------------------------------------------------------- + # new/0 + # --------------------------------------------------------------------------- + + describe "new/0" do + test "returns an empty repo with no commit" do + repo = Repo.new() + assert repo.commit == nil + assert repo.blocks == %{} + end + end + + # --------------------------------------------------------------------------- + # put_record/3 and get_record/2 + # --------------------------------------------------------------------------- + + describe "put_record/3 and get_record/2" do + test "round-trips a record" do + repo = Repo.new() + record = %{"text" => "hello world", "createdAt" => "2024-01-01T00:00:00Z"} + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", record) + {:ok, fetched} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + assert fetched == record + end + + test "replaces an existing record" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"v" => 1}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"v" => 2}) + {:ok, fetched} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + assert fetched["v"] == 2 + end + + test "returns not_found for missing path" do + repo = Repo.new() + assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + end + + test "stores multiple records independently" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) + {:ok, r1} = Repo.get_record(repo, "app.bsky.feed.post/aaaa") + {:ok, r2} = Repo.get_record(repo, "app.bsky.feed.post/bbbb") + assert r1["n"] == 1 + assert r2["n"] == 2 + end + + test "rejects an invalid path string" do + repo = Repo.new() + assert {:error, :invalid_path} = Repo.put_record(repo, "no-slash", %{}) + assert {:error, :invalid_path} = Repo.put_record(repo, "/leading", %{}) + assert {:error, :invalid_path} = Repo.put_record(repo, "a/b/c", %{}) + assert {:error, :invalid_path} = Repo.put_record(repo, "", %{}) + end + + test "accepts an Atex.Repo.Path struct" do + repo = Repo.new() + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + {:ok, repo} = Repo.put_record(repo, path, %{"text" => "via struct"}) + {:ok, record} = Repo.get_record(repo, path) + assert record["text"] == "via struct" + end + + test "Path struct and equivalent string retrieve the same record" do + repo = Repo.new() + path = Path.new!("app.bsky.feed.post", "3jzfcijpj2z2a") + {:ok, repo} = Repo.put_record(repo, path, %{"text" => "hi"}) + {:ok, r1} = Repo.get_record(repo, path) + {:ok, r2} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + assert r1 == r2 + end + end + + # --------------------------------------------------------------------------- + # delete_record/2 + # --------------------------------------------------------------------------- + + describe "delete_record/2" do + test "removes an existing record" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"x" => 1}) + {:ok, repo} = Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + end + + test "returns not_found for missing path" do + repo = Repo.new() + assert {:error, :not_found} = Repo.delete_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a") + end + + test "does not affect other records" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) + {:ok, repo} = Repo.delete_record(repo, "app.bsky.feed.post/aaaa") + assert {:error, :not_found} = Repo.get_record(repo, "app.bsky.feed.post/aaaa") + assert {:ok, %{"n" => 2}} = Repo.get_record(repo, "app.bsky.feed.post/bbbb") + end + + test "rejects invalid path" do + repo = Repo.new() + assert {:error, :invalid_path} = Repo.delete_record(repo, "bad") + end + + test "accepts an Atex.Repo.Path struct" do + repo = Repo.new() + path = Path.new!("app.bsky.feed.post", "aaaa") + {:ok, repo} = Repo.put_record(repo, path, %{"n" => 1}) + {:ok, repo} = Repo.delete_record(repo, path) + assert {:error, :not_found} = Repo.get_record(repo, path) + end + end + + # --------------------------------------------------------------------------- + # commit/3 + # --------------------------------------------------------------------------- + + describe "commit/3" do + test "sets the commit DID" do + {repo, _key} = committed_repo() + assert repo.commit.did == @did + end + + test "sets version to 3" do + {repo, _key} = committed_repo() + assert repo.commit.version == 3 + end + + test "sets prev to nil" do + {repo, _key} = committed_repo() + assert repo.commit.prev == nil + end + + test "rev is a valid TID string" do + {repo, _key} = committed_repo() + assert Atex.TID.match?(repo.commit.rev) + end + + test "produces a non-nil sig" do + {repo, _key} = committed_repo() + assert is_binary(repo.commit.sig) + end + + test "data CID matches the MST root" do + key = jwk() + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"x" => 1}) + {:ok, repo} = Repo.commit(repo, @did, key) + + assert repo.commit.data == repo.tree.root + end + + test "rev increases monotonically across sequential commits" do + key = jwk() + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) + {:ok, repo} = Repo.commit(repo, @did, key) + rev1 = repo.commit.rev + + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) + {:ok, repo} = Repo.commit(repo, @did, key) + rev2 = repo.commit.rev + + assert rev2 > rev1 + end + end + + # --------------------------------------------------------------------------- + # verify_commit/2 + # --------------------------------------------------------------------------- + + describe "verify_commit/2" do + test "passes with the correct public key" do + key = jwk() + {repo, _} = committed_repo(key) + assert :ok = Repo.verify_commit(repo, JOSE.JWK.to_public(key)) + end + + test "fails with a different key" do + {repo, _key} = committed_repo() + other_key = JOSE.JWK.to_public(jwk()) + assert {:error, _} = Repo.verify_commit(repo, other_key) + end + + test "returns error when no commit exists" do + repo = Repo.new() + assert {:error, :no_commit} = Repo.verify_commit(repo, jwk()) + end + end + + # --------------------------------------------------------------------------- + # to_car/1 + # --------------------------------------------------------------------------- + + describe "to_car/1" do + test "returns error when no commit exists" do + repo = Repo.new() + assert {:error, :no_commit} = Repo.to_car(repo) + end + + test "returns a binary" do + {repo, _key} = committed_repo() + assert {:ok, bin} = Repo.to_car(repo) + assert is_binary(bin) + end + + test "CAR root is the commit CID" do + {repo, _key} = committed_repo() + {:ok, bin} = Repo.to_car(repo) + {:ok, car} = DASL.CAR.decode(bin) + {:ok, commit_cid} = Atex.Repo.Commit.cid(repo.commit) + assert [^commit_cid] = car.roots + end + + test "empty repo produces a valid CAR" do + key = jwk() + repo = Repo.new() + {:ok, repo} = Repo.commit(repo, @did, key) + assert {:ok, bin} = Repo.to_car(repo) + assert is_binary(bin) + end + end + + # --------------------------------------------------------------------------- + # from_car/1 + # --------------------------------------------------------------------------- + + describe "from_car/1" do + test "round-trips a single-record repo" do + key = jwk() + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/3jzfcijpj2z2a", %{"text" => "hi"}) + {:ok, repo} = Repo.commit(repo, @did, key) + {:ok, bin} = Repo.to_car(repo) + + {:ok, repo2} = Repo.from_car(bin) + assert repo2.commit.did == @did + assert {:ok, %{"text" => "hi"}} = Repo.get_record(repo2, "app.bsky.feed.post/3jzfcijpj2z2a") + end + + test "round-trips a multi-record repo" do + key = jwk() + repo = Repo.new() + + records = [ + {"app.bsky.feed.post/aaaa", %{"n" => 1}}, + {"app.bsky.feed.post/bbbb", %{"n" => 2}}, + {"app.bsky.actor.profile/self", %{"displayName" => "Test"}} + ] + + repo = + Enum.reduce(records, repo, fn {path, rec}, acc -> + {:ok, acc} = Repo.put_record(acc, path, rec) + acc + end) + + {:ok, repo} = Repo.commit(repo, @did, key) + {:ok, bin} = Repo.to_car(repo) + {:ok, repo2} = Repo.from_car(bin) + + for {path, record} <- records do + assert {:ok, ^record} = Repo.get_record(repo2, path) + end + end + + test "commit signature survives round-trip" do + key = jwk() + {repo, _} = committed_repo(key) + {:ok, bin} = Repo.to_car(repo) + {:ok, repo2} = Repo.from_car(bin) + assert :ok = Repo.verify_commit(repo2, JOSE.JWK.to_public(key)) + end + + test "returns error for invalid binary" do + assert match?({:error, _, _}, Repo.from_car("not a car")) or + match?({:error, _}, Repo.from_car("not a car")) + end + + test "round-trips an empty repo" do + key = jwk() + repo = Repo.new() + {:ok, repo} = Repo.commit(repo, @did, key) + {:ok, bin} = Repo.to_car(repo) + {:ok, repo2} = Repo.from_car(bin) + assert repo2.commit.did == @did + end + end + + # --------------------------------------------------------------------------- + # list_collections/1 + # --------------------------------------------------------------------------- + + describe "list_collections/1" do + test "returns empty list for empty repo" do + assert {:ok, []} = Repo.list_collections(Repo.new()) + end + + test "returns deduplicated collection names in MST order" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/bbbb", %{}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/cccc", %{}) + {:ok, cols} = Repo.list_collections(repo) + # MST key order: "app.bsky.feed.like/..." < "app.bsky.feed.post/..." + assert "app.bsky.feed.like" in cols + assert "app.bsky.feed.post" in cols + assert length(cols) == 2 + end + + test "each collection appears exactly once" do + repo = Repo.new() + + repo = + Enum.reduce(1..5, repo, fn i, acc -> + {:ok, acc} = Repo.put_record(acc, "app.bsky.feed.post/key#{i}", %{"n" => i}) + acc + end) + + {:ok, cols} = Repo.list_collections(repo) + assert cols == ["app.bsky.feed.post"] + end + end + + # --------------------------------------------------------------------------- + # list_record_keys/2 + # --------------------------------------------------------------------------- + + describe "list_record_keys/2" do + test "returns empty list for empty repo" do + assert {:ok, []} = Repo.list_record_keys(Repo.new(), "app.bsky.feed.post") + end + + test "returns empty list for non-existent collection" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{}) + assert {:ok, []} = Repo.list_record_keys(repo, "app.bsky.feed.post") + end + + test "returns rkeys in sorted order" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/cccc", %{}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) + {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post") + assert keys == ["aaaa", "bbbb", "cccc"] + end + + test "does not bleed into adjacent collections" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.graph.follow/cccc", %{}) + {:ok, keys} = Repo.list_record_keys(repo, "app.bsky.feed.post") + assert keys == ["bbbb"] + end + end + + # --------------------------------------------------------------------------- + # list_records/2 + # --------------------------------------------------------------------------- + + describe "list_records/2" do + test "returns empty list for empty repo" do + assert {:ok, []} = Repo.list_records(Repo.new(), "app.bsky.feed.post") + end + + test "returns {rkey, record} pairs in sorted order" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/aaaa", %{"n" => 1}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"n" => 2}) + {:ok, records} = Repo.list_records(repo, "app.bsky.feed.post") + assert Enum.map(records, &elem(&1, 0)) == ["aaaa", "bbbb"] + assert Enum.map(records, fn {_, r} -> r["n"] end) == [1, 2] + end + + test "only returns records for the specified collection" do + repo = Repo.new() + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.like/aaaa", %{"liked" => true}) + {:ok, repo} = Repo.put_record(repo, "app.bsky.feed.post/bbbb", %{"text" => "hi"}) + {:ok, records} = Repo.list_records(repo, "app.bsky.feed.post") + assert length(records) == 1 + assert {"bbbb", %{"text" => "hi"}} = hd(records) + end + end + + # --------------------------------------------------------------------------- + # stream_car/1 + # --------------------------------------------------------------------------- + + describe "stream_car/1" do + defp build_committed_repo(records) do + key = jwk() + + repo = + Enum.reduce(records, Repo.new(), fn {path, rec}, acc -> + {:ok, acc} = Repo.put_record(acc, path, rec) + acc + end) + + {:ok, repo} = Repo.commit(repo, @did, key) + {:ok, bin} = Repo.to_car(repo) + {repo, bin, key} + end + + test "first item is {:commit, commit}" do + {_repo, bin, _key} = + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}]) + + [first | _] = Repo.stream_car([bin]) |> Enum.to_list() + assert match?({:commit, %Atex.Repo.Commit{}}, first) + end + + test "commit in stream has correct DID" do + {_repo, bin, _key} = + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}]) + + [{:commit, commit} | _] = Repo.stream_car([bin]) |> Enum.to_list() + assert commit.did == @did + end + + test "emits a {:record, path, map} for each record" do + records = [ + {"app.bsky.feed.post/aaaa", %{"n" => 1}}, + {"app.bsky.feed.post/bbbb", %{"n" => 2}} + ] + + {_repo, bin, _key} = build_committed_repo(records) + items = Repo.stream_car([bin]) |> Enum.to_list() + record_items = Enum.filter(items, &match?({:record, _, _}, &1)) + assert length(record_items) == 2 + + paths = Enum.map(record_items, fn {:record, path, _} -> to_string(path) end) |> Enum.sort() + assert paths == ["app.bsky.feed.post/aaaa", "app.bsky.feed.post/bbbb"] + end + + test "record content is correct" do + {_repo, bin, _key} = + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"text" => "hello stream"}}]) + + items = Repo.stream_car([bin]) |> Enum.to_list() + [{:record, path, record}] = Enum.filter(items, &match?({:record, _, _}, &1)) + assert path.collection == "app.bsky.feed.post" + assert path.rkey == "aaaa" + assert record["text"] == "hello stream" + end + + test "path items are Atex.Repo.Path structs" do + {_repo, bin, _key} = + build_committed_repo([{"app.bsky.feed.post/aaaa", %{"n" => 1}}]) + + items = Repo.stream_car([bin]) |> Enum.to_list() + [{:record, path, _}] = Enum.filter(items, &match?({:record, _, _}, &1)) + assert %Atex.Repo.Path{} = path + end + + test "empty repo stream has only commit item" do + key = jwk() + repo = Repo.new() + {:ok, repo} = Repo.commit(repo, @did, key) + {:ok, bin} = Repo.to_car(repo) + items = Repo.stream_car([bin]) |> Enum.to_list() + assert length(items) == 1 + assert match?([{:commit, _}], items) + end + + test "stream and from_car agree on record content" do + records = [ + {"app.bsky.feed.post/aaaa", %{"n" => 1}}, + {"app.bsky.actor.profile/self", %{"displayName" => "Test"}} + ] + + {_repo, bin, _key} = build_committed_repo(records) + + {:ok, repo} = Repo.from_car(bin) + + streamed = + Repo.stream_car([bin]) + |> Stream.filter(&match?({:record, _, _}, &1)) + |> Enum.map(fn {:record, path, rec} -> {to_string(path), rec} end) + |> Map.new() + + for {path_str, _record} <- records do + {:ok, from_car_rec} = Repo.get_record(repo, path_str) + assert Map.get(streamed, path_str) == from_car_rec + end + end + end +end diff --git a/test/fixtures/alt.car b/test/fixtures/alt.car new file mode 100644 index 0000000000000000000000000000000000000000..1a81f670942e8b102bf4af4b61362d9ae21ab34a GIT binary patch literal 22427 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/comet.car b/test/fixtures/comet.car new file mode 100644 index 0000000000000000000000000000000000000000..383c2b61ca45a4dafa9b92bc55f415392d5c2efa GIT binary patch literal 47039 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 literal 0 HcmV?d00001 -- 2.51.2