cvsa/packages/core/lib/randomID.ts

143 lines
5.8 KiB
TypeScript

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;
}