Current section

Files

Jump to
le_ids src ids cuid.gleam
Raw

src/ids/cuid.gleam

//// A module for generating CUIDs (Collision-resistant Unique Identifiers).
//// The implementation requires a counter, so an actor is used to keep track
//// of that state. This means before generating a CUID, an actor needs to be
//// started and all work is done via a channel.
////
//// Slugs are also supported.
////
import gleam/erlang.{Millisecond}
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/list
import gleam/otp/actor.{type Next, type StartResult}
import gleam/string
/// The messages handled by the actor.
///
/// The actor shouldn't be called directly so this type is opaque.
pub opaque type Message {
Generate(Subject(String))
GenerateSlug(Subject(String))
}
/// The internal state of the actor.
///
/// The state keeps track of a counter and a fingerprint.
/// Both are used when generating a CUID.
pub opaque type State {
State(count: Int, fingerprint: String)
}
/// Starts a CUID generator.
pub fn start() -> StartResult(Message) {
actor.start(State(0, get_fingerprint()), handle_msg)
}
/// Generates a CUID using the given channel.
///
/// ### Usage
/// ```gleam
/// import ids/cuid
///
/// let assert Ok(channel) = cuid.start()
///
/// let id: String = cuid.generate(channel)
///
/// let slug: String = cuid.slug(channel)
/// ```
pub fn generate(channel: Subject(Message)) -> String {
actor.call(channel, Generate, 1000)
}
/// Checks if a string is a CUID.
pub fn is_cuid(id: String) -> Bool {
string.starts_with(id, "c")
}
/// Generates a slug using the given channel.
pub fn slug(channel: Subject(Message)) -> String {
actor.call(channel, GenerateSlug, 1000)
}
/// Checks if a string is a slug.
pub fn is_slug(slug: String) -> Bool {
let slug_length = string.length(slug)
slug_length >= 7 && slug_length <= 10
}
const base: Int = 36
fn handle_msg(msg: Message, state: State) -> Next(Message, State) {
case msg {
Generate(reply) -> {
let id =
format_id([
"c",
timestamp(),
format_count(state.count),
state.fingerprint,
random_block(),
random_block(),
])
actor.send(reply, id)
actor.continue(State(..state, count: new_count(state.count)))
}
GenerateSlug(reply) -> {
let slug =
format_id([
timestamp()
|> string.slice(-2, 2),
format_count(state.count)
|> string.slice(-4, 4),
string.concat([
string.slice(state.fingerprint, 0, 1),
string.slice(state.fingerprint, -1, 1),
]),
random_block()
|> string.slice(-2, 2),
])
actor.send(reply, slug)
actor.continue(State(..state, count: new_count(state.count)))
}
}
}
const block_size: Int = 4
const discrete_values: Int = 1_679_616
fn format_id(id_data: List(String)) -> String {
id_data
|> string.concat()
|> string.lowercase()
}
fn new_count(count: Int) -> Int {
case count < discrete_values {
True -> count + 1
False -> 0
}
}
fn timestamp() -> String {
let secs = erlang.system_time(Millisecond)
secs
|> int.to_base36()
}
fn format_count(num: Int) -> String {
num
|> int.to_base36()
|> string.pad_left(to: block_size, with: "0")
}
type CharList
@external(erlang, "os", "getpid")
fn os_getpid() -> CharList
@external(erlang, "erlang", "list_to_binary")
fn char_list_to_string(cl: CharList) -> String
@external(erlang, "net_adm", "localhost")
fn net_adm_localhost() -> List(Int)
fn get_fingerprint() -> String {
let operator = base * base
let assert Ok(pid) =
os_getpid()
|> char_list_to_string()
|> int.parse()
let id = pid % operator * operator
let localhost = net_adm_localhost()
let sum =
localhost
|> list.fold(from: 0, with: fn(char, acc) { char + acc })
let hostid = { sum + list.length(localhost) + base } % operator
id + hostid
|> int.to_base36()
}
@external(erlang, "rand", "uniform")
fn rand_uniform(n: Int) -> Int
fn random_block() -> String {
rand_uniform(discrete_values - 1)
|> int.to_base36()
|> string.pad_left(to: block_size, with: "0")
}