From b872e58ec5e87039c8c97eec417c88a204f6b22a Mon Sep 17 00:00:00 2001 From: SANJU Date: Sat, 10 Oct 2026 20:35:41 +0530 Subject: [PATCH 1/5] feat: add batch generation for standard IDs --- src/constants/index.ts | 4 ++++ src/generators/generate.ts | 45 ++++++++++++++++++++++++++++++++++---- src/index.ts | 2 +- src/types/index.ts | 4 ++++ test/generate.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 5 deletions(-) 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/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/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}$/); + } + }); +}); From 71caf8b9e7bf8fecd4a149d7aaaf86b1f126749e Mon Sep 17 00:00:00 2001 From: SANJU Date: Sat, 10 Oct 2026 20:35:55 +0530 Subject: [PATCH 2/5] perf: add batch generation benchmark --- scripts/benchmark.mjs | 80 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 scripts/benchmark.mjs 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)}%`, +); From 860d2d2667096c28d64f9892107b8847fc7c4b62 Mon Sep 17 00:00:00 2001 From: SANJU Date: Sat, 10 Oct 2026 20:36:06 +0530 Subject: [PATCH 3/5] feat: add batch generation for sortable IDs --- src/generators/sortable.ts | 47 +++++++++++++++++++++++++++++++------- test/sortable.test.ts | 17 ++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) 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/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", () => { From fa9e2e64ea8d7a7629a066209655b2d71b100d47 Mon Sep 17 00:00:00 2001 From: SANJU Date: Sat, 10 Oct 2026 20:36:12 +0530 Subject: [PATCH 4/5] fix(random): handle large byte requests safely --- src/internal/random.ts | 49 ++++++++++++++++++++++++++++++++++-------- test/random.test.ts | 20 +++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) 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/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()", () => { From e87ae26c67cf2001fecd4d38cb02a7bacfef86e6 Mon Sep 17 00:00:00 2001 From: SANJU Date: Sat, 10 Oct 2026 20:39:06 +0530 Subject: [PATCH 5/5] docs: document batch generation --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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