+8
CHANGELOG.md
+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
+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
+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
+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