1defmodule 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
165end
166
167defimpl String.Chars, for: Atex.TID do
168 def to_string(tid), do: Atex.TID.encode(tid)
169end