From b754347a3cccbd17919ac95625ce414d952ca858 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Wed, 29 Jul 2026 07:11:59 +0000 Subject: [PATCH] Add TID Took the TID module from annot.at https://tangled.org/jola.dev/annot.at/blob/8f77d1e89c32a7bda6ebb5ebd5db1e7bc2f0f795/lib/annot_at/atproto/tid.ex and then cleaned it up and added a test suite with examples from the spec. --- lib/latch/tid.ex | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/latch/tid_test.exs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 file(s) changed, 156 insertion(s)(+), 0 deletion(s)(-) diff --git a/lib/latch/tid.ex b/lib/latch/tid.ex new file mode 100644 --- /dev/null +++ b/lib/latch/tid.ex @@ -0,0 +1,81 @@ +defmodule Latch.TID do + @moduledoc """ + Timestamp identifiers, frequently used as record keys in atproto. Time-sortable, + can be used as logical clocks within a system, and designed to reduce the risk + of collisions. + + https://atproto.com/specs/tid + """ + + @alphabet "234567abcdefghijklmnopqrstuvwxyz" + @valid_clock_id_range 0..1023 + @offset 1024 + + @doc """ + Validates a given string against a regex to ensure it conforms with the spec. + """ + @spec valid?(String.t()) :: boolean() + def valid?(tid) when is_binary(tid) do + String.match?(tid, ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/) + end + + @doc """ + Generate a TID that encodes the current time and a random clock ID, making it + unlikely (but not impossible) to result in collisions. + + ## Options + + * `:clock_id` - override the random clock ID + * `:time_fun` - a function that returns the current time as microseconds + + Overriding both `:clock_id` and `:time_fun` allows you to create deterministic tests. + """ + @spec now(keyword()) :: String.t() + def now(opts \\ []) do + clock_id = Keyword.get_lazy(opts, :clock_id, &random_clock_id/0) + + if clock_id not in @valid_clock_id_range do + raise "invalid clock ID" + end + + time_fun = Keyword.get(opts, :time_fun, &now_μs/0) + + shifted = time_fun.() * @offset + timestamp = shifted + clock_id + new(timestamp) + end + + @doc """ + Generate a TID that encodes the given time and a random clock ID, making it + unlikely (but not impossible) to result in collisions. + + Overriding `:clock_id` allows you to create deterministic tests. + """ + @spec at_time(DateTime.t(), integer()) :: String.t() + def at_time(%DateTime{} = datetime, clock_id \\ random_clock_id()) + when clock_id in @valid_clock_id_range do + timestamp_μs = DateTime.to_unix(datetime, :microsecond) * @offset + new(timestamp_μs + clock_id) + end + + @doc """ + Takes a raw integer and formats it as a TID. + + In most cases you'll want to use the helpers `now` and `at_time`. + """ + @spec new(non_neg_integer()) :: String.t() + def new(int) when int in 0..0x7FFF_FFFF_FFFF_FFFF do + int + |> Integer.digits(32) + |> Enum.map_join(&<<:binary.at(@alphabet, &1)>>) + |> String.pad_leading(13, "2") + end + + defp random_clock_id do + :rand.uniform(@offset) - 1 + end + + defp now_μs do + System.os_time(:microsecond) + end +end diff --git a/test/latch/tid_test.exs b/test/latch/tid_test.exs new file mode 100644 --- /dev/null +++ b/test/latch/tid_test.exs @@ -0,0 +1,75 @@ +defmodule Latch.TidTest do + use ExUnit.Case, async: true + + alias Latch.TID + + describe "valid?/1" do + test "validates" do + # Examples from https://atproto.com/specs/tid + assert TID.valid?(TID.now()) + assert TID.valid?("3jzfcijpj2z2a") + assert TID.valid?("7777777777777") + assert TID.valid?("3zzzzzzzzzzzz") + assert TID.valid?("2222222222222") + + # not base32 + refute TID.valid?("3jzfcijpj2z21") + refute TID.valid?("0000000000000") + # case-sensitive + refute TID.valid?("3JZFCIJPJ2Z2A") + # too long/short + refute TID.valid?("3jzfcijpj2z2aa") + refute TID.valid?("3jzfcijpj2z2") + refute TID.valid?("222") + # legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC) + refute TID.valid?("3jzf-cij-pj2z-2a") + # high bit can't be set + refute TID.valid?("zzzzzzzzzzzzz") + refute TID.valid?("kjzfcijpj2z2a") + end + end + + describe "now/0" do + test "generates a new TID" do + assert TID.valid?(TID.now()) + end + + test "can be made deterministic" do + assert TID.now(time_fun: fn -> 1_785_308_213_234_023 end, clock_id: 1000) == "3mrrduxumfbzc" + end + + test "raises on invalid clock ID" do + assert_raise RuntimeError, fn -> + TID.now(clock_id: 99_999) + end + end + end + + describe "at_time/1" do + test "generates a new TID" do + assert TID.valid?(TID.at_time(DateTime.utc_now())) + end + + test "can be made deterministic" do + assert TID.at_time(~U[2026-07-29 08:00:00.00Z], 1000) == "3mrrhft7k22zc" + end + + test "raises on invalid clock ID" do + assert_raise FunctionClauseError, fn -> + TID.at_time(DateTime.utc_now(), 99_999) + end + end + end + + describe "new/1" do + test "generates a new TID" do + assert TID.valid?(TID.new(0)) + end + + test "raises on invalid input" do + assert_raise FunctionClauseError, fn -> + TID.new(-1) + end + end + end +end -- tangled.sh