From be3f7bf6939e044b1fcf0afcc6958e39dff97f17 Mon Sep 17 00:00:00 2001 From: Miguel Date: Sun, 11 Oct 2026 12:30:03 -0300 Subject: [PATCH] feat: add many() for batch ID generation id, sortableId and the generators from createId/createSortableId gain many(prefix, count). Random bytes are drawn in chunks of at most 65,536 bytes (the getRandomValues quota), count is capped at 10,000, and the existing rejection sampling is reused. BatchIdGenerator is a new type, so IdGenerator stays unchanged. --- CHANGELOG.md | 4 +- README.md | 15 ++++ src/constants/index.ts | 2 + src/generators/generate.ts | 32 ++++++-- src/generators/sortable.ts | 25 ++++-- src/index.ts | 7 +- src/internal/count.ts | 7 ++ src/internal/random.ts | 42 ++++++++++ src/types/index.ts | 4 + test/many.test.ts | 157 +++++++++++++++++++++++++++++++++++++ 10 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 src/internal/count.ts create mode 100644 test/many.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3453ca8..d13a5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `strictPrefix` option on `createId()` and `createSortableId()`. When `true`, the prefix must be 1-63 ASCII letters or digits, otherwise a `TypeError` names the first offending character. Off by default, so existing behavior is unchanged. - `entropyBits(options?)`, `sizeForEntropy(bits, alphabet?)` and `collisionProbability(count, options?)` compute the entropy and birthday-collision probability of an ID configuration. `collisionProbability` works in log space with `Math.expm1`, so realistic inputs do not round to 0. +- `strictPrefix` option on `createId()` and `createSortableId()`. When `true`, the prefix must be 1-63 ASCII letters or digits, otherwise a `TypeError` names the first offending character. Off by default, so existing behavior is unchanged. +- `many(prefix, count)` on `id`, `sortableId` and the generators returned by `createId()` and `createSortableId()`. It returns `count` IDs (0 to 10,000) and draws random bytes in chunks of at most 65,536 bytes, so it works in browsers, Deno and Bun. Sortable batches stay strictly increasing (with the default `monotonic: true`). It checks the prefix exactly like a single call, including `strictPrefix` and even when `count` is 0. +- `BatchIdGenerator` type. `IdGenerator` is unchanged, so existing code that assigns a plain function to it keeps compiling. ## [1.1.2] - 2026-10-09 diff --git a/README.md b/README.md index 5c7def5..af7d79f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,21 @@ id("user"); // => "user_a8Kd0f2bQ1nR7pZ3xW4mT6y" id("us er"); // throws TypeError: prefid: prefix "us er" contains an invalid character " " at index 2. ``` +## Bulk generation + +Need many IDs at once (seeding, bulk inserts)? `many` draws the random bytes in +a few large chunks instead of once per ID: + +```ts +import { id, sortableId } from "prefid"; + +id.many("user", 1000); // => ["user_…", …] (1000 IDs) +sortableId.many("evt", 1000); // still strictly increasing +``` + +`count` is an integer from 0 to 10,000. For sortable IDs there is no speed-up +over calling `sortableId` in a loop; `many` is there for the same API. + ## Install ```bash diff --git a/src/constants/index.ts b/src/constants/index.ts index 06b9cb4..543c63f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -14,3 +14,5 @@ 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_BATCH = 10_000; diff --git a/src/generators/generate.ts b/src/generators/generate.ts index d181b9d..8a033e9 100644 --- a/src/generators/generate.ts +++ b/src/generators/generate.ts @@ -2,15 +2,21 @@ import { DEFAULT_ALPHABET, DEFAULT_SEPARATOR, DEFAULT_SIZE, + MAX_BATCH, MAX_SIZE, } from "../constants/index.js"; +import { assertValidCount } from "../internal/count.js"; import { assertValidPrefix } from "../internal/prefix.js"; -import { randomString } from "../internal/random.js"; -import type { IdGenerator, IdOptions, PrefixedId } from "../types/index.js"; +import { randomString, randomStrings } from "../internal/random.js"; +import type { + BatchIdGenerator, + IdOptions, + PrefixedId, +} from "../types/index.js"; export function createId( defaults: IdOptions & { separator?: S } = {}, -): IdGenerator { +): BatchIdGenerator { const size = defaults.size ?? DEFAULT_SIZE; const separator = defaults.separator ?? DEFAULT_SEPARATOR; const alphabet = defaults.alphabet ?? DEFAULT_ALPHABET; @@ -30,13 +36,27 @@ export function createId( throw new TypeError("prefid: `separator` must be a non-empty string."); } - return function id

(prefix: P): PrefixedId { + function id

(prefix: P): PrefixedId { assertValidPrefix(prefix, separator, strictPrefix); return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId< P, S >; - }; + } + + function many

