/** * KHAELOR * File: src/shared/ids.ts * Description: Monotonic ULID generation — globally unique, time-ordered identifiers. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { randomBytes } from "node:crypto"; /** Crockford base32 alphabet (no I, L, O, U). */ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const TIME_LENGTH = 10; const RANDOM_LENGTH = 16; let lastTime = -1; /** Random part as 16 base32 digits (0–31 each) — incremented within the same millisecond. */ let lastRandom: number[] = []; function encodeTime(time: number): string { let t = time; const chars = new Array(TIME_LENGTH); for (let i = TIME_LENGTH - 1; i >= 0; i--) { chars[i] = ENCODING[t % 32] as string; t = Math.floor(t / 32); } return chars.join(""); } function freshRandomDigits(): number[] { const bytes = randomBytes(RANDOM_LENGTH); const digits = new Array(RANDOM_LENGTH); for (let i = 0; i < RANDOM_LENGTH; i++) { digits[i] = (bytes[i] as number) % 32; } return digits; } function incrementDigits(digits: number[]): void { for (let i = RANDOM_LENGTH - 1; i >= 0; i--) { const d = digits[i] as number; if (d < 31) { digits[i] = d + 1; return; } digits[i] = 0; } // Full overflow within one millisecond is practically unreachable; restart randomly. const fresh = freshRandomDigits(); for (let i = 0; i < RANDOM_LENGTH; i++) digits[i] = fresh[i] as number; } /** * Generate a ULID: 10 chars of 48-bit epoch-millisecond time + 16 chars of randomness. * Monotonic within a process: two calls in the same millisecond produce * lexicographically increasing ids. */ export function ulid(time: number = Date.now()): string { if (!Number.isInteger(time) || time < 0 || time > 2 ** 48 - 1) { throw new RangeError(`ulid: time out of range: ${time}`); } if (time === lastTime) { incrementDigits(lastRandom); } else { lastTime = time; lastRandom = freshRandomDigits(); } let random = ""; for (let i = 0; i < RANDOM_LENGTH; i++) { random += ENCODING[lastRandom[i] as number] as string; } return encodeTime(time) + random; } /** Structural check for a ULID string. */ export function isUlid(value: string): boolean { if (value.length !== TIME_LENGTH + RANDOM_LENGTH) return false; for (const ch of value) { if (!ENCODING.includes(ch)) return false; } return true; }