mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai): support UUIDv7 follower timestamps
This commit is contained in:
parent
6fd734b4c6
commit
ef37865445
4 changed files with 74 additions and 252 deletions
|
|
@ -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<ArrayBuffer>) => 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<ArrayBuffer>) => 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("")}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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<ArrayBuffer>): 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("")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue