Skip to content

Commit 4cfa596

Browse files
d-csclaude
andauthored
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>
1 parent eecb27c commit 4cfa596

6 files changed

Lines changed: 394 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Add isomorphic id-shape run-ops residency classifier and ksuid mint/decode primitives.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
RunId,
4+
WaitpointId,
5+
SnapshotId,
6+
QueueId,
7+
generateKsuidId,
8+
decodeKsuid,
9+
KSUID_PAYLOAD_BYTES,
10+
} from "./friendlyId.js";
11+
12+
const CUID_LEN = 25;
13+
const KSUID_LEN = 27;
14+
15+
describe("RunId + WaitpointId mint cuid by default; ksuid via generateKsuidId", () => {
16+
it("default: run + waitpoint mint cuid (25) and round-trip", () => {
17+
for (const util of [RunId, WaitpointId]) {
18+
const { id, friendlyId } = util.generate();
19+
expect(id.length).toBe(CUID_LEN);
20+
expect(util.fromFriendlyId(friendlyId)).toBe(id);
21+
expect(util.toId(friendlyId)).toBe(id);
22+
expect(util.toId(id)).toBe(id);
23+
expect(util.toFriendlyId(id)).toBe(friendlyId);
24+
}
25+
});
26+
27+
it("explicit ksuid: a run/waitpoint friendlyId over generateKsuidId() is 27-char and round-trips", () => {
28+
for (const util of [RunId, WaitpointId]) {
29+
const id = generateKsuidId();
30+
const friendlyId = util.toFriendlyId(id);
31+
expect(id.length).toBe(KSUID_LEN);
32+
expect(util.fromFriendlyId(friendlyId)).toBe(id);
33+
expect(util.toId(friendlyId)).toBe(id);
34+
expect(util.toId(id)).toBe(id);
35+
}
36+
});
37+
38+
it("SnapshotId + QueueId stay cuid (25)", () => {
39+
expect(SnapshotId.generate().id.length).toBe(CUID_LEN);
40+
expect(QueueId.generate().id.length).toBe(CUID_LEN);
41+
});
42+
43+
it("disjoint lengths: 27 (ksuid) vs 25 (cuid) — the classifier margin", () => {
44+
expect(generateKsuidId().length).not.toBe(SnapshotId.generate().id.length);
45+
});
46+
47+
it("generateKsuidId() is directly callable and yields 27 chars", () => {
48+
expect(generateKsuidId().length).toBe(KSUID_LEN);
49+
});
50+
});
51+
52+
describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)", () => {
53+
afterEach(() => vi.useRealTimers());
54+
55+
it("is exactly 27 base62 chars", () => {
56+
expect(generateKsuidId()).toMatch(/^[0-9A-Za-z]{27}$/);
57+
});
58+
59+
it("carries a decodable timestamp within a few seconds of now", () => {
60+
const before = Math.floor(Date.now() / 1000);
61+
const { timestampSeconds: ts } = decodeKsuid(generateKsuidId());
62+
expect(ts).toBeGreaterThanOrEqual(before - 2);
63+
expect(ts).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 2);
64+
});
65+
66+
it("is k-sortable: ids from later seconds sort lexicographically after earlier ones", () => {
67+
vi.useFakeTimers();
68+
const ids: string[] = [];
69+
for (const t of ["2026-01-01T00:00:00Z", "2026-01-01T00:05:00Z", "2026-09-01T12:00:00Z"]) {
70+
vi.setSystemTime(new Date(t));
71+
ids.push(generateKsuidId());
72+
}
73+
expect([...ids].sort()).toEqual(ids);
74+
});
75+
76+
it("is unique across many mints in the same second", () => {
77+
const n = 1000;
78+
expect(new Set(Array.from({ length: n }, () => generateKsuidId())).size).toBe(n);
79+
});
80+
});
81+
82+
describe("KSUID payload encode/decode (foundation primitive)", () => {
83+
it("round-trips a full 16-byte payload exactly", () => {
84+
const payload = new Uint8Array(KSUID_PAYLOAD_BYTES).map((_, i) => (i * 17 + 1) & 0xff);
85+
const { payload: decoded } = decodeKsuid(generateKsuidId(payload));
86+
expect(Array.from(decoded)).toEqual(Array.from(payload));
87+
});
88+
89+
it("preserves a partial payload prefix and keeps the remainder for entropy", () => {
90+
const meta = new Uint8Array([9, 8, 7, 6]);
91+
const { payload } = decodeKsuid(generateKsuidId(meta));
92+
expect(Array.from(payload.slice(0, 4))).toEqual([9, 8, 7, 6]);
93+
expect(payload.length).toBe(KSUID_PAYLOAD_BYTES);
94+
});
95+
96+
it("still carries a decodable timestamp when a payload is embedded", () => {
97+
const before = Math.floor(Date.now() / 1000);
98+
const { timestampSeconds } = decodeKsuid(generateKsuidId(new Uint8Array([1, 2, 3])));
99+
expect(timestampSeconds).toBeGreaterThanOrEqual(before - 2);
100+
expect(timestampSeconds).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 2);
101+
});
102+
103+
it("stays 27 chars with a full payload and decodes through a friendlyId prefix", () => {
104+
const id = generateKsuidId(new Uint8Array(KSUID_PAYLOAD_BYTES).fill(0xab));
105+
expect(id).toMatch(/^[0-9A-Za-z]{27}$/);
106+
expect(Array.from(decodeKsuid(`run_${id}`).payload)).toEqual(
107+
new Array(KSUID_PAYLOAD_BYTES).fill(0xab)
108+
);
109+
});
110+
111+
it("throws if the payload exceeds the 16-byte budget", () => {
112+
expect(() => generateKsuidId(new Uint8Array(KSUID_PAYLOAD_BYTES + 1))).toThrow();
113+
});
114+
115+
it("decodeKsuid rejects a body that is not 27 base62 chars", () => {
116+
expect(() => decodeKsuid("run_tooShort")).toThrow();
117+
});
118+
});

packages/core/src/v3/isomorphic/friendlyId.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,152 @@ export function generateFriendlyId(prefix: string, size?: number) {
77
return `${prefix}_${idGenerator(size)}`;
88
}
99

10-
export function generateInternalId() {
10+
// KSUID epoch (2014-05-13T16:53:20Z) — seconds offset applied to the unix timestamp.
11+
const KSUID_EPOCH = 1_400_000_000;
12+
const KSUID_TIMESTAMP_BYTES = 4;
13+
export const KSUID_PAYLOAD_BYTES = 16;
14+
const KSUID_TOTAL_BYTES = KSUID_TIMESTAMP_BYTES + KSUID_PAYLOAD_BYTES;
15+
export const KSUID_STRING_LENGTH = 27;
16+
const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
17+
18+
// globalThis.crypto is absent on Node 18.20 (a supported engine) without a flag, so fall back to
19+
// node:crypto's webcrypto, loaded only when the global is missing to stay isomorphic.
20+
type RandomFiller = (array: Uint8Array) => void;
21+
22+
function resolveGetRandomValues(): RandomFiller {
23+
const globalCrypto = (globalThis as { crypto?: Crypto }).crypto;
24+
if (globalCrypto?.getRandomValues) {
25+
return (array) => globalCrypto.getRandomValues(array);
26+
}
27+
const webcrypto = loadNodeWebCrypto();
28+
if (webcrypto?.getRandomValues) {
29+
return (array) => webcrypto.getRandomValues(array);
30+
}
31+
throw new Error("No Web Crypto getRandomValues implementation available");
32+
}
33+
34+
function loadNodeWebCrypto(): Crypto | undefined {
35+
try {
36+
return (typeof require === "function" ? require("node:crypto") : undefined)?.webcrypto;
37+
} catch {
38+
return undefined;
39+
}
40+
}
41+
42+
// Resolve the crypto source lazily on first use (memoized), so merely importing this
43+
// widely-used module never throws when crypto is unavailable — only minting a KSUID would.
44+
let cachedGetRandomValues: RandomFiller | undefined;
45+
const getRandomValues: RandomFiller = (array) =>
46+
(cachedGetRandomValues ??= resolveGetRandomValues())(array);
47+
48+
/** Encode raw bytes as base62 (big-endian), left-padded to the given length. */
49+
function base62Encode(bytes: Uint8Array, length: number): string {
50+
const digits = Array.from(bytes);
51+
let result = "";
52+
53+
while (digits.length > 0) {
54+
let remainder = 0;
55+
const quotient: number[] = [];
56+
57+
for (let i = 0; i < digits.length; i++) {
58+
const acc = (digits[i] ?? 0) + remainder * 256;
59+
const q = Math.floor(acc / 62);
60+
remainder = acc % 62;
61+
62+
if (quotient.length > 0 || q > 0) {
63+
quotient.push(q);
64+
}
65+
}
66+
67+
result = BASE62_ALPHABET.charAt(remainder) + result;
68+
digits.length = 0;
69+
digits.push(...quotient);
70+
}
71+
72+
return result.padStart(length, BASE62_ALPHABET.charAt(0));
73+
}
74+
75+
/**
76+
* 27-char, base62, time-ordered KSUID body (length-disjoint from the 25-char cuid): a 4-byte
77+
* timestamp (seconds since the KSUID epoch) + a 16-byte payload; ids from different seconds
78+
* sort in mint order. Payload defaults to CSPRNG entropy; callers may supply up to
79+
* KSUID_PAYLOAD_BYTES metadata bytes (written first, remainder stays random for uniqueness).
80+
*/
81+
export function generateKsuidId(payload?: Uint8Array): string {
82+
const bytes = new Uint8Array(KSUID_TOTAL_BYTES);
83+
84+
const timestamp = Math.floor(Date.now() / 1000) - KSUID_EPOCH;
85+
bytes[0] = (timestamp >>> 24) & 0xff;
86+
bytes[1] = (timestamp >>> 16) & 0xff;
87+
bytes[2] = (timestamp >>> 8) & 0xff;
88+
bytes[3] = timestamp & 0xff;
89+
90+
if (payload && payload.length > KSUID_PAYLOAD_BYTES) {
91+
throw new Error(
92+
`KSUID payload must be at most ${KSUID_PAYLOAD_BYTES} bytes (got ${payload.length})`
93+
);
94+
}
95+
const reserved = payload?.length ?? 0;
96+
if (payload && reserved > 0) {
97+
bytes.set(payload, KSUID_TIMESTAMP_BYTES);
98+
}
99+
if (reserved < KSUID_PAYLOAD_BYTES) {
100+
getRandomValues(bytes.subarray(KSUID_TIMESTAMP_BYTES + reserved));
101+
}
102+
103+
return base62Encode(bytes, KSUID_STRING_LENGTH);
104+
}
105+
106+
/** Decoded parts of a KSUID body: its mint timestamp and 16-byte payload. */
107+
export type DecodedKsuid = {
108+
timestampSeconds: number;
109+
timestamp: Date;
110+
payload: Uint8Array;
111+
};
112+
113+
/**
114+
* Decode a KSUID body (or a `prefix_<body>` friendly id) into its timestamp + 16-byte payload.
115+
* The inverse of generateKsuidId's layout. Throws if the body is not 27 base62 chars.
116+
*/
117+
export function decodeKsuid(idOrFriendlyId: string): DecodedKsuid {
118+
const underscore = idOrFriendlyId.indexOf("_");
119+
const body = underscore === -1 ? idOrFriendlyId : idOrFriendlyId.slice(underscore + 1);
120+
if (body.length !== KSUID_STRING_LENGTH) {
121+
throw new Error(
122+
`Not a KSUID body: expected ${KSUID_STRING_LENGTH} base62 chars, got ${body.length}`
123+
);
124+
}
125+
126+
let n = BigInt(0);
127+
for (const ch of body) {
128+
const digit = BASE62_ALPHABET.indexOf(ch);
129+
if (digit < 0) {
130+
throw new Error(`Invalid base62 character in KSUID body: ${ch}`);
131+
}
132+
n = n * BigInt(62) + BigInt(digit);
133+
}
134+
135+
const bytes = new Uint8Array(KSUID_TOTAL_BYTES);
136+
for (let i = KSUID_TOTAL_BYTES - 1; i >= 0; i--) {
137+
bytes[i] = Number(n & BigInt(0xff));
138+
n >>= BigInt(8);
139+
}
140+
141+
const timestampSeconds =
142+
(bytes[0] ?? 0) * 0x1000000 +
143+
(bytes[1] ?? 0) * 0x10000 +
144+
(bytes[2] ?? 0) * 0x100 +
145+
(bytes[3] ?? 0) +
146+
KSUID_EPOCH;
147+
148+
return {
149+
timestampSeconds,
150+
timestamp: new Date(timestampSeconds * 1000),
151+
payload: bytes.slice(KSUID_TIMESTAMP_BYTES),
152+
};
153+
}
154+
155+
export function generateInternalId(): string {
11156
return cuid();
12157
}
13158

packages/core/src/v3/isomorphic/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./friendlyId.js";
2+
export * from "./runOpsResidency.js";
23
export * from "./duration.js";
34
export * from "./maxDuration.js";
45
export * from "./queueName.js";
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import { RunId, WaitpointId, SnapshotId, generateKsuidId } from "./friendlyId.js";
3+
import {
4+
ownerEngine,
5+
classifyResidency,
6+
classifyKind,
7+
isClassifiable,
8+
UnclassifiableRunId,
9+
} from "./runOpsResidency.js";
10+
11+
const SAMPLES = 50_000; // property-scale; CI-fast. (Bump locally toward "millions" for deeper coverage.)
12+
13+
describe("ownerEngine — residency classifier", () => {
14+
it("cuid-length ids (default mint) classify LEGACY, friendly + internal", () => {
15+
for (const util of [RunId, WaitpointId]) {
16+
const { id, friendlyId } = util.generate();
17+
expect(ownerEngine(id)).toBe("LEGACY");
18+
expect(ownerEngine(friendlyId)).toBe("LEGACY"); // strips run_/waitpoint_ prefix
19+
expect(classifyResidency(id)).toBe("LEGACY"); // alias agrees
20+
expect(classifyKind(id)).toBe("cuid");
21+
expect(isClassifiable(id)).toBe(true);
22+
}
23+
});
24+
25+
it("ksuid-length ids (explicit generateKsuidId) classify NEW, friendly + internal", () => {
26+
for (const util of [RunId, WaitpointId]) {
27+
const id = generateKsuidId();
28+
const friendlyId = util.toFriendlyId(id);
29+
expect(ownerEngine(id)).toBe("NEW");
30+
expect(ownerEngine(friendlyId)).toBe("NEW");
31+
expect(classifyResidency(id)).toBe("NEW");
32+
expect(classifyKind(id)).toBe("ksuid");
33+
}
34+
});
35+
36+
it("disjointness: no cuid sample is ever NEW, no ksuid sample is ever LEGACY", () => {
37+
for (let i = 0; i < SAMPLES; i++) {
38+
expect(ownerEngine(RunId.generate().id)).toBe("LEGACY");
39+
expect(ownerEngine(generateKsuidId())).toBe("NEW");
40+
}
41+
});
42+
43+
it("throws UnclassifiableRunId on malformed lengths (24, 26, 28, empty)", () => {
44+
for (const bad of ["", "x".repeat(24), "x".repeat(26), "x".repeat(28), "x".repeat(40)]) {
45+
expect(() => ownerEngine(bad)).toThrow(UnclassifiableRunId);
46+
expect(isClassifiable(bad)).toBe(false);
47+
}
48+
});
49+
50+
it("error carries the offending value + length for diagnostics", () => {
51+
try {
52+
ownerEngine("x".repeat(26));
53+
throw new Error("should have thrown");
54+
} catch (e) {
55+
expect(e).toBeInstanceOf(UnclassifiableRunId);
56+
expect((e as UnclassifiableRunId).message).toContain("26");
57+
}
58+
});
59+
60+
it("SnapshotId (always cuid) classifies LEGACY — proves snapshot needs no residency key", () => {
61+
expect(ownerEngine(SnapshotId.generate().id)).toBe("LEGACY");
62+
});
63+
});

0 commit comments

Comments
 (0)