UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

218 lines (197 loc) 6.65 kB
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]; }, }); }