feat(core): id-shape residency classifier + ksuid mint primitives (run-ops split base) (#4112)

## What

Foundation for the run-ops database split: an isomorphic **id-shape
residency classifier** and the **ksuid mint primitives**, added to
`@trigger.dev/core` under `v3/isomorphic`.

- **`runOpsResidency.ts`** — classifies a run id by its shape: 25-char
cuid → `LEGACY`, 27-char ksuid → `NEW`. Pure and environment-free (safe
on both client and server).
- **`friendlyId.ts`** — ksuid mint primitives and id helpers.
- Both exported via `v3/isomorphic/index.ts`.

## Why

This is the **base of a stacked series** implementing the run-ops DB
split (routing run-execution data to a dedicated database by id-shape).
Later PRs in the series consume this classifier and these primitives to
route reads and writes across the two databases.

On its own this PR is **purely additive** — new isomorphic helpers with
unit tests, no runtime wiring, and no behaviour change to existing code
paths.

## Tests

Unit tests for the classifier (`runOpsResidency.test.ts`) and the id /
mint primitives (`friendlyId.test.ts`).

## Notes

- Draft, stacked on `main`; subsequent PRs in the series build on top of
this one.
- A changeset for `@trigger.dev/core` will be added before this is
marked ready for review.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Sutton
2026-07-03 11:05:39 +01:00
committed by GitHub
parent eecb27c672
commit 4cfa596271
6 changed files with 394 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add isomorphic id-shape run-ops residency classifier and ksuid mint/decode primitives.
@@ -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();
});
});
+146 -1
View File
@@ -7,7 +7,152 @@ 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;
}
}
// 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 {
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_<body>` 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();
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./friendlyId.js";
export * from "./runOpsResidency.js";
export * from "./duration.js";
export * from "./maxDuration.js";
export * from "./queueName.js";
@@ -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");
});
});
@@ -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 `<prefix>_` (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;