Skip to content

[bug] : Sortable generator makes timestamps its own decoder rejects, and one bad clock reading breaks the generator for good #55

Open

Description

@suhailopensource

What's wrong

There are two linked problems in createSortableId.

1. It makes timestamps its own decoder rejects.
Since #29, getTimestamp refuses decoded values above 2^48−1 ms (the year 10889). The generator only checks that the time fits the character width, and 9 base62 characters can hold far more than 2^48.

const s = createSortableId({ now: () => 2 ** 48 })("evt");
// "evt_1HvWXNAa8rJNAzRwphhk3h53w", with no error
getTimestamp(s);   // undefined

const us = createSortableId({ now: () => 1760000000000000 })("evt"); // a microsecond clock
getDate(us);              // undefined
getTimestampOrThrow(us);  // TypeError: prefid: "evt_…" is not a well-formed sortable id.

The same happens with timestampSize: 12 plus now: () => 2 ** 50, and with BASE32_CROCKFORD at 2^48 ("e_8000000000…"). A monotonic spill-over at exactly 2^48−1 also makes unreadable IDs. The value 2^48−1 itself round-trips.

2. One bad clock reading breaks the generator for good.
In src/generators/sortable.ts, lastTime and lastRandom are saved before encodeTime checks the value. After one out-of-range reading, every later call reuses the bad time.

let first = true;
const g = createSortableId({ now: () => (first ? ((first = false), 62 ** 9) : Date.now()) });
for (let i = 0; i < 4; i++) { try { g("e"); } catch (e) { console.log(e.message); } }
// 4 x: prefid: timestamp 13537086546263552 does not fit in 9 "0".."z" characters.

The clock is back to Date.now(), but every call still throws. If the first reading is 2 ** 48 + 5 instead, nothing throws, but every later ID is pinned above 2^48 and decodes to undefined:

e_1HvWXNAaDDAoZvLOKweCFDoAh undefined
e_1HvWXNAaDDAoZvLOKweCFDoAi undefined

Why it matters

now is documented for custom clocks and epochs. Passing microseconds by mistake is plausible, and then every ID is "not well-formed" with no error when it's created. One clock glitch shouldn't poison a long-running process.

What to do

In the generator, compute the candidate time and random indices first. Check them (time <= SORTABLE_TIME_MAX, and the value fits the width). Only then save lastTime/lastRandom. Throw:

RangeError: prefid: timestamp X exceeds the sortable range (2^48-1 ms).

Important: if you add the throw without moving the state update, fixing problem 1 triggers problem 2, so fix both together. Raising the decoder's ceiling instead would undo #29.

Acceptance criteria

  • now: () => 2 ** 48 throws a RangeError with default options, with timestampSize: 12 and with BASE32_CROCKFORD.
  • 2^48−1 still works.
  • A spill-over past 2^48−1 throws, and the next call with a normal clock works.
  • Both poisoning repros above: after one bad reading, the next normal reading gives a correct, decodable ID.
  • A CHANGELOG "Fixed" entry.

Files: src/generators/sortable.ts, test/sortable.test.ts, CHANGELOG.md

Activity

  1. suhailopensource commented on Oct 13, 2026

    @suhailopensource
    OwnerAuthor

    Correction to the acceptance criteria: after a spill-over past 2^48-1, the generator should keep throwing, not recover — no valid ID can sort after the ones it already issued. On an error it should simply not save the bad value, without resetting its state. Details in #57.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions