Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `createId(options).many(prefix, count)` — generate multiple prefixed IDs in a batch using shared random-character generation.
- `createSortableId(options).many(prefix, count)` — generate multiple sortable IDs while preserving monotonic ordering.

### Changed

- Random-byte requests are chunked to respect the Web Crypto `getRandomValues()` limit.

## [1.1.2] - 2026-10-09

No changes to the published code: `dist/` is byte-identical to 1.1.1. This release
Expand Down
80 changes: 80 additions & 0 deletions scripts/benchmark.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@

import { performance } from "node:perf_hooks";
import { createId, id } from "prefid";

const TOTAL = 20_000;
const WARMUP_RUNS = 3;
const MEASURED_RUNS = 7;
const MAX_BATCH_SIZE = 10_000;

const batchId = createId();

function generateIds() {
return Array.from({ length: TOTAL }, () => id("user"));
}

function generateIdsMany() {
const ids = [];

for (let remaining = TOTAL; remaining > 0;) {
const count = Math.min(remaining, MAX_BATCH_SIZE);
ids.push(...batchId.many("user", count));
remaining -= count;
}

return ids;
}

function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}

function benchmark(label, generate) {
const times = [];

for (let i = 0; i < WARMUP_RUNS; i++) {
generate();
}

for (let i = 0; i < MEASURED_RUNS; i++) {
const start = performance.now();
const ids = generate();
const elapsed = performance.now() - start;

if (ids.length !== TOTAL) {
throw new Error(`Expected ${TOTAL} IDs, got ${ids.length}`);
}

times.push(elapsed);
console.log(`${label} run ${i + 1}: ${elapsed.toFixed(2)} ms`);
}

return median(times);
}

console.log(`Node ${process.version}`);
console.log(`IDs per run: ${TOTAL.toLocaleString()}`);
console.log(`Warmups: ${WARMUP_RUNS}, measured runs: ${MEASURED_RUNS}`);

const baselineMedian = benchmark("Individual", generateIds);
const batchMedian = benchmark("Batch", generateIdsMany);

function printResults(label, elapsed) {
console.log(`\n--- ${label} ---`);
console.log(`Median: ${elapsed.toFixed(2)} ms`);
console.log(`Time per ID: ${((elapsed * 1000) / TOTAL).toFixed(3)} µs`);
console.log(`Throughput: ${Math.round((TOTAL * 1000) / elapsed)} IDs/sec`);
}

printResults("Baseline: Individual generation", baselineMedian);
printResults("Batch: many()", batchMedian);

console.log("\n--- Comparison ---");
console.log(`Speedup: ${(baselineMedian / batchMedian).toFixed(2)}x`);
console.log(
`Time reduction: ${(
((baselineMedian - batchMedian) / baselineMedian) *
100
).toFixed(1)}%`,
);
4 changes: 4 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ export const DEFAULT_SORTABLE_RANDOM_SIZE = 16;
export const SORTABLE_TIME_MAX = 2 ** 48 - 1;

export const MAX_DATE_MS = 8_640_000_000_000_000;

export const MAX_RANDOM_BYTES = 65_536;

export const MAX_BATCH_SIZE = 10_000;
45 changes: 41 additions & 4 deletions src/generators/generate.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@

import {
DEFAULT_ALPHABET,
DEFAULT_SEPARATOR,
DEFAULT_SIZE,
MAX_SIZE,
MAX_BATCH_SIZE
} from "../constants/index.js";
import { assertValidPrefix } from "../internal/prefix.js";
import { randomString } from "../internal/random.js";
import type { IdGenerator, IdOptions, PrefixedId } from "../types/index.js";
import type { BatchIdGenerator, IdGenerator, IdOptions, PrefixedId } from "../types/index.js";

