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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
Reference in New Issue
Block a user