Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>` type. `IdGenerator` is unchanged, so existing code that assigns a plain function to it keeps compiling.

## [1.1.2] - 2026-10-09

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
32 changes: 26 additions & 6 deletions src/generators/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S extends string = "_">(
defaults: IdOptions & { separator?: S } = {},
): IdGenerator<S> {
): BatchIdGenerator<S> {
const size = defaults.size ?? DEFAULT_SIZE;
const separator = defaults.separator ?? DEFAULT_SEPARATOR;
const alphabet = defaults.alphabet ?? DEFAULT_ALPHABET;
Expand All @@ -30,13 +36,27 @@ 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> {
function id<P extends string>(prefix: P): PrefixedId<P, S> {
assertValidPrefix(prefix, separator, strictPrefix);
return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId<
P,
S
>;
};
}

function many<P extends string>(
prefix: P,
count: number,
): PrefixedId<P, S>[] {
assertValidPrefix(prefix, separator, strictPrefix);
assertValidCount(count, MAX_BATCH);
const head = `${prefix}${separator}`;
return randomStrings(alphabet, size, count).map(
(body) => `${head}${body}` as PrefixedId<P, S>,
);
}

return Object.assign(id, { many });
}

export const id: IdGenerator = createId();
export const id: BatchIdGenerator = createId();
25 changes: 20 additions & 5 deletions src/generators/sortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,7 +105,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 @@ -132,7 +134,7 @@ export function createSortableId<S extends string = "_">(
let lastTime = -1;
let lastRandom: number[] = [];

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

const reading = clock();
Expand Down Expand Up @@ -163,10 +165,23 @@ export function createSortableId<S extends string = "_">(
for (let i = 0; i < randomSize; i++) body += alphabet[lastRandom[i]];

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

function many<P extends string>(
prefix: P,
count: number,
): PrefixedId<P, S>[] {
assertValidPrefix(prefix, separator, strictPrefix);
assertValidCount(count, MAX_BATCH);
const out: PrefixedId<P, S>[] = 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,
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/internal/count.ts
Original file line number Diff line number Diff line change
@@ -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}.`,
);
}
}
42 changes: 42 additions & 0 deletions src/internal/random.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,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>[];
};
157 changes: 157 additions & 0 deletions test/many.test.ts
Original file line number Diff line number Diff line change
@@ -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<P, S>[]", () => {
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<typeof prefix>;
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);
}
});
});
Loading