add support for converting to and from v7 uuids

Update version, update license, add tests for UUIDv7.
This commit is contained in:
Joe Ardent 2025-06-22 15:56:00 -07:00
parent 1e93d0b1e4
commit b47826d4b3
7 changed files with 141 additions and 29 deletions

View file

@ -1,9 +1,11 @@
[package] [package]
name = "julid-rs" name = "julid-rs"
version = "1.6.18033988" # 1.61803398874989484
# ^
version = "1.6.180339887"
authors = ["Joe Ardent <code@ardent.nebcorp.com>"] authors = ["Joe Ardent <code@ardent.nebcorp.com>"]
edition = "2021" edition = "2024"
keywords = ["ulid", "library", "sqlite", "extension", "julid"] keywords = ["ulid", "library", "sqlite", "extension", "julid", "uuid", "guid"]
description = "A crate and loadable extension for SQLite that provides Joe's ULIDs." description = "A crate and loadable extension for SQLite that provides Joe's ULIDs."
readme = "README.md" readme = "README.md"
@ -14,6 +16,7 @@ repository = "https://git.kittencollective.com/nebkor/julid-rs"
default = ["serde", "sqlx"] # just the regular crate default = ["serde", "sqlx"] # just the regular crate
serde = ["dep:serde"] serde = ["dep:serde"]
sqlx = ["dep:sqlx"] 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; # 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 # see https://gitlab.com/nebkor/julid/-/issues/1
@ -27,16 +30,19 @@ crate-type = ["cdylib", "rlib"]
rand = "0.8" rand = "0.8"
# for the CLI # 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"] } chrono = { version = "0.4", default-features = false, features = ["std", "time"] }
# all other deps are optional # all other deps are optional
serde = { version = "1.0", features = ["derive"], optional = true } serde = { version = "1.0", features = ["derive"], optional = true }
sqlx = { version = "0.7", features = ["sqlite"], default-features = false, optional = true } sqlx = { version = "0.7", features = ["sqlite"], default-features = false, optional = true }
sqlite-loadable = { version = "0.0.5", optional = true } sqlite-loadable = { version = "0.0.5", optional = true }
uuid = { version = "1.17", default-features = false, optional = true }
[dev-dependencies] [dev-dependencies]
divan = "0.1" divan = "0.1"
uuid = { version = "1.17", default-features = false, features = ["v7"] }
julid-rs = { path = ".", features = ["uuid"] }
[[bench]] [[bench]]
name = "simple" name = "simple"

View file

@ -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 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 are unclear, refer to the [Fuck Around and Find Out
License](https://git.sr.ht/~boringcactus/fafol/tree/master/LICENSE-v0.2.md). 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.

View file

@ -1 +1 @@
1.618033988 1.6180339887

View file

@ -27,13 +27,13 @@ struct Cli {
fn main() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
if let Some(ts) = cli.input { if let Some(julid_string_input) = cli.input {
if let Ok(ts) = Julid::from_str(&ts) { if let Ok(ts) = Julid::from_str(&julid_string_input) {
println!("Created at:\t\t{}", ts.created_at()); println!("Created at:\t\t{}", ts.created_at());
println!("Monotonic counter:\t{}", ts.counter()); println!("Monotonic counter:\t{}", ts.counter());
println!("Random:\t\t\t{}", ts.random()); println!("Random:\t\t\t{}", ts.random());
} else { } 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); std::process::exit(1);
} }
} else { } else {

View file

@ -6,20 +6,11 @@ use std::{
use rand::{random, thread_rng, Rng}; 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. /// This is used to ensure monotonicity for new IDs.
static LAST_MSB: AtomicU64 = AtomicU64::new(0); 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 { macro_rules! bitmask {
($len:expr) => { ($len:expr) => {
((1 << $len) - 1) ((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'.
/// ///
/// The Alpha Julid is special form of Julid that is specified to have /// The Alpha Julid is special form of Julid that is specified to have
@ -161,15 +160,7 @@ impl Julid {
/// assert!(result.is_ok()); /// assert!(result.is_ok());
/// assert_eq!(&result.unwrap().to_string(), text); /// assert_eq!(&result.unwrap().to_string(), text);
/// ``` /// ```
pub const fn from_str(encoded: &str) -> Result<Julid, DecodeError> { pub const fn from_str(encoded: &str) -> Result<Self, DecodeError> {
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<Julid, DecodeError> {
match base32::decode(encoded) { match base32::decode(encoded) {
Ok(int_val) => Ok(Julid(int_val)), Ok(int_val) => Ok(Julid(int_val)),
Err(err) => Err(err), Err(err) => Err(err),
@ -182,7 +173,7 @@ impl Julid {
} }
/// Creates a Julid using the provided bytes array, assumed big-endian. /// 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)) Self(u128::from_be_bytes(bytes))
} }
} }

View file

@ -11,12 +11,25 @@ pub mod serde;
/// Traits from the SQLx crate for getting Julids into and out of SQLite /// Traits from the SQLx crate for getting Julids into and out of SQLite
/// databases from normal Rust applications. (feature `sqlx` (default)) /// databases from normal Rust applications. (feature `sqlx` (default))
pub mod sqlx; pub mod sqlx;
#[cfg(feature = "uuid")]
/// UUIDv7s are almost as good as Julids, and can be interconverted almost
/// perfectly.
pub mod uuid;
#[doc(inline)] #[doc(inline)]
pub use base32::DecodeError; pub use base32::DecodeError;
#[doc(inline)] #[doc(inline)]
pub use julid::Julid; 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 /// 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 /// SQLite extension. By default, it and the `plugin` module it depends on will
/// not be built. Build with `cargo build --features plugin` /// not be built. Build with `cargo build --features plugin`

94
src/uuid.rs Normal file
View file

@ -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<Self, UuidError> {
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);
}
}