export function createId<S extends string = "_">(
defaults: IdOptions & { separator?: S } = {},
): IdGenerator<S> {
defaults: IdOptions & { separator?: S; } = {},
): BatchIdGenerator<S> {
const size = defaults.size ?? DEFAULT_SIZE;
const separator = defaults.separator ?? DEFAULT_SEPARATOR;
const alphabet = defaults.alphabet ?? DEFAULT_ALPHABET;
Expand All @@ -29,13 +31,48 @@ export function createId<S extends string = "_">(
throw new TypeError("prefid: `separator` must be a non-empty string.");
}

return function id<P extends string>(prefix: P): PrefixedId<P, S> {
const id: BatchIdGenerator<S> = function <P extends string>(
prefix: P,
): PrefixedId<P, S> {
assertValidPrefix(prefix, separator);
return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId<
P,
S
>;
};

id.many = function <P extends string>(
prefix: P,
count: number,
): PrefixedId<P, S>[] {
if (
!Number.isInteger(count) ||
count < 0 ||
count > MAX_BATCH_SIZE
) {
throw new RangeError(
`prefid: batch count must be an integer between 0 and ${MAX_BATCH_SIZE}.`,
);
}

assertValidPrefix(prefix, separator);

if (count === 0) return [];

const characters = randomString(alphabet, size * count);
const ids: PrefixedId<P, S>[] = [];

for (let i = 0; i < count; i++) {
const start = i * size;
const body = characters.slice(start, start + size);

ids.push(`${prefix}${separator}${body}` as PrefixedId<P, S>);
}

return ids;
};

return id;
}

export const id: IdGenerator = createId();
47 changes: 39 additions & 8 deletions src/generators/sortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "../constants/index.js";
import { assertValidPrefix } from "../internal/prefix.js";
import { randomIndices } from "../internal/random.js";
import type { IdGenerator, PrefixedId } from "../types/index.js";
import type { BatchIdGenerator, IdGenerator, PrefixedId } from "../types/index.js";

export interface SortableIdOptions {
separator?: string;
Expand Down Expand Up @@ -40,8 +40,8 @@ function assertSortableAlphabet(alphabet: string): void {
if (alphabet.charCodeAt(i) <= alphabet.charCodeAt(i - 1)) {
throw new RangeError(
"prefid: a sortable `alphabet` must have characters in strictly " +
"ascending code-point order (no duplicates), otherwise a " +
"lexicographic sort would not match chronological order.",
"ascending code-point order (no duplicates), otherwise a " +
"lexicographic sort would not match chronological order.",
);
}
}
Expand All @@ -57,8 +57,7 @@ function encodeTime(time: number, alphabet: string, width: number): string {
}
if (value > 0) {
throw new RangeError(
`prefid: timestamp ${time} does not fit in ${width} "${alphabet[0]}".."${
alphabet[radix - 1]
`prefid: timestamp ${time} does not fit in ${width} "${alphabet[0]}".."${alphabet[radix - 1]
}" characters.`,
);
}
Expand Down Expand Up @@ -102,7 +101,7 @@ function assertSize(value: number, name: string): void {

export function createSortableId<S extends string = "_">(
options: SortableIdOptions & { separator?: S } = {},
): IdGenerator<S> {
): BatchIdGenerator<S> {
const separator = options.separator ?? DEFAULT_SEPARATOR;
const alphabet = options.alphabet ?? DEFAULT_ALPHABET;
const monotonic = options.monotonic ?? true;
Expand Down Expand Up @@ -130,13 +129,17 @@ export function createSortableId<S extends string = "_">(
let lastTime = -1;
let lastRandom: number[] = [];

return function sortableId<P extends string>(prefix: P): PrefixedId<P, S> {

const sortableId: BatchIdGenerator<S> = function <P extends string>(
prefix: P,
): PrefixedId<P, S> {
assertValidPrefix(prefix, separator);

const reading = clock();
if (typeof reading !== "number" || !Number.isFinite(reading)) {
throw new TypeError("prefid: `now` must return a finite number.");
}

let time = Math.floor(reading);
if (time < 0) {
throw new RangeError("prefid: `now` must return a non-negative number.");
Expand All @@ -145,23 +148,51 @@ export function createSortableId<S extends string = "_">(
if (monotonic && time <= lastTime) {
time = lastTime;
const next = incrementIndices(lastRandom, radix);

if (next) {
lastRandom = next;
} else {
time = lastTime + 1;
lastRandom = randomIndices(radix, randomSize);
}

lastTime = time;
} else {
lastTime = time;
lastRandom = randomIndices(radix, randomSize);
}

let body = encodeTime(time, alphabet, timestampSize);
for (let i = 0; i < randomSize; i++) body += alphabet[lastRandom[i]];
for (let i = 0; i < randomSize; i++) {
body += alphabet[lastRandom[i]];
}

return `${prefix}${separator}${body}` as PrefixedId<P, S>;
};

sortableId.many = function <P extends string>(
prefix: P,
count: number,
): PrefixedId<P, S>[] {
if (!Number.isInteger(count) || count < 0 || count > 10_000) {
throw new RangeError(
"prefid: batch count must be an integer between 0 and 10000.",
);
}

assertValidPrefix(prefix, separator);

const ids: PrefixedId<P, S>[] = [];

for (let i = 0; i < count; i++) {
ids.push(sortableId(prefix));
}

return ids;
};

return sortableId;

}

export const sortableId: IdGenerator = createSortableId();
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export { template } from "./generators/template.js";
export { ensureUnique } from "./utils/ensure-unique.js";
export { getPrefix, isId, parseId } from "./utils/validate.js";

export type { IdGenerator, IdOptions, PrefixedId } from "./types/index.js";
export type { BatchIdGenerator, IdGenerator, IdOptions, PrefixedId } from "./types/index.js";
export type {
GetTimestampOptions,
SortableIdOptions,
Expand Down
49 changes: 40 additions & 9 deletions src/internal/random.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { MAX_RANDOM_BYTES } from "../constants";

type BytesProvider = (length: number) => Uint8Array;

interface NodeCryptoLike {
Expand Down Expand Up @@ -31,9 +33,9 @@ export function universalProvider(length: number): Uint8Array {

throw new Error(
"prefid: no secure random source found. This environment exposes " +
"neither `globalThis.crypto.getRandomValues` nor Node's `crypto` module. " +
"If you are on Node 18 ESM, import from the package's default entry so the " +
"Node-specific build is selected.",
"neither `globalThis.crypto.getRandomValues` nor Node's `crypto` module. " +
"If you are on Node 18 ESM, import from the package's default entry so the " +
"Node-specific build is selected.",
);
}

Expand All @@ -44,26 +46,55 @@ export function setBytesProvider(provider: BytesProvider): void {
}

export function secureRandomBytes(length: number): Uint8Array {
return bytesProvider(length);
if (!Number.isInteger(length) || length < 0) {
throw new RangeError(
"prefid: random byte length must be a non-negative integer.",
);
}

if (length === 0) return new Uint8Array(0);

if (length <= MAX_RANDOM_BYTES) {
return bytesProvider(length);
}

const result = new Uint8Array(length);

for (let offset = 0; offset < length; offset += MAX_RANDOM_BYTES) {
const chunkSize = Math.min(MAX_RANDOM_BYTES, length - offset);
result.set(bytesProvider(chunkSize), offset);
}

return result;
}


export function randomIndices(radix: number, size: number): number[] {
const mask = (2 << Math.floor(Math.log2(radix - 1))) - 1;
const step = Math.ceil((1.6 * mask * size) / radix);
const step = Math.max(
1,
Math.ceil((1.6 * mask * size) / radix),
);

const out: number[] = [];
for (;;) {
const bytes = secureRandomBytes(step);
for (let i = 0; i < step; i++) {

while (out.length < size) {
const requestSize = Math.min(step, MAX_RANDOM_BYTES);
const bytes = secureRandomBytes(requestSize);

for (let i = 0; i < bytes.length && out.length < size; i++) {
const index = bytes[i] & mask;

if (index < radix) {
out.push(index);
if (out.length === size) return out;
}
}
}

return out;
}


export function randomString(alphabet: string, size: number): string {
const indices = randomIndices(alphabet.length, size);
let result = "";
Expand Down
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ export interface IdOptions {
export type IdGenerator<S extends string = "_"> = <P extends string>(
prefix: P,
) => PrefixedId<P, S>;

export type BatchIdGenerator<S extends string = "_"> = IdGenerator<S> & {
many: <P extends string>(prefix: P, count: number) => PrefixedId<P, S>[];
};
Loading