Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ULID support #15

Merged
merged 11 commits into from
Oct 6, 2023
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [CUID](https://github.com/ericelliott/cuid)
- [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
- [NanoID](https://github.com/ai/nanoid)
- [ULID](https://github.com/ulid/spec)

## Installation

Expand Down
240 changes: 240 additions & 0 deletions src/ids/ulid.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
//// A module for generating ULIDs (Universally Unique Lexicographically Sortable Identifier).

import gleam/string
import gleam/int
import gleam/result
import gleam/list
import gleam/erlang
import gleam/otp/actor.{Next, StartResult}
import gleam/erlang/process.{Subject}

pub const crockford_alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"

pub const max_time = 281_474_976_710_655

@external(erlang, "crypto", "strong_rand_bytes")
fn crypto_strong_rand_bytes(n: Int) -> BitString

@external(erlang, "erlang", "bit_size")
fn bit_size(b: BitString) -> Int

@external(erlang, "binary", "decode_unsigned")
fn decode_unsigned(b: BitString) -> Int

@external(erlang, "binary", "encode_unsigned")
fn encode_unsigned(i: Int) -> BitString

/// The messages handled by the actor.
/// The actor shouldn't be called directly so this type is opaque.
pub opaque type Message {
Generate(reply_with: Subject(Result(String, String)))
GenerateFromTimestamp(
timestamp: Int,
reply_with: Subject(Result(String, String)),
)
}

/// The internal state of the actor.
/// The state keeps track of the last ULID components to make sure to handle the monotonicity correctly.
pub opaque type State {
State(last_time: Int, last_random: BitString)
}

/// Starts a ULID generator.
pub fn start() -> StartResult(Message) {
actor.start(State(0, <<>>), handle_msg)
}

/// Generates an ULID using the given channel with a monotonicity check.
/// This guarantees sortability if multiple ULID get created in the same millisecond.
///
/// ### Usage
/// ```gleam
/// import ids/ulid
///
/// let assert Ok(channel) = ulid.start()
/// let Ok(id) = ulid.monotonic_generate(channel)
/// ```
pub fn monotonic_generate(channel: Subject(Message)) -> Result(String, String) {
actor.call(channel, Generate, 1000)
}

/// Generates an ULID from a timestamp using the given channel with a monotonicity check.
/// This guarantees sortability if the same timestamp is used repeatedly back to back.
///
/// ### Usage
/// ```gleam
/// import ids/ulid
///
/// let assert Ok(channel) = ulid.start()
/// let Ok(id) = ulid.monotonic_from_timestamp(channel, 1_696_346_659_217)
/// ```
pub fn monotonic_from_timestamp(
channel: Subject(Message),
timestamp: Int,
) -> Result(String, String) {
actor.call(
channel,
fn(subject) { GenerateFromTimestamp(timestamp, subject) },
1000,
)
}

/// Generates an ULID.
pub fn generate() -> String {
let timestamp = erlang.system_time(erlang.Millisecond)

case from_timestamp(timestamp) {
Ok(ulid) -> ulid
_error -> panic as "Error: Couldn't generate ULID."
}
}

/// Generates an ULID with the supplied unix timestamp in milliseconds.
pub fn from_timestamp(timestamp: Int) -> Result(String, String) {
from_parts(timestamp, crypto_strong_rand_bytes(10))
}

/// Generates an ULID with the supplied timestamp and randomness.
pub fn from_parts(
timestamp: Int,
randomness: BitString,
) -> Result(String, String) {
case #(timestamp, randomness) {
#(time, <<rand:bit_string-size(80)>>) if time <= max_time ->
<<timestamp:size(48), rand:bit_string>>
|> encode_base32()
|> Ok
_other -> {
let error =
string.concat([
"Error: The timestamp is too large or randomness isn't 80 bits. Please use an Unix timestamp smaller than ",
int.to_string(max_time),
".",
])
Error(error)
}
}
}

/// Decodes an ULID into #(timestamp, randomness).
pub fn decode(ulid: String) -> Result(#(Int, BitString), String) {
case decode_base32(ulid) {
Ok(<<timestamp:unsigned-size(48), randomness:bit_string-size(80)>>) ->
Ok(#(timestamp, randomness))
_other -> Error("Error: Decoding failed. Is a valid ULID being supplied?")
}
}

/// Encode a bit_string using crockfords base32 encoding.
fn encode_base32(bytes: BitString) -> String {
// calculate how many bits to pad to make the bit_string divisible by 5
let to_pad =
bytes
|> bit_size()
|> int.modulo(5)
|> result.unwrap(5)
|> int.subtract(5)
|> int.absolute_value()
|> int.modulo(5)
|> result.unwrap(0)

encode_bytes(<<0:size(to_pad), bytes:bit_string>>)
}

/// Recursively grabs 5 bits and uses them as index in the crockford alphabet and concatinates them to a string.
fn encode_bytes(binary: BitString) -> String {
case binary {
<<index:unsigned-size(5), rest:bit_string>> -> {
crockford_alphabet
|> string.to_graphemes()
|> list.at(index)
|> result.unwrap("0")
|> string.append(encode_bytes(rest))
}
<<>> -> ""
}
}

/// Decode a string using crockford's base32 encoding.
fn decode_base32(binary: String) -> Result(BitString, Nil) {
let crockford_with_index =
crockford_alphabet
|> string.to_graphemes()
|> list.index_map(fn(i, x) { #(x, i) })

let bits =
binary
|> string.to_graphemes()
|> list.fold(
<<>>,
fn(acc, c) {
let index =
crockford_with_index
|> list.key_find(c)
|> result.unwrap(0)

<<acc:bit_string, index:5>>
},
)

let padding =
bits
|> bit_size()
|> int.modulo(8)
|> result.unwrap(0)

case bits {
<<0:size(padding), res:bit_string>> -> Ok(res)
_other -> Error(Nil)
}
}

/// Actor message handler.
fn handle_msg(msg: Message, state: State) -> Next(Message, State) {
case msg {
Generate(reply) -> {
erlang.system_time(erlang.Millisecond)
|> generate_response_ulid(reply, state)
}

GenerateFromTimestamp(timestamp, reply) ->
timestamp
|> generate_response_ulid(reply, state)
}
}

/// Response message helper.
fn generate_response_ulid(
timestamp: Int,
reply: Subject(Result(String, String)),
state: State,
) -> Next(Message, State) {
case state.last_time == timestamp {
True -> {
let randomness =
state.last_random
|> decode_unsigned()
|> int.add(1)
|> encode_unsigned()

case from_parts(timestamp, randomness) {
Ok(ulid) -> actor.send(reply, Ok(ulid))
_error -> actor.send(reply, Error("Error: Couldn't generate ULID."))
}

actor.continue(State(last_time: timestamp, last_random: randomness))
}

False -> {
let randomness = crypto_strong_rand_bytes(10)

case from_parts(timestamp, randomness) {
Ok(ulid) -> actor.send(reply, Ok(ulid))
_error -> actor.send(reply, Error("Error: Couldn't generate ULID."))
}

actor.continue(State(last_time: timestamp, last_random: randomness))
}
}
}
130 changes: 130 additions & 0 deletions test/ids/ulid_test.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import ids/ulid
import gleeunit/should
import gleam/string
import gleam/list
import gleam/result

@external(erlang, "binary", "decode_unsigned")
fn decode_unsigned(b: BitString) -> Int

pub fn gen_test() {
let id = ulid.generate()
id
|> check_length()
|> check_starting_character()
|> check_crockford_characters()
}

pub fn from_timestamp_test() {
let assert Ok(id_1) = ulid.from_timestamp(1_696_346_659_217)
id_1
|> check_length()
|> check_starting_character()
|> check_crockford_characters()

let assert Ok(id_2) = ulid.from_timestamp(281_474_976_710_655)
id_2
|> string.starts_with("7ZZZZZZZZZ")
|> should.be_true()
}

pub fn from_parts_test() {
let assert Ok(id_1) =
ulid.from_parts(
1_696_346_659_217,
<<150, 184, 121, 192, 42, 76, 148, 57, 61, 61>>,
)
id_1
|> should.equal("01HBV27PCHJTW7KG1A9JA3JF9X")

let assert Ok(id_2) =
ulid.from_parts(
281_474_976_710_655,
<<255, 255, 255, 255, 255, 255, 255, 255, 255, 255>>,
)
id_2
|> should.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ")
}

pub fn decode_test() {
let timestamp = 1_696_346_659_217
let random = <<150, 184, 121, 192, 42, 76, 148, 57, 61, 61>>

let assert Ok(#(decode_timestamp, randomness)) =
ulid.from_parts(timestamp, random)
|> result.then(ulid.decode)
decode_timestamp
|> should.equal(timestamp)
randomness
|> should.equal(random)

let assert Ok(#(decode_max_time, randomness)) =
ulid.decode("7ZZZZZZZZZZZZZZZZZZZZZZZZZ")
decode_max_time
|> should.equal(ulid.max_time)
randomness
|> should.equal(<<255, 255, 255, 255, 255, 255, 255, 255, 255, 255>>)

ulid.decode("8ZZZZZZZZZZZZZZZZZZZZZZZZZ")
|> should.be_error()
}

pub fn monotonicity_test() {
let assert Ok(actor) = ulid.start()

let assert Ok(id_1) = ulid.monotonic_generate(actor)
id_1
|> check_length()
|> check_starting_character()
|> check_crockford_characters()

let timestamp = 1_696_346_660_217
let assert Ok(#(_, random_1)) =
ulid.monotonic_from_timestamp(actor, timestamp)
|> result.then(ulid.decode)
let assert Ok(#(_, random_2)) =
ulid.monotonic_from_timestamp(actor, timestamp)
|> result.then(ulid.decode)

random_2
|> decode_unsigned()
|> should.equal({ decode_unsigned(random_1) + 1 })
}

fn check_length(ulid) -> String {
ulid
|> string.length()
|> should.equal(26)

ulid
}

fn check_starting_character(ulid) -> String {
ulid
|> string.first()
|> fn(x) {
case x {
Ok(y) ->
"01234567"
|> string.to_graphemes()
|> list.contains(y)
_error -> False
}
}
|> should.be_true()

ulid
}

fn check_crockford_characters(ulid) -> String {
ulid
|> string.to_graphemes()
|> list.all(fn(x) {
ulid.crockford_alphabet
|> string.to_graphemes()
|> list.contains(x)
})
|> should.be_true()

ulid
}