const getSecureRandomInt = (max: number): number => { const array = new Uint32Array(1); crypto.getRandomValues(array); // Using modulo bias is technically present, but negligible here because the space (56) is tiny compared to 2^32. return array[0] % max; } /** * Generates a random ID with characteristics similar to UUIDv7, * incorporating a timestamp prefix for sortability and a random suffix, * using a customizable length and a specific character set. * * This function aims for sortability by placing a time-based component at the beginning, * similar to UUIDv7, while allowing a variable total length and using a character set * designed to avoid visually similar characters. * * The character set includes uppercase and lowercase letters and numbers, * excluding visually similar characters (0, O, I, l, 1). * * **Length Reference**: * * With a collision probability of **0.1%**, * the maximum ID generation rate per millisecond for the following lengths is: * - **10**: 1.8 IDs / ms or 1,844 QPS * - **12**: 27 IDs / ms or 26,998 QPS * - **16**: 5784 IDs / ms or 5,784,295 QPS * * With a collision probability of **0.001%**, * the maximum ID generation rate per millisecond for the following lengths is: * - **11**: 1.5 IDs / ms or 1,520 QPS * - **14**: 85 IDs / ms or 85,124 QPS * - **16**: 1246 IDs / ms or 1,245,983 QPS * * With a collision probability of **0.00001%**, * the maximum ID generation rate per millisecond for the following lengths is: * - **14**: 18 IDs / ms or 18,339 QPS * - **15**: 70 IDs / ms or 70,164 QPS * - **16**: 1246 IDs / ms or 268,438 QPS * * The formula: max_qps = 1000 * (2 * (56**(length - 8)) * -log(1 - prob))**(1/3) * * @param length The desired total length of the ID. Must be at least 8. * @returns A sortable random ID string of the specified length. * @throws Error if the requested length is less than the minimum required for the timestamp prefix (8). */ export function generateRandomId(length: number): string { // Character set excluding 0, O, I, l, 1 const allowedChars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 56 characters const base = allowedChars.length; // 56 const TIMESTAMP_PREFIX_LENGTH = 8; // Fixed length for the timestamp part to ensure sortability if (length < TIMESTAMP_PREFIX_LENGTH) { throw new Error(`Length must be at least ${TIMESTAMP_PREFIX_LENGTH} to include the timestamp prefix.`); } // --- Generate Timestamp Prefix --- const timestamp = Date.now(); // Milliseconds since epoch (Unix time) let timestampBaseString = ""; let tempTimestamp = timestamp; const firstChar = allowedChars[0]; // Character for padding ('a') // Convert timestamp to a base-56 string // We process from the least significant "digit" to the most significant while (tempTimestamp > 0) { timestampBaseString = allowedChars[tempTimestamp % base] + timestampBaseString; tempTimestamp = Math.floor(tempTimestamp / base); } // Pad the timestamp string at the beginning to ensure a fixed length. // This is crucial for chronological sortability of the generated IDs. while (timestampBaseString.length < TIMESTAMP_PREFIX_LENGTH) { timestampBaseString = firstChar + timestampBaseString; } // Although highly unlikely with an 8-character prefix using base 56 for current timestamps, // this would truncate if the timestamp string somehow exceeded the prefix length. if (timestampBaseString.length > TIMESTAMP_PREFIX_LENGTH) { timestampBaseString = timestampBaseString.substring(timestampBaseString.length - TIMESTAMP_PREFIX_LENGTH); } // --- Generate Random Suffix --- const randomLength = length - TIMESTAMP_PREFIX_LENGTH; let randomSuffix = ""; const allowedCharsLength = allowedChars.length; for (let i = 0; i < randomLength; i++) { const randomIndex = getSecureRandomInt(allowedCharsLength); randomSuffix += allowedChars[randomIndex]; } // --- Concatenate and Return --- return timestampBaseString + randomSuffix; } /** * Decodes the timestamp (in milliseconds since epoch) from an ID * generated by a function that uses a fixed-length timestamp prefix * encoded in a specific base and character set (like the previous example). * * It extracts the timestamp prefix and converts it back from the * custom base-56 encoding to a number. * * @param id The ID string containing the timestamp prefix. * @returns The timestamp in milliseconds since epoch. * @throws Error if the ID is too short or contains invalid characters in the timestamp prefix. */ export function decodeTimestampFromId(id: string): number { // Character set must match the encoding function used to generate the ID const allowedChars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 56 characters const base = allowedChars.length; // 56 const TIMESTAMP_PREFIX_LENGTH = 8; // Fixed length for the timestamp part if (id.length < TIMESTAMP_PREFIX_LENGTH) { throw new Error( `ID must be at least ${TIMESTAMP_PREFIX_LENGTH} characters long to contain a timestamp prefix.` ); } // Extract the timestamp prefix from the beginning of the ID string const timestampPrefix = id.substring(0, TIMESTAMP_PREFIX_LENGTH); let timestamp = 0; // Convert the base-56 timestamp string back to a number // Iterate through the prefix characters from left to right (most significant 'digit') for (let i = 0; i < timestampPrefix.length; i++) { const char = timestampPrefix[i]; // Find the index (value) of the character in the allowed character set const charIndex = allowedChars.indexOf(char); if (charIndex === -1) { // If a character is not in the allowed set, the ID is likely invalid throw new Error(`Invalid character "${char}" found in timestamp prefix.`); } // Standard base conversion: accumulate the value // For each digit, multiply the current total by the base and add the digit's value timestamp = timestamp * base + charIndex; } return timestamp; }