adaptive_pipeline_domain/value_objects/
pipeline_id.rs

1// /////////////////////////////////////////////////////////////////////////////
2// Adaptive Pipeline
3// Copyright (c) 2025 Michael Gardner, A Bit of Help, Inc.
4// SPDX-License-Identifier: BSD-3-Clause
5// See LICENSE file in the project root.
6// /////////////////////////////////////////////////////////////////////////////
7
8//! # Pipeline Identifier Value Object - Core Infrastructure
9//!
10//! This module provides a comprehensive pipeline identifier value object that
11//! implements type-safe pipeline identification, temporal ordering, and
12//! pipeline lifecycle management for the adaptive pipeline system's core
13//! infrastructure.
14//!
15//! ## Overview
16//!
17//! The pipeline identifier system provides:
18//!
19//! - **Type-Safe Identification**: Strongly-typed pipeline identifiers with
20//!   compile-time validation
21//! - **Temporal Ordering**: ULID-based time-ordered creation sequence for
22//!   pipeline management
23//! - **Pipeline Lifecycle**: Natural ordering for pipeline processing and audit
24//!   trails
25//! - **Cross-Platform Compatibility**: Consistent representation across
26//!   languages and systems
27//! - **Serialization**: Comprehensive serialization across storage backends and
28//!   APIs
29//! - **Validation**: Pipeline-specific validation and business rules
30//!
31//! ## Key Features
32//!
33//! ### 1. Type-Safe Pipeline Management
34//!
35//! Strongly-typed pipeline identifiers with comprehensive validation:
36//!
37//! - **Compile-Time Safety**: Cannot be confused with other entity IDs
38//! - **Domain Semantics**: Clear intent in function signatures and APIs
39//! - **Runtime Validation**: Pipeline-specific validation rules
40//! - **Future Evolution**: Extensible for pipeline-specific methods
41//!
42//! ### 2. Temporal Ordering and Lifecycle
43//!
44//! ULID-based temporal ordering for pipeline management:
45//!
46//! - **Time-Ordered Creation**: Natural chronological ordering of pipelines
47//! - **Audit Trails**: Complete chronological history of pipeline creation
48//! - **Range Queries**: Efficient time-based pipeline queries
49//! - **Processing Order**: Deterministic pipeline processing sequences
50//!
51//! ### 3. Cross-Platform Compatibility
52//!
53//! Consistent pipeline identification across platforms:
54//!
55//! - **JSON Serialization**: Standard JSON representation
56//! - **Database Storage**: Optimized database storage patterns
57//! - **API Integration**: RESTful API compatibility
58//! - **Multi-Language**: Consistent interface across languages
59//!
60//! ## Usage Examples
61//!
62//! ### Basic Pipeline ID Creation
63
64//!
65//! ### Time-Based Queries and Range Operations
66//!
67//!
68//! ### Serialization and Cross-Platform Usage
69//!
70//!
71//! ## Performance Characteristics
72//!
73//! - **Creation Time**: ~2μs for new pipeline ID generation
74//! - **Validation Time**: ~1μs for pipeline ID validation
75//! - **Serialization**: ~3μs for JSON serialization
76//! - **Memory Usage**: ~32 bytes per pipeline ID instance
77//! - **Thread Safety**: Immutable value objects are fully thread-safe
78//!
79//! ## Cross-Platform Compatibility
80//!
81//! - **Rust**: `PipelineId` newtype wrapper with full validation
82//! - **Go**: `PipelineID` struct with equivalent interface
83//! - **JSON**: String representation of ULID for API compatibility
84//! - **Database**: TEXT column with ULID string storage
85
86use serde::{Deserialize, Serialize};
87use std::fmt::{self, Display};
88use ulid::Ulid;
89
90use super::generic_id::{GenericId, IdCategory};
91use crate::PipelineError;
92
93/// Pipeline entity identifier value object for type-safe pipeline management
94///
95/// This value object provides type-safe pipeline identification with temporal
96/// ordering, pipeline lifecycle management, and comprehensive validation
97/// capabilities. It implements Domain-Driven Design (DDD) value object patterns
98/// with immutable semantics.
99///
100/// # Key Features
101///
102/// - **Type Safety**: Strongly-typed pipeline identifiers that cannot be
103///   confused with other IDs
104/// - **Temporal Ordering**: ULID-based time-ordered creation sequence for
105///   pipeline management
106/// - **Pipeline Lifecycle**: Natural chronological ordering for audit trails
107///   and processing
108/// - **Cross-Platform**: Consistent representation across languages and storage
109///   systems
110/// - **Validation**: Comprehensive pipeline-specific validation and business
111///   rules
112/// - **Serialization**: Full serialization support for storage and API
113///   integration
114///
115/// # Benefits Over Raw ULIDs
116///
117/// - **Type Safety**: `PipelineId` cannot be confused with `StageId` or other
118///   entity IDs
119/// - **Domain Semantics**: Clear intent in function signatures and business
120///   logic
121/// - **Validation**: Pipeline-specific validation rules and constraints
122/// - **Future Evolution**: Extensible for pipeline-specific methods and
123///   features
124///
125/// # Usage Examples
126///
127///
128/// # Cross-Language Mapping
129///
130/// - **Rust**: `PipelineId` newtype wrapper with full validation
131/// - **Go**: `PipelineID` struct with equivalent interface
132/// - **JSON**: String representation of ULID for API compatibility
133/// - **Database**: TEXT column with ULID string storage
134#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
135pub struct PipelineId(GenericId<PipelineMarker>);
136
137/// Marker type for Pipeline entities
138#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
139struct PipelineMarker;
140
141impl IdCategory for PipelineMarker {
142    fn category_name() -> &'static str {
143        "pipeline"
144    }
145
146    fn validate_id(ulid: &Ulid) -> Result<(), PipelineError> {
147        // Common validation: not nil, reasonable timestamp
148        if ulid.0 == 0 {
149            return Err(PipelineError::InvalidConfiguration(
150                "Pipeline ID cannot be nil ULID".to_string(),
151            ));
152        }
153
154        // Check if timestamp is reasonable (not more than 1 day in the future)
155        let now = chrono::Utc::now().timestamp_millis() as u64;
156        let id_timestamp = ulid.timestamp_ms();
157        let one_day_ms = 24 * 60 * 60 * 1000;
158
159        if id_timestamp > now + one_day_ms {
160            return Err(PipelineError::InvalidConfiguration(
161                "Pipeline ID timestamp is too far in the future".to_string(),
162            ));
163        }
164
165        Ok(())
166    }
167}
168
169impl PipelineId {
170    /// Creates a new pipeline ID with current timestamp
171    ///
172    /// # Purpose
173    /// Generates a unique, time-ordered pipeline identifier using ULID.
174    /// Each ID captures the exact moment of pipeline creation.
175    ///
176    /// # Why
177    /// Time-ordered pipeline IDs provide:
178    /// - Natural chronological sorting for audit trails
179    /// - Efficient range queries by creation time
180    /// - Guaranteed uniqueness with 128-bit randomness
181    /// - No coordination needed across distributed systems
182    ///
183    /// # Time Ordering
184    /// Pipeline IDs are naturally sorted by creation time, making them
185    /// perfect for chronological pipeline processing and audit trails.
186    ///
187    /// # Returns
188    /// New `PipelineId` with current millisecond timestamp
189    ///
190    /// # Examples
191    pub fn new() -> Self {
192        Self(GenericId::new())
193    }
194
195    /// Creates a pipeline ID from an existing ULID
196    ///
197    /// # Use Cases
198    /// - Deserializing from database
199    /// - Converting from external systems
200    /// - Testing with known IDs
201    pub fn from_ulid(ulid: Ulid) -> Result<Self, PipelineError> {
202        Ok(Self(GenericId::from_ulid(ulid)?))
203    }
204
205    /// Creates a pipeline ID from a string representation
206    ///
207    /// # Purpose
208    /// Parses and validates a pipeline ID from its ULID string representation.
209    /// Used for deserialization, API input, and database retrieval.
210    ///
211    /// # Why
212    /// String parsing enables:
213    /// - RESTful API integration
214    /// - Database round-trip serialization
215    /// - Configuration file support
216    /// - Cross-language interoperability
217    ///
218    /// # Format
219    /// Accepts standard ULID string format (26 characters, base32 encoded)
220    /// Example: "01ARZ3NDEKTSV4RRFFQ69G5FAV"
221    ///
222    /// # Arguments
223    /// * `s` - ULID string (26 characters, Crockford Base32)
224    ///
225    /// # Returns
226    /// * `Ok(PipelineId)` - Valid pipeline ID
227    /// * `Err(PipelineError::InvalidConfiguration)` - Invalid ULID format
228    ///
229    /// # Errors
230    /// Returns `PipelineError::InvalidConfiguration` when:
231    /// - String is not 26 characters
232    /// - Contains invalid Base32 characters
233    /// - Validation rules fail
234    ///
235    /// # Examples
236    pub fn from_string(s: &str) -> Result<Self, PipelineError> {
237        Ok(Self(GenericId::from_string(s)?))
238    }
239
240    /// Creates a pipeline ID from a timestamp (useful for range queries)
241    ///
242    /// # Purpose
243    /// Generates a pipeline ID with a specific timestamp.
244    /// Primary use case is creating boundary IDs for time-range queries.
245    ///
246    /// # Why
247    /// Timestamp-based IDs enable:
248    /// - Efficient time-range queries ("created after midnight")
249    /// - Time-based pagination in databases
250    /// - Migration from timestamp-based systems
251    /// - Reproducible IDs for testing
252    ///
253    /// # Arguments
254    /// * `timestamp_ms` - Milliseconds since Unix epoch
255    ///
256    /// # Returns
257    /// `PipelineId` with specified timestamp (random component generated)
258    ///
259    /// # Use Cases
260    /// - Creating boundary IDs for time-range queries
261    /// - "Find all pipelines created after midnight"
262    /// - Time-based pagination
263    /// - Migration from timestamp-based systems
264    ///
265    /// # Examples
266    pub fn from_timestamp_ms(timestamp_ms: u64) -> Self {
267        Self(GenericId::from_timestamp_ms(timestamp_ms).unwrap_or_else(|_| GenericId::new()))
268    }
269
270    /// Gets the underlying ULID value
271    ///
272    /// # Use Cases
273    /// - Database storage
274    /// - External API integration
275    /// - Logging and debugging
276    pub fn as_ulid(&self) -> Ulid {
277        self.0.as_ulid()
278    }
279
280    /// Gets the timestamp component of the pipeline ID
281    ///
282    /// # Returns
283    /// Milliseconds since Unix epoch when this pipeline ID was created
284    ///
285    /// # Use Cases
286    /// - Time-range queries
287    /// - Debugging pipeline creation times
288    /// - Audit trails and compliance
289    pub fn timestamp_ms(&self) -> u64 {
290        self.0.timestamp_ms()
291    }
292
293    /// Gets the creation time as a DateTime
294    ///
295    /// # Use Cases
296    /// - Human-readable timestamps in logs
297    /// - Time-based filtering in UI
298    /// - Audit reports
299    pub fn datetime(&self) -> chrono::DateTime<chrono::Utc> {
300        self.0.datetime()
301    }
302
303    /// Converts to lowercase string representation
304    ///
305    /// # Use Cases
306    /// - Case-insensitive systems
307    /// - URL paths
308    /// - Database systems that prefer lowercase
309    pub fn to_lowercase(&self) -> String {
310        self.0.to_lowercase()
311    }
312
313    /// Validates the pipeline ID
314    ///
315    /// # Pipeline-Specific Validation
316    /// - Must be a valid ULID
317    /// - Must not be nil (all zeros)
318    /// - Timestamp must be reasonable (not too far in future)
319    /// - Additional pipeline-specific rules can be added here
320    pub fn validate(&self) -> Result<(), PipelineError> {
321        self.0.validate()?;
322
323        // Add pipeline-specific validation here if needed
324        // For example: check if pipeline ID follows naming conventions
325
326        Ok(())
327    }
328
329    /// Checks if this is a nil (zero) pipeline ID
330    ///
331    /// # Use Cases
332    /// - Validation in constructors
333    /// - Default value checking
334    /// - Debugging empty states
335    pub fn is_nil(&self) -> bool {
336        self.0.is_nil()
337    }
338
339    /// Creates a nil pipeline ID (for testing/default values)
340    ///
341    /// # Warning
342    /// Nil IDs should not be used in production. This method is primarily
343    /// for testing and as a default value that can be easily identified.
344    #[cfg(test)]
345    pub fn nil() -> Self {
346        Self(GenericId::nil())
347    }
348}
349
350impl Default for PipelineId {
351    /// Creates a new random pipeline ID as the default
352    ///
353    /// # Design Decision
354    /// We use a random ID rather than nil to prevent accidental use
355    /// of uninitialized IDs in production code.
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361impl Display for PipelineId {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        write!(f, "{}", self.0)
364    }
365}
366
367impl std::str::FromStr for PipelineId {
368    type Err = PipelineError;
369
370    fn from_str(s: &str) -> Result<Self, Self::Err> {
371        Self::from_string(s)
372    }
373}
374
375// Conversion traits for interoperability
376impl From<Ulid> for PipelineId {
377    fn from(ulid: Ulid) -> Self {
378        Self::from_ulid(ulid).unwrap_or_else(|_| Self::new())
379    }
380}
381
382impl From<PipelineId> for Ulid {
383    fn from(id: PipelineId) -> Self {
384        id.as_ulid()
385    }
386}
387
388impl AsRef<Ulid> for PipelineId {
389    fn as_ref(&self) -> &Ulid {
390        self.0.as_ref()
391    }
392}
393
394/// Utility functions for working with pipeline IDs
395pub mod pipeline_id_utils {
396    use super::*;
397    use crate::value_objects::generic_id::generic_id_utils;
398
399    /// Generates a batch of unique pipeline IDs
400    ///
401    /// # Use Cases
402    /// - Bulk pipeline creation
403    /// - Testing with multiple pipelines
404    /// - Pre-allocation for performance
405    pub fn generate_batch(count: usize) -> Vec<PipelineId> {
406        generic_id_utils::generate_batch::<PipelineMarker>(count)
407            .into_iter()
408            .map(PipelineId)
409            .collect()
410    }
411
412    /// Generates pipeline IDs with specific timestamp
413    ///
414    /// # Use Cases
415    /// - Testing with controlled timestamps
416    /// - Bulk operations with same creation time
417    /// - Migration scenarios
418    pub fn generate_batch_at_time(count: usize, timestamp_ms: u64) -> Vec<PipelineId> {
419        generic_id_utils::generate_batch_at_time::<PipelineMarker>(count, timestamp_ms)
420            .into_iter()
421            .map(PipelineId)
422            .collect()
423    }
424
425    /// Creates a boundary pipeline ID for time-range queries
426    ///
427    /// # Use Cases
428    /// - "Find all pipelines created after this time"
429    /// - Time-based pagination
430    /// - Audit queries
431    ///
432    /// # Example
433    pub fn boundary_id_for_time(timestamp_ms: u64) -> PipelineId {
434        PipelineId::from_timestamp_ms(timestamp_ms)
435    }
436
437    /// Sorts pipeline IDs by creation time (natural ULID order)
438    ///
439    /// # Note
440    /// Pipeline IDs are naturally time-ordered, so this is just a regular sort
441    pub fn sort_by_time(mut ids: Vec<PipelineId>) -> Vec<PipelineId> {
442        ids.sort();
443        ids
444    }
445
446    /// Validates a collection of pipeline IDs
447    ///
448    /// # Returns
449    /// - `Ok(())` if all IDs are valid and unique
450    /// - `Err(PipelineError)` if any validation fails
451    pub fn validate_batch(ids: &[PipelineId]) -> Result<(), PipelineError> {
452        let generic_ids: Vec<GenericId<PipelineMarker>> = ids.iter().map(|id| id.0.clone()).collect();
453        generic_id_utils::validate_batch(&generic_ids)
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    /// Tests pipeline ID creation and uniqueness guarantees.
462    ///
463    /// This test validates that pipeline IDs are created with unique
464    /// values and proper time-based ordering using ULID timestamps.
465    ///
466    /// # Test Coverage
467    ///
468    /// - Pipeline ID creation with `new()`
469    /// - Uniqueness guarantee for different IDs
470    /// - Time-based ordering of IDs
471    /// - ULID timestamp resolution
472    /// - Chronological sequence validation
473    ///
474    /// # Test Scenario
475    ///
476    /// Creates two pipeline IDs with a small time delay between them,
477    /// then verifies they are unique and properly ordered by timestamp.
478    ///
479    /// # Assertions
480    ///
481    /// - Different IDs are not equal
482    /// - Later-created ID is greater than earlier ID
483    /// - Time-based ordering is maintained
484    /// - ULID uniqueness is preserved
485    #[test]
486    fn test_pipeline_id_creation() {
487        let id1 = PipelineId::new();
488
489        // Sleep for 1ms to ensure different timestamps
490        std::thread::sleep(std::time::Duration::from_millis(1));
491
492        let id2 = PipelineId::new();
493
494        // IDs should be unique
495        assert_ne!(id1, id2);
496
497        // IDs should be time-ordered (id2 created after id1)
498        // This works because ULIDs have millisecond resolution
499        assert!(id2 > id1);
500    }
501
502    /// Tests pipeline ID time-based ordering with specific timestamps.
503    ///
504    /// This test validates that pipeline IDs created from specific
505    /// timestamps maintain proper chronological ordering and that
506    /// timestamp values are preserved accurately.
507    ///
508    /// # Test Coverage
509    ///
510    /// - Pipeline ID creation from specific timestamps
511    /// - Chronological ordering validation
512    /// - Timestamp preservation and retrieval
513    /// - Time-based comparison operations
514    /// - ULID timestamp accuracy
515    ///
516    /// # Test Scenario
517    ///
518    /// Creates two pipeline IDs from known timestamps (1 minute apart),
519    /// then verifies ordering and timestamp retrieval.
520    ///
521    /// # Assertions
522    ///
523    /// - Later timestamp ID is greater than earlier timestamp ID
524    /// - Timestamp values are preserved exactly
525    /// - Chronological ordering is maintained
526    /// - Time-based comparisons work correctly
527    #[test]
528    fn test_pipeline_id_time_ordering() {
529        let timestamp1 = 1640995200000; // 2022-01-01
530        let timestamp2 = 1640995260000; // 2022-01-01 + 1 minute
531
532        let id1 = PipelineId::from_timestamp_ms(timestamp1);
533        let id2 = PipelineId::from_timestamp_ms(timestamp2);
534
535        assert!(id2 > id1);
536        assert_eq!(id1.timestamp_ms(), timestamp1);
537        assert_eq!(id2.timestamp_ms(), timestamp2);
538    }
539
540    /// Tests pipeline ID JSON serialization and deserialization.
541    ///
542    /// This test validates that pipeline IDs can be serialized to JSON
543    /// and deserialized back to identical objects, ensuring data
544    /// integrity during persistence and API operations.
545    ///
546    /// # Test Coverage
547    ///
548    /// - JSON serialization
549    /// - JSON deserialization
550    /// - Roundtrip data integrity
551    /// - Serde compatibility
552    /// - Data preservation
553    ///
554    /// # Test Scenario
555    ///
556    /// Creates a pipeline ID, serializes it to JSON, then deserializes
557    /// it back and verifies the result matches the original.
558    ///
559    /// # Assertions
560    ///
561    /// - JSON serialization succeeds
562    /// - JSON deserialization succeeds
563    /// - Original and deserialized IDs are identical
564    /// - No data loss during roundtrip
565    #[test]
566    fn test_pipeline_id_serialization() {
567        let id = PipelineId::new();
568
569        // Test JSON serialization
570        let json = serde_json::to_string(&id).unwrap();
571        let deserialized: PipelineId = serde_json::from_str(&json).unwrap();
572
573        assert_eq!(id, deserialized);
574    }
575
576    /// Tests pipeline ID string conversion and parsing.
577    ///
578    /// This test validates that pipeline IDs can be converted to strings
579    /// and parsed back to identical objects, supporting string-based
580    /// storage and transmission.
581    ///
582    /// # Test Coverage
583    ///
584    /// - String conversion with `to_string()`
585    /// - String parsing with `from_string()`
586    /// - Roundtrip string integrity
587    /// - String format validation
588    /// - Parsing accuracy
589    ///
590    /// # Test Scenario
591    ///
592    /// Creates a pipeline ID, converts it to string, then parses it
593    /// back and verifies the result matches the original.
594    ///
595    /// # Assertions
596    ///
597    /// - String conversion succeeds
598    /// - String parsing succeeds
599    /// - Original and parsed IDs are identical
600    /// - String format is valid
601    #[test]
602    fn test_pipeline_id_string_conversion() {
603        let id = PipelineId::new();
604        let id_string = id.to_string();
605        let parsed_id = PipelineId::from_string(&id_string).unwrap();
606
607        assert_eq!(id, parsed_id);
608    }
609
610    /// Tests pipeline ID validation for valid and invalid cases.
611    ///
612    /// This test validates that pipeline ID validation correctly
613    /// identifies valid IDs and rejects nil or invalid ones.
614    ///
615    /// # Test Coverage
616    ///
617    /// - Valid pipeline ID validation
618    /// - Nil pipeline ID detection
619    /// - Validation error handling
620    /// - ID integrity checking
621    /// - Invalid ID rejection
622    ///
623    /// # Test Scenarios
624    ///
625    /// - Valid pipeline ID: Should pass validation
626    /// - Nil pipeline ID: Should fail validation
627    ///
628    /// # Assertions
629    ///
630    /// - Valid pipeline IDs pass validation
631    /// - Nil pipeline IDs fail validation
632    /// - Validation errors are properly returned
633    /// - ID integrity is maintained
634    #[test]
635    fn test_pipeline_id_validation() {
636        let valid_id = PipelineId::new();
637        assert!(valid_id.validate().is_ok());
638
639        let nil_id = PipelineId::nil();
640        assert!(nil_id.validate().is_err());
641    }
642
643    /// Tests pipeline ID type safety and compile-time guarantees.
644    ///
645    /// This test validates that pipeline IDs provide compile-time
646    /// type safety, preventing accidental mixing of different
647    /// entity ID types while allowing safe comparisons.
648    ///
649    /// # Test Coverage
650    ///
651    /// - Type safety enforcement
652    /// - Compile-time type checking
653    /// - Safe ID comparison
654    /// - ULID access for advanced operations
655    /// - Type system integration
656    ///
657    /// # Test Scenario
658    ///
659    /// Creates pipeline IDs and demonstrates type safety by showing
660    /// that different entity types cannot be directly compared, but
661    /// underlying ULID values can be accessed when needed.
662    ///
663    /// # Assertions
664    ///
665    /// - Different pipeline IDs have different ULID values
666    /// - Type safety prevents invalid comparisons
667    /// - ULID access works for advanced operations
668    /// - Compile-time guarantees are maintained
669    #[test]
670    fn test_pipeline_id_type_safety() {
671        let pipeline_id = PipelineId::new();
672
673        // This demonstrates compile-time type safety
674        // Different entity IDs cannot be compared directly
675        // let stage_id = StageId::new();
676        // pipeline_id == stage_id; // This would not compile ✅
677
678        // But we can compare their underlying values if needed
679        let another_pipeline_id = PipelineId::new();
680        assert_ne!(pipeline_id.as_ulid(), another_pipeline_id.as_ulid());
681    }
682
683    /// Tests pipeline ID utility functions for batch operations.
684    ///
685    /// This test validates utility functions for batch generation,
686    /// validation, time-based operations, and sorting of pipeline IDs.
687    ///
688    /// # Test Coverage
689    ///
690    /// - Batch pipeline ID generation
691    /// - Batch validation
692    /// - Time-based boundary ID creation
693    /// - Time-based sorting
694    /// - Utility function integration
695    ///
696    /// # Test Scenario
697    ///
698    /// Generates a batch of pipeline IDs, validates them, creates
699    /// time-based boundary IDs, and tests sorting operations.
700    ///
701    /// # Assertions
702    ///
703    /// - Batch generation creates correct number of IDs
704    /// - Batch validation passes for all IDs
705    /// - Boundary ID has correct timestamp
706    /// - Sorting maintains ID count
707    /// - Time-based operations work correctly
708    #[test]
709    fn test_pipeline_id_utils() {
710        use super::pipeline_id_utils::*;
711
712        // Test batch generation
713        let ids = generate_batch(5);
714        assert_eq!(ids.len(), 5);
715
716        // Test batch validation
717        assert!(validate_batch(&ids).is_ok());
718
719        // Test time-based operations
720        let base_time = 1640995200000;
721        let boundary_id = boundary_id_for_time(base_time);
722        assert_eq!(boundary_id.timestamp_ms(), base_time);
723
724        // Test sorting
725        let sorted_ids = sort_by_time(ids.clone());
726        assert_eq!(sorted_ids.len(), ids.len());
727        // IDs should already be in order due to ULID time ordering
728    }
729}