diff --git a/CHANGELOG.md b/CHANGELOG.md index 89464c4..3453ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 70eae47..5c7def5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/generators/generate.ts b/src/generators/generate.ts index 8d3823c..d181b9d 100644 --- a/src/generators/generate.ts +++ b/src/generators/generate.ts @@ -14,6 +14,7 @@ export function createId( 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( @@ -30,7 +31,7 @@ export function createId( } return function id

(prefix: P): PrefixedId { - assertValidPrefix(prefix, separator); + assertValidPrefix(prefix, separator, strictPrefix); return `${prefix}${separator}${randomString(alphabet, size)}` as PrefixedId< P, S diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts index eb119de..bb516ea 100644 --- a/src/generators/sortable.ts +++ b/src/generators/sortable.ts @@ -16,6 +16,7 @@ export interface SortableIdOptions { randomSize?: number; timestampSize?: number; monotonic?: boolean; + strictPrefix?: boolean; now?: () => number; } @@ -106,6 +107,7 @@ export function createSortableId( 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; @@ -131,7 +133,7 @@ export function createSortableId( let lastRandom: number[] = []; return function sortableId

(prefix: P): PrefixedId { - assertValidPrefix(prefix, separator); + assertValidPrefix(prefix, separator, strictPrefix); const reading = clock(); if (typeof reading !== "number" || !Number.isFinite(reading)) { diff --git a/src/internal/prefix.ts b/src/internal/prefix.ts index 6c49c8b..7332b5e 100644 --- a/src/internal/prefix.ts +++ b/src/internal/prefix.ts @@ -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."); } @@ -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}.`, + ); + } + } } diff --git a/src/types/index.ts b/src/types/index.ts index d3ac1b7..747089b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ export interface IdOptions { size?: number; separator?: string; alphabet?: string; + strictPrefix?: boolean; } export type IdGenerator =

( diff --git a/test/generate.test.ts b/test/generate.test.ts index c83f306..4e64ccb 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -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); + }); +}); diff --git a/test/sortable.test.ts b/test/sortable.test.ts index f29c47d..e168310 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -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); + }); +});