diff --git a/CHANGELOG.md b/CHANGELOG.md
index 410cd40..44006a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts
index 8a3170d..51bdf7d 100644
--- a/src/generators/sortable.ts
+++ b/src/generators/sortable.ts
@@ -146,22 +146,35 @@ export function createSortableId(
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
; diff --git a/test/sortable.test.ts b/test/sortable.test.ts index e168310..de3892b 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -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()", () => { @@ -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", () => { @@ -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(); }); });