Packages

Aether ATProto is a set of common & shared logic to implement the AT Protocol

Current section

Files

Jump to
aether_atproto lib aether atproto tid.ex
Raw

lib/aether/atproto/tid.ex

defmodule Aether.ATProto.TID do
@moduledoc """
Timestamp Identifier (TID) generation and validation for ATProto.
TIDs are sortable, unique identifiers used for record keys and revisions.
They encode a microsecond timestamp and a random clock identifier.
## Format
- 13 characters long
- Base32-sortable encoding: `234567abcdefghijklmnopqrstuvwxyz`
- First character must be in: `234567abcdefghij`
- Encodes: 53 bits timestamp + 10 bits clock ID
## Usage
```elixir
# Generate a new TID
tid = Aether.ATProto.TID.new()
#=> "3jzfcijpj2z2a"
# Validate a TID
Aether.ATProto.TID.valid_tid?("3jzfcijpj2z2a")
#=> true
# Parse a TID to get timestamp
{:ok, timestamp} = Aether.ATProto.TID.parse_timestamp("3jzfcijpj2z2a")
```
## Ordering
TIDs are lexicographically sortable - comparing them as strings
gives correct chronological ordering.
"""
@base32_chars ~c"234567abcdefghijklmnopqrstuvwxyz"
@valid_first_chars ~c"234567abcdefghij"
@doc """
Generate a new TID with the current timestamp.
## Examples
iex> tid = Aether.ATProto.TID.new()
iex> String.length(tid)
13
iex> Aether.ATProto.TID.valid_tid?(tid)
true
"""
@spec new() :: String.t()
def new do
timestamp_us = System.os_time(:microsecond)
clock_id = :rand.uniform(1024) - 1
encode(timestamp_us, clock_id)
end
@doc """
Generate a TID from a specific timestamp in microseconds.
This function creates a Time-Ordered ID (TID) using the given timestamp and an optional
clock identifier. The TID format ensures temporal ordering while providing collision
resistance through the clock ID.
## Parameters
- `timestamp_us`: Unix timestamp in microseconds (integer)
- `clock_id`: Optional clock identifier (0-1023). If not provided, a random value in this range is used.
## Returns
- TID string in base32hex format (13 characters)
## Examples
# Generate TID with automatic clock ID (recommended for most use cases)
iex> tid = Aether.ATProto.TID.from_timestamp(1_700_000_000_000_000)
iex> String.length(tid)
13
# Generate TID with specific clock ID (for deterministic testing)
iex> Aether.ATProto.TID.from_timestamp(1_700_000_000_000_000, 42)
"3ke6kg3wk223e" # This value is deterministic with clock_id=42
# The function now properly handles the default case without type violations
iex> is_binary(Aether.ATProto.TID.from_timestamp(1_700_000_000_000_000))
true
"""
@spec from_timestamp(integer(), non_neg_integer()) :: String.t()
def from_timestamp(timestamp_us, clock_id \\ :rand.uniform(1024) - 1)
when is_integer(timestamp_us) do
encode(timestamp_us, clock_id)
end
@doc """
Validate a TID string.
## Examples
iex> Aether.ATProto.TID.valid_tid?("3jzfcijpj2z2a")
true
iex> Aether.ATProto.TID.valid_tid?("invalid")
false
iex> Aether.ATProto.TID.valid_tid?("1234567890123")
false
"""
@spec valid_tid?(String.t()) :: boolean()
def valid_tid?(tid) when is_binary(tid) do
case String.length(tid) do
13 ->
chars = String.to_charlist(tid)
valid_first?(hd(chars)) and Enum.all?(chars, &valid_char?/1)
_ ->
false
end
end
def valid_tid?(_), do: false
@doc """
Parse a TID to extract the timestamp in microseconds.
## Examples
iex> tid = Aether.ATProto.TID.new()
iex> {:ok, timestamp} = Aether.ATProto.TID.parse_timestamp(tid)
iex> is_integer(timestamp)
true
"""
@spec parse_timestamp(String.t()) :: {:ok, integer()} | {:error, :invalid_tid}
def parse_timestamp(tid) when is_binary(tid) do
if valid_tid?(tid) do
timestamp = decode_timestamp(tid)
{:ok, timestamp}
else
{:error, :invalid_tid}
end
end
@doc """
Compare two TIDs chronologically.
Returns `:gt` if tid1 is newer, `:lt` if older, `:eq` if equal.
## Examples
iex> tid1 = Aether.ATProto.TID.from_timestamp(1_700_000_000_000_000)
iex> tid2 = Aether.ATProto.TID.from_timestamp(1_700_000_000_000_001)
iex> Aether.ATProto.TID.compare(tid1, tid2)
:lt
"""
@spec compare(String.t(), String.t()) :: :gt | :lt | :eq
def compare(tid1, tid2) when is_binary(tid1) and is_binary(tid2) do
# TIDs are lexicographically sortable
cond do
tid1 > tid2 -> :gt
tid1 < tid2 -> :lt
true -> :eq
end
end
# Private functions
defp encode(timestamp_us, clock_id) do
# Combine timestamp (53 bits) and clock_id (10 bits) into 64-bit integer
# Top bit is always 0
value = Bitwise.bsl(timestamp_us, 10) + clock_id
# Encode to base32 (big-endian, most significant first)
encode_base32(value, 13, [])
end
defp encode_base32(_value, 0, acc), do: List.to_string(acc)
defp encode_base32(value, remaining, acc) do
# Extract from most significant bits first
shift_amount = (remaining - 1) * 5
char_index = Bitwise.band(Bitwise.bsr(value, shift_amount), 0x1F)
char = Enum.at(@base32_chars, char_index)
encode_base32(value, remaining - 1, acc ++ [char])
end
defp decode_timestamp(tid) do
chars = String.to_charlist(tid)
value =
Enum.reduce(chars, 0, fn char, acc ->
index = Enum.find_index(@base32_chars, &(&1 == char))
Bitwise.bsl(acc, 5) + index
end)
# Extract timestamp (top 53 bits)
Bitwise.bsr(value, 10)
end
defp valid_first?(char), do: char in @valid_first_chars
defp valid_char?(char), do: char in @base32_chars
end