Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ let ulid = ULID(ulid: uuid.uuid)
print(ulid.ulidString) // 01D132CXJVYQ7091KZPZR5WH1X
```

### Monotonically Sorted ULIDs

To generate ULIDs that are guaranteed to be monotonically sorted:

```swift
import ULID

// Create a monotonic factory
let factory = ULID.MonotonicFactory()

// Generate ULIDs that are guaranteed to sort correctly
let ulid1 = factory.create()
let ulid2 = factory.create()
let ulid3 = factory.create()

// These will always be true
assert(ulid1 < ulid2)
assert(ulid2 < ulid3)
```

## Installation

### Swift Package Manager
Expand Down
74 changes: 74 additions & 0 deletions Sources/ULID/ULID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,73 @@ public struct ULID: Hashable, Equatable, Comparable, CustomStringConvertible, Se
return ulidString
}

/// A factory that generates monotonically increasing ULIDs
public final class MonotonicFactory {
private var lastTime: UInt64 = 0
private var lastRandom: String?
private var generator: SystemRandomNumberGenerator

public init(generator: SystemRandomNumberGenerator = SystemRandomNumberGenerator()) {
self.generator = generator
}

/// Generate a new ULID that's guaranteed to be monotonically increasing
public func create(timestamp: Date = Date()) -> ULID {
let currentTime: UInt64 = UInt64(timestamp.timeIntervalSince1970 * 1000.0)

if let lastRandomPart: String = lastRandom, currentTime <= lastTime {
// Need to increment the random part
let incrementedRandom = incrementBase32(lastRandomPart)
if let incrementedULID: ULID = ULID(ulidString: encodeTime(lastTime) + incrementedRandom) {
lastRandom = incrementedRandom
return incrementedULID
}
// If increment failed, fall through to generate new random
}

// Generate new ULID normally
let newULID: ULID = ULID(timestamp: timestamp, generator: &generator)
lastTime = currentTime
lastRandom = String(newULID.ulidString.dropFirst(10))
return newULID
}

private func encodeTime(_ timestamp: UInt64) -> String {
var timeChars: [Character] = [Character](repeating: "0", count: 10)
var time: UInt64 = timestamp

for i: Int in (0..<10).reversed() {
timeChars[i] = Base32.crockfordsEncodingTable[Int(time % 32)].ascii
time /= 32
}

return String(timeChars)
}

private func incrementBase32(_ str: String) -> String {
var chars: [String.Element] = Array(str)
let encodingTable: String = String(bytes: Base32.crockfordsEncodingTable, encoding: .ascii)!

for i in (0..<chars.count).reversed() {
let currentChar: String.Element = chars[i]
if let currentIndex: String.Index = encodingTable.firstIndex(of: currentChar) {
if currentIndex < encodingTable.index(before: encodingTable.endIndex) {
// Normal increment case
chars[i] = encodingTable[encodingTable.index(after: currentIndex)]
return String(chars)
}
// Handle overflow by continuing to next position
chars[i] = encodingTable.first!
continue
}
// If character not found in encoding table, something is wrong
fatalError("Invalid base32 character in ULID")
}
// If we get here, we've overflowed the entire random part
return String(repeating: String(encodingTable.first!), count: str.count)
}
}

}

extension ULID: Codable {
Expand All @@ -225,3 +292,10 @@ extension ULID: Codable {
}

}

// Add ASCII conversion helper
private extension UInt8 {
var ascii: Character {
return Character(UnicodeScalar(self))
}
}
33 changes: 33 additions & 0 deletions Tests/ULIDTests/ULIDTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,39 @@ struct ULIDTests {
#expect(MemoryLayout<ULID>.size == 16)
}

@Test func testMonotonicFactory() {
let factory = ULID.MonotonicFactory()
let timestamp = Date()

// Test multiple increments in same millisecond
let ulid1 = factory.create(timestamp: timestamp)
let ulid2 = factory.create(timestamp: timestamp)
let ulid3 = factory.create(timestamp: timestamp)
let ulid4 = factory.create(timestamp: timestamp)
let ulid5 = factory.create(timestamp: timestamp)

// Verify all are strictly increasing
#expect(ulid1 < ulid2)
#expect(ulid2 < ulid3)
#expect(ulid3 < ulid4)
#expect(ulid4 < ulid5)

// Verify they all have the same timestamp component
let timeComponent: String = String(ulid1.ulidString.prefix(10))
#expect(timeComponent == String(ulid2.ulidString.prefix(10)))
#expect(timeComponent == String(ulid3.ulidString.prefix(10)))
#expect(timeComponent == String(ulid4.ulidString.prefix(10)))
#expect(timeComponent == String(ulid5.ulidString.prefix(10)))

// Test with future timestamp after sequence
let newerTimestamp: Date = timestamp.addingTimeInterval(1)
let ulid6 = factory.create(timestamp: newerTimestamp)

#expect(ulid5 < ulid6)
#expect(String(ulid5.ulidString.prefix(10)) != String(ulid6.ulidString.prefix(10)))
}


}

extension ULID {
Expand Down