11package dev.phillipslabs.kulid
22
33import kotlinx.datetime.Clock
4- import kotlinx.serialization.Serializable
4+ import kotlinx.io.Buffer
5+ import kotlinx.io.bytestring.ByteString
6+ import kotlinx.io.readByteString
57import org.kotlincrypto.random.CryptoRand
68import kotlin.jvm.JvmInline
79import kotlin.math.absoluteValue
@@ -14,9 +16,8 @@ internal const val MAX_TIME = (1L shl TIMESTAMP_BIT_SIZE) - 1 // 2^48 - 1
1416private const val RANDOM_BIT_SIZE = 80
1517private const val ULID_BIT_SIZE = TIMESTAMP_BIT_SIZE + RANDOM_BIT_SIZE
1618
17- private const val ULID_BYTE_SIZE = ULID_BIT_SIZE / 8
19+ internal const val ULID_BYTE_SIZE = ULID_BIT_SIZE / 8
1820private const val RANDOM_BYTE_SIZE = RANDOM_BIT_SIZE / 8
19- internal const val TIMESTAMP_BYTE_SIZE = TIMESTAMP_BIT_SIZE / 8
2021
2122private const val ENCODED_ULID_BYTE_SIZE = 26
2223
@@ -27,48 +28,91 @@ private const val ULID_ENCODING_FRONT_PADDING = ENCODED_ULID_BYTE_SIZE * 5 - ULI
2728// Crockford's base32 alphabet
2829private val ENCODING_CHARS = " 0123456789ABCDEFGHJKMNPQRSTVWXYZ" .toCharArray()
2930
30- // TODO we will eventually move this to a byte array once we want to support the binary format
31- @Serializable
31+ // @Serializable
3232@JvmInline
3333public value class ULID private constructor(
34- public val value : String ,
35- ) {
34+ public val value : ByteString ,
35+ ) : Comparable<ULID> {
36+ init {
37+ require(value.size == ULID_BYTE_SIZE ) { " ULID should have $ULID_BYTE_SIZE bytes, but had ${value.size} " }
38+ }
39+
40+ override fun compareTo (other : ULID ): Int = value.compareTo(other.value)
41+
42+ override fun toString (): String = encodeCrockfordBase32(value)
43+
3644 public companion object {
37- public val MAX : ULID = ULID (" 7ZZZZZZZZZZZZZZZZZZZZZZZZZ" )
38- public val MIN : ULID = ULID (" 00000000000000000000000000" )
45+ public val MAX : ULID = fromString (" 7ZZZZZZZZZZZZZZZZZZZZZZZZZ" )
46+ public val MIN : ULID = fromString (" 00000000000000000000000000" )
3947
4048 public fun generate (timestamp : Long = Clock .System .now().toEpochMilliseconds()): ULID {
4149 check(timestamp >= 0L ) { " Time must be non-negative" }
4250 check(timestamp <= MAX_TIME ) { " Time must be less than $MAX_TIME " }
4351
52+ val buf = Buffer ()
53+ // the timestamp we use is 48 bits, so we need to drop the uppermost 16 bits of a Long
54+ // toShort() uses the least significant 16 bits of a number
55+ buf.writeShort((timestamp shr 32 ).toShort())
56+ buf.writeInt(timestamp.toInt())
57+
4458 val randomness = CryptoRand .Default .nextBytes(ByteArray (RANDOM_BYTE_SIZE ))
59+ buf.write(randomness)
60+
61+ return ULID (buf.readByteString())
62+ }
4563
46- val combined = ByteArray (ULID_BYTE_SIZE )
64+ public fun fromString (encoded : String ): ULID {
65+ require(encoded.length == ENCODED_ULID_BYTE_SIZE ) { " Encoded ULID must be $ENCODED_ULID_BYTE_SIZE characters long" }
66+ val buf = Buffer ()
67+ var decodedByte = 0
4768
48- for (i in 0 until TIMESTAMP_BYTE_SIZE ) {
49- // toByte() uses the LSBs, so we need to shift the byte we're storing to the end to get it in correctly
50- combined[i] = (timestamp shr (8 * (TIMESTAMP_BYTE_SIZE - i - 1 ))).toByte()
51- }
69+ for (i in 0 until (encoded.length * 5 ) - ULID_ENCODING_FRONT_PADDING ) {
70+ val charIdx = (i + ULID_ENCODING_FRONT_PADDING ) / 5
71+ val char = encoded[charIdx]
72+ val decodedChar = ENCODING_CHARS .indexOf(char.uppercaseChar())
73+ require(decodedChar != - 1 ) { " Invalid character in ULID: $char " }
5274
53- randomness.copyInto(combined, destinationOffset = TIMESTAMP_BYTE_SIZE )
75+ val bitIdxOfChar = 4 - ((i + ULID_ENCODING_FRONT_PADDING ) % 5 )
76+ val bitMask = 0b1 shl bitIdxOfChar
77+ val bit = decodedChar and bitMask
5478
55- return ULID (encodeCrockfordBase32(combined))
79+ val bitIdxInDecodedByte = 7 - (i % 8 )
80+
81+ // this is where we have to move the masked bit to in order to get it in the right spot for the lookup
82+ // positive is to the right, negative is to the left
83+ val encodingShiftAmount = bitIdxOfChar - bitIdxInDecodedByte
84+
85+ val bitForDecodedByte =
86+ if (encodingShiftAmount >= 0 ) {
87+ (bit ushr encodingShiftAmount)
88+ } else {
89+ (bit shl encodingShiftAmount.absoluteValue)
90+ }
91+
92+ decodedByte = decodedByte or bitForDecodedByte
93+
94+ if (i % 8 == 7 ) {
95+ buf.writeByte(decodedByte.toByte())
96+ decodedByte = 0
97+ }
98+ }
99+ return ULID (buf.readByteString())
56100 }
57101
58- // ULIDS are nice in that we are byte-aligned but also don't have to worry about padding (I think?)
59- internal fun encodeCrockfordBase32 (data : ByteArray ) =
102+ internal fun encodeCrockfordBase32 (bytes : ByteString ) =
60103 buildString(ENCODED_ULID_BYTE_SIZE ) {
104+ require(bytes.size == ULID_BYTE_SIZE ) { " ULID should have $ULID_BYTE_SIZE bytes, but had ${bytes.size} " }
61105 var prevByteIdx = - 1
62106 var encodingIdx = 0
63107 var currentByte = 0
64108
65109 // iterate over every bit
66- for (i in 0 until data .size * 8 ) {
110+ for (i in 0 until bytes .size * 8 ) {
67111 val byteIdx = i / 8
68112 // determine the current byte we are working in
69113 if (prevByteIdx != byteIdx) {
70114 prevByteIdx = byteIdx
71- currentByte = data [byteIdx].toInt()
115+ currentByte = bytes [byteIdx].toInt()
72116 }
73117
74118 // this is what bit we are looking at in the byte we are encoding
@@ -89,17 +133,17 @@ public value class ULID private constructor(
89133 // shift to the left by the absolute value instead
90134 val bitForEncodingIdx =
91135 if (encodingShiftAmount >= 0 ) {
92- encodingIdx or (bit shr encodingShiftAmount)
136+ (bit ushr encodingShiftAmount)
93137 } else {
94- encodingIdx or (bit shl encodingShiftAmount.absoluteValue)
138+ (bit shl encodingShiftAmount.absoluteValue)
95139 }
96140
97141 // mask-in the shifted bit
98142 encodingIdx = encodingIdx or bitForEncodingIdx
99143
100144 // if we reach the 5 bit mark (accounting for any front-padding), or are at the end, do the lookup and add the char to the string
101- if ((i + ULID_ENCODING_FRONT_PADDING ) % 5 == 4 || i == data .size * 8 - 1 ) {
102- append(ENCODING_CHARS [encodingIdx % ENCODING_CHARS .size ])
145+ if ((i + ULID_ENCODING_FRONT_PADDING ) % 5 == 4 || i == bytes .size * 8 - 1 ) {
146+ append(ENCODING_CHARS [encodingIdx])
103147 encodingIdx = 0
104148 }
105149 }
0 commit comments