Skip to content

Commit 790aeb7

Browse files
committed
feat: module for dealing with ATProto TIDs
1 parent d194d48 commit 790aeb7

File tree

5 files changed

+224
-3
lines changed

5 files changed

+224
-3
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to
77
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

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+
917
## [0.1.0] - 2025-06-07
1018

1119
Initial release.

lib/aturi.ex

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
defmodule Atex.AtURI do
22
@moduledoc """
33
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
67
78
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
@@ -154,5 +155,5 @@ defmodule Atex.AtURI do
154155
end
155156

156157
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)
158159
end

lib/base32_sortable.ex

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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

lib/tid.ex

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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

test/tid_test.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule TIDTest do
2+
use ExUnit.Case, async: true
3+
doctest Atex.TID
4+
end

0 commit comments

Comments
 (0)