From b089309a86c733e40d546ba3afe81beb7236543f Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Wed, 1 Jul 2026 16:20:00 +0100 Subject: [PATCH 1/6] feat(run-ops): core residency classifier + ksuid mint primitives Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/v3/isomorphic/friendlyId.test.ts | 144 +++++++++++++++++ packages/core/src/v3/isomorphic/friendlyId.ts | 145 +++++++++++++++++- packages/core/src/v3/isomorphic/index.ts | 1 + .../src/v3/isomorphic/runOpsResidency.test.ts | 68 ++++++++ .../core/src/v3/isomorphic/runOpsResidency.ts | 59 +++++++ 5 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/v3/isomorphic/friendlyId.test.ts create mode 100644 packages/core/src/v3/isomorphic/runOpsResidency.test.ts create mode 100644 packages/core/src/v3/isomorphic/runOpsResidency.ts 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..625a43a2c0c --- /dev/null +++ b/packages/core/src/v3/isomorphic/friendlyId.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + RunId, + WaitpointId, + SnapshotId, + QueueId, + setKsuidMintEnabled, + isKsuidMintEnabled, + generateKsuidId, + decodeKsuid, + KSUID_PAYLOAD_BYTES, +} from "./friendlyId.js"; + +const CUID_LEN = 25; +const KSUID_LEN = 27; + +afterEach(() => setKsuidMintEnabled(false)); // never leak flag state across tests + +describe("KSUID mint (flag-gated) for RunId + WaitpointId", () => { + it("flag OFF: run + waitpoint mint cuid (25) and round-trip", () => { + setKsuidMintEnabled(false); + expect(isKsuidMintEnabled()).toBe(false); + 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("flag ON: run + waitpoint mint 27-char and round-trip", () => { + setKsuidMintEnabled(true); + expect(isKsuidMintEnabled()).toBe(true); + for (const util of [RunId, WaitpointId]) { + const { id, friendlyId } = util.generate(); + expect(id.length).toBe(KSUID_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("flag ON: SnapshotId stays cuid (25) — never 27-char", () => { + setKsuidMintEnabled(true); + const { id } = SnapshotId.generate(); + expect(id.length).toBe(CUID_LEN); + }); + + it("flag ON: a non-run/waitpoint entity stays cuid (25)", () => { + setKsuidMintEnabled(true); + expect(QueueId.generate().id.length).toBe(CUID_LEN); + }); + + it("disjoint lengths: 27 (new) vs 25 (cuid) — the classifier margin", () => { + setKsuidMintEnabled(true); + expect(RunId.generate().id.length).not.toBe(SnapshotId.generate().id.length); + }); + + it("generateKsuidId() is directly callable, independent of the flag", () => { + setKsuidMintEnabled(false); // must NOT be gated behind the global flag + expect(generateKsuidId().length).toBe(KSUID_LEN); + }); +}); + +describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)", () => { + const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const KSUID_EPOCH = 1_400_000_000; + + // Decode the 27-char base62 body back to the 4-byte timestamp prefix (unix seconds). + function decodeTimestamp(id: string): number { + let n = 0n; + for (const ch of id) n = n * 62n + BigInt(BASE62.indexOf(ch)); + return Number(n >> 128n) + KSUID_EPOCH; // top 4 of the 20 bytes + } + + 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 ts = decodeTimestamp(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..19c05a43894 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -7,7 +7,139 @@ export function generateFriendlyId(prefix: string, size?: number) { return `${prefix}_${idGenerator(size)}`; } -export function generateInternalId() { +// Injected by the server split-mode bootstrap. Default false = +// today's behavior (cuid). Isomorphic file: never read process.env here. +let ksuidMintEnabled = false; + +/** Server bootstrap calls this once with isSplitEnabled(). Off by default. */ +export function setKsuidMintEnabled(enabled: boolean): void { + ksuidMintEnabled = enabled; +} + +/** Test/diagnostic read-back of the current mint mode. */ +export function isKsuidMintEnabled(): boolean { + return ksuidMintEnabled; +} + +// 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; +const KSUID_STRING_LENGTH = 27; +const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/** 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) { + globalThis.crypto.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 interface 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 = 0n; + 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 * 62n + BigInt(digit); + } + + const bytes = new Uint8Array(KSUID_TOTAL_BYTES); + for (let i = KSUID_TOTAL_BYTES - 1; i >= 0; i--) { + bytes[i] = Number(n & 0xffn); + n >>= 8n; + } + + 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(useKsuidWhenEnabled = false): string { + if (useKsuidWhenEnabled && ksuidMintEnabled) { + return generateKsuidId(); + } return cuid(); } @@ -58,10 +190,13 @@ export function fromFriendlyId(friendlyId: string, expectedEntityName?: string): } export class IdUtil { - constructor(private entityName: string) {} + constructor( + private entityName: string, + private ksuidWhenEnabled = false + ) {} generate() { - const internalId = generateInternalId(); + const internalId = generateInternalId(this.ksuidWhenEnabled); return { id: internalId, @@ -90,9 +225,9 @@ export class IdUtil { export const BackgroundWorkerId = new IdUtil("worker"); export const CheckpointId = new IdUtil("checkpoint"); export const QueueId = new IdUtil("queue"); -export const RunId = new IdUtil("run"); +export const RunId = new IdUtil("run", true); export const SnapshotId = new IdUtil("snapshot"); -export const WaitpointId = new IdUtil("waitpoint"); +export const WaitpointId = new IdUtil("waitpoint", true); export const BatchId = new IdUtil("batch"); export const BulkActionId = new IdUtil("bulk"); export const AttemptId = new IdUtil("attempt"); 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..93db4e6efbb --- /dev/null +++ b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { RunId, WaitpointId, SnapshotId, setKsuidMintEnabled } from "./friendlyId.js"; +import { + ownerEngine, + classifyResidency, + classifyKind, + isClassifiable, + UnclassifiableRunId, +} from "./runOpsResidency.js"; + +afterEach(() => setKsuidMintEnabled(false)); // never leak mint-flag state across tests + +const SAMPLES = 50_000; // property-scale; CI-fast. (Bump locally toward "millions" for deeper coverage.) + +describe("ownerEngine — residency classifier", () => { + it("cuid-length ids (mint flag OFF) classify LEGACY, friendly + internal", () => { + setKsuidMintEnabled(false); + 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 (mint flag ON) classify NEW, friendly + internal", () => { + setKsuidMintEnabled(true); + for (const util of [RunId, WaitpointId]) { + const { id, friendlyId } = util.generate(); + 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++) { + setKsuidMintEnabled(false); + expect(ownerEngine(RunId.generate().id)).toBe("LEGACY"); + setKsuidMintEnabled(true); + expect(ownerEngine(RunId.generate().id)).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); + } + }); + + 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, even with flag ON) classifies LEGACY — proves snapshot needs no residency key", () => { + setKsuidMintEnabled(true); + 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..e1bc0f376ce --- /dev/null +++ b/packages/core/src/v3/isomorphic/runOpsResidency.ts @@ -0,0 +1,59 @@ +/** 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 = 27; + +/** 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 `_` (e.g. `run_`, `waitpoint_`) if present, + * so friendly and internal forms classify identically. Only the FIRST + * underscore is treated as the prefix separator (mirrors fromFriendlyId's + * two-part split contract in friendlyId.ts), without importing it. + */ +function internalForm(id: string): string { + const underscore = id.indexOf("_"); + return underscore === -1 ? id : id.slice(underscore + 1); +} + +/** Returns the underlying id FORMAT (cuid|ksuid), or throws if unclassifiable. */ +export function classifyKind(id: string): ResidencyKind { + const internal = internalForm(id); + if (internal.length === CUID_LENGTH) return "cuid"; + if (internal.length === KSUID_LENGTH) return "ksuid"; + throw new UnclassifiableRunId(id); +} + +/** Non-throwing predicate: is this id length one we can classify? */ +export function isClassifiable(id: string): boolean { + const len = internalForm(id).length; + return len === CUID_LENGTH || len === KSUID_LENGTH; +} + +/** Map an id to its owning run-ops store by length. Throws on ambiguity. */ +export function classifyResidency(id: string): Residency { + return classifyKind(id) === "ksuid" ? "NEW" : "LEGACY"; +} + +/** Primary public name (RoutingRunStore / cross-seam guard). */ +export const ownerEngine = classifyResidency; From 49eb05d50ce650895249e9d1c8d870b68c91d366 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Wed, 1 Jul 2026 18:41:31 +0100 Subject: [PATCH 2/6] refactor(core): remove test-only ksuid-mint process-global from friendlyId Production mints ksuid runs explicitly via generateKsuidId() at gated trigger sites; the mutable process-global only ever let tests puppet RunId/WaitpointId.generate() into ksuids and cannot express per-org behavior. Delete setKsuidMintEnabled/isKsuidMintEnabled and the flag branch; generateInternalId() and IdUtil now always mint cuid. Tests drive the ksuid path via generateKsuidId() directly, preserving the 25-vs-27 disjoint-length, k-sortability, uniqueness, and payload round-trip coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/v3/isomorphic/friendlyId.test.ts | 36 ++++++------------- packages/core/src/v3/isomorphic/friendlyId.ts | 30 +++------------- .../src/v3/isomorphic/runOpsResidency.test.ts | 22 +++++------- 3 files changed, 23 insertions(+), 65 deletions(-) diff --git a/packages/core/src/v3/isomorphic/friendlyId.test.ts b/packages/core/src/v3/isomorphic/friendlyId.test.ts index 625a43a2c0c..2d7c8c59740 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.test.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.test.ts @@ -4,8 +4,6 @@ import { WaitpointId, SnapshotId, QueueId, - setKsuidMintEnabled, - isKsuidMintEnabled, generateKsuidId, decodeKsuid, KSUID_PAYLOAD_BYTES, @@ -14,12 +12,8 @@ import { const CUID_LEN = 25; const KSUID_LEN = 27; -afterEach(() => setKsuidMintEnabled(false)); // never leak flag state across tests - -describe("KSUID mint (flag-gated) for RunId + WaitpointId", () => { - it("flag OFF: run + waitpoint mint cuid (25) and round-trip", () => { - setKsuidMintEnabled(false); - expect(isKsuidMintEnabled()).toBe(false); +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); @@ -30,37 +24,27 @@ describe("KSUID mint (flag-gated) for RunId + WaitpointId", () => { } }); - it("flag ON: run + waitpoint mint 27-char and round-trip", () => { - setKsuidMintEnabled(true); - expect(isKsuidMintEnabled()).toBe(true); + it("explicit ksuid: a run/waitpoint friendlyId over generateKsuidId() is 27-char and round-trips", () => { for (const util of [RunId, WaitpointId]) { - const { id, friendlyId } = util.generate(); + 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); - expect(util.toFriendlyId(id)).toBe(friendlyId); } }); - it("flag ON: SnapshotId stays cuid (25) — never 27-char", () => { - setKsuidMintEnabled(true); - const { id } = SnapshotId.generate(); - expect(id.length).toBe(CUID_LEN); - }); - - it("flag ON: a non-run/waitpoint entity stays cuid (25)", () => { - setKsuidMintEnabled(true); + 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 (new) vs 25 (cuid) — the classifier margin", () => { - setKsuidMintEnabled(true); - expect(RunId.generate().id.length).not.toBe(SnapshotId.generate().id.length); + 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, independent of the flag", () => { - setKsuidMintEnabled(false); // must NOT be gated behind the global flag + it("generateKsuidId() is directly callable and yields 27 chars", () => { expect(generateKsuidId().length).toBe(KSUID_LEN); }); }); diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index 19c05a43894..eee94d852b7 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -7,20 +7,6 @@ export function generateFriendlyId(prefix: string, size?: number) { return `${prefix}_${idGenerator(size)}`; } -// Injected by the server split-mode bootstrap. Default false = -// today's behavior (cuid). Isomorphic file: never read process.env here. -let ksuidMintEnabled = false; - -/** Server bootstrap calls this once with isSplitEnabled(). Off by default. */ -export function setKsuidMintEnabled(enabled: boolean): void { - ksuidMintEnabled = enabled; -} - -/** Test/diagnostic read-back of the current mint mode. */ -export function isKsuidMintEnabled(): boolean { - return ksuidMintEnabled; -} - // 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; @@ -136,10 +122,7 @@ export function decodeKsuid(idOrFriendlyId: string): DecodedKsuid { }; } -export function generateInternalId(useKsuidWhenEnabled = false): string { - if (useKsuidWhenEnabled && ksuidMintEnabled) { - return generateKsuidId(); - } +export function generateInternalId(): string { return cuid(); } @@ -190,13 +173,10 @@ export function fromFriendlyId(friendlyId: string, expectedEntityName?: string): } export class IdUtil { - constructor( - private entityName: string, - private ksuidWhenEnabled = false - ) {} + constructor(private entityName: string) {} generate() { - const internalId = generateInternalId(this.ksuidWhenEnabled); + const internalId = generateInternalId(); return { id: internalId, @@ -225,9 +205,9 @@ export class IdUtil { export const BackgroundWorkerId = new IdUtil("worker"); export const CheckpointId = new IdUtil("checkpoint"); export const QueueId = new IdUtil("queue"); -export const RunId = new IdUtil("run", true); +export const RunId = new IdUtil("run"); export const SnapshotId = new IdUtil("snapshot"); -export const WaitpointId = new IdUtil("waitpoint", true); +export const WaitpointId = new IdUtil("waitpoint"); export const BatchId = new IdUtil("batch"); export const BulkActionId = new IdUtil("bulk"); export const AttemptId = new IdUtil("attempt"); diff --git a/packages/core/src/v3/isomorphic/runOpsResidency.test.ts b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts index 93db4e6efbb..f2e11939d2c 100644 --- a/packages/core/src/v3/isomorphic/runOpsResidency.test.ts +++ b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { RunId, WaitpointId, SnapshotId, setKsuidMintEnabled } from "./friendlyId.js"; +import { describe, expect, it } from "vitest"; +import { RunId, WaitpointId, SnapshotId, generateKsuidId } from "./friendlyId.js"; import { ownerEngine, classifyResidency, @@ -8,13 +8,10 @@ import { UnclassifiableRunId, } from "./runOpsResidency.js"; -afterEach(() => setKsuidMintEnabled(false)); // never leak mint-flag state across tests - const SAMPLES = 50_000; // property-scale; CI-fast. (Bump locally toward "millions" for deeper coverage.) describe("ownerEngine — residency classifier", () => { - it("cuid-length ids (mint flag OFF) classify LEGACY, friendly + internal", () => { - setKsuidMintEnabled(false); + 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"); @@ -25,10 +22,10 @@ describe("ownerEngine — residency classifier", () => { } }); - it("ksuid-length ids (mint flag ON) classify NEW, friendly + internal", () => { - setKsuidMintEnabled(true); + it("ksuid-length ids (explicit generateKsuidId) classify NEW, friendly + internal", () => { for (const util of [RunId, WaitpointId]) { - const { id, friendlyId } = util.generate(); + const id = generateKsuidId(); + const friendlyId = util.toFriendlyId(id); expect(ownerEngine(id)).toBe("NEW"); expect(ownerEngine(friendlyId)).toBe("NEW"); expect(classifyResidency(id)).toBe("NEW"); @@ -38,10 +35,8 @@ describe("ownerEngine — residency classifier", () => { it("disjointness: no cuid sample is ever NEW, no ksuid sample is ever LEGACY", () => { for (let i = 0; i < SAMPLES; i++) { - setKsuidMintEnabled(false); expect(ownerEngine(RunId.generate().id)).toBe("LEGACY"); - setKsuidMintEnabled(true); - expect(ownerEngine(RunId.generate().id)).toBe("NEW"); + expect(ownerEngine(generateKsuidId())).toBe("NEW"); } }); @@ -61,8 +56,7 @@ describe("ownerEngine — residency classifier", () => { } }); - it("SnapshotId (always cuid, even with flag ON) classifies LEGACY — proves snapshot needs no residency key", () => { - setKsuidMintEnabled(true); + it("SnapshotId (always cuid) classifies LEGACY — proves snapshot needs no residency key", () => { expect(ownerEngine(SnapshotId.generate().id)).toBe("LEGACY"); }); }); From 8b617ba3ca623e02be53e66c71e26684090d7396 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 18:49:48 +0100 Subject: [PATCH 3/6] fix(core): avoid BigInt literals in decodeKsuid for pre-ES2020 consumers Consumer packages (e.g. @internal/zod-worker) typecheck core's source under a target below ES2020, where BigInt literals (123n) raise TS2737. Use BigInt(...) calls, which those targets accept. --- packages/core/src/v3/isomorphic/friendlyId.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index eee94d852b7..20c92843930 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -93,19 +93,19 @@ export function decodeKsuid(idOrFriendlyId: string): DecodedKsuid { ); } - let n = 0n; + 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 * 62n + BigInt(digit); + 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 & 0xffn); - n >>= 8n; + bytes[i] = Number(n & BigInt(0xff)); + n >>= BigInt(8); } const timestampSeconds = From b6f60e37b2ec34e6e5a1d5ec08c26e6f25ca9447 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 20:02:15 +0100 Subject: [PATCH 4/6] fix(core): guard ksuid random source and address review nits Fall back to node:crypto webcrypto when globalThis.crypto is unavailable (Node 18.20), share the ksuid length constant with the residency classifier, and tighten the ksuid/residency tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/v3/isomorphic/friendlyId.test.ts | 14 ++------ packages/core/src/v3/isomorphic/friendlyId.ts | 34 ++++++++++++++++--- .../src/v3/isomorphic/runOpsResidency.test.ts | 1 + .../core/src/v3/isomorphic/runOpsResidency.ts | 4 ++- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/core/src/v3/isomorphic/friendlyId.test.ts b/packages/core/src/v3/isomorphic/friendlyId.test.ts index 2d7c8c59740..9885cd14b81 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.test.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.test.ts @@ -50,16 +50,6 @@ describe("RunId + WaitpointId mint cuid by default; ksuid via generateKsuidId", }); describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)", () => { - const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - const KSUID_EPOCH = 1_400_000_000; - - // Decode the 27-char base62 body back to the 4-byte timestamp prefix (unix seconds). - function decodeTimestamp(id: string): number { - let n = 0n; - for (const ch of id) n = n * 62n + BigInt(BASE62.indexOf(ch)); - return Number(n >> 128n) + KSUID_EPOCH; // top 4 of the 20 bytes - } - afterEach(() => vi.useRealTimers()); it("is exactly 27 base62 chars", () => { @@ -68,7 +58,7 @@ describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered) it("carries a decodable timestamp within a few seconds of now", () => { const before = Math.floor(Date.now() / 1000); - const ts = decodeTimestamp(generateKsuidId()); + const { timestampSeconds: ts } = decodeKsuid(generateKsuidId()); expect(ts).toBeGreaterThanOrEqual(before - 2); expect(ts).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 2); }); @@ -85,7 +75,7 @@ describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered) it("is unique across many mints in the same second", () => { const n = 1000; - expect(new Set(Array.from({ length: n }, generateKsuidId)).size).toBe(n); + expect(new Set(Array.from({ length: n }, () => generateKsuidId())).size).toBe(n); }); }); diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index 20c92843930..115c944141b 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -12,9 +12,35 @@ 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; -const KSUID_STRING_LENGTH = 27; +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); @@ -67,18 +93,18 @@ export function generateKsuidId(payload?: Uint8Array): string { bytes.set(payload, KSUID_TIMESTAMP_BYTES); } if (reserved < KSUID_PAYLOAD_BYTES) { - globalThis.crypto.getRandomValues(bytes.subarray(KSUID_TIMESTAMP_BYTES + reserved)); + 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 interface DecodedKsuid { +export type DecodedKsuid = { timestampSeconds: number; timestamp: Date; payload: Uint8Array; -} +}; /** * Decode a KSUID body (or a `prefix_` friendly id) into its timestamp + 16-byte payload. diff --git a/packages/core/src/v3/isomorphic/runOpsResidency.test.ts b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts index f2e11939d2c..9ed13b05beb 100644 --- a/packages/core/src/v3/isomorphic/runOpsResidency.test.ts +++ b/packages/core/src/v3/isomorphic/runOpsResidency.test.ts @@ -43,6 +43,7 @@ describe("ownerEngine — residency classifier", () => { 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); } }); diff --git a/packages/core/src/v3/isomorphic/runOpsResidency.ts b/packages/core/src/v3/isomorphic/runOpsResidency.ts index e1bc0f376ce..edecec5ee7b 100644 --- a/packages/core/src/v3/isomorphic/runOpsResidency.ts +++ b/packages/core/src/v3/isomorphic/runOpsResidency.ts @@ -1,3 +1,5 @@ +import { KSUID_STRING_LENGTH } from "./friendlyId.js"; + /** The two run-ops stores a run/waitpoint can reside in. */ export type Residency = "LEGACY" | "NEW"; @@ -7,7 +9,7 @@ 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 = 27; +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 { From a98ebac35454a8d7844863de674e49c60dcd9884 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 08:49:01 +0100 Subject: [PATCH 5/6] chore(changeset): add @trigger.dev/core changeset for pr01 --- .changeset/runops-core-residency.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/runops-core-residency.md diff --git a/.changeset/runops-core-residency.md b/.changeset/runops-core-residency.md new file mode 100644 index 00000000000..a5b67d4a5c0 --- /dev/null +++ b/.changeset/runops-core-residency.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add isomorphic id-shape run-ops residency classifier and ksuid mint/decode primitives. From a2261e9c055e9e4ad19a25afe642807e7f331e54 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 10:39:00 +0100 Subject: [PATCH 6/6] fix(core): resolve KSUID crypto source lazily so importing friendlyId never throws --- packages/core/src/v3/isomorphic/friendlyId.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index 115c944141b..bb0e8a2acc7 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -39,7 +39,11 @@ function loadNodeWebCrypto(): Crypto | undefined { } } -const getRandomValues: RandomFiller = resolveGetRandomValues(); +// Resolve the crypto source lazily on first use (memoized), so merely importing this +// widely-used module never throws when crypto is unavailable — only minting a KSUID would. +let cachedGetRandomValues: RandomFiller | undefined; +const getRandomValues: RandomFiller = (array) => + (cachedGetRandomValues ??= resolveGetRandomValues())(array); /** Encode raw bytes as base62 (big-endian), left-padded to the given length. */ function base62Encode(bytes: Uint8Array, length: number): string {