1//! TID - Timestamp Identifier
2//!
3//! tids encode a timestamp and clock id in a base32-sortable format.
4//! format: 13 characters using alphabet "234567abcdefghijklmnopqrstuvwxyz"
5//! - first char must be 2-7 (high bit 0x40 must be 0)
6//! - remaining chars encode 53-bit timestamp + 10-bit clock id
7//!
8//! the encoding is designed to be lexicographically sortable by time.
9//! see: https://atproto.com/specs/record-key#record-key-type-tid
10
11const std = @import("std");
12
13pub const Tid = struct {
14 raw: [13]u8,
15
16 const alphabet = "234567abcdefghijklmnopqrstuvwxyz";
17
18 /// parse a tid string. returns null if invalid.
19 pub fn parse(s: []const u8) ?Tid {
20 if (s.len != 13) return null;
21
22 // first char high bit (0x40) must be 0, meaning only '2'-'7' allowed
23 if (s[0] & 0x40 != 0) return null;
24
25 var result: Tid = undefined;
26 for (s, 0..) |c, i| {
27 if (charToValue(c) == null) return null;
28 result.raw[i] = c;
29 }
30 return result;
31 }
32
33 /// timestamp in microseconds since unix epoch
34 pub fn timestamp(self: Tid) u64 {
35 var ts: u64 = 0;
36 for (self.raw[0..11]) |c| {
37 const val = charToValue(c) orelse unreachable;
38 ts = (ts << 5) | val;
39 }
40 return ts;
41 }
42
43 /// clock identifier (lower 10 bits)
44 pub fn clockId(self: Tid) u10 {
45 var id: u10 = 0;
46 for (self.raw[11..13]) |c| {
47 const val: u10 = @intCast(charToValue(c) orelse unreachable);
48 id = (id << 5) | val;
49 }
50 return id;
51 }
52
53 /// generate tid from timestamp and clock id
54 pub fn fromTimestamp(ts: u64, clock_id: u10) Tid {
55 var result: Tid = undefined;
56
57 // encode timestamp (53 bits -> 11 chars)
58 var t = ts;
59 var i: usize = 11;
60 while (i > 0) {
61 i -= 1;
62 result.raw[i] = alphabet[@intCast(t & 0x1f)];
63 t >>= 5;
64 }
65
66 // encode clock id (10 bits -> 2 chars)
67 var c: u10 = clock_id;
68 i = 13;
69 while (i > 11) {
70 i -= 1;
71 result.raw[i] = alphabet[@intCast(c & 0x1f)];
72 c >>= 5;
73 }
74
75 return result;
76 }
77
78 /// get the raw string representation
79 pub fn str(self: *const Tid) []const u8 {
80 return &self.raw;
81 }
82
83 fn charToValue(c: u8) ?u5 {
84 return switch (c) {
85 '2'...'7' => @intCast(c - '2'),
86 'a'...'z' => @intCast(c - 'a' + 6),
87 else => null,
88 };
89 }
90};
91
92test "parse valid tid" {
93 // generate a valid tid and parse it back
94 const generated = Tid.fromTimestamp(1704067200000000, 42);
95 const tid = Tid.parse(generated.str()) orelse return error.InvalidTid;
96 try std.testing.expectEqual(@as(u64, 1704067200000000), tid.timestamp());
97 try std.testing.expectEqual(@as(u10, 42), tid.clockId());
98}
99
100test "reject invalid tid" {
101 // wrong length
102 try std.testing.expect(Tid.parse("abc") == null);
103 try std.testing.expect(Tid.parse("") == null);
104
105 // invalid chars
106 try std.testing.expect(Tid.parse("0000000000000") == null);
107 try std.testing.expect(Tid.parse("1111111111111") == null);
108
109 // first char must be 2-7 (high bit 0x40 must be 0)
110 try std.testing.expect(Tid.parse("a222222222222") == null);
111 try std.testing.expect(Tid.parse("z222222222222") == null);
112}
113
114test "roundtrip" {
115 const ts: u64 = 1704067200000000; // 2024-01-01 00:00:00 UTC in microseconds
116 const clock: u10 = 42;
117
118 const tid = Tid.fromTimestamp(ts, clock);
119 try std.testing.expectEqual(ts, tid.timestamp());
120 try std.testing.expectEqual(clock, tid.clockId());
121}
122
123test "valid first chars" {
124 // first char must be 2-7 only
125 try std.testing.expect(Tid.parse("2222222222222") != null);
126 try std.testing.expect(Tid.parse("3222222222222") != null);
127 try std.testing.expect(Tid.parse("4222222222222") != null);
128 try std.testing.expect(Tid.parse("5222222222222") != null);
129 try std.testing.expect(Tid.parse("6222222222222") != null);
130 try std.testing.expect(Tid.parse("7222222222222") != null);
131}
132
133test "all valid chars in non-first position" {
134 // chars 2-7 and a-z are valid after first position
135 try std.testing.expect(Tid.parse("2aaaaaaaaaaaa") != null);
136 try std.testing.expect(Tid.parse("2zzzzzzzzzzzz") != null);
137 try std.testing.expect(Tid.parse("2234567234567") != null);
138}
139
140test "uppercase rejected" {
141 try std.testing.expect(Tid.parse("2AAAAAAAAAAAA") == null);
142 try std.testing.expect(Tid.parse("2AAAAAAAAaaaa") == null);
143}