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]
name = "julid-rs"
version = "1.6.18033988"
# 1.61803398874989484
# ^
version = "1.6.180339887"
authors = ["Joe Ardent <code@ardent.nebcorp.com>"]
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"

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
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.

View file

@ -1 +1 @@
1.618033988
1.6180339887

View file

@ -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 {

View file

@ -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<Julid, 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> {
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),
@ -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))
}
}

View file

@ -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`

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);
}
}