···66and this project adheres to
77[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8899+## [Unreleased]
1010+1111+## Added
1212+1313+- `Atex.TID` module for manipulating ATProto TIDs.
1414+- `Atex.Base32Sortable` module for encoding/decoding numbers as
1515+ `base32-sortable` strings.
1616+917## [0.1.0] - 2025-06-07
10181119Initial release.
+4-3
lib/aturi.ex
···11defmodule Atex.AtURI do
22 @moduledoc """
33 Struct and helper functions for manipulating `at://` URIs, which identify
44- specific records within the AT Protocol. For more information on the URI
55- scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme.
44+ specific records within the AT Protocol.
55+66+ ATProto spec: https://atproto.com/specs/at-uri-scheme
6778 This module only supports the restricted URI syntax used for the Lexicon
89 `at-uri` type, with no support for query strings or fragments. If/when the
···154155end
155156156157defimpl String.Chars, for: Atex.AtURI do
157157- def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri)
158158+ def to_string(uri), do: Atex.AtURI.to_string(uri)
158159end
+39
lib/base32_sortable.ex
···11+defmodule Atex.Base32Sortable do
22+ @moduledoc """
33+ Codec for the base32-sortable encoding.
44+ """
55+66+ @alphabet ~c(234567abcdefghijklmnopqrstuvwxyz)
77+ @alphabet_len length(@alphabet)
88+99+ @doc """
1010+ Encode an integer as a base32-sortable string.
1111+ """
1212+ @spec encode(integer()) :: String.t()
1313+ def encode(int) when is_integer(int), do: do_encode(int, "")
1414+1515+ @spec do_encode(integer(), String.t()) :: String.t()
1616+ defp do_encode(0, acc), do: acc
1717+1818+ defp do_encode(int, acc) do
1919+ char_index = rem(int, @alphabet_len)
2020+ new_int = div(int, @alphabet_len)
2121+2222+ # Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer.
2323+ do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc)
2424+ end
2525+2626+ @doc """
2727+ Decode a base32-sortable string to an integer.
2828+ """
2929+ @spec decode(String.t()) :: integer()
3030+ def decode(str) when is_binary(str), do: do_decode(str, 0)
3131+3232+ @spec do_decode(String.t(), integer()) :: integer()
3333+ defp do_decode(<<>>, acc), do: acc
3434+3535+ defp do_decode(<<char::utf8, rest::binary>>, acc) do
3636+ i = Enum.find_index(@alphabet, fn x -> x == char end)
3737+ do_decode(rest, acc * @alphabet_len + i)
3838+ end
3939+end
+169
lib/tid.ex
···11+defmodule Atex.TID do
22+ @moduledoc """
33+ Struct and helper functions for dealing with AT Protocol TIDs (Timestamp
44+ Identifiers), a 13-character string representation of a 64-bit number
55+ comprised of a Unix timestamp (in microsecond precision) and a random "clock
66+ identifier" to help avoid collisions.
77+88+ ATProto spec: https://atproto.com/specs/tid
99+1010+ TID strings are always 13 characters long. All bits in the 64-bit number are
1111+ encoded, essentially meaning that the string is padded with "2" if necessary,
1212+ (the 0th character in the base32-sortable alphabet).
1313+ """
1414+ import Bitwise
1515+ alias Atex.Base32Sortable
1616+ use TypedStruct
1717+1818+ @re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
1919+2020+ @typedoc """
2121+ A Unix timestamp representing when the TID was created.
2222+ """
2323+ @type timestamp() :: integer()
2424+2525+ @typedoc """
2626+ An integer to be used for the lower 10 bits of the TID.
2727+ """
2828+ @type clock_id() :: 0..1023
2929+3030+ typedstruct enforce: true do
3131+ field :timestamp, timestamp()
3232+ field :clock_id, clock_id()
3333+ end
3434+3535+ @doc """
3636+ Returns a TID for the current moment in time, along with a random clock ID.
3737+ """
3838+ @spec now() :: t()
3939+ def now,
4040+ do: %__MODULE__{
4141+ timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond),
4242+ clock_id: gen_clock_id()
4343+ }
4444+4545+ @doc """
4646+ Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds.
4747+4848+ If `clock_id` isn't provided, a random one will be generated.
4949+ """
5050+ @spec new(DateTime.t() | integer(), integer() | nil) :: t()
5151+ def new(source, clock_id \\ nil)
5252+5353+ def new(%DateTime{} = datetime, clock_id),
5454+ do: %__MODULE__{
5555+ timestamp: DateTime.to_unix(datetime, :microsecond),
5656+ clock_id: clock_id || gen_clock_id()
5757+ }
5858+5959+ def new(unix, clock_id) when is_integer(unix),
6060+ do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()}
6161+6262+ @doc """
6363+ Convert a TID struct to an instance of `DateTime`.
6464+ """
6565+ def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond)
6666+6767+ @doc """
6868+ Generate a random integer to be used as a `clock_id`.
6969+ """
7070+ @spec gen_clock_id() :: clock_id()
7171+ def gen_clock_id, do: :rand.uniform(1024) - 1
7272+7373+ @doc """
7474+ Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid.
7575+7676+ ## Examples
7777+7878+ Syntactically valid TIDs:
7979+8080+ iex> Atex.TID.decode("3jzfcijpj2z2a")
8181+ {:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}}
8282+8383+ iex> Atex.TID.decode("7777777777777")
8484+ {:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}}
8585+8686+ iex> Atex.TID.decode("3zzzzzzzzzzzz")
8787+ {:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}}
8888+8989+ iex> Atex.TID.decode("2222222222222")
9090+ {:ok, %Atex.TID{clock_id: 0, timestamp: 0}}
9191+9292+ Invalid TIDs:
9393+9494+ # not base32
9595+ iex> Atex.TID.decode("3jzfcijpj2z21")
9696+ :error
9797+ iex> Atex.TID.decode("0000000000000")
9898+ :error
9999+100100+ # case-sensitive
101101+ iex> Atex.TID.decode("3JZFCIJPJ2Z2A")
102102+ :error
103103+104104+ # too long/short
105105+ iex> Atex.TID.decode("3jzfcijpj2z2aa")
106106+ :error
107107+ iex> Atex.TID.decode("3jzfcijpj2z2")
108108+ :error
109109+ iex> Atex.TID.decode("222")
110110+ :error
111111+112112+ # legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC)
113113+ iex> Atex.TID.decode("3jzf-cij-pj2z-2a")
114114+ :error
115115+116116+ # high bit can't be set
117117+ iex> Atex.TID.decode("zzzzzzzzzzzzz")
118118+ :error
119119+ iex> Atex.TID.decode("kjzfcijpj2z2a")
120120+ :error
121121+122122+ """
123123+ @spec decode(String.t()) :: {:ok, t()} | :error
124124+ def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do
125125+ if Regex.match?(@re, tid) do
126126+ timestamp = Base32Sortable.decode(timestamp)
127127+ clock_id = Base32Sortable.decode(clock_id)
128128+129129+ {:ok,
130130+ %__MODULE__{
131131+ timestamp: timestamp,
132132+ clock_id: clock_id
133133+ }}
134134+ else
135135+ :error
136136+ end
137137+ end
138138+139139+ def decode(_tid), do: :error
140140+141141+ @doc """
142142+ Encode a TID struct into a string.
143143+144144+ ## Examples
145145+146146+ iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007})
147147+ "3jzfcijpj2z2a"
148148+149149+ iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285})
150150+ "7777777777777"
151151+152152+ iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247})
153153+ "3zzzzzzzzzzzz"
154154+155155+ iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0})
156156+ "2222222222222"
157157+158158+ """
159159+ @spec encode(t()) :: String.t()
160160+ def encode(%__MODULE__{} = tid) do
161161+ timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2")
162162+ clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2")
163163+ timestamp <> clock_id
164164+ end
165165+end
166166+167167+defimpl String.Chars, for: Atex.TID do
168168+ def to_string(tid), do: Atex.TID.encode(tid)
169169+end
+4
test/tid_test.exs
···11+defmodule TIDTest do
22+ use ExUnit.Case, async: true
33+ doctest Atex.TID
44+end