From 210e9161bc490f161fdec3fdf40ffcc1598fd0bf Mon Sep 17 00:00:00 2001 From: jdymitarai Date: Tue, 13 Oct 2026 14:01:22 +0900 Subject: [PATCH 1/2] fix: validate sortable timestamp range and avoid state poisoning Validate candidate timestamps against SORTABLE_TIME_MAX and defer generator state updates until validation passes, preventing out-of-range IDs and generator poisoning from transient clock glitches. Fixes #55 Signed-off-by: jdymitarai --- CHANGELOG.md | 4 ++ src/generators/sortable.ts | 38 +++++++++++++---- test/sortable.test.ts | 83 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 410cd40..8640f5c 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, preventing clock glitches or monotonic spill-overs from permanently poisoning the generator. + ## [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..914a6b3 100644 --- a/src/generators/sortable.ts +++ b/src/generators/sortable.ts @@ -146,22 +146,44 @@ 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); + } + + if (nextTime > SORTABLE_TIME_MAX) { + lastTime = -1; + lastRandom = []; + throw new RangeError( + `prefid: timestamp ${nextTime} exceeds the sortable range (2^48-1 ms).`, + ); + } + + let timeBody: string; + try { + timeBody = encodeTime(nextTime, alphabet, timestampSize); + } catch (err) { + lastTime = -1; + lastRandom = []; + throw err; } - let body = encodeTime(time, 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..9512454 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -132,6 +132,28 @@ describe("sortableId() — monotonic overflow", () => { const last = getTimestamp(ids[ids.length - 1], { alphabet: "01" }); expect(last!).toBeGreaterThan(100); }); + + it("throws when monotonic spill-over exceeds 2^48 - 1 and recovers on subsequent calls", () => { + let now = SORTABLE_TIME_MAX; + const { gen, set } = withClock(now, { alphabet: "01", randomSize: 1 }); + let error: unknown; + for (let i = 0; i < 3; i++) { + try { + gen("evt"); + } catch (err) { + error = err; + break; + } + } + expect(error).toBeInstanceOf(RangeError); + expect((error as RangeError).message).toBe( + `prefid: timestamp ${SORTABLE_TIME_MAX + 1} exceeds the sortable range (2^48-1 ms).`, + ); + + set(1_700_000_000_000); + const recovered = gen("evt"); + expect(getTimestamp(recovered, { alphabet: "01" })).toBe(1_700_000_000_000); + }); }); describe("getTimestamp()", () => { @@ -305,6 +327,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 +447,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(); }); }); From aee09a0f3624f9e70e42adca527c32314632c15b Mon Sep 17 00:00:00 2001 From: jdymitarai Date: Tue, 13 Oct 2026 19:33:38 +0900 Subject: [PATCH 2/2] fix: preserve generator state across out-of-range timestamps and simplify encodeTime --- CHANGELOG.md | 2 +- src/generators/sortable.ts | 11 +------- test/sortable.test.ts | 53 +++++++++++++++++++++++++------------- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8640f5c..44006a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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, preventing clock glitches or monotonic spill-overs from permanently poisoning the generator. +- `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 diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts index 914a6b3..51bdf7d 100644 --- a/src/generators/sortable.ts +++ b/src/generators/sortable.ts @@ -164,21 +164,12 @@ export function createSortableId( } if (nextTime > SORTABLE_TIME_MAX) { - lastTime = -1; - lastRandom = []; throw new RangeError( `prefid: timestamp ${nextTime} exceeds the sortable range (2^48-1 ms).`, ); } - let timeBody: string; - try { - timeBody = encodeTime(nextTime, alphabet, timestampSize); - } catch (err) { - lastTime = -1; - lastRandom = []; - throw err; - } + const timeBody = encodeTime(nextTime, alphabet, timestampSize); lastTime = nextTime; lastRandom = nextRandom; diff --git a/test/sortable.test.ts b/test/sortable.test.ts index 9512454..de3892b 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -133,26 +133,43 @@ describe("sortableId() — monotonic overflow", () => { expect(last!).toBeGreaterThan(100); }); - it("throws when monotonic spill-over exceeds 2^48 - 1 and recovers on subsequent calls", () => { + it("keeps throwing after spilling past 2^48 - 1", () => { let now = SORTABLE_TIME_MAX; - const { gen, set } = withClock(now, { alphabet: "01", randomSize: 1 }); - let error: unknown; - for (let i = 0; i < 3; i++) { - try { - gen("evt"); - } catch (err) { - error = err; - break; - } - } - expect(error).toBeInstanceOf(RangeError); - expect((error as RangeError).message).toBe( - `prefid: timestamp ${SORTABLE_TIME_MAX + 1} exceeds the sortable range (2^48-1 ms).`, - ); + const gen = createSortableId({ + now: () => now, + alphabet: "01", + randomSize: 1, + }); + + expect(() => { + for (let i = 0; i < 3; i++) gen("evt"); + }).toThrow(RangeError); - set(1_700_000_000_000); - const recovered = gen("evt"); - expect(getTimestamp(recovered, { alphabet: "01" })).toBe(1_700_000_000_000); + 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); }); });