Skip to content
Open
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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `createSortableId()` now throws a `RangeError` when the timestamp exceeds `SORTABLE_TIME_MAX` (2^48 - 1 ms), preventing generation of IDs that its own decoder (`getTimestamp()`) rejects. State updates are deferred until candidate validation succeeds, preserving monotonic ordering across bad clock readings and continuing to reject out-of-range timestamps.

## [1.2.0] - 2026-10-11

Opt-in additions only. Existing calls behave as in 1.1.2: for the same random
Expand Down
29 changes: 21 additions & 8 deletions src/generators/sortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,22 +146,35 @@ export function createSortableId<S extends string = "_">(
throw new RangeError("prefid: `now` must return a non-negative number.");
}

let nextTime: number;
let nextRandom: number[];

if (monotonic && time <= lastTime) {
time = lastTime;
const next = incrementIndices(lastRandom, radix);
if (next) {
lastRandom = next;
nextTime = lastTime;
nextRandom = next;
} else {
time = lastTime + 1;
lastRandom = randomIndices(radix, randomSize);
nextTime = lastTime + 1;
nextRandom = randomIndices(radix, randomSize);
}
lastTime = time;
} else {
lastTime = time;
lastRandom = randomIndices(radix, randomSize);
nextTime = time;
nextRandom = randomIndices(radix, randomSize);
}

let body = encodeTime(time, alphabet, timestampSize);
if (nextTime > SORTABLE_TIME_MAX) {
throw new RangeError(
`prefid: timestamp ${nextTime} exceeds the sortable range (2^48-1 ms).`,
);
}

const timeBody = encodeTime(nextTime, alphabet, timestampSize);

lastTime = nextTime;
lastRandom = nextRandom;

let body = timeBody;
for (let i = 0; i < randomSize; i++) body += alphabet[lastRandom[i]];

return `${prefix}${separator}${body}` as PrefixedId<P, S>;
Expand Down
100 changes: 96 additions & 4 deletions test/sortable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,45 @@ describe("sortableId() — monotonic overflow", () => {
const last = getTimestamp(ids[ids.length - 1], { alphabet: "01" });
expect(last!).toBeGreaterThan(100);
});

it("keeps throwing after spilling past 2^48 - 1", () => {
let now = SORTABLE_TIME_MAX;
const gen = createSortableId({
now: () => now,
alphabet: "01",
randomSize: 1,
});

expect(() => {
for (let i = 0; i < 3; i++) gen("evt");
}).toThrow(RangeError);

now = Date.now();
expect(() => gen("evt")).toThrow(RangeError);

const fresh = createSortableId({
now: () => now,
alphabet: "01",
randomSize: 1,
});
expect(() => fresh("evt")).not.toThrow();
});

it("keeps ids in order after a bad clock reading", () => {
let now = 2_000;
const gen = createSortableId({ now: () => now });

const a = gen("evt");
now = 1_000;
const b = gen("evt");
now = 2 ** 48 + 5;
expect(() => gen("evt")).toThrow(RangeError);
now = 1_000;
const c = gen("evt");

expect(b > a).toBe(true);
expect(c > b).toBe(true);
});
});

describe("getTimestamp()", () => {
Expand Down Expand Up @@ -305,6 +344,62 @@ describe("createSortableId() — validation", () => {
const gen = createSortableId({ now: () => -1 });
expect(() => gen("u")).toThrow(RangeError);
});

it("rejects timestamps exceeding SORTABLE_TIME_MAX", () => {
const defaultGen = createSortableId({ now: () => 2 ** 48 });
expect(() => defaultGen("evt")).toThrow(RangeError);
expect(() => defaultGen("evt")).toThrow(
`prefid: timestamp ${2 ** 48} exceeds the sortable range (2^48-1 ms).`,
);

const wideGen = createSortableId({
timestampSize: 12,
now: () => 2 ** 48,
});
expect(() => wideGen("evt")).toThrow(RangeError);

const wideGen50 = createSortableId({
timestampSize: 12,
now: () => 2 ** 50,
});
expect(() => wideGen50("evt")).toThrow(RangeError);

const crockfordGen = createSortableId({
alphabet: BASE32_CROCKFORD,
now: () => 2 ** 48,
});
expect(() => crockfordGen("evt")).toThrow(RangeError);

const microGen = createSortableId({
now: () => 1_760_000_000_000_000,
});
expect(() => microGen("evt")).toThrow(RangeError);
});

it("accepts a timestamp at exactly SORTABLE_TIME_MAX", () => {
const gen = createSortableId({ now: () => SORTABLE_TIME_MAX });
const id = gen("evt");
expect(getTimestamp(id)).toBe(SORTABLE_TIME_MAX);
expect(getDate(id)?.getTime()).toBe(SORTABLE_TIME_MAX);
});

it("does not poison generator state after an out-of-range clock reading", () => {
let first = true;
const g1 = createSortableId({
now: () => (first ? ((first = false), 62 ** 9) : 1_700_000_000_000),
});
expect(() => g1("evt")).toThrow(RangeError);
const id1 = g1("evt");
expect(getTimestamp(id1)).toBe(1_700_000_000_000);

first = true;
const g2 = createSortableId({
now: () => (first ? ((first = false), 2 ** 48 + 5) : 1_700_000_000_000),
});
expect(() => g2("evt")).toThrow(RangeError);
const id2 = g2("evt");
expect(getTimestamp(id2)).toBe(1_700_000_000_000);
});
});

describe("createSortableId() — custom separator type", () => {
Expand Down Expand Up @@ -369,10 +464,7 @@ describe("getDate()", () => {
});

it("returns undefined for a timestamp outside the sortable time range", () => {
const at = (t: number) =>
createSortableId({ now: () => t, monotonic: false })("evt");

expect(getDate(at(MAX_DATE_MS))).toBeUndefined();
expect(getDate("evt_1HvWXNAa8rJNAzRwphhk3h53w")).toBeUndefined();
});
});

Expand Down
Loading