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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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.

## [1.1.2] - 2026-10-09
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ const eventId = createSortableId({ alphabet: BASE32_CROCKFORD });
eventId("evt"); // => "evt_00VQ5A1K0MBJGJFX6PWYY6WKY" — no 0/O or 1/l confusion
```

## Strict prefixes

By default any non-empty prefix without the separator is accepted. Pass
`strictPrefix: true` to `createId` or `createSortableId` to require 1-63 ASCII
letters or digits, so a stray space or typo can't end up in your database:

```ts
import { createId } from "prefid";

const id = createId({ strictPrefix: true });
id("user"); // => "user_a8Kd0f2bQ1nR7pZ3xW4mT6y"
id("us er"); // throws TypeError: prefid: prefix "us er" contains an invalid character " " at index 2.
```

## Install

```bash
Expand Down
3 changes: 2 additions & 1 deletion src/generators/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function createId<S extends string = "_">(
const size = defaults.size ?? DEFAULT_SIZE;
const separator = defaults.separator ?? DEFAULT_SEPARATOR;
const alphabet = defaults.alphabet ?? DEFAULT_ALPHABET;
const strictPrefix = defaults.strictPrefix ?? false;

if (!Number.isInteger(size) || size < 1 || size > MAX_SIZE) {
throw new RangeError(
Expand All @@ -30,7 +31,7 @@ export function createId<S extends string = "_">(
}

return function id<P extends string>(prefix: P): PrefixedId<P, S> {
assertValidPrefix(prefix, separator);
assertValidPrefix(prefix, separator, strictPrefix);
return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId<
P,
S
Expand Down
4 changes: 3 additions & 1 deletion src/generators/sortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface SortableIdOptions {
randomSize?: number;
timestampSize?: number;
monotonic?: boolean;
strictPrefix?: boolean;
now?: () => number;
}

Expand Down Expand Up @@ -106,6 +107,7 @@ export function createSortableId<S extends string = "_">(
const separator = options.separator ?? DEFAULT_SEPARATOR;
const alphabet = options.alphabet ?? DEFAULT_ALPHABET;
const monotonic = options.monotonic ?? true;
const strictPrefix = options.strictPrefix ?? false;
const clock = options.now ?? Date.now;
const randomSize = options.randomSize ?? DEFAULT_SORTABLE_RANDOM_SIZE;

Expand All @@ -131,7 +133,7 @@ export function createSortableId<S extends string = "_">(
let lastRandom: number[] = [];

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

const reading = clock();
if (typeof reading !== "number" || !Number.isFinite(reading)) {
Expand Down
25 changes: 24 additions & 1 deletion src/internal/prefix.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export function assertValidPrefix(prefix: string, separator: string): void {
const STRICT_MAX_LENGTH = 63;
const NON_ALPHANUMERIC = /[^A-Za-z0-9]/;

export function assertValidPrefix(
prefix: string,
separator: string,
strict: boolean,
): void {
if (typeof prefix !== "string" || prefix.length === 0) {
throw new TypeError("prefid: prefix must be a non-empty string.");
}
Expand All @@ -7,4 +14,20 @@ export function assertValidPrefix(prefix: string, separator: string): void {
`prefid: prefix "${prefix}" must not contain the separator "${separator}".`,
);
}
if (strict) {
const bad = NON_ALPHANUMERIC.exec(prefix);
if (bad) {
const char = String.fromCodePoint(
prefix.codePointAt(bad.index) as number,
);
throw new TypeError(
`prefid: prefix "${prefix}" contains an invalid character "${char}" at index ${bad.index}.`,
);
}
if (prefix.length > STRICT_MAX_LENGTH) {
throw new TypeError(
`prefid: prefix is ${prefix.length} characters long; strict mode allows at most ${STRICT_MAX_LENGTH}.`,
);
}
}
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface IdOptions {
size?: number;
separator?: string;
alphabet?: string;
strictPrefix?: boolean;
}

export type IdGenerator<S extends string = "_"> = <P extends string>(
Expand Down
38 changes: 38 additions & 0 deletions test/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,41 @@ describe("createId()", () => {
expect(() => createId({ separator: "" })).toThrow(TypeError);
});
});

describe("strictPrefix", () => {
const strict = createId({ strictPrefix: true });

it("accepts alphanumeric prefixes", () => {
expect(strict("user2").startsWith("user2_")).toBe(true);
});

it("rejects a space and names the character and index", () => {
expect(() => strict("us er")).toThrow(
'prefid: prefix "us er" contains an invalid character " " at index 2.',
);
});

it("rejects punctuation", () => {
expect(() => strict("us-er")).toThrow(TypeError);
});

it("rejects non-ASCII characters", () => {
expect(() => strict("usér")).toThrow(/invalid character "é" at index 2/);
});

it("names a whole emoji, not half of it", () => {
expect(() => strict("ab😀")).toThrow(/invalid character "😀" at index 2/);
});

it("allows 63 characters and rejects 64", () => {
expect(() => strict("a".repeat(63))).not.toThrow();
expect(() => strict("a".repeat(64))).toThrow(
"prefid: prefix is 64 characters long; strict mode allows at most 63.",
);
});

it("leaves default mode unchanged", () => {
expect(id("us er!ü").startsWith("us er!ü_")).toBe(true);
expect(createId()("a".repeat(64)).startsWith("a".repeat(64))).toBe(true);
});
});
17 changes: 17 additions & 0 deletions test/sortable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,20 @@ describe("getDate()", () => {
expect(getDate(at(MAX_DATE_MS))).toBeUndefined();
});
});

describe("createSortableId strictPrefix", () => {
const strict = createSortableId({ strictPrefix: true });

it("accepts alphanumeric prefixes", () => {
expect(strict("evt").startsWith("evt_")).toBe(true);
});

it("rejects invalid characters and over-long prefixes", () => {
expect(() => strict("e vt")).toThrow(/invalid character " " at index 1/);
expect(() => strict("a".repeat(64))).toThrow(TypeError);
});

it("leaves default mode unchanged", () => {
expect(createSortableId()("e vt").startsWith("e vt_")).toBe(true);
});
});
Loading