From c5f70ed1499cd3340de83f4b88dc04b90666befa Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Wed, 2 Sep 2026 21:52:58 -0700 Subject: [PATCH] fix(schema): widen the identifier time field so sortable ids stop wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both id generators pack Date.now() * 0x1000 + counter into a 6-byte time field. That value needs 53 bits and the field holds 48, so the top bits are dropped and the sortable prefix wraps every 2^36 ms, about 795 days. The last wrap was 2026-08-14T11:19:55Z; the next is 2028-10-17T20:04:31Z. Identifier.timestamp() decodes the truncated field, so it reports 1970-01-20 for an id minted today. Widening the field to 7 bytes holds the encoding until 2527. Ids stay 26 characters: the random suffix goes from 14 to 12 base62 characters, which is still 62^12 of entropy on top of a per-millisecond counter. Ids minted before this change still do not sort against ids minted after it. That is unchanged by this commit — the 2026-08-14 wrap already broke it — and is why the comparison sites use time ordering. --- packages/core/src/id/id.ts | 4 +-- packages/core/test/id.test.ts | 16 ++++++++++++ packages/opencode/src/id/id.ts | 14 +++++++---- packages/schema/src/identifier.ts | 13 +++++++--- packages/schema/test/identifier.test.ts | 33 +++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 packages/core/test/id.test.ts create mode 100644 packages/schema/test/identifier.test.ts diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts index be1efc446a6e..d638ca586f05 100644 --- a/packages/core/src/id/id.ts +++ b/packages/core/src/id/id.ts @@ -1,4 +1,4 @@ -import { create as createIdentifier } from "@opencode-ai/schema/identifier" +import { create as createIdentifier, timeChars } from "@opencode-ai/schema/identifier" const prefixes = { job: "job", @@ -39,7 +39,7 @@ export function create(prefix: string, direction: "descending" | "ascending", ti /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ export function timestamp(id: string): number { const prefix = id.split("_")[0] - const hex = id.slice(prefix.length + 1, prefix.length + 13) + const hex = id.slice(prefix.length + 1, prefix.length + 1 + timeChars) const encoded = BigInt("0x" + hex) return Number(encoded / BigInt(0x1000)) } diff --git a/packages/core/test/id.test.ts b/packages/core/test/id.test.ts new file mode 100644 index 000000000000..7be73d1b3c3b --- /dev/null +++ b/packages/core/test/id.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { Identifier } from "../src/id/id" + +describe("Identifier.timestamp", () => { + test("round-trips the time an ascending id was minted", () => { + const before = Date.now() + const decoded = Identifier.timestamp(Identifier.ascending("session")) + expect(decoded).toBeGreaterThanOrEqual(before) + expect(decoded).toBeLessThanOrEqual(Date.now()) + }) + + test("round-trips an explicit timestamp", () => { + const stamp = Date.parse("2026-09-03T04:39:56.208Z") + expect(Identifier.timestamp(Identifier.create("ses", "ascending", stamp))).toBe(stamp) + }) +}) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 847a5c032924..acb7d67a04d2 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -14,6 +14,10 @@ const prefixes = { } as const const LENGTH = 26 +// See packages/schema/src/identifier.ts: a 6-byte time field truncates +// Date.now() * 0x1000 and wraps every ~795 days. 7 bytes hold it until 2527. +const TIME_BYTES = 7 +const TIME_CHARS = TIME_BYTES * 2 // State for monotonic ID generation let lastTimestamp = 0 @@ -61,18 +65,18 @@ export function create(prefix: string, direction: "descending" | "ascending", ti now = direction === "descending" ? ~now : now - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + const timeBytes = Buffer.alloc(TIME_BYTES) + for (let i = 0; i < TIME_BYTES; i++) { + timeBytes[i] = Number((now >> BigInt(8 * (TIME_BYTES - 1 - i))) & BigInt(0xff)) } - return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) + return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - TIME_CHARS) } /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ export function timestamp(id: string): number { const prefix = id.split("_")[0] - const hex = id.slice(prefix.length + 1, prefix.length + 13) + const hex = id.slice(prefix.length + 1, prefix.length + 1 + TIME_CHARS) const encoded = BigInt("0x" + hex) return Number(encoded / BigInt(0x1000)) } diff --git a/packages/schema/src/identifier.ts b/packages/schema/src/identifier.ts index 9812a673fb06..0e2ebaf2343e 100644 --- a/packages/schema/src/identifier.ts +++ b/packages/schema/src/identifier.ts @@ -1,4 +1,11 @@ const length = 26 +// Date.now() * 0x1000 needs 53 bits. A 6-byte time field holds 48, so the top +// bits were dropped and the sortable prefix wrapped every 2^36 ms, about 795 +// days. The last wrap was 2026-08-14T11:19:55Z, which sorted every id minted +// after it below the preceding two years of history. 7 bytes hold the encoding +// until 2527. Ids stay 26 characters. +const timeBytes = 7 +export const timeChars = timeBytes * 2 const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let lastTimestamp = 0 let counter = 0 @@ -20,11 +27,11 @@ export function create(descending: boolean, timestamp = Date.now()) { const current = BigInt(timestamp) * 0x1000n + BigInt(counter) const value = descending ? ~current : current - const time = Array.from({ length: 6 }, (_, index) => - Number((value >> BigInt(40 - 8 * index)) & 0xffn) + const time = Array.from({ length: timeBytes }, (_, index) => + Number((value >> BigInt(8 * (timeBytes - 1 - index))) & 0xffn) .toString(16) .padStart(2, "0"), ).join("") - const bytes = crypto.getRandomValues(new Uint8Array(length - 12)) + const bytes = crypto.getRandomValues(new Uint8Array(length - timeChars)) return time + Array.from(bytes, (byte) => chars[byte % 62]).join("") } diff --git a/packages/schema/test/identifier.test.ts b/packages/schema/test/identifier.test.ts new file mode 100644 index 000000000000..af6b02cc88f8 --- /dev/null +++ b/packages/schema/test/identifier.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { create } from "../src/identifier" + +// The most recent 48-bit wrap, 2026-08-14T11:19:55Z. Ids either side of it +// must still sort in time order. +const WRAP = 2 ** 36 * Math.floor(Date.now() / 2 ** 36) +const DAY = 86_400_000 + +describe("identifier", () => { + test("ids are 26 characters", () => { + expect(create(false)).toHaveLength(26) + expect(create(true)).toHaveLength(26) + }) + + test("ascending ids sort in time order across the wrap", () => { + expect(create(false, WRAP - DAY) < create(false, WRAP + DAY)).toBe(true) + }) + + test("descending ids sort in reverse time order across the wrap", () => { + expect(create(true, WRAP + DAY) < create(true, WRAP - DAY)).toBe(true) + }) + + test("ascending ids sort in time order over four centuries", () => { + const stamps = [0, 1_000_000_000_000, WRAP - 1, WRAP + 1, Date.parse("2400-01-01T00:00:00Z")] + const ids = stamps.map((stamp) => create(false, stamp)) + expect(ids).toEqual([...ids].sort()) + }) + + test("ids minted in the same millisecond stay ordered", () => { + const ids = Array.from({ length: 32 }, () => create(false, WRAP)) + expect(ids).toEqual([...ids].sort()) + }) +})