From 790aeb70cd94c1d7215369e2de9cf57a8b9efaae Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Sun, 8 Jun 2025 22:22:43 +1000 Subject: [PATCH] feat: module for dealing with ATProto TIDs --- CHANGELOG.md | 8 ++ lib/aturi.ex | 7 +- lib/base32_sortable.ex | 39 ++++++++++ lib/tid.ex | 169 +++++++++++++++++++++++++++++++++++++++++ test/tid_test.exs | 4 + 5 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 lib/base32_sortable.ex create mode 100644 lib/tid.ex create mode 100644 test/tid_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 009e6d3..c1c3ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## Added + +- `Atex.TID` module for manipulating ATProto TIDs. +- `Atex.Base32Sortable` module for encoding/decoding numbers as + `base32-sortable` strings. + ## [0.1.0] - 2025-06-07 Initial release. diff --git a/lib/aturi.ex b/lib/aturi.ex index a04847d..650b379 100644 --- a/lib/aturi.ex +++ b/lib/aturi.ex @@ -1,8 +1,9 @@ defmodule Atex.AtURI do @moduledoc """ Struct and helper functions for manipulating `at://` URIs, which identify - specific records within the AT Protocol. For more information on the URI - scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme. + specific records within the AT Protocol. + + ATProto spec: https://atproto.com/specs/at-uri-scheme This module only supports the restricted URI syntax used for the Lexicon `at-uri` type, with no support for query strings or fragments. If/when the @@ -154,5 +155,5 @@ defmodule Atex.AtURI do end defimpl String.Chars, for: Atex.AtURI do - def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri) + def to_string(uri), do: Atex.AtURI.to_string(uri) end diff --git a/lib/base32_sortable.ex b/lib/base32_sortable.ex new file mode 100644 index 0000000..0e53734 --- /dev/null +++ b/lib/base32_sortable.ex @@ -0,0 +1,39 @@ +defmodule Atex.Base32Sortable do + @moduledoc """ + Codec for the base32-sortable encoding. + """ + + @alphabet ~c(234567abcdefghijklmnopqrstuvwxyz) + @alphabet_len length(@alphabet) + + @doc """ + Encode an integer as a base32-sortable string. + """ + @spec encode(integer()) :: String.t() + def encode(int) when is_integer(int), do: do_encode(int, "") + + @spec do_encode(integer(), String.t()) :: String.t() + defp do_encode(0, acc), do: acc + + defp do_encode(int, acc) do + char_index = rem(int, @alphabet_len) + new_int = div(int, @alphabet_len) + + # Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer. + do_encode(new_int, <> <> acc) + end + + @doc """ + Decode a base32-sortable string to an integer. + """ + @spec decode(String.t()) :: integer() + def decode(str) when is_binary(str), do: do_decode(str, 0) + + @spec do_decode(String.t(), integer()) :: integer() + defp do_decode(<<>>, acc), do: acc + + defp do_decode(<>, acc) do + i = Enum.find_index(@alphabet, fn x -> x == char end) + do_decode(rest, acc * @alphabet_len + i) + end +end diff --git a/lib/tid.ex b/lib/tid.ex new file mode 100644 index 0000000..7380ad1 --- /dev/null +++ b/lib/tid.ex @@ -0,0 +1,169 @@ +defmodule Atex.TID do + @moduledoc """ + Struct and helper functions for dealing with AT Protocol TIDs (Timestamp + Identifiers), a 13-character string representation of a 64-bit number + comprised of a Unix timestamp (in microsecond precision) and a random "clock + identifier" to help avoid collisions. + + ATProto spec: https://atproto.com/specs/tid + + TID strings are always 13 characters long. All bits in the 64-bit number are + encoded, essentially meaning that the string is padded with "2" if necessary, + (the 0th character in the base32-sortable alphabet). + """ + import Bitwise + alias Atex.Base32Sortable + use TypedStruct + + @re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/ + + @typedoc """ + A Unix timestamp representing when the TID was created. + """ + @type timestamp() :: integer() + + @typedoc """ + An integer to be used for the lower 10 bits of the TID. + """ + @type clock_id() :: 0..1023 + + typedstruct enforce: true do + field :timestamp, timestamp() + field :clock_id, clock_id() + end + + @doc """ + Returns a TID for the current moment in time, along with a random clock ID. + """ + @spec now() :: t() + def now, + do: %__MODULE__{ + timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond), + clock_id: gen_clock_id() + } + + @doc """ + Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds. + + If `clock_id` isn't provided, a random one will be generated. + """ + @spec new(DateTime.t() | integer(), integer() | nil) :: t() + def new(source, clock_id \\ nil) + + def new(%DateTime{} = datetime, clock_id), + do: %__MODULE__{ + timestamp: DateTime.to_unix(datetime, :microsecond), + clock_id: clock_id || gen_clock_id() + } + + def new(unix, clock_id) when is_integer(unix), + do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()} + + @doc """ + Convert a TID struct to an instance of `DateTime`. + """ + def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond) + + @doc """ + Generate a random integer to be used as a `clock_id`. + """ + @spec gen_clock_id() :: clock_id() + def gen_clock_id, do: :rand.uniform(1024) - 1 + + @doc """ + Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid. + + ## Examples + + Syntactically valid TIDs: + + iex> Atex.TID.decode("3jzfcijpj2z2a") + {:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}} + + iex> Atex.TID.decode("7777777777777") + {:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}} + + iex> Atex.TID.decode("3zzzzzzzzzzzz") + {:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}} + + iex> Atex.TID.decode("2222222222222") + {:ok, %Atex.TID{clock_id: 0, timestamp: 0}} + + Invalid TIDs: + + # not base32 + iex> Atex.TID.decode("3jzfcijpj2z21") + :error + iex> Atex.TID.decode("0000000000000") + :error + + # case-sensitive + iex> Atex.TID.decode("3JZFCIJPJ2Z2A") + :error + + # too long/short + iex> Atex.TID.decode("3jzfcijpj2z2aa") + :error + iex> Atex.TID.decode("3jzfcijpj2z2") + :error + iex> Atex.TID.decode("222") + :error + + # legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC) + iex> Atex.TID.decode("3jzf-cij-pj2z-2a") + :error + + # high bit can't be set + iex> Atex.TID.decode("zzzzzzzzzzzzz") + :error + iex> Atex.TID.decode("kjzfcijpj2z2a") + :error + + """ + @spec decode(String.t()) :: {:ok, t()} | :error + def decode(<> = tid) do + if Regex.match?(@re, tid) do + timestamp = Base32Sortable.decode(timestamp) + clock_id = Base32Sortable.decode(clock_id) + + {:ok, + %__MODULE__{ + timestamp: timestamp, + clock_id: clock_id + }} + else + :error + end + end + + def decode(_tid), do: :error + + @doc """ + Encode a TID struct into a string. + + ## Examples + + iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007}) + "3jzfcijpj2z2a" + + iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285}) + "7777777777777" + + iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247}) + "3zzzzzzzzzzzz" + + iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0}) + "2222222222222" + + """ + @spec encode(t()) :: String.t() + def encode(%__MODULE__{} = tid) do + timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2") + clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2") + timestamp <> clock_id + end +end + +defimpl String.Chars, for: Atex.TID do + def to_string(tid), do: Atex.TID.encode(tid) +end diff --git a/test/tid_test.exs b/test/tid_test.exs new file mode 100644 index 0000000..2deb5b2 --- /dev/null +++ b/test/tid_test.exs @@ -0,0 +1,4 @@ +defmodule TIDTest do + use ExUnit.Case, async: true + doctest Atex.TID +end -- 2.51.2