@convex-dev/workflow
Version:
Convex component for durably executing workflows.
218 lines (197 loc) • 6.65 kB
text/typescript
type GenerationState = { now: number; latest: boolean };
// Simple hash function to convert a string to a 32-bit seed
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash >>> 0; // Ensure unsigned
}
// A simple, fast seeded PRNG
// https://gist.github.com/tommyettinger/46a874533244883189143505d203312c?permalink_comment_id=4854318#gistcomment-4854318
function createSeededRandom(seed: number): () => number {
return () => {
seed = (seed + 0x9e3779b9) | 0;
let t = Math.imul(seed ^ (seed >>> 16), 0x21f0aaad);
t = Math.imul(t ^ (t >>> 15), 0x735a2d97);
return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
};
}
// Testable unit: patches Math object to use seeded random
export function patchMath(math: typeof Math, seed: string): typeof Math {
const patchedMath = Object.create(Object.getPrototypeOf(math));
// Copy all properties from original Math
for (const key of Object.getOwnPropertyNames(math)) {
if (key !== "random") {
const descriptor = Object.getOwnPropertyDescriptor(math, key);
if (descriptor) {
Object.defineProperty(patchedMath, key, descriptor);
}
}
}
// Override random to use seeded PRNG
const seededRandom = createSeededRandom(hashString(seed));
patchedMath.random = seededRandom;
return patchedMath;
}
// Testable unit: creates deterministic Date constructor
export function createDeterministicDate(
originalDate: typeof Date,
getGenerationState: () => GenerationState,
): typeof Date {
function DeterministicDate(this: unknown, ...args: unknown[]) {
// `Date()` was called directly, not as a constructor.
if (!(this instanceof DeterministicDate)) {
const date = new (DeterministicDate as typeof Date)();
return date.toString();
}
if (args.length === 0) {
const { now } = getGenerationState();
return new originalDate(now) as unknown as Date;
}
return new (originalDate as typeof Date)(
...(args as ConstructorParameters<typeof Date>),
) as unknown as Date;
}
DeterministicDate.now = function () {
const { now } = getGenerationState();
return now;
};
DeterministicDate.parse = originalDate.parse;
DeterministicDate.UTC = originalDate.UTC;
DeterministicDate.prototype = originalDate.prototype;
DeterministicDate.prototype.constructor = DeterministicDate as typeof Date;
// TODO: Additional methods that should be patched for full determinism:
// - getTimezoneOffset() - should return 0 (UTC)
// - toLocaleString() - should use fixed locale (en-US) and UTC timezone
// - toLocaleDateString() - should use fixed locale (en-US) and UTC timezone
// - toLocaleTimeString() - should use fixed locale (en-US) and UTC timezone
// These would require more complex prototype manipulation to work correctly.
return DeterministicDate as typeof Date;
}
export function setupEnvironment(
getGenerationState: () => GenerationState,
workflowId: string,
): () => void {
const global = globalThis as Record<string, unknown>;
const patchedKeys = [
"Math",
"Date",
"console",
"process",
"Crypto",
"crypto",
"CryptoKey",
"SubtleCrypto",
] as const;
const originals = Object.fromEntries(
patchedKeys.map((key) => [key, global[key]]),
);
// Patch Math with seeded random based on workflowId
global.Math = patchMath(originals.Math as typeof Math, workflowId);
// Patch Date
global.Date = createDeterministicDate(
originals.Date as typeof Date,
getGenerationState,
);
// Patch console
global.console = createConsole(
originals.console as Console,
getGenerationState,
);
// Note: we intentionally don't patch fetch, setTimeout, or setInterval.
// In the real Convex runtime these don't exist (for mutations/queries), so
// user code already can't call them. Patching them here would break test
// environments (e.g. convex-test) that share the same JS global scope and
// rely on these globals internally.
// Remove non-deterministic globals
delete global.process;
delete global.Crypto;
delete global.crypto;
delete global.CryptoKey;
delete global.SubtleCrypto;
return () => Object.assign(global, originals);
}
function noop() {}
// exported for testing
export function createConsole(
console: Console,
getGenerationState: () => GenerationState,
): Console {
const counts: Record<string, number> = {};
const times: Record<string, number> = {};
return new Proxy(console, {
get: (target, prop) => {
const { now, latest } = getGenerationState();
switch (prop) {
case "assert":
case "clear":
case "debug":
case "dir":
case "dirxml":
case "error":
case "info":
case "log":
case "table":
case "trace":
case "warn":
case "profile":
case "profileEnd":
case "timeStamp":
if (!latest) {
return noop;
}
return target[prop];
case "Console":
throw new Error(
"console.Console() is not supported within workflows",
);
case "count":
return (label?: string) => {
const key = label ?? "default";
counts[key] = (counts[key] ?? 0) + 1;
if (latest) {
target.info(`${key}: ${counts[key]}`);
}
};
case "countReset":
return (label?: string) => {
const key = label ?? "default";
counts[key] = 0;
};
case "group":
case "groupCollapsed":
if (!latest) {
// Don't print anything if latest is false
return () => target.group();
}
return target[prop];
case "groupEnd":
return target[prop];
case "time":
if (!latest) {
return (label?: string) => {
times[label ?? "default"] = now;
};
}
return target[prop];
case "timeEnd":
case "timeLog":
if (!latest) {
return noop;
}
return (label?: string, ...data: unknown[]) => {
const key = label ?? "default";
if (times[key] === undefined) {
target[prop](label);
} else {
target.info(`${key}: ${now - times[key]}ms`, ...data);
}
};
}
return target[prop as keyof Console];
},
});
}