diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b69c95..1d81b1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs
new file mode 100644
index 0000000..78c9010
--- /dev/null
+++ b/scripts/benchmark.mjs
@@ -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)}%`,
+);
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 06b9cb4..00197d6 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -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;
\ No newline at end of file
diff --git a/src/generators/generate.ts b/src/generators/generate.ts
index 8d3823c..f2edf16 100644
--- a/src/generators/generate.ts
+++ b/src/generators/generate.ts
@@ -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(
- defaults: IdOptions & { separator?: S } = {},
-): IdGenerator {
+ defaults: IdOptions & { separator?: S; } = {},
+): BatchIdGenerator {
const size = defaults.size ?? DEFAULT_SIZE;
const separator = defaults.separator ?? DEFAULT_SEPARATOR;
const alphabet = defaults.alphabet ?? DEFAULT_ALPHABET;
@@ -29,13 +31,48 @@ export function createId(
throw new TypeError("prefid: `separator` must be a non-empty string.");
}
- return function id
(prefix: P): PrefixedId
{
+ const id: BatchIdGenerator = function
( + prefix: P, + ): PrefixedId
{ assertValidPrefix(prefix, separator); return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId< P, S >; }; + + id.many = function
( + prefix: P, + count: number, + ): PrefixedId
[] { + 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
[] = []; + + 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
);
+ }
+
+ return ids;
+ };
+
+ return id;
}
export const id: IdGenerator = createId();
diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts
index eb119de..c21bd40 100644
--- a/src/generators/sortable.ts
+++ b/src/generators/sortable.ts
@@ -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;
@@ -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.",
);
}
}
@@ -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.`,
);
}
@@ -102,7 +101,7 @@ function assertSize(value: number, name: string): void {
export function createSortableId(
options: SortableIdOptions & { separator?: S } = {},
-): IdGenerator {
+): BatchIdGenerator {
const separator = options.separator ?? DEFAULT_SEPARATOR;
const alphabet = options.alphabet ?? DEFAULT_ALPHABET;
const monotonic = options.monotonic ?? true;
@@ -130,13 +129,17 @@ export function createSortableId(
let lastTime = -1;
let lastRandom: number[] = [];
- return function sortableId
(prefix: P): PrefixedId
{
+
+ const sortableId: BatchIdGenerator = function
( + prefix: P, + ): PrefixedId
{
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.");
@@ -145,12 +148,14 @@ export function createSortableId(
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;
@@ -158,10 +163,36 @@ export function createSortableId(
}
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
; }; + + sortableId.many = function
( + prefix: P, + count: number, + ): PrefixedId
[] { + 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
[] = [];
+
+ for (let i = 0; i < count; i++) {
+ ids.push(sortableId(prefix));
+ }
+
+ return ids;
+ };
+
+ return sortableId;
+
}
export const sortableId: IdGenerator = createSortableId();
diff --git a/src/index.ts b/src/index.ts
index 5523083..c00743b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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,
diff --git a/src/internal/random.ts b/src/internal/random.ts
index 0244638..8e4430f 100644
--- a/src/internal/random.ts
+++ b/src/internal/random.ts
@@ -1,3 +1,5 @@
+import { MAX_RANDOM_BYTES } from "../constants";
+
type BytesProvider = (length: number) => Uint8Array;
interface NodeCryptoLike {
@@ -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.",
);
}
@@ -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 = "";
diff --git a/src/types/index.ts b/src/types/index.ts
index d3ac1b7..6e61b1b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -12,3 +12,7 @@ export interface IdOptions {
export type IdGenerator =
( prefix: P, ) => PrefixedId
;
+
+export type BatchIdGenerator = IdGenerator & {
+ many:
(prefix: P, count: number) => PrefixedId
[]; +}; \ No newline at end of file diff --git a/test/generate.test.ts b/test/generate.test.ts index c83f306..6541902 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -85,3 +85,46 @@ describe("createId()", () => { expect(() => createId({ separator: "" })).toThrow(TypeError); }); }); + +describe("createId().many()", () => { + it("generates the requested number of IDs", () => { + const generator = createId(); + + const ids = generator.many("user", 5); + + expect(ids).toHaveLength(5); + expect(ids.every((value) => value.startsWith("user_"))).toBe(true); + }); + + it("generates distinct IDs", () => { + const generator = createId(); + + const ids = generator.many("user", 100); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it("returns an empty array for a count of zero", () => { + expect(createId().many("user", 0)).toEqual([]); + }); + + it("rejects invalid batch counts", () => { + const generator = createId(); + + expect(() => generator.many("user", -1)).toThrow(RangeError); + expect(() => generator.many("user", 1.5)).toThrow(RangeError); + expect(() => generator.many("user", 10_001)).toThrow(RangeError); + }); + + it("respects custom size and separator", () => { + const generator = createId({ size: 4, separator: ":" }); + + const ids = generator.many("user", 3); + + expect(ids).toHaveLength(3); + + for (const value of ids) { + expect(value).toMatch(/^user:[A-Za-z0-9]{4}$/); + } + }); +}); diff --git a/test/random.test.ts b/test/random.test.ts index ee562cb..29f7fa2 100644 --- a/test/random.test.ts +++ b/test/random.test.ts @@ -25,6 +25,26 @@ describe("secureRandomBytes()", () => { it("returns different bytes on each call", () => { expect(toHex(secureRandomBytes(16))).not.toBe(toHex(secureRandomBytes(16))); }); + + + it("handles requests larger than the Web Crypto limit", () => { + const requestedLengths: number[] = []; + + setBytesProvider((length) => { + requestedLengths.push(length); + return new Uint8Array(length).fill(1); + }); + + try { + const bytes = secureRandomBytes(70_000); + + expect(bytes).toHaveLength(70_000); + expect(requestedLengths).toEqual([65_536, 4_464]); + } finally { + setBytesProvider(universalProvider); + } + }); + }); describe("randomString()", () => { diff --git a/test/sortable.test.ts b/test/sortable.test.ts index f29c47d..8fb7aa3 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -199,6 +199,23 @@ describe("createSortableId() — configuration", () => { expect(getTimestamp(a)).toBe(getTimestamp(b)); expect(a).not.toBe(b); }); + + + it("generates strictly increasing IDs in a batch", () => { + const generator = createSortableId({ + monotonic: true, + now: () => 1_800_000_000_000, + }); + + const ids = generator.many("evt", 100); + + expect(ids).toHaveLength(100); + + for (let i = 1; i < ids.length; i++) { + expect(ids[i] > ids[i - 1]).toBe(true); + } + }); + }); describe("BASE32_CROCKFORD preset", () => {