( + prefix: P, + count: number, + ): PrefixedId[] { + assertValidPrefix(prefix, separator, strictPrefix); + assertValidCount(count, MAX_BATCH); + const head = `${prefix}${separator}`; + return randomStrings(alphabet, size, count).map( + (body) => `${head}${body}` as PrefixedId, + ); + } + + return Object.assign(id, { many }); } -export const id: IdGenerator = createId(); +export const id: BatchIdGenerator = createId(); diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts index bb516ea..8a3170d 100644 --- a/src/generators/sortable.ts +++ b/src/generators/sortable.ts @@ -2,13 +2,15 @@ import { DEFAULT_ALPHABET, DEFAULT_SEPARATOR, DEFAULT_SORTABLE_RANDOM_SIZE, + MAX_BATCH, MAX_DATE_MS, MAX_SIZE, SORTABLE_TIME_MAX, } from "../constants/index.js"; +import { assertValidCount } from "../internal/count.js"; import { assertValidPrefix } from "../internal/prefix.js"; import { randomIndices } from "../internal/random.js"; -import type { IdGenerator, PrefixedId } from "../types/index.js"; +import type { BatchIdGenerator, PrefixedId } from "../types/index.js"; export interface SortableIdOptions { separator?: string; @@ -103,7 +105,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; @@ -132,7 +134,7 @@ export function createSortableId( let lastTime = -1; let lastRandom: number[] = []; - return function sortableId

(prefix: P): PrefixedId { + function sortableId

(prefix: P): PrefixedId { assertValidPrefix(prefix, separator, strictPrefix); const reading = clock(); @@ -163,10 +165,23 @@ export function createSortableId( for (let i = 0; i < randomSize; i++) body += alphabet[lastRandom[i]]; return `${prefix}${separator}${body}` as PrefixedId; - }; + } + + function many

( + prefix: P, + count: number, + ): PrefixedId[] { + assertValidPrefix(prefix, separator, strictPrefix); + assertValidCount(count, MAX_BATCH); + const out: PrefixedId[] = new Array(count); + for (let i = 0; i < count; i++) out[i] = sortableId(prefix); + return out; + } + + return Object.assign(sortableId, { many }); } -export const sortableId: IdGenerator = createSortableId(); +export const sortableId: BatchIdGenerator = createSortableId(); export function getTimestamp( value: string, diff --git a/src/index.ts b/src/index.ts index 208cd13..a45a170 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,12 @@ export { } from "./utils/entropy.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/count.ts b/src/internal/count.ts new file mode 100644 index 0000000..6d30a7d --- /dev/null +++ b/src/internal/count.ts @@ -0,0 +1,7 @@ +export function assertValidCount(count: number, max: number): void { + if (!Number.isInteger(count) || count < 0 || count > max) { + throw new RangeError( + `prefid: \`count\` must be an integer between 0 and ${max}.`, + ); + } +} diff --git a/src/internal/random.ts b/src/internal/random.ts index 0244638..66fedcf 100644 --- a/src/internal/random.ts +++ b/src/internal/random.ts @@ -70,3 +70,45 @@ export function randomString(alphabet: string, size: number): string { for (let i = 0; i < size; i++) result += alphabet[indices[i]]; return result; } + +const MAX_BYTES_PER_CALL = 65_536; + +export function randomIndexBatch( + radix: number, + size: number, + count: number, +): number[] { + const total = size * count; + const mask = (2 << Math.floor(Math.log2(radix - 1))) - 1; + const out: number[] = []; + + while (out.length < total) { + const missing = total - out.length; + const step = Math.min( + MAX_BYTES_PER_CALL, + Math.max(1, Math.ceil((1.6 * mask * missing) / radix)), + ); + const bytes = secureRandomBytes(step); + for (let i = 0; i < step && out.length < total; i++) { + const index = bytes[i] & mask; + if (index < radix) out.push(index); + } + } + return out; +} + +export function randomStrings( + alphabet: string, + size: number, + count: number, +): string[] { + const indices = randomIndexBatch(alphabet.length, size, count); + const out: string[] = new Array(count); + let cursor = 0; + for (let n = 0; n < count; n++) { + let result = ""; + for (let i = 0; i < size; i++) result += alphabet[indices[cursor++]]; + out[n] = result; + } + return out; +} diff --git a/src/types/index.ts b/src/types/index.ts index 747089b..ee209c3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,3 +13,7 @@ export interface IdOptions { export type IdGenerator =

( prefix: P, ) => PrefixedId; + +export type BatchIdGenerator = IdGenerator & { + many:

(prefix: P, count: number) => PrefixedId[]; +}; diff --git a/test/many.test.ts b/test/many.test.ts new file mode 100644 index 0000000..b815dd4 --- /dev/null +++ b/test/many.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MAX_BATCH } from "../src/constants/index.js"; +import { + createId, + createSortableId, + id, + sortableId, + type IdGenerator, + type PrefixedId, +} from "../src/index.js"; +import { setBytesProvider, universalProvider } from "../src/internal/random.js"; + +afterEach(() => setBytesProvider(universalProvider)); + +describe("id.many()", () => { + it("returns distinct, prefixed ids of the right length", () => { + const ids = id.many("user", 1000); + expect(ids).toHaveLength(1000); + expect(new Set(ids).size).toBe(1000); + for (const value of ids) { + expect(value.startsWith("user_")).toBe(true); + expect(value.length).toBe("user_".length + 24); + } + }); + + it("is typed as PrefixedId[]", () => { + const ids: `user_${string}`[] = id.many("user", 2); + const custom: `evt-${string}`[] = createId({ separator: "-" }).many( + "evt", + 2, + ); + expect(ids).toHaveLength(2); + expect(custom[0].startsWith("evt-")).toBe(true); + }); + + it("honours size, alphabet and separator", () => { + const gen = createId({ size: 5, alphabet: "ab", separator: ":" }); + for (const value of gen.many("k", 50)) expect(value).toMatch(/^k:[ab]{5}$/); + }); + + it("returns an empty array for a count of 0", () => { + expect(id.many("user", 0)).toEqual([]); + }); + + it("accepts the maximum count", () => { + expect(id.many("user", MAX_BATCH)).toHaveLength(MAX_BATCH); + }); + + it("rejects an invalid count", () => { + for (const bad of [-1, 1.5, Number.NaN, Infinity, MAX_BATCH + 1]) { + const call = () => id.many("user", bad); + expect(call).toThrow(RangeError); + expect(call).toThrow(/`count`/); + } + }); + + it("validates the prefix like the single generator", () => { + expect(() => id.many("", 1)).toThrow(TypeError); + expect(() => id.many("us_er", 1)).toThrow(TypeError); + }); + + it("skips out-of-range bytes instead of wrapping them", () => { + setBytesProvider((length) => + Uint8Array.from({ length }, (_, i) => (i % 2 === 0 ? 3 : 1)), + ); + expect(createId({ alphabet: "abc", size: 4 }).many("k", 2)).toEqual([ + "k_bbbb", + "k_bbbb", + ]); + }); + + it("gives each id its own slice of the random bytes", () => { + let next = 0; + setBytesProvider((length) => + Uint8Array.from({ length }, () => next++ & 0xff), + ); + expect(createId({ size: 4, alphabet: "abcdefgh" }).many("u", 2)).toEqual([ + "u_abcd", + "u_efgh", + ]); + }); + + it("never asks the random source for more than 65,536 bytes at once", () => { + const sizes: number[] = []; + setBytesProvider((length) => { + sizes.push(length); + return universalProvider(length); + }); + expect(id.many("user", MAX_BATCH)).toHaveLength(MAX_BATCH); + expect(sizes.length).toBeGreaterThan(1); + expect(Math.max(...sizes)).toBeLessThanOrEqual(65_536); + }); + + it("draws far fewer random calls than one per id", () => { + let calls = 0; + setBytesProvider((length) => { + calls++; + return universalProvider(length); + }); + id.many("user", 1000); + expect(calls).toBeLessThan(1000 / 10); + }); +}); + +describe("sortableId.many()", () => { + it("returns strictly increasing ids", () => { + const ids = sortableId.many("evt", 2000); + const sorted = [...ids].sort(); + expect(ids).toEqual(sorted); + expect(new Set(ids).size).toBe(2000); + }); + + it("stays strictly increasing across a monotonic overflow", () => { + const gen = createSortableId({ + alphabet: "01", + randomSize: 2, + timestampSize: 4, + now: () => 5, + }); + const ids = gen.many("e", 10); + expect(new Set(ids).size).toBe(10); + expect(ids).toEqual([...ids].sort()); + }); + + it("validates count and prefix", () => { + for (const bad of [-1, 1.5, MAX_BATCH + 1]) { + expect(() => sortableId.many("evt", bad)).toThrow(/`count`/); + } + expect(() => sortableId.many("", 0)).toThrow(TypeError); + }); +}); + +describe("IdGenerator compatibility", () => { + it("still accepts a plain function", () => { + const gen: IdGenerator = (prefix) => + `${prefix}_x` as PrefixedId; + expect(gen("a")).toBe("a_x"); + const asBase: IdGenerator = id; + expect(asBase("a").startsWith("a_")).toBe(true); + }); +}); + +describe("many() with strictPrefix", () => { + it("rejects an invalid prefix like the single generator", () => { + for (const gen of [ + createId({ strictPrefix: true }), + createSortableId({ strictPrefix: true }), + ]) { + expect(() => gen.many("bad id", 3)).toThrow( + /invalid character " " at index 3/, + ); + expect(() => gen.many("bad id", 0)).toThrow(TypeError); + expect(() => gen.many("a".repeat(64), 1)).toThrow(TypeError); + expect(gen.many("ok", 3)).toHaveLength(3); + } + }); +});