From 1f51965134c4bc9a9faa16e4fdb802337858985e Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 20:30:21 +0000 Subject: [PATCH] feat: add new TID functions closes #16 Signed-off-by: Trezy --- src/lua/sandbox.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- src/lua/tid.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/docs/docs/guides/scripting.md | 2 +- packages/docs/docs/reference/lua/utility-globals.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 file(s) changed, 343 insertion(s)(+), 5 deletion(s)(-) diff --git a/src/lua/sandbox.rs b/src/lua/sandbox.rs --- a/src/lua/sandbox.rs +++ b/src/lua/sandbox.rs @@ -1,6 +1,9 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; -use super::tid::generate_tid; +use super::tid::{ + generate_tid, tid_from_iso8601, tid_from_number, tid_from_unix_microseconds, tid_to_iso8601, + tid_to_number, tid_to_unix_microseconds, +}; const INSTRUCTION_LIMIT: u32 = 1_000_000; @@ -59,9 +62,51 @@ })?; globals.set("log", log_fn)?; - // Utility: TID() returns a fresh AT Protocol TID string - let tid_fn = lua.create_function(|_, ()| Ok(generate_tid()))?; - globals.set("TID", tid_fn)?; + // Utility: TID table — callable as TID() to generate, plus conversion methods + let tid_table = lua.create_table()?; + tid_table.set( + "toISO8601", + lua.create_function(|_, tid: String| { + tid_to_iso8601(&tid).ok_or_else(|| mlua::Error::runtime(format!("invalid TID: {tid}"))) + })?, + )?; + tid_table.set( + "fromISO8601", + lua.create_function(|_, iso: String| { + tid_from_iso8601(&iso) + .ok_or_else(|| mlua::Error::runtime(format!("invalid ISO 8601: {iso}"))) + })?, + )?; + tid_table.set( + "toUnixMicroseconds", + lua.create_function(|_, tid: String| { + tid_to_unix_microseconds(&tid) + .ok_or_else(|| mlua::Error::runtime(format!("invalid TID: {tid}"))) + })?, + )?; + tid_table.set( + "fromUnixMicroseconds", + lua.create_function(|_, us: i64| Ok(tid_from_unix_microseconds(us)))?, + )?; + tid_table.set( + "toNumber", + lua.create_function(|_, tid: String| { + tid_to_number(&tid) + .map(|v| v as i64) + .ok_or_else(|| mlua::Error::runtime(format!("invalid TID: {tid}"))) + })?, + )?; + tid_table.set( + "fromNumber", + lua.create_function(|_, val: i64| Ok(tid_from_number(val as u64)))?, + )?; + let tid_meta = lua.create_table()?; + tid_meta.set( + "__call", + lua.create_function(|_, _: mlua::MultiValue| Ok(generate_tid()))?, + )?; + let _ = tid_table.set_metatable(Some(tid_meta)); + globals.set("TID", tid_table)?; // Utility: toarray(table) marks a table as a JSON array for serialization. // Ensures empty tables serialize as [] instead of {}. @@ -185,6 +230,100 @@ let a: String = lua.load("return TID()").eval().unwrap(); let b: String = lua.load("return TID()").eval().unwrap(); assert_ne!(a, b); + } + + #[test] + fn sandbox_tid_to_iso8601() { + let lua = create_sandbox().unwrap(); + let iso: String = lua.load(r#"return TID.toISO8601(TID())"#).eval().unwrap(); + assert!(iso.contains("T") && iso.ends_with("Z")); + } + + #[test] + fn sandbox_tid_from_iso8601() { + let lua = create_sandbox().unwrap(); + let tid: String = lua + .load(r#"return TID.fromISO8601("2024-01-01T00:00:00Z")"#) + .eval() + .unwrap(); + assert_eq!(tid.len(), 13); + } + + #[test] + fn sandbox_tid_roundtrip() { + let lua = create_sandbox().unwrap(); + let result: String = lua + .load( + r#" + local tid = TID() + local iso = TID.toISO8601(tid) + local tid2 = TID.fromISO8601(iso) + return TID.toISO8601(tid2) + "#, + ) + .eval() + .unwrap(); + assert!(result.contains("T") && result.ends_with("Z")); + } + + #[test] + fn sandbox_tid_to_unix_microseconds() { + let lua = create_sandbox().unwrap(); + let us: i64 = lua + .load(r#"return TID.toUnixMicroseconds(TID.fromISO8601("2024-01-01T00:00:00Z"))"#) + .eval() + .unwrap(); + assert_eq!(us, 1_704_067_200_000_000); + } + + #[test] + fn sandbox_tid_from_unix_microseconds() { + let lua = create_sandbox().unwrap(); + let tid: String = lua + .load("return TID.fromUnixMicroseconds(1704067200000000)") + .eval() + .unwrap(); + assert_eq!(tid.len(), 13); + let iso: String = lua + .load(format!(r#"return TID.toISO8601("{tid}")"#)) + .eval() + .unwrap(); + assert_eq!(iso, "2024-01-01T00:00:00.000000Z"); + } + + #[test] + fn sandbox_tid_number_lossless_roundtrip() { + let lua = create_sandbox().unwrap(); + let result: bool = lua + .load( + r#" + local tid = TID() + local n = TID.toNumber(tid) + local tid2 = TID.fromNumber(n) + return tid == tid2 + "#, + ) + .eval() + .unwrap(); + assert!(result, "toNumber/fromNumber should be lossless"); + } + + #[test] + fn sandbox_tid_to_iso8601_errors_on_invalid() { + let lua = create_sandbox().unwrap(); + let result = lua + .load(r#"return TID.toISO8601("garbage")"#) + .eval::(); + assert!(result.is_err()); + } + + #[test] + fn sandbox_tid_from_iso8601_errors_on_invalid() { + let lua = create_sandbox().unwrap(); + let result = lua + .load(r#"return TID.fromISO8601("not a date")"#) + .eval::(); + assert!(result.is_err()); } #[test] diff --git a/src/lua/tid.rs b/src/lua/tid.rs --- a/src/lua/tid.rs +++ b/src/lua/tid.rs @@ -1,5 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use chrono::{DateTime, TimeZone, Utc}; + /// Base32-sortstring alphabet used by AT Protocol TIDs. const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; @@ -30,6 +32,58 @@ } // SAFETY: all bytes come from BASE32_SORT which is ASCII String::from_utf8(buf.to_vec()).unwrap() +} + +/// Decode a 13-character base32-sortstring back to a u64. +fn decode_base32_sort(tid: &str) -> Option { + if tid.len() != 13 { + return None; + } + let mut val: u64 = 0; + for byte in tid.bytes() { + let idx = BASE32_SORT.iter().position(|&b| b == byte)?; + val = (val << 5) | idx as u64; + } + Some(val) +} + +/// Extract the microsecond timestamp from a TID and return it as an ISO 8601 +/// string. The 10-bit clock ID is discarded (lossy). +pub fn tid_to_iso8601(tid: &str) -> Option { + let val = decode_base32_sort(tid)?; + let us = (val >> 10) as i64; + let dt: DateTime = Utc.timestamp_micros(us).single()?; + Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)) +} + +/// Create a TID from an ISO 8601 timestamp string. Uses a zero clock ID, so +/// the result won't match any specific generated TID but will sort correctly +/// relative to TIDs from the same moment. +pub fn tid_from_iso8601(iso: &str) -> Option { + let dt = iso.parse::>().ok()?; + let us = dt.timestamp_micros() as u64; + Some(encode_base32_sort(us << 10)) +} + +/// Extract the microsecond timestamp from a TID (lossy — drops clock ID). +pub fn tid_to_unix_microseconds(tid: &str) -> Option { + let val = decode_base32_sort(tid)?; + Some((val >> 10) as i64) +} + +/// Create a TID from a microsecond timestamp. Uses a zero clock ID. +pub fn tid_from_unix_microseconds(us: i64) -> String { + encode_base32_sort((us as u64) << 10) +} + +/// Lossless: decode a TID to its full u64 representation (timestamp + clock ID). +pub fn tid_to_number(tid: &str) -> Option { + decode_base32_sort(tid) +} + +/// Lossless: encode a u64 back into a TID. +pub fn tid_from_number(val: u64) -> String { + encode_base32_sort(val) } #[cfg(test)] @@ -72,5 +126,98 @@ // Zero should encode to all '2's (the first character in the alphabet) let result = encode_base32_sort(0); assert_eq!(result, "2222222222222"); + } + + #[test] + fn decode_inverts_encode() { + let val: u64 = 0x123456789ABCDEF; + let encoded = encode_base32_sort(val); + assert_eq!(decode_base32_sort(&encoded), Some(val)); + } + + #[test] + fn decode_rejects_invalid() { + assert_eq!(decode_base32_sort("short"), None); + assert_eq!(decode_base32_sort("AAAAAAAAAAAAA"), None); + } + + #[test] + fn tid_to_iso8601_roundtrip() { + let tid = generate_tid(); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert!(iso.ends_with('Z'), "should be UTC: {iso}"); + // fromISO8601 won't match exactly (clock ID is lost) but the + // timestamp portion should produce a TID that converts back to + // the same ISO string. + let tid2 = tid_from_iso8601(&iso).expect("valid ISO"); + let iso2 = tid_to_iso8601(&tid2).expect("valid TID"); + assert_eq!(iso, iso2); + } + + #[test] + fn tid_from_iso8601_known_value() { + let tid = tid_from_iso8601("2024-01-01T00:00:00Z").expect("valid ISO"); + assert_eq!(tid.len(), 13); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert_eq!(iso, "2024-01-01T00:00:00.000000Z"); + } + + #[test] + fn tid_from_iso8601_rejects_garbage() { + assert!(tid_from_iso8601("not a date").is_none()); + } + + #[test] + fn tid_from_iso8601_with_offset() { + let tid = tid_from_iso8601("2024-01-01T05:00:00+05:00").expect("valid ISO with offset"); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert_eq!(iso, "2024-01-01T00:00:00.000000Z"); + } + + #[test] + fn tid_from_iso8601_with_fractional_seconds() { + let tid = tid_from_iso8601("2024-06-15T12:30:45.123456Z").expect("valid ISO"); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert_eq!(iso, "2024-06-15T12:30:45.123456Z"); + } + + #[test] + fn tid_to_unix_microseconds_known_value() { + let tid = tid_from_iso8601("2024-01-01T00:00:00Z").expect("valid ISO"); + let us = tid_to_unix_microseconds(&tid).expect("valid TID"); + assert_eq!(us, 1_704_067_200_000_000); + } + + #[test] + fn tid_microseconds_roundtrip() { + let tid = generate_tid(); + let us = tid_to_unix_microseconds(&tid).expect("valid TID"); + let tid2 = tid_from_unix_microseconds(us); + let us2 = tid_to_unix_microseconds(&tid2).expect("valid TID"); + assert_eq!(us, us2); + } + + #[test] + fn tid_to_unix_microseconds_rejects_invalid() { + assert!(tid_to_unix_microseconds("garbage").is_none()); + } + + #[test] + fn tid_number_lossless_roundtrip() { + let tid = generate_tid(); + let val = tid_to_number(&tid).expect("valid TID"); + let tid2 = tid_from_number(val); + assert_eq!(tid, tid2); + } + + #[test] + fn tid_to_number_rejects_invalid() { + assert!(tid_to_number("garbage").is_none()); + } + + #[test] + fn tid_to_iso8601_rejects_invalid() { + assert!(tid_to_iso8601("garbage").is_none()); + assert!(tid_to_iso8601("AAAAAAAAAAAAA").is_none()); } } diff --git a/packages/docs/docs/guides/scripting.md b/packages/docs/docs/guides/scripting.md --- a/packages/docs/docs/guides/scripting.md +++ b/packages/docs/docs/guides/scripting.md @@ -71,7 +71,7 @@ | ---------------- | ------- | ------------------------------------------------------------------- | | `now()` | string | Current UTC timestamp in ISO 8601 format | | `log(message)` | — | Log a message (appears in server logs at debug level) | -| `TID()` | string | Generate a fresh atproto TID (13-character sortable identifier) | +| `TID()` | string | Generate a fresh atproto TID (13-character sortable identifier). Also provides conversion methods — see [Utility Globals reference](../reference/lua/utility-globals.md#tid). | | `toarray(table)` | table | Mark a table as a JSON array for serialization (see [below](#toarray)) | ### toarray diff --git a/packages/docs/docs/reference/lua/utility-globals.md b/packages/docs/docs/reference/lua/utility-globals.md --- a/packages/docs/docs/reference/lua/utility-globals.md +++ b/packages/docs/docs/reference/lua/utility-globals.md @@ -35,6 +35,58 @@ r:save() ``` +### TID.toISO8601 + +```lua +local iso = TID.toISO8601(tid) +-- "2026-04-19T15:30:00.123456Z" +``` + +Converts a TID to an ISO 8601 timestamp string with microsecond precision. This is lossy — the 10-bit clock ID embedded in the TID is discarded. + +### TID.fromISO8601 + +```lua +local tid = TID.fromISO8601("2026-04-19T15:30:00Z") +``` + +Creates a TID from an ISO 8601 timestamp string. Accepts timezone offsets and fractional seconds. The resulting TID uses a zero clock ID, so it won't match any specific generated TID but will sort correctly relative to TIDs from the same moment. + +### TID.toUnixMicroseconds + +```lua +local us = TID.toUnixMicroseconds(tid) +-- 1745074200123456 +``` + +Extracts the microsecond timestamp from a TID (microseconds since the Unix epoch). Lossy — drops the clock ID. + +### TID.fromUnixMicroseconds + +```lua +local tid = TID.fromUnixMicroseconds(1745074200123456) +``` + +Creates a TID from a microsecond timestamp. Uses a zero clock ID. + +### TID.toNumber + +```lua +local n = TID.toNumber(tid) +local same_tid = TID.fromNumber(n) +-- same_tid == tid +``` + +Decodes a TID to its full numeric representation (timestamp + clock ID). This is the only lossless conversion — `TID.fromNumber(TID.toNumber(tid))` always returns the original TID. + +### TID.fromNumber + +```lua +local tid = TID.fromNumber(n) +``` + +Encodes a number back into a TID. Inverse of `TID.toNumber`. + ## toarray ```lua -- tangled.sh