A set of utilities for working with the AT Protocol in Elixir.
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