diff --git a/packages/core/src/v3/isomorphic/friendlyId.test.ts b/packages/core/src/v3/isomorphic/friendlyId.test.ts new file mode 100644 index 00000000000..9885cd14b81 --- /dev/null +++ b/packages/core/src/v3/isomorphic/friendlyId.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + RunId, + WaitpointId, + SnapshotId, + QueueId, + generateKsuidId, + decodeKsuid, + KSUID_PAYLOAD_BYTES, +} from "./friendlyId.js"; + +const CUID_LEN = 25; +const KSUID_LEN = 27; + +describe("RunId + WaitpointId mint cuid by default; ksuid via generateKsuidId", () => { + it("default: run + waitpoint mint cuid (25) and round-trip", () => { + for (const util of [RunId, WaitpointId]) { + const { id, friendlyId } = util.generate(); + expect(id.length).toBe(CUID_LEN); + expect(util.fromFriendlyId(friendlyId)).toBe(id); + expect(util.toId(friendlyId)).toBe(id); + expect(util.toId(id)).toBe(id); + expect(util.toFriendlyId(id)).toBe(friendlyId); + } + }); + + it("explicit ksuid: a run/waitpoint friendlyId over generateKsuidId() is 27-char and round-trips", () => { + for (const util of [RunId, WaitpointId]) { + const id = generateKsuidId(); + const friendlyId = util.toFriendlyId(id); + expect(id.length).toBe(KSUID_LEN); + expect(util.fromFriendlyId(friendlyId)).toBe(id); + expect(util.toId(friendlyId)).toBe(id); + expect(util.toId(id)).toBe(id); + } + }); + + it("SnapshotId + QueueId stay cuid (25)", () => { + expect(SnapshotId.generate().id.length).toBe(CUID_LEN); + expect(QueueId.generate().id.length).toBe(CUID_LEN); + }); + + it("disjoint lengths: 27 (ksuid) vs 25 (cuid) — the classifier margin", () => { + expect(generateKsuidId().length).not.toBe(SnapshotId.generate().id.length); + }); + + it("generateKsuidId() is directly callable and yields 27 chars", () => { + expect(generateKsuidId().length).toBe(KSUID_LEN); + }); +}); + +describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)", () => { + afterEach(() => vi.useRealTimers()); + + it("is exactly 27 base62 chars", () => { + expect(generateKsuidId()).toMatch(/^[0-9A-Za-z]{27}$/); + }); + + it("carries a decodable timestamp within a few seconds of now", () => { + const before = Math.floor(Date.now() / 1000); + const { timestampSeconds: ts } = decodeKsuid(generateKsuidId()); + expect(ts).toBeGreaterThanOrEqual(before - 2); + expect(ts).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 2); + }); + + it("is k-sortable: ids from later seconds sort lexicographically after earlier ones", () => { + vi.useFakeTimers(); + const ids: string[] = []; + for (const t of ["2026-01-01T00:00:00Z", "2026-01-01T00:05:00Z", "2026-09-01T12:00:00Z"]) { + vi.setSystemTime(new Date(t)); + ids.push(generateKsuidId()); + } + expect([...ids].sort()).toEqual(ids); + }); + + it("is unique across many mints in the same second", () => { + const n = 1000; + expect(new Set(Array.from({ length: n }, () => generateKsuidId())).size).toBe(n); + }); +}); + +describe("KSUID payload encode/decode (foundation primitive)", () => { + it("round-trips a full 16-byte payload exactly", () => { + const payload = new Uint8Array(KSUID_PAYLOAD_BYTES).map((_, i) => (i * 17 + 1) & 0xff); + const { payload: decoded } = decodeKsuid(generateKsuidId(payload)); + expect(Array.from(decoded)).toEqual(Array.from(payload)); + }); + + it("preserves a partial payload prefix and keeps the remainder for entropy", () => { + const meta = new Uint8Array([9, 8, 7, 6]); + const { payload } = decodeKsuid(generateKsuidId(meta)); + expect(Array.from(payload.slice(0, 4))).toEqual([9, 8, 7, 6]); + expect(payload.length).toBe(KSUID_PAYLOAD_BYTES); + }); + + it("still carries a decodable timestamp when a payload is embedded", () => { + const before = Math.floor(Date.now() / 1000); + const { timestampSeconds } = decodeKsuid(generateKsuidId(new Uint8Array([1, 2, 3]))); + expect(timestampSeconds).toBeGreaterThanOrEqual(before - 2); + expect(timestampSeconds).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 2); + }); + + it("stays 27 chars with a full payload and decodes through a friendlyId prefix", () => { + const id = generateKsuidId(new Uint8Array(KSUID_PAYLOAD_BYTES).fill(0xab)); + expect(id).toMatch(/^[0-9A-Za-z]{27}$/); + expect(Array.from(decodeKsuid(`run_${id}`).payload)).toEqual( + new Array(KSUID_PAYLOAD_BYTES).fill(0xab) + ); + }); + + it("throws if the payload exceeds the 16-byte budget", () => { + expect(() => generateKsuidId(new Uint8Array(KSUID_PAYLOAD_BYTES + 1))).toThrow(); + }); + + it("decodeKsuid rejects a body that is not 27 base62 chars", () => { + expect(() => decodeKsuid("run_tooShort")).toThrow(); + }); +}); diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index 66575c7c178..115c944141b 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -7,7 +7,148 @@ export function generateFriendlyId(prefix: string, size?: number) { return `${prefix}_${idGenerator(size)}`; } -export function generateInternalId() { +// KSUID epoch (2014-05-13T16:53:20Z) — seconds offset applied to the unix timestamp. +const KSUID_EPOCH = 1_400_000_000; +const KSUID_TIMESTAMP_BYTES = 4; +export const KSUID_PAYLOAD_BYTES = 16; +const KSUID_TOTAL_BYTES = KSUID_TIMESTAMP_BYTES + KSUID_PAYLOAD_BYTES; +export const KSUID_STRING_LENGTH = 27; +const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +// globalThis.crypto is absent on Node 18.20 (a supported engine) without a flag, so fall back to +// node:crypto's webcrypto, loaded only when the global is missing to stay isomorphic. +type RandomFiller = (array: Uint8Array) => void; + +function resolveGetRandomValues(): RandomFiller { + const globalCrypto = (globalThis as { crypto?: Crypto }).crypto; + if (globalCrypto?.getRandomValues) { + return (array) => globalCrypto.getRandomValues(array); + } + const webcrypto = loadNodeWebCrypto(); + if (webcrypto?.getRandomValues) { + return (array) => webcrypto.getRandomValues(array); + } + throw new Error("No Web Crypto getRandomValues implementation available"); +} + +function loadNodeWebCrypto(): Crypto | undefined { + try { + return (typeof require === "function" ? require("node:crypto") : undefined)?.webcrypto; + } catch { + return undefined; + } +} + +const getRandomValues: RandomFiller = resolveGetRandomValues(); + +/** Encode raw bytes as base62 (big-endian), left-padded to the given length. */ +function base62Encode(bytes: Uint8Array, length: number): string { + const digits = Array.from(bytes); + let result = ""; + + while (digits.length > 0) { + let remainder = 0; + const quotient: number[] = []; + + for (let i = 0; i < digits.length; i++) { + const acc = (digits[i] ?? 0) + remainder * 256; + const q = Math.floor(acc / 62); + remainder = acc % 62; + + if (quotient.length > 0 || q > 0) { + quotient.push(q); + } + } + + result = BASE62_ALPHABET.charAt(remainder) + result; + digits.length = 0; + digits.push(...quotient); + } + + return result.padStart(length, BASE62_ALPHABET.charAt(0)); +} + +/** + * 27-char, base62, time-ordered KSUID body (length-disjoint from the 25-char cuid): a 4-byte + * timestamp (seconds since the KSUID epoch) + a 16-byte payload; ids from different seconds + * sort in mint order. Payload defaults to CSPRNG entropy; callers may supply up to + * KSUID_PAYLOAD_BYTES metadata bytes (written first, remainder stays random for uniqueness). + */ +export function generateKsuidId(payload?: Uint8Array): string { + const bytes = new Uint8Array(KSUID_TOTAL_BYTES); + + const timestamp = Math.floor(Date.now() / 1000) - KSUID_EPOCH; + bytes[0] = (timestamp >>> 24) & 0xff; + bytes[1] = (timestamp >>> 16) & 0xff; + bytes[2] = (timestamp >>> 8) & 0xff; + bytes[3] = timestamp & 0xff; + + if (payload && payload.length > KSUID_PAYLOAD_BYTES) { + throw new Error( + `KSUID payload must be at most ${KSUID_PAYLOAD_BYTES} bytes (got ${payload.length})` + ); + } + const reserved = payload?.length ?? 0; + if (payload && reserved > 0) { + bytes.set(payload, KSUID_TIMESTAMP_BYTES); + } + if (reserved < KSUID_PAYLOAD_BYTES) { + getRandomValues(bytes.subarray(KSUID_TIMESTAMP_BYTES + reserved)); + } + + return base62Encode(bytes, KSUID_STRING_LENGTH); +} + +/** Decoded parts of a KSUID body: its mint timestamp and 16-byte payload. */ +export type DecodedKsuid = { + timestampSeconds: number; + timestamp: Date; + payload: Uint8Array; +}; + +/** + * Decode a KSUID body (or a `prefix_
` friendly id) into its timestamp + 16-byte payload. + * The inverse of generateKsuidId's layout. Throws if the body is not 27 base62 chars. + */ +export function decodeKsuid(idOrFriendlyId: string): DecodedKsuid { + const underscore = idOrFriendlyId.indexOf("_"); + const body = underscore === -1 ? idOrFriendlyId : idOrFriendlyId.slice(underscore + 1); + if (body.length !== KSUID_STRING_LENGTH) { + throw new Error( + `Not a KSUID body: expected ${KSUID_STRING_LENGTH} base62 chars, got ${body.length}` + ); + } + + let n = BigInt(0); + for (const ch of body) { + const digit = BASE62_ALPHABET.indexOf(ch); + if (digit < 0) { + throw new Error(`Invalid base62 character in KSUID body: ${ch}`); + } + n = n * BigInt(62) + BigInt(digit); + } + + const bytes = new Uint8Array(KSUID_TOTAL_BYTES); + for (let i = KSUID_TOTAL_BYTES - 1; i >= 0; i--) { + bytes[i] = Number(n & BigInt(0xff)); + n >>= BigInt(8); + } + + const timestampSeconds = + (bytes[0] ?? 0) * 0x1000000 + + (bytes[1] ?? 0) * 0x10000 + + (bytes[2] ?? 0) * 0x100 + + (bytes[3] ?? 0) + + KSUID_EPOCH; + + return { + timestampSeconds, + timestamp: new Date(timestampSeconds * 1000), + payload: bytes.slice(KSUID_TIMESTAMP_BYTES), + }; +} + +export function generateInternalId(): string { return cuid(); } diff --git a/packages/core/src/v3/isomorphic/index.ts b/packages/core/src/v3/isomorphic/index.ts index d220acd515d..e51135f94c0 100644 --- a/packages/core/src/v3/isomorphic/index.ts +++ b/packages/core/src/v3/isomorphic/index.ts @@ -1,4 +1,5 @@ export * from "./friendlyId.js"; +export * from "./runOpsResidency.js"; export * from "./duration.js"; export * from "./maxDuration.js"; export * from "./queueName.js"; diff --git a/packages/core/src/v3/isomorphic/runOpsResidency.test.ts b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts new file mode 100644 index 00000000000..9ed13b05beb --- /dev/null +++ b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { RunId, WaitpointId, SnapshotId, generateKsuidId } from "./friendlyId.js"; +import { + ownerEngine, + classifyResidency, + classifyKind, + isClassifiable, + UnclassifiableRunId, +} from "./runOpsResidency.js"; + +const SAMPLES = 50_000; // property-scale; CI-fast. (Bump locally toward "millions" for deeper coverage.) + +describe("ownerEngine — residency classifier", () => { + it("cuid-length ids (default mint) classify LEGACY, friendly + internal", () => { + for (const util of [RunId, WaitpointId]) { + const { id, friendlyId } = util.generate(); + expect(ownerEngine(id)).toBe("LEGACY"); + expect(ownerEngine(friendlyId)).toBe("LEGACY"); // strips run_/waitpoint_ prefix + expect(classifyResidency(id)).toBe("LEGACY"); // alias agrees + expect(classifyKind(id)).toBe("cuid"); + expect(isClassifiable(id)).toBe(true); + } + }); + + it("ksuid-length ids (explicit generateKsuidId) classify NEW, friendly + internal", () => { + for (const util of [RunId, WaitpointId]) { + const id = generateKsuidId(); + const friendlyId = util.toFriendlyId(id); + expect(ownerEngine(id)).toBe("NEW"); + expect(ownerEngine(friendlyId)).toBe("NEW"); + expect(classifyResidency(id)).toBe("NEW"); + expect(classifyKind(id)).toBe("ksuid"); + } + }); + + it("disjointness: no cuid sample is ever NEW, no ksuid sample is ever LEGACY", () => { + for (let i = 0; i < SAMPLES; i++) { + expect(ownerEngine(RunId.generate().id)).toBe("LEGACY"); + expect(ownerEngine(generateKsuidId())).toBe("NEW"); + } + }); + + it("throws UnclassifiableRunId on malformed lengths (24, 26, 28, empty)", () => { + for (const bad of ["", "x".repeat(24), "x".repeat(26), "x".repeat(28), "x".repeat(40)]) { + expect(() => ownerEngine(bad)).toThrow(UnclassifiableRunId); + expect(isClassifiable(bad)).toBe(false); + } + }); + + it("error carries the offending value + length for diagnostics", () => { + try { + ownerEngine("x".repeat(26)); + throw new Error("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(UnclassifiableRunId); + expect((e as UnclassifiableRunId).message).toContain("26"); + } + }); + + it("SnapshotId (always cuid) classifies LEGACY — proves snapshot needs no residency key", () => { + expect(ownerEngine(SnapshotId.generate().id)).toBe("LEGACY"); + }); +}); diff --git a/packages/core/src/v3/isomorphic/runOpsResidency.ts b/packages/core/src/v3/isomorphic/runOpsResidency.ts new file mode 100644 index 00000000000..edecec5ee7b --- /dev/null +++ b/packages/core/src/v3/isomorphic/runOpsResidency.ts @@ -0,0 +1,61 @@ +import { KSUID_STRING_LENGTH } from "./friendlyId.js"; + +/** The two run-ops stores a run/waitpoint can reside in. */ +export type Residency = "LEGACY" | "NEW"; + +/** Underlying id format. cuid → LEGACY store, ksuid → NEW store. */ +export type ResidencyKind = "cuid" | "ksuid"; + +/** @bugsnag/cuid emits 25-char ids (cuid path, flag OFF). */ +export const CUID_LENGTH = 25; +/** KSUID / nanoid-27 emits 27-char ids (ksuid path, flag ON). */ +export const KSUID_LENGTH = KSUID_STRING_LENGTH; + +/** Thrown when an id length matches neither the cuid nor the ksuid margin. */ +export class UnclassifiableRunId extends Error { + readonly value: string; + readonly valueLength: number; + constructor(value: string) { + super( + `Unclassifiable run-ops id: length ${value.length} matches neither cuid (${CUID_LENGTH}) nor ksuid (${KSUID_LENGTH}) — value=${JSON.stringify( + value + )}` + ); + this.name = "UnclassifiableRunId"; + this.value = value; + this.valueLength = value.length; + } +} + +/** + * Strip a single leading `