A set of utilities for working with the AT Protocol in Elixir.

feat: module for dealing with ATProto TIDs

ovyerus.com 790aeb70 d194d488

verified
+8
CHANGELOG.md
··· 6 6 and this project adheres to 7 7 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 8 9 + ## [Unreleased] 10 + 11 + ## Added 12 + 13 + - `Atex.TID` module for manipulating ATProto TIDs. 14 + - `Atex.Base32Sortable` module for encoding/decoding numbers as 15 + `base32-sortable` strings. 16 + 9 17 ## [0.1.0] - 2025-06-07 10 18 11 19 Initial release.
+4 -3
lib/aturi.ex
··· 1 1 defmodule Atex.AtURI do 2 2 @moduledoc """ 3 3 Struct and helper functions for manipulating `at://` URIs, which identify 4 - specific records within the AT Protocol. For more information on the URI 5 - scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme. 4 + specific records within the AT Protocol. 5 + 6 + ATProto spec: https://atproto.com/specs/at-uri-scheme 6 7 7 8 This module only supports the restricted URI syntax used for the Lexicon 8 9 `at-uri` type, with no support for query strings or fragments. If/when the ··· 154 155 end 155 156 156 157 defimpl String.Chars, for: Atex.AtURI do 157 - def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri) 158 + def to_string(uri), do: Atex.AtURI.to_string(uri) 158 159 end
+39
lib/base32_sortable.ex
··· 1 + defmodule Atex.Base32Sortable do 2 + @moduledoc """ 3 + Codec for the base32-sortable encoding. 4 + """ 5 + 6 + @alphabet ~c(234567abcdefghijklmnopqrstuvwxyz) 7 + @alphabet_len length(@alphabet) 8 + 9 + @doc """ 10 + Encode an integer as a base32-sortable string. 11 + """ 12 + @spec encode(integer()) :: String.t() 13 + def encode(int) when is_integer(int), do: do_encode(int, "") 14 + 15 + @spec do_encode(integer(), String.t()) :: String.t() 16 + defp do_encode(0, acc), do: acc 17 + 18 + defp do_encode(int, acc) do 19 + char_index = rem(int, @alphabet_len) 20 + new_int = div(int, @alphabet_len) 21 + 22 + # Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer. 23 + do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc) 24 + end 25 + 26 + @doc """ 27 + Decode a base32-sortable string to an integer. 28 + """ 29 + @spec decode(String.t()) :: integer() 30 + def decode(str) when is_binary(str), do: do_decode(str, 0) 31 + 32 + @spec do_decode(String.t(), integer()) :: integer() 33 + defp do_decode(<<>>, acc), do: acc 34 + 35 + defp do_decode(<<char::utf8, rest::binary>>, acc) do 36 + i = Enum.find_index(@alphabet, fn x -> x == char end) 37 + do_decode(rest, acc * @alphabet_len + i) 38 + end 39 + end
+169
lib/tid.ex
··· 1 + defmodule Atex.TID do 2 + @moduledoc """ 3 + Struct and helper functions for dealing with AT Protocol TIDs (Timestamp 4 + Identifiers), a 13-character string representation of a 64-bit number 5 + comprised of a Unix timestamp (in microsecond precision) and a random "clock 6 + identifier" to help avoid collisions. 7 + 8 + ATProto spec: https://atproto.com/specs/tid 9 + 10 + TID strings are always 13 characters long. All bits in the 64-bit number are 11 + encoded, essentially meaning that the string is padded with "2" if necessary, 12 + (the 0th character in the base32-sortable alphabet). 13 + """ 14 + import Bitwise 15 + alias Atex.Base32Sortable 16 + use TypedStruct 17 + 18 + @re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/ 19 + 20 + @typedoc """ 21 + A Unix timestamp representing when the TID was created. 22 + """ 23 + @type timestamp() :: integer() 24 + 25 + @typedoc """ 26 + An integer to be used for the lower 10 bits of the TID. 27 + """ 28 + @type clock_id() :: 0..1023 29 + 30 + typedstruct enforce: true do 31 + field :timestamp, timestamp() 32 + field :clock_id, clock_id() 33 + end 34 + 35 + @doc """ 36 + Returns a TID for the current moment in time, along with a random clock ID. 37 + """ 38 + @spec now() :: t() 39 + def now, 40 + do: %__MODULE__{ 41 + timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond), 42 + clock_id: gen_clock_id() 43 + } 44 + 45 + @doc """ 46 + Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds. 47 + 48 + If `clock_id` isn't provided, a random one will be generated. 49 + """ 50 + @spec new(DateTime.t() | integer(), integer() | nil) :: t() 51 + def new(source, clock_id \\ nil) 52 + 53 + def new(%DateTime{} = datetime, clock_id), 54 + do: %__MODULE__{ 55 + timestamp: DateTime.to_unix(datetime, :microsecond), 56 + clock_id: clock_id || gen_clock_id() 57 + } 58 + 59 + def new(unix, clock_id) when is_integer(unix), 60 + do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()} 61 + 62 + @doc """ 63 + Convert a TID struct to an instance of `DateTime`. 64 + """ 65 + def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond) 66 + 67 + @doc """ 68 + Generate a random integer to be used as a `clock_id`. 69 + """ 70 + @spec gen_clock_id() :: clock_id() 71 + def gen_clock_id, do: :rand.uniform(1024) - 1 72 + 73 + @doc """ 74 + Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid. 75 + 76 + ## Examples 77 + 78 + Syntactically valid TIDs: 79 + 80 + iex> Atex.TID.decode("3jzfcijpj2z2a") 81 + {:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}} 82 + 83 + iex> Atex.TID.decode("7777777777777") 84 + {:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}} 85 + 86 + iex> Atex.TID.decode("3zzzzzzzzzzzz") 87 + {:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}} 88 + 89 + iex> Atex.TID.decode("2222222222222") 90 + {:ok, %Atex.TID{clock_id: 0, timestamp: 0}} 91 + 92 + Invalid TIDs: 93 + 94 + # not base32 95 + iex> Atex.TID.decode("3jzfcijpj2z21") 96 + :error 97 + iex> Atex.TID.decode("0000000000000") 98 + :error 99 + 100 + # case-sensitive 101 + iex> Atex.TID.decode("3JZFCIJPJ2Z2A") 102 + :error 103 + 104 + # too long/short 105 + iex> Atex.TID.decode("3jzfcijpj2z2aa") 106 + :error 107 + iex> Atex.TID.decode("3jzfcijpj2z2") 108 + :error 109 + iex> Atex.TID.decode("222") 110 + :error 111 + 112 + # legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC) 113 + iex> Atex.TID.decode("3jzf-cij-pj2z-2a") 114 + :error 115 + 116 + # high bit can't be set 117 + iex> Atex.TID.decode("zzzzzzzzzzzzz") 118 + :error 119 + iex> Atex.TID.decode("kjzfcijpj2z2a") 120 + :error 121 + 122 + """ 123 + @spec decode(String.t()) :: {:ok, t()} | :error 124 + def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do 125 + if Regex.match?(@re, tid) do 126 + timestamp = Base32Sortable.decode(timestamp) 127 + clock_id = Base32Sortable.decode(clock_id) 128 + 129 + {:ok, 130 + %__MODULE__{ 131 + timestamp: timestamp, 132 + clock_id: clock_id 133 + }} 134 + else 135 + :error 136 + end 137 + end 138 + 139 + def decode(_tid), do: :error 140 + 141 + @doc """ 142 + Encode a TID struct into a string. 143 + 144 + ## Examples 145 + 146 + iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007}) 147 + "3jzfcijpj2z2a" 148 + 149 + iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285}) 150 + "7777777777777" 151 + 152 + iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247}) 153 + "3zzzzzzzzzzzz" 154 + 155 + iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0}) 156 + "2222222222222" 157 + 158 + """ 159 + @spec encode(t()) :: String.t() 160 + def encode(%__MODULE__{} = tid) do 161 + timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2") 162 + clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2") 163 + timestamp <> clock_id 164 + end 165 + end 166 + 167 + defimpl String.Chars, for: Atex.TID do 168 + def to_string(tid), do: Atex.TID.encode(tid) 169 + end
+4
test/tid_test.exs
··· 1 + defmodule TIDTest do 2 + use ExUnit.Case, async: true 3 + doctest Atex.TID 4 + end