Skip to main content

obzenflow_idkit/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// SPDX-FileCopyrightText: 2025-2026 ObzenFlow Contributors
3// https://obzenflow.dev
4
5//! Phantom-typed ULID identifiers.
6//!
7//! `Id<K>` is a zero-cost wrapper around [`Ulid`] with a phantom kind `K`, preventing
8//! accidental mixing of IDs across domains.
9//!
10//! By default this crate is type-only (no RNG dependencies). Enable the `gen` feature in
11//! application crates to generate IDs, and configure the RNG backend per target (for
12//! browser wasm, enable `getrandom`'s `js` feature in the app crate).
13//!
14//! Define marker types (`struct User; type UserId = Id<User>;`) in your domain crates; this
15//! crate stays generic.
16//!
17//! ## Example
18//! ```rust
19//! use obzenflow_idkit::{Id, Ulid};
20//!
21//! struct User;
22//! struct Order;
23//!
24//! type UserId = Id<User>;
25//! type OrderId = Id<Order>;
26//!
27//! let _user_id = UserId::from_ulid(Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap());
28//! let _order_id = OrderId::from_ulid(Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAW").unwrap());
29//!
30//! // These are different types (won't compile):
31//! // let mixed: UserId = _order_id;
32//!
33//! // With `gen` enabled in an app crate:
34//! // let generated = UserId::new();
35//! ```
36
37use core::fmt::{self, Debug};
38use core::hash::Hash;
39use core::marker::PhantomData;
40use core::str::FromStr;
41
42pub use ulid::Ulid;
43
44/// A phantom-typed ID wrapper that provides type safety across domains.
45///
46/// The type parameter `K` is a phantom type that exists only at compile time
47/// to prevent mixing IDs from different domains.
48#[derive(Clone, Copy)]
49pub struct Id<K> {
50    inner: Ulid,
51    _phantom: PhantomData<K>,
52}
53
54// Manual implementations to avoid requiring K to implement these traits
55
56impl<K> Debug for Id<K> {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.debug_struct("Id").field("inner", &self.inner).finish()
59    }
60}
61
62impl<K> PartialEq for Id<K> {
63    fn eq(&self, other: &Self) -> bool {
64        self.inner == other.inner
65    }
66}
67
68impl<K> Eq for Id<K> {}
69
70impl<K> Hash for Id<K> {
71    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
72        self.inner.hash(state)
73    }
74}
75
76impl<K> PartialOrd for Id<K> {
77    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
78        Some(self.cmp(other))
79    }
80}
81
82impl<K> Ord for Id<K> {
83    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
84        self.inner.cmp(&other.inner)
85    }
86}
87
88impl<K> Id<K> {
89    /// Generate a new ID. Works on both native and WASM platforms.
90    ///
91    /// Requires the `gen` feature. For browser wasm, enable `getrandom`'s `js` feature in
92    /// the application crate.
93    #[cfg(feature = "gen")]
94    #[inline]
95    pub fn new() -> Self {
96        Self::from_ulid(new_ulid())
97    }
98
99    /// Create from an existing ULID.
100    #[inline]
101    pub fn from_ulid(ulid: Ulid) -> Self {
102        Self {
103            inner: ulid,
104            _phantom: PhantomData,
105        }
106    }
107
108    /// Extract the underlying ULID.
109    #[inline]
110    pub fn as_ulid(&self) -> Ulid {
111        self.inner
112    }
113
114    /// Extract the underlying ULID, consuming `self`.
115    #[inline]
116    pub fn ulid(self) -> Ulid {
117        self.inner
118    }
119
120    /// Get the raw bytes representation.
121    #[inline]
122    pub fn to_bytes(&self) -> [u8; 16] {
123        self.inner.to_bytes()
124    }
125
126    /// Create from raw bytes.
127    #[inline]
128    pub fn from_bytes(bytes: [u8; 16]) -> Self {
129        Self {
130            inner: Ulid::from_bytes(bytes),
131            _phantom: PhantomData,
132        }
133    }
134
135    /// Get the timestamp in milliseconds.
136    #[inline]
137    pub fn timestamp_ms(&self) -> u64 {
138        self.inner.timestamp_ms()
139    }
140}
141
142impl<K> Default for Id<K> {
143    #[cfg(feature = "gen")]
144    #[inline]
145    fn default() -> Self {
146        Self::new()
147    }
148
149    #[cfg(not(feature = "gen"))]
150    #[inline]
151    fn default() -> Self {
152        panic!("Id::default() requires the 'gen' feature to be enabled")
153    }
154}
155
156impl<K> fmt::Display for Id<K> {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        fmt::Display::fmt(&self.inner, f)
159    }
160}
161
162impl<K> FromStr for Id<K> {
163    type Err = ulid::DecodeError;
164
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        Ok(Self {
167            inner: Ulid::from_str(s)?,
168            _phantom: PhantomData,
169        })
170    }
171}
172
173#[cfg(feature = "serde")]
174impl<K> serde::Serialize for Id<K> {
175    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
176    where
177        S: serde::Serializer,
178    {
179        let s = self.to_string();
180        serializer.serialize_str(&s)
181    }
182}
183
184#[cfg(feature = "serde")]
185impl<'de, K> serde::Deserialize<'de> for Id<K> {
186    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187    where
188        D: serde::Deserializer<'de>,
189    {
190        use std::borrow::Cow;
191
192        let s: Cow<'de, str> = Cow::deserialize(deserializer)?;
193        let ulid = Ulid::from_str(&s).map_err(serde::de::Error::custom)?;
194        Ok(Self::from_ulid(ulid))
195    }
196}
197
198// Optional convenience functions for raw ULID generation (no phantom typing).
199
200/// Generate a raw ULID.
201///
202/// Requires the `gen` feature.
203#[cfg(feature = "gen")]
204#[inline]
205pub fn new_ulid() -> Ulid {
206    fn now() -> std::time::SystemTime {
207        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
208        {
209            use web_time::web::SystemTimeExt;
210            return web_time::SystemTime::now().to_std();
211        }
212        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
213        return std::time::SystemTime::now();
214    }
215
216    fn random_u128_80() -> u128 {
217        let mut bytes = [0u8; 10];
218        getrandom::getrandom(&mut bytes).expect("getrandom failed");
219
220        let mut buf = [0u8; 16];
221        buf[6..].copy_from_slice(&bytes);
222        u128::from_be_bytes(buf)
223    }
224
225    let timestamp_ms = now()
226        .duration_since(std::time::SystemTime::UNIX_EPOCH)
227        .unwrap_or(std::time::Duration::ZERO)
228        .as_millis() as u64;
229
230    Ulid::from_parts(timestamp_ms, random_u128_80())
231}
232
233/// Generate a ULID as a string.
234///
235/// Requires the `gen` feature.
236#[cfg(feature = "gen")]
237#[inline]
238pub fn new_ulid_string() -> String {
239    new_ulid().to_string()
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    // Test domain types - just empty marker types
247    struct User;
248
249    type UserId = Id<User>;
250
251    #[cfg(feature = "gen")]
252    struct Order;
253    #[cfg(feature = "gen")]
254    type OrderId = Id<Order>;
255
256    #[cfg(feature = "gen")]
257    #[test]
258    fn test_id_generation() {
259        let id1 = UserId::new();
260        let id2 = UserId::new();
261        assert_ne!(id1, id2);
262    }
263
264    #[cfg(feature = "gen")]
265    #[test]
266    fn test_type_safety() {
267        let user_id = UserId::new();
268        let order_id = OrderId::new();
269
270        // This test verifies that UserId and OrderId are different types
271        // The following would not compile:
272        // let _: UserId = order_id;
273
274        // But we can extract and compare the inner ULIDs if needed
275        assert_ne!(user_id.as_ulid(), order_id.as_ulid());
276    }
277
278    #[test]
279    fn test_string_roundtrip() {
280        let ulid = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap();
281        let id = UserId::from_ulid(ulid);
282        let s = id.to_string();
283        let id2 = UserId::from_str(&s).unwrap();
284        assert_eq!(id, id2);
285    }
286
287    #[test]
288    fn test_bytes_roundtrip() {
289        let ulid = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap();
290        let id = UserId::from_ulid(ulid);
291        let bytes = id.to_bytes();
292        let id2 = UserId::from_bytes(bytes);
293        assert_eq!(id, id2);
294    }
295
296    #[cfg(feature = "serde")]
297    #[test]
298    fn test_serde_roundtrip() {
299        let ulid = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap();
300        let id = UserId::from_ulid(ulid);
301        let json = serde_json::to_string(&id).unwrap();
302        let id2: UserId = serde_json::from_str(&json).unwrap();
303        assert_eq!(id, id2);
304    }
305}