From b47826d4b36e29444de96a96cb833788dc823761 Mon Sep 17 00:00:00 2001 From: Joe Ardent Date: Sun, 22 Jun 2025 15:56:00 -0700 Subject: [PATCH] add support for converting to and from v7 uuids Update version, update license, add tests for UUIDv7. --- Cargo.toml | 14 +++++--- LICENSE.md | 10 +++++- VERSION | 2 +- src/bin/gen.rs | 6 ++-- src/julid.rs | 31 ++++++----------- src/lib.rs | 13 +++++++ src/uuid.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 src/uuid.rs diff --git a/Cargo.toml b/Cargo.toml index 6cddb48..4443810 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "julid-rs" -version = "1.6.18033988" +# 1.61803398874989484 +# ^ +version = "1.6.180339887" authors = ["Joe Ardent "] -edition = "2021" -keywords = ["ulid", "library", "sqlite", "extension", "julid"] +edition = "2024" +keywords = ["ulid", "library", "sqlite", "extension", "julid", "uuid", "guid"] description = "A crate and loadable extension for SQLite that provides Joe's ULIDs." readme = "README.md" @@ -14,6 +16,7 @@ repository = "https://git.kittencollective.com/nebkor/julid-rs" default = ["serde", "sqlx"] # just the regular crate serde = ["dep:serde"] sqlx = ["dep:sqlx"] +uuid = ["dep:uuid"] # WARNING! don't enable this feature in your project's Cargo.toml if using julid-rs as a dependency; # see https://gitlab.com/nebkor/julid/-/issues/1 @@ -27,16 +30,19 @@ crate-type = ["cdylib", "rlib"] rand = "0.8" # for the CLI -clap = { version = "4.3", default-features = false, features = ["help", "usage", "std", "derive"] } +clap = { version = "4", default-features = false, features = ["help", "usage", "std", "derive"] } chrono = { version = "0.4", default-features = false, features = ["std", "time"] } # all other deps are optional serde = { version = "1.0", features = ["derive"], optional = true } sqlx = { version = "0.7", features = ["sqlite"], default-features = false, optional = true } sqlite-loadable = { version = "0.0.5", optional = true } +uuid = { version = "1.17", default-features = false, optional = true } [dev-dependencies] divan = "0.1" +uuid = { version = "1.17", default-features = false, features = ["v7"] } +julid-rs = { path = ".", features = ["uuid"] } [[bench]] name = "simple" diff --git a/LICENSE.md b/LICENSE.md index 589cfb6..e38aba9 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,5 +1,13 @@ -# The Chaos License (GLP) +# Dual Licensed (combined terms are binding) + +This software is governed under the combined terms of the following two licenses: + +## The Chaos License (GLP) This software is released under the terms of the Chaos License. In cases where the terms of the license are unclear, refer to the [Fuck Around and Find Out License](https://git.sr.ht/~boringcactus/fafol/tree/master/LICENSE-v0.2.md). + +## The Butlerian Jihad License (DUN) + +If you feed this code into an LLM, I will fuck you up. diff --git a/VERSION b/VERSION index ae28b93..2fce4ea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.618033988 +1.6180339887 diff --git a/src/bin/gen.rs b/src/bin/gen.rs index 683becc..05bfaf1 100644 --- a/src/bin/gen.rs +++ b/src/bin/gen.rs @@ -27,13 +27,13 @@ struct Cli { fn main() { let cli = Cli::parse(); - if let Some(ts) = cli.input { - if let Ok(ts) = Julid::from_str(&ts) { + if let Some(julid_string_input) = cli.input { + if let Ok(ts) = Julid::from_str(&julid_string_input) { println!("Created at:\t\t{}", ts.created_at()); println!("Monotonic counter:\t{}", ts.counter()); println!("Random:\t\t\t{}", ts.random()); } else { - eprintln!("Could not parse input '{}' as a Julid", ts); + eprintln!("Could not parse input '{julid_string_input}' as a Julid"); std::process::exit(1); } } else { diff --git a/src/julid.rs b/src/julid.rs index ecc8de0..011a9bb 100644 --- a/src/julid.rs +++ b/src/julid.rs @@ -6,20 +6,11 @@ use std::{ use rand::{random, thread_rng, Rng}; -use crate::{base32, DecodeError}; +use crate::{base32, DecodeError, COUNTER_BITS, RANDOM_BITS, TIME_BITS, UNIQUE_BITS}; /// This is used to ensure monotonicity for new IDs. static LAST_MSB: AtomicU64 = AtomicU64::new(0); -/// The number of bits in a Julid's time portion -const TIME_BITS: u8 = 48; -/// The number of bits in the monotonic counter for intra-millisecond IDs -const COUNTER_BITS: u8 = 16; -/// The number of random bits + bits in the monotonic counter -const UNIQUE_BITS: u8 = 80; -/// The number of fully random bits -const RANDOM_BITS: u8 = 64; - macro_rules! bitmask { ($len:expr) => { ((1 << $len) - 1) @@ -74,6 +65,14 @@ impl Julid { } } + /// Return a new Julid with the given timestamp (in milliseconds), no + /// counter bits set, and 64 random lower bits. + pub fn at(ts_ms: u64) -> Self { + let hi = ts_ms << COUNTER_BITS; + let lo = random(); + (hi, lo).into() + } + /// The 'Alpha Julid'. /// /// The Alpha Julid is special form of Julid that is specified to have @@ -161,15 +160,7 @@ impl Julid { /// assert!(result.is_ok()); /// assert_eq!(&result.unwrap().to_string(), text); /// ``` - pub const fn from_str(encoded: &str) -> Result { - match base32::decode(encoded) { - Ok(int_val) => Ok(Julid(int_val)), - Err(err) => Err(err), - } - } - - #[deprecated(since = "1.6.1803398", note = "use `from_str` instead")] - pub const fn from_string(encoded: &str) -> Result { + pub const fn from_str(encoded: &str) -> Result { match base32::decode(encoded) { Ok(int_val) => Ok(Julid(int_val)), Err(err) => Err(err), @@ -182,7 +173,7 @@ impl Julid { } /// Creates a Julid using the provided bytes array, assumed big-endian. - pub const fn from_bytes(bytes: [u8; 16]) -> Julid { + pub const fn from_bytes(bytes: [u8; 16]) -> Self { Self(u128::from_be_bytes(bytes)) } } diff --git a/src/lib.rs b/src/lib.rs index 86b694f..be0a0f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,12 +11,25 @@ pub mod serde; /// Traits from the SQLx crate for getting Julids into and out of SQLite /// databases from normal Rust applications. (feature `sqlx` (default)) pub mod sqlx; +#[cfg(feature = "uuid")] +/// UUIDv7s are almost as good as Julids, and can be interconverted almost +/// perfectly. +pub mod uuid; #[doc(inline)] pub use base32::DecodeError; #[doc(inline)] pub use julid::Julid; +/// The number of bits in a Julid's time portion +pub const TIME_BITS: u8 = 48; +/// The number of bits in the monotonic counter for intra-millisecond IDs +pub const COUNTER_BITS: u8 = 16; +/// The number of random bits + bits in the monotonic counter +pub const UNIQUE_BITS: u8 = 80; +/// The number of fully random bits +pub const RANDOM_BITS: u8 = 64; + /// This `unsafe extern "C"` function is the main entry point into the loadable /// SQLite extension. By default, it and the `plugin` module it depends on will /// not be built. Build with `cargo build --features plugin` diff --git a/src/uuid.rs b/src/uuid.rs new file mode 100644 index 0000000..78f396d --- /dev/null +++ b/src/uuid.rs @@ -0,0 +1,94 @@ +use std::fmt; + +use uuid::{Uuid, Variant}; + +use crate::Julid; + +impl Julid { + /// Convert to UUIDv7, possibly losing counter bits and altering the top + /// two bits from the lower 64. + /// + /// UUIDv7s are very similar to Julids, but use 12 bits for a monotonic + /// counter instead of 16, and only 62 bits of entropy vs Julids' 64. This + /// means that some bits in the original Julid are overwritten with + /// UUID-specific values, but only six bits in total are potentially + /// altered. + pub fn as_uuid(&self) -> Uuid { + let counter_mask = (1 << 12) - 1; + let entropy_mask = (1 << 62) - 1; + let timestamp = self.timestamp(); + // https://www.ietf.org/rfc/rfc9562.html#name-uuid-version-7 "ver" is 0b0111 + let counter = (self.counter() & counter_mask) | (0b0111 << 12); + // https://www.ietf.org/rfc/rfc9562.html#name-uuid-version-7 "var" is 0b10 + let entropy = (self.random() & entropy_mask) | (0b10 << 62); + let top = (timestamp << 16) | counter as u64; + Uuid::from_u64_pair(top, entropy) + } + + /// Create from a UUIDv7; will fail if the UUID is not a valid v7 UUID. + /// + /// UUIDv7s are very similar to Julids, but use 12 bits for a monotonic + /// counter instead of 16, and only 62 bits of entropy vs Julids' 64. + /// Therefore, no bits need to be altered when converting to a Julid. + pub fn from_uuid(id: Uuid) -> Result { + let ver = id.get_version_num(); + if ver != 7 { + return Err(UuidError::UnsupportedVersion(ver)); + } + let var = id.get_variant(); + if var != Variant::RFC4122 { + return Err(UuidError::UnsupportedVariant(var)); + } + + Ok(id.as_u64_pair().into()) + } +} + +#[derive(Debug)] +pub enum UuidError { + UnsupportedVersion(usize), + UnsupportedVariant(uuid::Variant), +} + +impl std::error::Error for UuidError {} + +impl fmt::Display for UuidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + let text = match *self { + UuidError::UnsupportedVersion(v) => format!("unsupported version {v}"), + UuidError::UnsupportedVariant(v) => format!("unsupported variant: {v:?}"), + }; + write!(f, "{text}") + } +} + +#[cfg(test)] +mod test { + use crate::Julid; + + #[test] + fn into_uuid() { + // see example from https://docs.rs/uuid/1.17.0/uuid/struct.Uuid.html#method.new_v7 + let ts = 1497624119 * 1000; + let j = Julid::at(ts); + let u = j.as_uuid().hyphenated().to_string(); + assert!(u.starts_with("015cb15a-86d8-7")); + } + + #[test] + fn from_uuid() { + let j1 = Julid::new(); + let u1 = j1.as_uuid(); + let ju1 = Julid::from_uuid(u1).unwrap(); + // casting a julid to a uuid alters the counter and entropy bits slightly, so + // the original julid and one derived from a uuid made from it won't be the + // same. + assert_ne!(j1, ju1); + + let u2 = ju1.as_uuid(); + let ju2 = Julid::from_uuid(u2).unwrap(); + // but once we've made that alteration, we've reached the fixed point + assert_eq!(ju1, ju2); + assert_eq!(u1, u2); + } +}