From ef3786544556f8852443ef9c8b4981160fb956bc Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Wed, 12 Aug 2026 11:09:49 +0300 Subject: [PATCH] feat(ai): support UUIDv7 follower timestamps --- .../agent/src/harness/session/id-generator.ts | 67 ---------- .../agent/test/harness/id-generator.test.ts | 115 ------------------ packages/ai/src/utils/uuid.ts | 77 ++++++------ packages/ai/test/uuid.test.ts | 67 +++++----- 4 files changed, 74 insertions(+), 252 deletions(-) delete mode 100644 packages/agent/src/harness/session/id-generator.ts delete mode 100644 packages/agent/test/harness/id-generator.test.ts diff --git a/packages/agent/src/harness/session/id-generator.ts b/packages/agent/src/harness/session/id-generator.ts deleted file mode 100644 index 63e0ca678..000000000 --- a/packages/agent/src/harness/session/id-generator.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { IdGenerator } from "./types.ts"; - -const MAX_UUID_V7_TIMESTAMP = 0xffffffffffff; -const MAX_SEQUENCE = (1n << 41n) - 1n; - -export interface Uuidv7IdGeneratorOptions { - readonly now?: () => number; - readonly randomBytes?: (bytes: Uint8Array) => void; -} - -/** Session-scoped UUIDv7 generator with exact follower timestamps and monotonic ordinary ids. */ -export class Uuidv7IdGenerator implements IdGenerator { - private readonly now: () => number; - private readonly randomBytes: (bytes: Uint8Array) => void; - private sequence: bigint | undefined; - private lastOrdinaryTimestamp = -1; - - constructor(options: Uuidv7IdGeneratorOptions = {}) { - this.now = options.now ?? Date.now; - this.randomBytes = options.randomBytes ?? ((bytes) => globalThis.crypto.getRandomValues(bytes)); - } - - next(timestampMs?: number): string { - const requestedTimestamp = timestampMs ?? this.now(); - if ( - !Number.isInteger(requestedTimestamp) || - requestedTimestamp < 0 || - requestedTimestamp > MAX_UUID_V7_TIMESTAMP - ) { - throw new RangeError(`UUIDv7 timestamp must be an integer between 0 and ${MAX_UUID_V7_TIMESTAMP}`); - } - - const effectiveTimestamp = - timestampMs === undefined ? Math.max(requestedTimestamp, this.lastOrdinaryTimestamp) : timestampMs; - if (timestampMs === undefined) this.lastOrdinaryTimestamp = effectiveTimestamp; - - const bytes = new Uint8Array(16); - this.randomBytes(bytes); - // Seed 40 of 41 counter bits, leaving at least 2^40 ordered values before exhaustion. - // The remaining 33 UUID random-field bits are refreshed on every call. - if (this.sequence === undefined) { - this.sequence = - (BigInt(bytes[1]) << 32n) | - (BigInt(bytes[2]) << 24n) | - (BigInt(bytes[3]) << 16n) | - (BigInt(bytes[4]) << 8n) | - BigInt(bytes[5]); - } else { - if (this.sequence === MAX_SEQUENCE) throw new RangeError("UUIDv7 generator sequence exhausted"); - this.sequence++; - } - - const timestamp = BigInt(effectiveTimestamp); - for (let index = 5; index >= 0; index--) { - bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 0xff; - } - bytes[6] = 0x70 | Number((this.sequence >> 37n) & 0x0fn); - bytes[7] = Number((this.sequence >> 29n) & 0xffn); - bytes[8] = 0x80 | Number((this.sequence >> 23n) & 0x3fn); - bytes[9] = Number((this.sequence >> 15n) & 0xffn); - bytes[10] = Number((this.sequence >> 7n) & 0xffn); - bytes[11] = Number((this.sequence & 0x7fn) << 1n) | (bytes[11] & 0x01); - - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); - return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; - } -} diff --git a/packages/agent/test/harness/id-generator.test.ts b/packages/agent/test/harness/id-generator.test.ts deleted file mode 100644 index a78bd82ea..000000000 --- a/packages/agent/test/harness/id-generator.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Uuidv7IdGenerator } from "../../src/harness/session/id-generator.ts"; - -const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -const MAX_UUID_V7_TIMESTAMP = 0xffffffffffff; -const INVALID_TIMESTAMP_MESSAGE = `UUIDv7 timestamp must be an integer between 0 and ${MAX_UUID_V7_TIMESTAMP}`; - -function decodeTimestamp(uuid: string): number { - return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16); -} - -describe("Uuidv7IdGenerator", () => { - it("mints an RFC 9562 UUIDv7 with the required version and variant", () => { - const generator = new Uuidv7IdGenerator({ - now: () => 0, - randomBytes: (bytes) => bytes.fill(0), - }); - - expect(generator.next()).toMatch(UUID_V7_PATTERN); - }); - - it("encodes the current Unix millisecond in the first 48 bits", () => { - const timestamp = 0x0123456789ab; - const generator = new Uuidv7IdGenerator({ - now: () => timestamp, - randomBytes: (bytes) => bytes.fill(0), - }); - - expect(decodeTimestamp(generator.next())).toBe(timestamp); - }); - - it("mints distinct follower tails under the leader's exact timestamp prefix", () => { - const generator = new Uuidv7IdGenerator({ - now: () => 1_700_000_000_000, - randomBytes: (bytes) => bytes.fill(0), - }); - const leader = generator.next(); - const leaderTimestamp = decodeTimestamp(leader); - const followers = [generator.next(leaderTimestamp), generator.next(leaderTimestamp)]; - - expect(followers.map(decodeTimestamp)).toEqual([leaderTimestamp, leaderTimestamp]); - expect(new Set([leader, ...followers])).toHaveLength(3); - }); - - it("does not advance a follower timestamp when ordinary generation has moved ahead", () => { - const clockValues = [1_000, 1_001]; - const generator = new Uuidv7IdGenerator({ - now: () => clockValues.shift() ?? 0, - randomBytes: (bytes) => bytes.fill(0), - }); - const leaderTimestamp = decodeTimestamp(generator.next()); - generator.next(); - - expect(decodeTimestamp(generator.next(leaderTimestamp))).toBe(leaderTimestamp); - }); - - it("keeps ordinary same-millisecond ids unique and lexically increasing", () => { - const generator = new Uuidv7IdGenerator({ - now: () => 1_700_000_000_000, - randomBytes: (bytes) => bytes.fill(0), - }); - const ids = [generator.next(), generator.next(), generator.next()]; - - expect(new Set(ids)).toHaveLength(ids.length); - expect(ids).toEqual([...ids].sort()); - }); - - it("preserves ordinary ordering when the clock rolls back", () => { - const clockValues = [1_001, 1_000, 999, 1_002]; - const generator = new Uuidv7IdGenerator({ - now: () => clockValues.shift() ?? 0, - randomBytes: (bytes) => bytes.fill(0), - }); - const ids = [generator.next(), generator.next(), generator.next(), generator.next()]; - - expect(ids.map(decodeTimestamp)).toEqual([1_001, 1_001, 1_001, 1_002]); - expect(ids).toEqual([...ids].sort()); - }); - - it("uses fresh injected randomness for each UUID tail", () => { - let randomByte = 0; - const generator = new Uuidv7IdGenerator({ - now: () => 1_700_000_000_000, - randomBytes: (bytes) => bytes.fill(++randomByte), - }); - - expect([generator.next().slice(-8), generator.next().slice(-8)]).toEqual(["01010101", "02020202"]); - }); - - it.each([0, MAX_UUID_V7_TIMESTAMP])("accepts the UUIDv7 timestamp boundary %s", (timestamp) => { - const explicitGenerator = new Uuidv7IdGenerator({ randomBytes: (bytes) => bytes.fill(0) }); - const clockGenerator = new Uuidv7IdGenerator({ - now: () => timestamp, - randomBytes: (bytes) => bytes.fill(0), - }); - - expect(decodeTimestamp(explicitGenerator.next(timestamp))).toBe(timestamp); - expect(decodeTimestamp(clockGenerator.next())).toBe(timestamp); - }); - - it.each([-1, MAX_UUID_V7_TIMESTAMP + 1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( - "rejects the impossible UUIDv7 timestamp %s before consuming randomness", - (timestamp) => { - let randomCalls = 0; - const generator = new Uuidv7IdGenerator({ - now: () => timestamp, - randomBytes: () => randomCalls++, - }); - - expect(() => generator.next()).toThrowError(new RangeError(INVALID_TIMESTAMP_MESSAGE)); - expect(() => generator.next(timestamp)).toThrowError(new RangeError(INVALID_TIMESTAMP_MESSAGE)); - expect(randomCalls).toBe(0); - }, - ); -}); diff --git a/packages/ai/src/utils/uuid.ts b/packages/ai/src/utils/uuid.ts index 53d900ca3..8dfc6dd71 100644 --- a/packages/ai/src/utils/uuid.ts +++ b/packages/ai/src/utils/uuid.ts @@ -1,48 +1,45 @@ -let lastTimestamp = -Infinity; -let sequence = 0; +const MAX_UUID_V7_TIMESTAMP = 0xffffffffffff; +const MAX_SEQUENCE = (1n << 41n) - 1n; -function fillRandomBytes(bytes: Uint8Array): void { - if (globalThis.crypto?.getRandomValues) { - globalThis.crypto.getRandomValues(bytes); - return; - } - for (let i = 0; i < bytes.length; i++) { - bytes[i] = Math.floor(Math.random() * 256); - } -} +let lastOrdinaryTimestamp = -1; +let sequence: bigint | undefined; -/** Generate a time-ordered UUIDv7. */ -export function uuidv7(): string { - const random = new Uint8Array(16); - fillRandomBytes(random); - const timestamp = Date.now(); - - if (timestamp > lastTimestamp) { - sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9]; - lastTimestamp = timestamp; - } else { - sequence = (sequence + 1) >>> 0; - if (sequence === 0) lastTimestamp++; +/** Generate a time-ordered UUIDv7. A supplied timestamp is preserved for follower ids. */ +export function uuidv7(timestampMs?: number): string { + const requestedTimestamp = timestampMs ?? Date.now(); + if (!Number.isInteger(requestedTimestamp) || requestedTimestamp < 0 || requestedTimestamp > MAX_UUID_V7_TIMESTAMP) { + throw new RangeError(`UUIDv7 timestamp must be an integer between 0 and ${MAX_UUID_V7_TIMESTAMP}`); } + const effectiveTimestamp = + timestampMs === undefined ? Math.max(requestedTimestamp, lastOrdinaryTimestamp) : timestampMs; + if (timestampMs === undefined) lastOrdinaryTimestamp = effectiveTimestamp; + const bytes = new Uint8Array(16); - bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; - bytes[1] = (lastTimestamp / 0x100000000) & 0xff; - bytes[2] = (lastTimestamp / 0x1000000) & 0xff; - bytes[3] = (lastTimestamp / 0x10000) & 0xff; - bytes[4] = (lastTimestamp / 0x100) & 0xff; - bytes[5] = lastTimestamp & 0xff; - bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); - bytes[7] = (sequence >>> 20) & 0xff; - bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); - bytes[9] = (sequence >>> 6) & 0xff; - bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03); - bytes[11] = random[11]; - bytes[12] = random[12]; - bytes[13] = random[13]; - bytes[14] = random[14]; - bytes[15] = random[15]; + globalThis.crypto.getRandomValues(bytes); + if (sequence === undefined) { + sequence = + (BigInt(bytes[1]) << 32n) | + (BigInt(bytes[2]) << 24n) | + (BigInt(bytes[3]) << 16n) | + (BigInt(bytes[4]) << 8n) | + BigInt(bytes[5]); + } else { + if (sequence === MAX_SEQUENCE) throw new RangeError("UUIDv7 generator sequence exhausted"); + sequence++; + } + + const timestamp = BigInt(effectiveTimestamp); + for (let index = 5; index >= 0; index--) { + bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 0xff; + } + bytes[6] = 0x70 | Number((sequence >> 37n) & 0x0fn); + bytes[7] = Number((sequence >> 29n) & 0xffn); + bytes[8] = 0x80 | Number((sequence >> 23n) & 0x3fn); + bytes[9] = Number((sequence >> 15n) & 0xffn); + bytes[10] = Number((sequence >> 7n) & 0xffn); + bytes[11] = Number((sequence & 0x7fn) << 1n) | (bytes[11] & 0x01); const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); - return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; } diff --git a/packages/ai/test/uuid.test.ts b/packages/ai/test/uuid.test.ts index 3e5426502..6100104ce 100644 --- a/packages/ai/test/uuid.test.ts +++ b/packages/ai/test/uuid.test.ts @@ -9,42 +9,49 @@ function parseTimestamp(uuid: string): number { } afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("uuidv7", () => { - it("uses the RFC 9562 layout and preserves monotonic order", () => { - const randomValues = [ - new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]), - new Uint8Array(16), - new Uint8Array(16), - ]; - const getRandomValues = vi.fn((bytes: Uint8Array) => { - bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length)); - return bytes; + it("generates ordered UUIDv7s while preserving follower timestamps", () => { + vi.useFakeTimers(); + vi.setSystemTime(TIMESTAMP); + + const first = uuidv7(); + const second = uuidv7(); + vi.setSystemTime(TIMESTAMP - 1); + const afterRollback = uuidv7(); + vi.setSystemTime(TIMESTAMP + 1); + const afterAdvance = uuidv7(); + const ordinaryIds = [first, second, afterRollback, afterAdvance]; + const followerTimestamp = TIMESTAMP - 1_000; + const followers = [uuidv7(followerTimestamp), uuidv7(followerTimestamp)]; + + for (const id of [...ordinaryIds, ...followers]) expect(id).toMatch(UUID_V7_RE); + expect(ordinaryIds).toEqual([...ordinaryIds].sort()); + expect(new Set(ordinaryIds)).toHaveLength(ordinaryIds.length); + expect(ordinaryIds.map(parseTimestamp)).toEqual([TIMESTAMP, TIMESTAMP, TIMESTAMP, TIMESTAMP + 1]); + expect(followers.map(parseTimestamp)).toEqual([followerTimestamp, followerTimestamp]); + expect(new Set(followers)).toHaveLength(followers.length); + }); + + it("uses fresh randomness for every UUID tail", () => { + let randomByte = 0; + vi.stubGlobal("crypto", { + getRandomValues(bytes: Uint8Array) { + return bytes.fill(++randomByte); + }, }); - vi.stubGlobal("crypto", { getRandomValues }); - const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP); - try { - const first = uuidv7(); - const second = uuidv7(); - const third = uuidv7(); + expect([uuidv7(TIMESTAMP).slice(-8), uuidv7(TIMESTAMP).slice(-8)]).toEqual(["01010101", "02020202"]); + }); - expect(first).toBe("01234567-89ab-7fff-bfff-f91122334455"); - expect(second).toBe("01234567-89ab-7fff-bfff-fc0000000000"); - expect(third).toBe("01234567-89ac-7000-8000-000000000000"); - expect(first).toMatch(UUID_V7_RE); - expect(second).toMatch(UUID_V7_RE); - expect(third).toMatch(UUID_V7_RE); - expect(parseTimestamp(first)).toBe(TIMESTAMP); - expect(parseTimestamp(second)).toBe(TIMESTAMP); - expect(parseTimestamp(third)).toBe(TIMESTAMP + 1); - expect(first < second).toBe(true); - expect(second < third).toBe(true); - expect(getRandomValues).toHaveBeenCalledTimes(3); - } finally { - dateNow.mockRestore(); - } + it.each([0, 2 ** 48 - 1])("accepts timestamp boundary %s", (timestamp) => { + expect(parseTimestamp(uuidv7(timestamp))).toBe(timestamp); + }); + + it.each([-1, 2 ** 48, 1.5, Number.NaN, Number.POSITIVE_INFINITY])("rejects invalid timestamp %s", (timestamp) => { + expect(() => uuidv7(timestamp)).toThrow(RangeError); }); });