diff --git a/.gitignore b/.gitignore index 7cf618c..dbffb69 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules frontend/package.json # npm pack output *.tgz +*.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1849e..65a18fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.2.7 alpha + +- Support for console logging & timing in workflows +- Support for Date.now() in workflows +- Batches the call to start steps +- Adds the workflow name to the workpool execution for observability +- Logs any error that shows up in the workflow body +- Will call onComplete for Workflows with startAsync that fail + on their first invocation. +- Increases the max journal size from 1MB to 8MB +- Adds the WorkflowId type to step.workflowId + ## 0.2.6 - Allow calling components directly from steps diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49ef6ee..ced4222 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,21 +3,17 @@ ## Running locally ```sh -npm i -cd example -npm i -npx convex dev +npm run setup +npm run dev ``` ## Testing ```sh -rm -rf dist/ && npm run build +npm run clean npm run typecheck -npm run test -cd example npm run lint -cd .. +npm run test ``` ## Deploying @@ -25,26 +21,19 @@ cd .. ### Building a one-off package ```sh -rm -rf dist/ && npm run build +npm run clean +npm run build npm pack ``` ### Deploying a new version ```sh -# this will change the version and commit it (if you run it in the root directory) -npm version patch -npm publish --dry-run -# sanity check files being included -npm publish -git push --tags +npm run release ``` #### Alpha release -The same as above, but it requires extra flags so the release is only installed with `@alpha`: - ```sh -npm version prerelease --preid alpha -npm publish --tag alpha +npm run alpha ``` diff --git a/README.md b/README.md index cdd8d43..6e65134 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Welcome to the world of Convex workflows. - Output from previous steps is available to pass to subsequent steps. - Run queries, mutations, and actions. - Specify retry behavior on a per-step basis, along with a default policy. -- Specify how many workflows can run in parallel to manage load. +- Specify how many workflow steps can run in parallel to manage load. - Cancel long-running workflows. - Clean up workflows after they're done. @@ -447,10 +447,8 @@ Here are a few limitations to keep in mind: mutation apply and limit the number and size of steps you can perform to 16MiB (including the workflow state overhead). See more about mutation limits here: https://docs.convex.dev/production/state/limits#transactions -- `console.log()` isn't currently captured, so you may see duplicate log lines - within your Convex dashboard if you log within the workflow definition. - We currently do not collect backtraces from within function calls from workflows. -- If you need to use side effects like `fetch`, `Math.random()`, or `Date.now()`, +- If you need to use side effects like `fetch` or use randomness, you'll need to do that in a step, not in the workflow definition. - If the implementation of the workflow meaningfully changes (steps added, removed, or reordered) then it will fail with a determinism violation. diff --git a/convex.json b/convex.json new file mode 100644 index 0000000..1704a04 --- /dev/null +++ b/convex.json @@ -0,0 +1,3 @@ +{ + "functions": "example/convex" +} diff --git a/example/.gitignore b/example/.gitignore deleted file mode 100644 index 983c26f..0000000 --- a/example/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -!**/glob-import/dir/node_modules -.DS_Store -.idea -*.cpuprofile -*.local -*.log -/.vscode/ -/docs/.vitepress/cache -dist -dist-ssr -explorations -node_modules -playground-temp -temp -TODOs.md -.eslintcache diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 31f6ecd..0d97802 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -91,31 +91,32 @@ export declare const components: { }; } >; - startStep: FunctionReference< + startSteps: FunctionReference< "mutation", "internal", { generationNumber: number; - name: string; - retry?: - | boolean - | { base: number; initialBackoffMs: number; maxAttempts: number }; - schedulerOptions?: { runAt?: number } | { runAfter?: number }; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; + steps: Array<{ + retry?: + | boolean + | { base: number; initialBackoffMs: number; maxAttempts: number }; + schedulerOptions?: { runAt?: number } | { runAfter?: number }; + step: { + args: any; + argsSize: number; + completedAt?: number; + functionType: "query" | "mutation" | "action"; + handle: string; + inProgress: boolean; + name: string; + runResult?: + | { kind: "success"; returnValue: any } + | { error: string; kind: "failed" } + | { kind: "canceled" }; + startedAt: number; + workId?: string; + }; + }>; workflowId: string; workpoolOptions?: { defaultRetryBehavior?: { @@ -128,7 +129,7 @@ export declare const components: { retryActionsByDefault?: boolean; }; }, - { + Array<{ _creationTime: number; _id: string; step: { @@ -148,7 +149,7 @@ export declare const components: { }; stepNumber: number; workflowId: string; - } + }> >; }; workflow: { diff --git a/example/convex/example.ts b/example/convex/example.ts index ee448e8..3aafc5a 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -28,13 +28,16 @@ export const exampleWorkflow = workflow.define({ windSpeed: number; windGust: number; }> => { + console.time("overall"); + console.time("geocoding"); // Run in parallel! const [{ latitude, longitude, name }, weather2] = await Promise.all([ step.runAction(internal.example.getGeocoding, args, { runAfter: 100 }), step.runAction(internal.example.getGeocoding, args, { retry: true }), ]); console.log("Is geocoding is consistent?", latitude === weather2.latitude); - + console.timeLog("geocoding", name); + console.time("weather"); const weather = await step.runAction(internal.example.getWeather, { latitude, longitude, @@ -45,6 +48,12 @@ export const exampleWorkflow = workflow.define({ console.log( `Weather in ${name}: ${farenheit.toFixed(1)}°F (${temperature}°C), ${windSpeed} km/h, ${windGust} km/h`, ); + console.timeLog("weather", temperature); + await step.runMutation(internal.example.updateFlow, { + workflowId: step.workflowId, + out: { name, celsius, farenheit, windSpeed, windGust }, + }); + console.timeEnd("overall"); return { name, celsius, farenheit, windSpeed, windGust }; }, workpoolOptions: { @@ -109,7 +118,8 @@ export const flowCompleted = internalMutation({ await ctx.db.patch(flow._id, { out: args.result, }); - await workflow.cleanup(ctx, args.workflowId); + // To delete the workflow data after it completes: + // await workflow.cleanup(ctx, args.workflowId); }, }); diff --git a/example/convex/setup.test.ts b/example/convex/setup.test.ts index 7b37655..a1a4451 100644 --- a/example/convex/setup.test.ts +++ b/example/convex/setup.test.ts @@ -5,10 +5,10 @@ import schema from "./schema"; export const modules = import.meta.glob("./**/*.*s"); // Sorry about everything -import componentSchema from "../node_modules/@convex-dev/workflow/src/component/schema"; +import componentSchema from "../../node_modules/@convex-dev/workflow/src/component/schema"; export { componentSchema }; export const componentModules = import.meta.glob( - "../node_modules/@convex-dev/workflow/src/component/**/*.ts", + "../../node_modules/@convex-dev/workflow/src/component/**/*.ts", ); export function initConvexTest() { diff --git a/example/package-lock.json b/example/package-lock.json deleted file mode 100644 index e4757cb..0000000 --- a/example/package-lock.json +++ /dev/null @@ -1,1160 +0,0 @@ -{ - "name": "uses-component", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "uses-component", - "version": "0.0.0", - "dependencies": { - "@convex-dev/workflow": "file:..", - "convex": "file:../node_modules/convex" - }, - "devDependencies": { - "@types/node": "22.18.6", - "convex-test": "0.0.36", - "eslint": "9.36.0", - "typescript": "5.9.2" - } - }, - "..": { - "name": "@convex-dev/workflow", - "version": "0.2.6", - "license": "Apache-2.0", - "dependencies": { - "async-channel": "^0.2.0" - }, - "devDependencies": { - "@edge-runtime/vm": "5.0.0", - "@eslint/eslintrc": "3.3.1", - "@eslint/js": "9.36.0", - "@types/node": "22.18.6", - "@typescript-eslint/eslint-plugin": "8.40.0", - "@typescript-eslint/parser": "8.40.0", - "chokidar-cli": "3.0.0", - "convex-test": "0.0.38", - "cpy-cli": "6.0.0", - "eslint": "9.36.0", - "globals": "16.4.0", - "npm-run-all2": "8.0.4", - "openai": "5.21.0", - "pkg-pr-new": "0.0.60", - "prettier": "3.6.2", - "typescript": "5.9.2", - "typescript-eslint": "8.40.0", - "vitest": "3.2.4" - }, - "peerDependencies": { - "@convex-dev/workpool": "^0.2.17", - "convex": ">=1.25.0 <1.35.0", - "convex-helpers": "^0.1.99" - } - }, - "../node_modules/convex": { - "version": "1.25.2", - "license": "Apache-2.0", - "dependencies": { - "esbuild": "0.25.4", - "jwt-decode": "^4.0.0", - "prettier": "3.5.3" - }, - "bin": { - "convex": "bin/main.js" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=7.0.0" - }, - "peerDependencies": { - "@auth0/auth0-react": "^2.0.1", - "@clerk/clerk-react": "^4.12.8 || ^5.0.0", - "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@auth0/auth0-react": { - "optional": true - }, - "@clerk/clerk-react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/@convex-dev/workflow": { - "resolved": "..", - "link": true - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.8.0.tgz", - "integrity": "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.2", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", - "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/convex": { - "resolved": "../node_modules/convex", - "link": true - }, - "node_modules/convex-test": { - "version": "0.0.36", - "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.36.tgz", - "integrity": "sha512-xcmjiYodRNypQLIVTSq/23BSH1sbJ8GKKKSX9A/JmZovrm1SEV0ATYriOlvRyoU6+3BNWt0AvP2Wql2HOSMHOg==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "convex": "^1.16.4" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/example/package.json b/example/package.json deleted file mode 100644 index e74d28d..0000000 --- a/example/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "uses-component", - "private": true, - "version": "0.0.0", - "scripts": { - "dev": "convex dev --live-component-sources --typecheck-components", - "logs": "convex logs", - "lint": "tsc -p convex && eslint convex" - }, - "dependencies": { - "@convex-dev/workflow": "file:..", - "convex": "file:../node_modules/convex" - }, - "devDependencies": { - "@types/node": "22.18.6", - "convex-test": "0.0.36", - "eslint": "9.36.0", - "typescript": "5.9.2" - } -} diff --git a/package-lock.json b/package-lock.json index e0a55ee..a1c942f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@convex-dev/workflow", - "version": "0.2.6", + "version": "0.2.7-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@convex-dev/workflow", - "version": "0.2.6", + "version": "0.2.7-alpha.0", "license": "Apache-2.0", "dependencies": { "async-channel": "^0.2.0" }, "devDependencies": { + "@convex-dev/workflow": "file:.", "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.36.0", @@ -32,7 +33,7 @@ "vitest": "3.2.4" }, "peerDependencies": { - "@convex-dev/workpool": "^0.2.17", + "@convex-dev/workpool": "^0.2.19-alpha.2", "convex": ">=1.25.0 <1.35.0", "convex-helpers": "^0.1.99" } @@ -89,10 +90,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@convex-dev/workflow": { + "resolved": "", + "link": true + }, "node_modules/@convex-dev/workpool": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.2.17.tgz", - "integrity": "sha512-Z2GjubsieZATdLYip+WTRLQNRmo+Jl9OoeN0GjsgIhLriHrNyddijosgGH656LtdmVs4SdqyqkwvMwabRixakA==", + "version": "0.2.19-alpha.2", + "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.2.19-alpha.2.tgz", + "integrity": "sha512-OrrU8x69SKTr3XbRRg4RUuOnRcD5Al3nii66OA48ZNYRJrswJ5SzZ9h9vKNYH7L66M3nqXCm9BEfiXbQMdvYUQ==", "license": "Apache-2.0", "peer": true, "peerDependencies": { diff --git a/package.json b/package.json index 4b3fe64..4191ab2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@convex-dev/workflow", - "version": "0.2.6", + "version": "0.2.7-alpha.0", "description": "Convex component for durably executing workflows.", "keywords": [ "convex", @@ -13,15 +13,15 @@ "license": "Apache-2.0", "type": "module", "scripts": { - "example": "cd example && npm run dev", + "example": "convex dev --typecheck-components --live-component-sources", "dev": "run-p -r 'example' 'build:watch'", "dashboard": "cd example && npx convex dashboard", "all": "run-p -r 'example' 'build:watch' 'test:watch'", - "setup": "npm i && npm run build && cd example && npm i && npx convex dev --once && printf 'VITE_CONVEX_SITE_URL=' >> .env.local && npx convex env get CONVEX_SITE_URL >> .env.local", + "setup": "npm i && npm run build && npx convex dev --once", "build:watch": "cd src && npx chokidar -d 1000 '../tsconfig.json' '**/*.ts' -c 'npm run build' --initial", "build": "tsc --project ./tsconfig.build.json && npm run copy:dts && echo '{\\n \"type\": \"module\"\\n}' > dist/package.json", "copy:dts": "rsync -a --include='*/' --include='*.d.ts' --exclude='*' src/ dist/ || cpy 'src/**/*.d.ts' 'dist/' --parents", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p example/convex", "clean": "rm -rf dist tsconfig.build.tsbuildinfo", "alpha": "npm run clean && npm run build && run-p test lint typecheck && npm version prerelease --preid alpha && npm publish --tag alpha && git push --tags", "release": "npm run clean && npm run build && run-p test lint typecheck && npm version patch && npm publish && git push --tags && git push", @@ -29,7 +29,7 @@ "test:watch": "vitest --typecheck", "test:debug": "vitest --inspect-brk --no-file-parallelism", "test:coverage": "vitest run --coverage --coverage.reporter=text", - "lint": "eslint src", + "lint": "eslint src && eslint example/convex", "version": "pbcopy <<<$npm_package_version; vim CHANGELOG.md && git add CHANGELOG.md" }, "files": [ @@ -50,7 +50,7 @@ } }, "peerDependencies": { - "@convex-dev/workpool": "^0.2.17", + "@convex-dev/workpool": "^0.2.18", "convex": ">=1.25.0 <1.35.0", "convex-helpers": "^0.1.99" }, @@ -58,6 +58,7 @@ "async-channel": "^0.2.0" }, "devDependencies": { + "@convex-dev/workflow": "file:.", "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.36.0", diff --git a/src/client/environment.test.ts b/src/client/environment.test.ts new file mode 100644 index 0000000..7e52a1b --- /dev/null +++ b/src/client/environment.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + patchMath, + createDeterministicDate, + createConsole, +} from "./environment.js"; + +describe("environment patching units", () => { + describe("patchMath", () => { + it("should preserve all Math methods except random", () => { + const originalMath = Math; + const patchedMath = patchMath(originalMath); + + // Should preserve all other methods + expect(patchedMath.abs).toBe(originalMath.abs); + expect(patchedMath.sin).toBe(originalMath.sin); + expect(patchedMath.cos).toBe(originalMath.cos); + expect(patchedMath.PI).toBe(originalMath.PI); + expect(patchedMath.E).toBe(originalMath.E); + }); + + it("should replace Math.random with function that throws", () => { + const originalMath = Math; + const patchedMath = patchMath(originalMath); + + expect(() => patchedMath.random()).toThrow( + "Math.random() isn't yet supported within workflows", + ); + }); + + it("should not mutate the original Math object", () => { + const originalMath = Math; + const originalRandom = Math.random; + + patchMath(originalMath); + + // Original Math should be unchanged + expect(Math.random).toBe(originalRandom); + }); + }); + + describe("createDeterministicDate", () => { + const mockGetGenerationState = vi.fn(); + + beforeEach(() => { + mockGetGenerationState.mockReturnValue({ + now: 1234567890000, + latest: true, + }); + }); + + afterEach(() => { + mockGetGenerationState.mockReset(); + }); + + it("should create Date that uses generation state for Date.now()", () => { + const testTime = 9876543210000; + mockGetGenerationState.mockReturnValue({ now: testTime, latest: true }); + + const DeterministicDate = createDeterministicDate( + Date, + mockGetGenerationState, + ); + + expect(DeterministicDate.now()).toBe(testTime); + expect(mockGetGenerationState).toHaveBeenCalled(); + }); + + it("should create new Date with current timestamp when no args", () => { + const testTime = 1111111111111; + mockGetGenerationState.mockReturnValue({ now: testTime, latest: true }); + + const DeterministicDate = createDeterministicDate( + Date, + mockGetGenerationState, + ); + const date = new DeterministicDate(); + + expect(date.getTime()).toBe(testTime); + }); + + it("should create Date with provided args", () => { + const DeterministicDate = createDeterministicDate( + Date, + mockGetGenerationState, + ); + const date = new DeterministicDate(2023, 0, 1); + + expect(date.getFullYear()).toBe(2023); + expect(date.getMonth()).toBe(0); + expect(date.getDate()).toBe(1); + }); + + it("should return string when called without new", () => { + const DeterministicDate = createDeterministicDate( + Date, + mockGetGenerationState, + ); + + const dateString = (DeterministicDate as unknown as () => string)(); + expect(typeof dateString).toBe("string"); + }); + + it("should preserve original Date static methods", () => { + const originalDate = Date; + const DeterministicDate = createDeterministicDate( + originalDate, + mockGetGenerationState, + ); + + expect(DeterministicDate.parse).toBe(originalDate.parse); + expect(DeterministicDate.UTC).toBe(originalDate.UTC); + + // Prototype should be the same as original (no patching currently) + expect(DeterministicDate.prototype).toBe(originalDate.prototype); + expect(DeterministicDate.prototype.constructor).toBe(DeterministicDate); + }); + + it("should not affect the original Date constructor", () => { + const originalNow = Date.now; + + createDeterministicDate(Date, mockGetGenerationState); + + // Original Date should be unchanged + expect(Date.now).toBe(originalNow); + }); + + describe("behavior validation vs original Date", () => { + it("should produce identical outputs for specific dates", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + + // Test with specific timestamps + const timestamps = [ + 0, // Unix epoch + 946684800000, // Y2K + 1640995200000, // 2022-01-01 + 2023, 0, 15, 10, 30, 45, 123 // Feb 15, 2023 10:30:45.123 + ]; + + for (const ts of [timestamps[0], timestamps[1], timestamps[2]]) { + const original = new Date(ts); + const deterministic = new DeterministicDate(ts); + + expect(deterministic.getTime()).toBe(original.getTime()); + expect(deterministic.getFullYear()).toBe(original.getFullYear()); + expect(deterministic.getMonth()).toBe(original.getMonth()); + expect(deterministic.getDate()).toBe(original.getDate()); + expect(deterministic.getHours()).toBe(original.getHours()); + expect(deterministic.getMinutes()).toBe(original.getMinutes()); + expect(deterministic.getSeconds()).toBe(original.getSeconds()); + expect(deterministic.getMilliseconds()).toBe(original.getMilliseconds()); + } + + // Test with constructor args + const original = new Date(2023, 0, 15, 10, 30, 45, 123); + const deterministic = new DeterministicDate(2023, 0, 15, 10, 30, 45, 123); + + expect(deterministic.getFullYear()).toBe(original.getFullYear()); + expect(deterministic.getMonth()).toBe(original.getMonth()); + expect(deterministic.getDate()).toBe(original.getDate()); + expect(deterministic.getHours()).toBe(original.getHours()); + expect(deterministic.getMinutes()).toBe(original.getMinutes()); + expect(deterministic.getSeconds()).toBe(original.getSeconds()); + expect(deterministic.getMilliseconds()).toBe(original.getMilliseconds()); + }); + + it("should produce identical string representations for deterministic dates", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + + const timestamp = 1640995200000; // 2022-01-01T00:00:00.000Z + const original = new Date(timestamp); + const deterministic = new DeterministicDate(timestamp); + + expect(deterministic.toISOString()).toBe(original.toISOString()); + expect(deterministic.toUTCString()).toBe(original.toUTCString()); + expect(deterministic.toDateString()).toBe(original.toDateString()); + expect(deterministic.toTimeString()).toBe(original.toTimeString()); + expect(deterministic.toJSON()).toBe(original.toJSON()); + expect(deterministic.valueOf()).toBe(original.valueOf()); + expect(deterministic.getTime()).toBe(original.getTime()); + }); + + it("should handle UTC methods identically", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + + const timestamp = 1640995200123; // 2022-01-01T00:00:00.123Z + const original = new Date(timestamp); + const deterministic = new DeterministicDate(timestamp); + + expect(deterministic.getUTCFullYear()).toBe(original.getUTCFullYear()); + expect(deterministic.getUTCMonth()).toBe(original.getUTCMonth()); + expect(deterministic.getUTCDate()).toBe(original.getUTCDate()); + expect(deterministic.getUTCHours()).toBe(original.getUTCHours()); + expect(deterministic.getUTCMinutes()).toBe(original.getUTCMinutes()); + expect(deterministic.getUTCSeconds()).toBe(original.getUTCSeconds()); + expect(deterministic.getUTCMilliseconds()).toBe(original.getUTCMilliseconds()); + expect(deterministic.getUTCDay()).toBe(original.getUTCDay()); + }); + + it("should handle static methods identically", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + + const dateString = "2023-01-15T10:30:45.123Z"; + const year = 2023; + const month = 0; // January + const day = 15; + const hour = 10; + const minute = 30; + const second = 45; + const ms = 123; + + expect(DeterministicDate.parse(dateString)).toBe(Date.parse(dateString)); + expect(DeterministicDate.UTC(year, month, day, hour, minute, second, ms)) + .toBe(Date.UTC(year, month, day, hour, minute, second, ms)); + }); + + it("should maintain Date compatibility", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + + const date = new DeterministicDate(2023, 0, 1); + + // Should be an instance of Date (important for type compatibility) + expect(date instanceof Date).toBe(true); + + // Should have all expected Date methods + expect(typeof date.getTime).toBe("function"); + expect(typeof date.getFullYear).toBe("function"); + expect(typeof date.toISOString).toBe("function"); + expect(typeof date.getTimezoneOffset).toBe("function"); + expect(typeof date.toLocaleString).toBe("function"); + }); + + it("should handle Date modification methods correctly", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + + const timestamp = 1640995200000; // 2022-01-01 + const original = new Date(timestamp); + const deterministic = new DeterministicDate(timestamp); + + // Test setters + const newTime = 1641081600000; // 2022-01-02 + original.setTime(newTime); + deterministic.setTime(newTime); + + expect(deterministic.getTime()).toBe(original.getTime()); + + // Test setFullYear + original.setFullYear(2024); + deterministic.setFullYear(2024); + + expect(deterministic.getFullYear()).toBe(original.getFullYear()); + expect(deterministic.getTime()).toBe(original.getTime()); + }); + + it("should have timezone and locale methods available for future patching", () => { + const DeterministicDate = createDeterministicDate(Date, mockGetGenerationState); + const date = new DeterministicDate(1640995200000); // 2022-01-01T00:00:00.000Z + + // These methods exist but are not yet fully patched for determinism + // They currently still use system timezone/locale settings + expect(typeof date.getTimezoneOffset).toBe("function"); + expect(typeof date.toLocaleString).toBe("function"); + expect(typeof date.toLocaleDateString).toBe("function"); + expect(typeof date.toLocaleTimeString).toBe("function"); + + // The methods work but results depend on system settings + const timezoneOffset = date.getTimezoneOffset(); + const localeString = date.toLocaleString(); + const localeDateString = date.toLocaleDateString(); + const localeTimeString = date.toLocaleTimeString(); + + expect(typeof timezoneOffset).toBe("number"); + expect(typeof localeString).toBe("string"); + expect(typeof localeDateString).toBe("string"); + expect(typeof localeTimeString).toBe("string"); + + // Should be consistent when called multiple times + expect(date.getTimezoneOffset()).toBe(timezoneOffset); + expect(date.toLocaleString()).toBe(localeString); + expect(date.toLocaleDateString()).toBe(localeDateString); + expect(date.toLocaleTimeString()).toBe(localeTimeString); + + // TODO: These methods should be patched for full determinism: + // - getTimezoneOffset() should return 0 (UTC) + // - locale methods should use fixed locale (en-US) and UTC timezone + }); + }); + }); + + describe("createConsole", () => { + const mockGetGenerationState = vi.fn(); + let mockConsole: { + log: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + debug: ReturnType; + group: ReturnType; + groupEnd: ReturnType; + }; + + beforeEach(() => { + mockConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + }; + }); + + afterEach(() => { + mockGetGenerationState.mockReset(); + }); + + it("should allow console methods when latest is true", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + proxiedConsole.log("test"); + proxiedConsole.info("test"); + proxiedConsole.warn("test"); + proxiedConsole.error("test"); + + expect(mockConsole.log).toHaveBeenCalledWith("test"); + expect(mockConsole.info).toHaveBeenCalledWith("test"); + expect(mockConsole.warn).toHaveBeenCalledWith("test"); + expect(mockConsole.error).toHaveBeenCalledWith("test"); + }); + + it("should return noop function when latest is false", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: false }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + // Methods should be functions (noop) but not call the original + expect(typeof proxiedConsole.log).toBe("function"); + expect(typeof proxiedConsole.info).toBe("function"); + + proxiedConsole.log("test"); + proxiedConsole.info("test"); + + expect(mockConsole.log).not.toHaveBeenCalled(); + expect(mockConsole.info).not.toHaveBeenCalled(); + }); + + it("should throw error for console.Console access", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + expect(() => proxiedConsole.Console).toThrow( + "console.Console() is not supported within workflows", + ); + }); + + it("should handle console.count with state tracking", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + proxiedConsole.count("test"); + proxiedConsole.count("test"); + proxiedConsole.count(); // default label + + expect(mockConsole.info).toHaveBeenCalledWith("test: 1"); + expect(mockConsole.info).toHaveBeenCalledWith("test: 2"); + expect(mockConsole.info).toHaveBeenCalledWith("default: 1"); + }); + + it("should handle console.countReset", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + proxiedConsole.count("test"); + proxiedConsole.count("test"); + proxiedConsole.countReset("test"); + proxiedConsole.count("test"); + + expect(mockConsole.info).toHaveBeenCalledWith("test: 1"); + expect(mockConsole.info).toHaveBeenCalledWith("test: 2"); + expect(mockConsole.info).toHaveBeenCalledWith("test: 1"); + }); + + it("should always pass through groupEnd", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: false }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + proxiedConsole.groupEnd(); + expect(mockConsole.groupEnd).toHaveBeenCalled(); + }); + + it("should handle time/timeEnd with generation state", () => { + const startTime = 1000; + const endTime = 1500; + + // Mock different return values for different calls + mockGetGenerationState + .mockReturnValueOnce({ now: startTime, latest: false }) // for time() + .mockReturnValueOnce({ now: endTime, latest: true }); // for timeEnd() + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + proxiedConsole.time("test"); + proxiedConsole.timeEnd("test"); + + expect(mockConsole.info).toHaveBeenCalledWith("test: 500ms"); + }); + + it("should not call console methods for count when latest is false", () => { + mockGetGenerationState.mockReturnValue({ now: 1000, latest: false }); + + const proxiedConsole = createConsole( + mockConsole as unknown as Console, + mockGetGenerationState, + ); + + proxiedConsole.count("test"); + proxiedConsole.count("test"); + + // Should not call info when latest is false + expect(mockConsole.info).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/client/environment.ts b/src/client/environment.ts index 2693abd..04b5638 100644 --- a/src/client/environment.ts +++ b/src/client/environment.ts @@ -1,53 +1,181 @@ -import { OriginalEnv } from "./step.js"; -import { StepContext } from "./stepContext.js"; +type GenerationState = { now: number; latest: boolean }; -export function setupEnvironment(_ctx: StepContext): OriginalEnv { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const global = globalThis as any; +// Testable unit: patches Math object to restrict non-deterministic functions +export function patchMath(math: typeof Math): typeof Math { + const patchedMath = Object.create(Object.getPrototypeOf(math)); - global.Math.random = () => { - throw new Error("Math.random() isn't currently supported within workflows"); + // 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 throw + patchedMath.random = () => { + throw new Error("Math.random() isn't yet supported within workflows"); }; - const originalDate = global.Date; - delete global.Date; + return patchedMath; +} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function Date(this: any, ...args: any[]) { +// 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 Date)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const date = new (Date as any)(); + if (!(this instanceof DeterministicDate)) { + const date = new (DeterministicDate as typeof Date)(); return date.toString(); } if (args.length === 0) { - const unixTsMs = Date.now(); - return new originalDate(unixTsMs); + const { now } = getGenerationState(); + return new originalDate(now) as unknown as Date; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new (originalDate as any)(...args); + return new (originalDate as typeof Date)( + ...(args as ConstructorParameters), + ) as unknown as Date; } - Date.now = function () { - throw new Error("Date.now() isn't currently supported within workflows."); + + DeterministicDate.now = function () { + const { now } = getGenerationState(); + return now; }; - Date.parse = originalDate.parse; - Date.UTC = originalDate.UTC; - Date.prototype = originalDate.prototype; - Date.prototype.constructor = Date; + DeterministicDate.parse = originalDate.parse; + DeterministicDate.UTC = originalDate.UTC; + DeterministicDate.prototype = originalDate.prototype; + DeterministicDate.prototype.constructor = DeterministicDate as typeof Date; - global.Date = 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. - delete global.process; + return DeterministicDate as typeof Date; +} - delete global.Crypto; - delete global.crypto; - delete global.CryptoKey; - delete global.SubtleCrypto; +export function setupEnvironment( + getGenerationState: () => GenerationState, +): void { + const global = globalThis as Record; + + // Patch Math + global.Math = patchMath(global.Math as typeof Math); + + // Patch Date + const originalDate = global.Date as typeof Date; + global.Date = createDeterministicDate(originalDate, getGenerationState); + // Patch console + global.console = createConsole(global.console as Console, getGenerationState); + + // Patch fetch global.fetch = (_input: RequestInfo | URL, _init?: RequestInit) => { throw new Error( `Fetch isn't currently supported within workflows. Perform the fetch within an action and call it with step.runAction().`, ); }; - return { Date: originalDate }; + + // Remove non-deterministic globals + delete global.process; + delete global.Crypto; + delete global.crypto; + delete global.CryptoKey; + delete global.SubtleCrypto; + global.setTimeout = () => { + throw new Error("setTimeout isn't supported within workflows yet"); + }; + global.setInterval = () => { + throw new Error("setInterval isn't supported within workflows yet"); + }; +} + +function noop() {} + +// exported for testing +export function createConsole( + console: Console, + getGenerationState: () => GenerationState, +): Console { + const counts: Record = {}; + const times: Record = {}; + 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]; + }, + }); } diff --git a/src/client/index.ts b/src/client/index.ts index 37340f4..2cc5c5b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,8 +1,11 @@ +import type { + WorkpoolOptions, + WorkpoolRetryOptions, +} from "@convex-dev/workpool"; import { createFunctionHandle, type FunctionArgs, type FunctionReference, - type FunctionReturnType, type FunctionVisibility, type GenericDataModel, type GenericMutationCtx, @@ -10,46 +13,15 @@ import { type RegisteredMutation, type ReturnValueForOptionalValidator, } from "convex/server"; -import { safeFunctionName } from "./safeFunctionName.js"; import type { ObjectType, PropertyValidators, Validator } from "convex/values"; -import { api } from "../component/_generated/api.js"; -import { OnCompleteArgs, OpaqueIds, UseApi, WorkflowId } from "../types.js"; -import { workflowMutation } from "./workflowMutation.js"; -import type { - RetryOption, - WorkpoolOptions, - WorkpoolRetryOptions, -} from "@convex-dev/workpool"; import type { Step } from "../component/schema.js"; -export { vWorkflowId } from "../types.js"; - -export type { WorkflowId }; +import type { OnCompleteArgs, WorkflowId } from "../types.js"; +import { safeFunctionName } from "./safeFunctionName.js"; +import type { OpaqueIds, WorkflowComponent, WorkflowStep } from "./types.js"; +import { workflowMutation } from "./workflowMutation.js"; -export type RunOptions = { - /** - * The name of the function. By default, if you pass in api.foo.bar.baz, - * it will use "foo/bar:baz" as the name. If you pass in a function handle, - * it will use the function handle directly. - */ - name?: string; -} & ( - | { - /** - * The time (ms since epoch) to run the action at. - * If not provided, the action will be run as soon as possible. - * Note: this is advisory only. It may run later. - */ - runAt?: number; - } - | { - /** - * The number of milliseconds to run the action after. - * If not provided, the action will be run as soon as possible. - * Note: this is advisory only. It may run later. - */ - runAfter?: number; - } -); +export { vWorkflowId, type WorkflowId } from "../types.js"; +export type { RunOptions } from "./types.js"; export type CallbackOptions = { /** @@ -82,51 +54,6 @@ export type CallbackOptions = { context?: unknown; }; -export type WorkflowStep = { - /** - * The ID of the workflow currently running. - */ - workflowId: string; - /** - * Run a query with the given name and arguments. - * - * @param query - The query to run, like `internal.index.exampleQuery`. - * @param args - The arguments to the query function. - * @param opts - Options for scheduling and naming the query. - */ - runQuery>( - query: Query, - args: FunctionArgs, - opts?: RunOptions, - ): Promise>; - - /** - * Run a mutation with the given name and arguments. - * - * @param mutation - The mutation to run, like `internal.index.exampleMutation`. - * @param args - The arguments to the mutation function. - * @param opts - Options for scheduling and naming the mutation. - */ - runMutation>( - mutation: Mutation, - args: FunctionArgs, - opts?: RunOptions, - ): Promise>; - - /** - * Run an action with the given name and arguments. - * - * @param action - The action to run, like `internal.index.exampleAction`. - * @param args - The arguments to the action function. - * @param opts - Options for retrying, scheduling and naming the action. - */ - runAction>( - action: Action, - args: FunctionArgs, - opts?: RunOptions & RetryOption, - ): Promise>; -}; - export type WorkflowDefinition< ArgsValidator extends PropertyValidators, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -151,7 +78,7 @@ export type WorkflowStatus = export class WorkflowManager { constructor( - public component: UseApi, + public component: WorkflowComponent, public options?: { workpoolOptions: WorkpoolOptions; }, diff --git a/src/client/safeFunctionName.ts b/src/client/safeFunctionName.ts index 1e3600c..3497903 100644 --- a/src/client/safeFunctionName.ts +++ b/src/client/safeFunctionName.ts @@ -1,7 +1,7 @@ import { - FunctionReference, - FunctionType, - FunctionVisibility, + type FunctionReference, + type FunctionType, + type FunctionVisibility, getFunctionAddress, getFunctionName, } from "convex/server"; diff --git a/src/client/step.ts b/src/client/step.ts index e4600dd..0e8a837 100644 --- a/src/client/step.ts +++ b/src/client/step.ts @@ -1,31 +1,23 @@ +import type { + RetryBehavior, + RunResult, + WorkpoolOptions, +} from "@convex-dev/workpool"; import { BaseChannel } from "async-channel"; import { - type GenericMutationCtx, - type GenericDataModel, - type FunctionType, - type FunctionReference, createFunctionHandle, + type FunctionReference, + type FunctionType, + type GenericDataModel, + type GenericMutationCtx, } from "convex/server"; -import { convexToJson, Value } from "convex/values"; +import { convexToJson, type Value } from "convex/values"; import { type JournalEntry, journalEntrySize, valueSize, } from "../component/schema.js"; -import { api } from "../component/_generated/api.js"; -import type { UseApi } from "../types.js"; -import type { - RetryBehavior, - WorkpoolOptions, - RunResult, -} from "@convex-dev/workpool"; -import type { SchedulerOptions } from "./types.js"; - -export type OriginalEnv = { - Date: { - now: () => number; - }; -}; +import type { SchedulerOptions, WorkflowComponent } from "./types.js"; export type WorkerResult = | { type: "handlerDone"; runResult: RunResult } @@ -43,7 +35,7 @@ export type StepRequest = { reject: (error: unknown) => void; }; -const MAX_JOURNAL_SIZE = 1 << 20; +const MAX_JOURNAL_SIZE = 8 << 20; export class StepExecutor { private journalEntrySize: number; @@ -52,19 +44,22 @@ export class StepExecutor { private workflowId: string, private generationNumber: number, private ctx: GenericMutationCtx, - private component: UseApi, + private component: WorkflowComponent, private journalEntries: Array, private receiver: BaseChannel, - private originalEnv: OriginalEnv, + private now: number, private workpoolOptions: WorkpoolOptions | undefined, ) { this.journalEntrySize = journalEntries.reduce( (size, entry) => size + journalEntrySize(entry), 0, ); + + if (this.journalEntrySize > MAX_JOURNAL_SIZE) { + throw new Error(journalSizeError(this.journalEntrySize, this.workflowId)); + } } async run(): Promise { - // eslint-disable-next-line no-constant-condition while (true) { const message = await this.receiver.get(); // In the future we can correlate the calls to entries by handle, args, @@ -75,26 +70,33 @@ export class StepExecutor { this.completeMessage(message, entry); continue; } - // TODO: is this too late? - if (this.journalEntrySize > MAX_JOURNAL_SIZE) { - message.reject(journalSizeError(this.journalEntrySize)); - continue; - } const messages = [message]; const size = this.receiver.bufferSize; for (let i = 0; i < size; i++) { const message = await this.receiver.get(); messages.push(message); } - for (const message of messages) { - await this.startStep(message); - } + await this.startSteps(messages); return { type: "executorBlocked", }; } } + getGenerationState() { + if (this.journalEntries.length <= this.receiver.bufferSize) { + return { now: this.now, latest: true }; + } + return { + // We use the next entry's startedAt, since we're in code just before that + // step is invoked. We use the bufferSize, since multiple steps may be + // currently enqueued in one generation, but the code after it has already + // started executing. + now: this.journalEntries[this.receiver.bufferSize].step.startedAt, + latest: false, + }; + } + completeMessage(message: StepRequest, entry: JournalEntry) { if (entry.step.inProgress) { throw new Error( @@ -126,40 +128,54 @@ export class StepExecutor { } } - async startStep(message: StepRequest): Promise { - const step = { - inProgress: true, - name: message.name, - functionType: message.functionType, - handle: await createFunctionHandle(message.function), - args: message.args, - argsSize: valueSize(message.args as Value), - outcome: undefined, - startedAt: this.originalEnv.Date.now(), - completedAt: undefined, - }; - const entry = (await this.ctx.runMutation( - this.component.journal.startStep, + async startSteps(messages: StepRequest[]): Promise { + const steps = await Promise.all( + messages.map(async (message) => { + const step = { + inProgress: true, + name: message.name, + functionType: message.functionType, + handle: await createFunctionHandle(message.function), + args: message.args, + argsSize: valueSize(message.args as Value), + outcome: undefined, + startedAt: this.now, + completedAt: undefined, + }; + return { + retry: message.retry, + schedulerOptions: message.schedulerOptions, + step, + }; + }), + ); + const entries = (await this.ctx.runMutation( + this.component.journal.startSteps, { workflowId: this.workflowId, generationNumber: this.generationNumber, - step, - name: message.name, - retry: message.retry, + steps, workpoolOptions: this.workpoolOptions, - schedulerOptions: message.schedulerOptions, }, - )) as JournalEntry; - this.journalEntrySize += journalEntrySize(entry); - return entry; + )) as JournalEntry[]; + for (const entry of entries) { + this.journalEntrySize += journalEntrySize(entry); + if (this.journalEntrySize > MAX_JOURNAL_SIZE) { + throw new Error( + journalSizeError(this.journalEntrySize, this.workflowId) + + ` The failing step was ${entry.step.name} (${entry._id})`, + ); + } + } + return entries; } } -function journalSizeError(size: number): Error { +function journalSizeError(size: number, workflowId: string): string { const lines = [ - `Workflow journal size limit exceeded (${size} bytes > ${MAX_JOURNAL_SIZE} bytes).`, + `Workflow ${workflowId} journal size limit exceeded (${size} bytes > ${MAX_JOURNAL_SIZE} bytes).`, "Consider breaking up the workflow into multiple runs, using smaller step \ arguments or return values, or using fewer steps.", ]; - return new Error(lines.join("\n")); + return lines.join("\n"); } diff --git a/src/client/stepContext.ts b/src/client/stepContext.ts index e1e1af7..8145672 100644 --- a/src/client/stepContext.ts +++ b/src/client/stepContext.ts @@ -1,18 +1,19 @@ import { BaseChannel } from "async-channel"; -import { +import type { FunctionReference, FunctionArgs, FunctionReturnType, FunctionType, } from "convex/server"; import { safeFunctionName } from "./safeFunctionName.js"; -import type { RunOptions, WorkflowStep } from "./index.js"; import type { StepRequest } from "./step.js"; import type { RetryOption } from "@convex-dev/workpool"; +import type { RunOptions, WorkflowStep } from "./types.js"; +import type { WorkflowId } from "../types.js"; export class StepContext implements WorkflowStep { constructor( - public workflowId: string, + public workflowId: WorkflowId, private sender: BaseChannel, ) {} diff --git a/src/client/types.ts b/src/client/types.ts index 5f5443a..9e90065 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,3 +1,16 @@ +import type { RetryOption, WorkId } from "@convex-dev/workpool"; +import type { + Expand, + FunctionArgs, + FunctionReference, + FunctionReturnType, +} from "convex/server"; +import type { api } from "../component/_generated/api.js"; +import type { GenericId } from "convex/values"; +import type { WorkflowId } from "../types.js"; + +export type WorkflowComponent = UseApi; + export type RunOptions = { /** * The name of the function. By default, if you pass in api.foo.bar.baz, @@ -24,3 +37,77 @@ export type SchedulerOptions = */ runAfter?: number; }; + +export type WorkflowStep = { + /** + * The ID of the workflow currently running. + */ + workflowId: WorkflowId; + /** + * Run a query with the given name and arguments. + * + * @param query - The query to run, like `internal.index.exampleQuery`. + * @param args - The arguments to the query function. + * @param opts - Options for scheduling and naming the query. + */ + runQuery>( + query: Query, + args: FunctionArgs, + opts?: RunOptions, + ): Promise>; + + /** + * Run a mutation with the given name and arguments. + * + * @param mutation - The mutation to run, like `internal.index.exampleMutation`. + * @param args - The arguments to the mutation function. + * @param opts - Options for scheduling and naming the mutation. + */ + runMutation>( + mutation: Mutation, + args: FunctionArgs, + opts?: RunOptions, + ): Promise>; + + /** + * Run an action with the given name and arguments. + * + * @param action - The action to run, like `internal.index.exampleAction`. + * @param args - The arguments to the action function. + * @param opts - Options for retrying, scheduling and naming the action. + */ + runAction>( + action: Action, + args: FunctionArgs, + opts?: RunOptions & RetryOption, + ): Promise>; +}; + +export type UseApi = Expand<{ + [mod in keyof API]: API[mod] extends FunctionReference< + infer FType, + "public", + infer FArgs, + infer FReturnType, + infer FComponentPath + > + ? FunctionReference< + FType, + "internal", + OpaqueIds, + OpaqueIds, + FComponentPath + > + : UseApi; +}>; + +export type OpaqueIds = + T extends GenericId + ? string + : T extends WorkId + ? string + : T extends (infer U)[] + ? OpaqueIds[] + : T extends object + ? { [K in keyof T]: OpaqueIds } + : T; diff --git a/src/client/validator.ts b/src/client/validator.ts index 02b360b..a94b481 100644 --- a/src/client/validator.ts +++ b/src/client/validator.ts @@ -1,4 +1,9 @@ -import { GenericValidator, PropertyValidators, v, Value } from "convex/values"; +import { + type GenericValidator, + type PropertyValidators, + v, + type Value, +} from "convex/values"; export function checkArgs( args: Value, diff --git a/src/client/workflowMutation.ts b/src/client/workflowMutation.ts index a0fb102..2355a87 100644 --- a/src/client/workflowMutation.ts +++ b/src/client/workflowMutation.ts @@ -1,26 +1,30 @@ import { BaseChannel } from "async-channel"; import { assert } from "convex-helpers"; -import { validate } from "convex-helpers/validators"; -import { internalMutationGeneric, RegisteredMutation } from "convex/server"; +import { validate, ValidationError } from "convex-helpers/validators"; +import { + internalMutationGeneric, + type RegisteredMutation, +} from "convex/server"; import { asObjectValidator, - ObjectType, - PropertyValidators, + type ObjectType, + type PropertyValidators, v, } from "convex/values"; -import { api } from "../component/_generated/api.js"; import { createLogger } from "../component/logging.js"; -import { JournalEntry } from "../component/schema.js"; -import { UseApi } from "../types.js"; +import { type JournalEntry } from "../component/schema.js"; import { setupEnvironment } from "./environment.js"; -import { WorkflowDefinition } from "./index.js"; -import { StepExecutor, StepRequest, WorkerResult } from "./step.js"; +import type { WorkflowDefinition } from "./index.js"; +import { StepExecutor, type StepRequest, type WorkerResult } from "./step.js"; import { StepContext } from "./stepContext.js"; import { checkArgs } from "./validator.js"; -import { RunResult, WorkpoolOptions } from "@convex-dev/workpool"; +import { type RunResult, type WorkpoolOptions } from "@convex-dev/workpool"; +import { type WorkflowComponent } from "./types.js"; +import { vWorkflowId } from "../types.js"; +import { formatErrorWithStack } from "../shared.js"; const workflowArgs = v.object({ - workflowId: v.id("workflows"), + workflowId: vWorkflowId, generationNumber: v.number(), }); const INVALID_WORKFLOW_MESSAGE = `Invalid arguments for workflow: Did you invoke the workflow with ctx.runMutation() instead of workflow.start()?`; @@ -30,7 +34,7 @@ const INVALID_WORKFLOW_MESSAGE = `Invalid arguments for workflow: Did you invoke // one "poll" of the workflow, replaying its execution from the journal until // it blocks next. export function workflowMutation( - component: UseApi, + component: WorkflowComponent, registered: WorkflowDefinition, defaultWorkpoolOptions?: WorkpoolOptions, ): RegisteredMutation<"internal", ObjectType, void> { @@ -88,7 +92,6 @@ export function workflowMutation( workpoolOptions.maxParallelism ?? 10, ); const step = new StepContext(workflowId, channel); - const originalEnv = setupEnvironment(step); const executor = new StepExecutor( workflowId, generationNumber, @@ -96,9 +99,10 @@ export function workflowMutation( component, journalEntries as JournalEntry[], channel, - originalEnv, + Date.now(), workpoolOptions, ); + setupEnvironment(executor.getGenerationState.bind(executor)); const handlerWorker = async (): Promise => { let runResult: RunResult; @@ -114,7 +118,13 @@ export function workflowMutation( }); } catch (error) { const message = - error instanceof Error ? error.message : `${error}`; + error instanceof ValidationError + ? error.message + : formatErrorWithStack(error); + console.error( + "Workflow handler returned invalid return value: ", + message, + ); runResult = { kind: "failed", error: "Invalid return value: " + message, @@ -122,7 +132,9 @@ export function workflowMutation( } } } catch (error) { - runResult = { kind: "failed", error: (error as Error).message }; + const message = formatErrorWithStack(error); + console.error(message); + runResult = { kind: "failed", error: message }; } return { type: "handlerDone", runResult }; }; diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index ced29e6..2caf60c 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -85,31 +85,32 @@ export type Mounts = { }; } >; - startStep: FunctionReference< + startSteps: FunctionReference< "mutation", "public", { generationNumber: number; - name: string; - retry?: - | boolean - | { base: number; initialBackoffMs: number; maxAttempts: number }; - schedulerOptions?: { runAt?: number } | { runAfter?: number }; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; + steps: Array<{ + retry?: + | boolean + | { base: number; initialBackoffMs: number; maxAttempts: number }; + schedulerOptions?: { runAt?: number } | { runAfter?: number }; + step: { + args: any; + argsSize: number; + completedAt?: number; + functionType: "query" | "mutation" | "action"; + handle: string; + inProgress: boolean; + name: string; + runResult?: + | { kind: "success"; returnValue: any } + | { error: string; kind: "failed" } + | { kind: "canceled" }; + startedAt: number; + workId?: string; + }; + }>; workflowId: string; workpoolOptions?: { defaultRetryBehavior?: { @@ -122,7 +123,7 @@ export type Mounts = { retryActionsByDefault?: boolean; }; }, - { + Array<{ _creationTime: number; _id: string; step: { @@ -142,7 +143,7 @@ export type Mounts = { }; stepNumber: number; workflowId: string; - } + }> >; }; workflow: { @@ -288,6 +289,30 @@ export declare const components: { }, string >; + enqueueBatch: FunctionReference< + "mutation", + "internal", + { + config: { + logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; + maxParallelism: number; + }; + items: Array<{ + fnArgs: any; + fnHandle: string; + fnName: string; + fnType: "action" | "mutation" | "query"; + onComplete?: { context?: any; fnHandle: string }; + retryBehavior?: { + base: number; + initialBackoffMs: number; + maxAttempts: number; + }; + runAt: number; + }>; + }, + Array + >; status: FunctionReference< "query", "internal", @@ -296,6 +321,16 @@ export declare const components: { | { previousAttempts: number; state: "running" } | { state: "finished" } >; + statusBatch: FunctionReference< + "query", + "internal", + { ids: Array }, + Array< + | { previousAttempts: number; state: "pending" } + | { previousAttempts: number; state: "running" } + | { state: "finished" } + > + >; }; }; }; diff --git a/src/component/journal.ts b/src/component/journal.ts index 42dc382..df9642e 100644 --- a/src/component/journal.ts +++ b/src/component/journal.ts @@ -2,17 +2,21 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server.js"; import { journalDocument, - JournalEntry, + type JournalEntry, journalEntrySize, step, workflowDocument, } from "./schema.js"; import { getWorkflow } from "./model.js"; import { logLevel } from "./logging.js"; -import { vRetryBehavior, WorkId } from "@convex-dev/workpool"; -import { getWorkpool, OnCompleteContext, workpoolOptions } from "./pool.js"; +import { vRetryBehavior, type WorkId } from "@convex-dev/workpool"; +import { + getWorkpool, + type OnCompleteContext, + workpoolOptions, +} from "./pool.js"; import { internal } from "./_generated/api.js"; -import { FunctionHandle } from "convex/server"; +import { type FunctionHandle } from "convex/server"; import { getDefaultLogger } from "./utils.js"; import { assert } from "convex-helpers"; @@ -45,31 +49,31 @@ export const load = query({ }, }); -export const startStep = mutation({ +export const startSteps = mutation({ args: { workflowId: v.string(), generationNumber: v.number(), - name: v.string(), - step, - workpoolOptions: v.optional(workpoolOptions), - retry: v.optional(v.union(v.boolean(), vRetryBehavior)), - schedulerOptions: v.optional( - v.union( - v.object({ runAt: v.optional(v.number()) }), - v.object({ runAfter: v.optional(v.number()) }), - ), + steps: v.array( + v.object({ + step, + retry: v.optional(v.union(v.boolean(), vRetryBehavior)), + schedulerOptions: v.optional( + v.union( + v.object({ runAt: v.optional(v.number()) }), + v.object({ runAfter: v.optional(v.number()) }), + ), + ), + }), ), + workpoolOptions: v.optional(workpoolOptions), }, - returns: journalDocument, - handler: async (ctx, args): Promise => { - if (!args.step.inProgress) { + returns: v.array(journalDocument), + handler: async (ctx, args): Promise => { + if (!args.steps.every((step) => step.step.inProgress)) { throw new Error(`Assertion failed: not in progress`); } - const workflow = await getWorkflow( - ctx, - args.workflowId, - args.generationNumber, - ); + const { generationNumber } = args; + const workflow = await getWorkflow(ctx, args.workflowId, generationNumber); const console = await getDefaultLogger(ctx); if (workflow.runResult !== undefined) { @@ -80,60 +84,68 @@ export const startStep = mutation({ .withIndex("workflow", (q) => q.eq("workflowId", workflow._id)) .order("desc") .first(); - const stepNumber = maxEntry ? maxEntry.stepNumber + 1 : 0; - const { name, step, generationNumber, retry } = args; - const stepId = await ctx.db.insert("steps", { - workflowId: workflow._id, - stepNumber, - step, - }); - const entry = await ctx.db.get(stepId); - assert(entry, "Step not found"); + const stepNumberBase = maxEntry ? maxEntry.stepNumber + 1 : 0; const workpool = await getWorkpool(ctx, args.workpoolOptions); const onComplete = internal.pool.onComplete; - const context: OnCompleteContext = { - generationNumber, - stepId, - }; - let workId: WorkId; - switch (step.functionType) { - case "query": { - workId = await workpool.enqueueQuery( - ctx, - step.handle as FunctionHandle<"query">, - step.args, - { context, onComplete, name, ...args.schedulerOptions }, - ); - break; - } - case "mutation": { - workId = await workpool.enqueueMutation( - ctx, - step.handle as FunctionHandle<"mutation">, - step.args, - { context, onComplete, name, ...args.schedulerOptions }, - ); - break; - } - case "action": { - workId = await workpool.enqueueAction( - ctx, - step.handle as FunctionHandle<"action">, - step.args, - { context, onComplete, name, retry, ...args.schedulerOptions }, - ); - break; - } - } - entry.step.workId = workId; - await ctx.db.replace(entry._id, entry); - console.event("started", { - workflowId: workflow._id, - workflowName: workflow.name, - stepName: step.name, - stepNumber, - }); - return entry; + const entries = await Promise.all( + args.steps.map(async (stepArgs, index) => { + const { step, retry, schedulerOptions } = stepArgs; + const { name, handle, args } = step; + const stepNumber = stepNumberBase + index; + const stepId = await ctx.db.insert("steps", { + workflowId: workflow._id, + stepNumber, + step, + }); + const entry = await ctx.db.get(stepId); + assert(entry, "Step not found"); + const context: OnCompleteContext = { + generationNumber, + stepId, + }; + let workId: WorkId; + switch (step.functionType) { + case "query": { + workId = await workpool.enqueueQuery( + ctx, + handle as FunctionHandle<"query">, + args, + { context, onComplete, name, ...schedulerOptions }, + ); + break; + } + case "mutation": { + workId = await workpool.enqueueMutation( + ctx, + handle as FunctionHandle<"mutation">, + args, + { context, onComplete, name, ...schedulerOptions }, + ); + break; + } + case "action": { + workId = await workpool.enqueueAction( + ctx, + handle as FunctionHandle<"action">, + args, + { context, onComplete, name, retry, ...schedulerOptions }, + ); + break; + } + } + entry.step.workId = workId; + await ctx.db.replace(entry._id, entry); + + console.event("started", { + workflowId: workflow._id, + workflowName: workflow.name, + stepName: name, + stepNumber, + }); + return entry; + }), + ); + return entries; }, }); diff --git a/src/component/logging.ts b/src/component/logging.ts index f48019a..18ce9e4 100644 --- a/src/component/logging.ts +++ b/src/component/logging.ts @@ -1,4 +1,4 @@ -import { v, Infer } from "convex/values"; +import { v, type Infer } from "convex/values"; export const DEFAULT_LOG_LEVEL: LogLevel = "WARN"; diff --git a/src/component/model.ts b/src/component/model.ts index cfd51df..8a5f46e 100644 --- a/src/component/model.ts +++ b/src/component/model.ts @@ -1,4 +1,4 @@ -import { QueryCtx } from "./_generated/server.js"; +import type { QueryCtx } from "./_generated/server.js"; export async function getWorkflow( ctx: QueryCtx, diff --git a/src/component/pool.ts b/src/component/pool.ts index 750a000..781ad68 100644 --- a/src/component/pool.ts +++ b/src/component/pool.ts @@ -3,18 +3,18 @@ import { vRetryBehavior, vWorkIdValidator, Workpool, - WorkpoolOptions, + type WorkpoolOptions, } from "@convex-dev/workpool"; import { assert } from "convex-helpers"; import { validate } from "convex-helpers/validators"; import { - FunctionHandle, - FunctionReference, - RegisteredAction, + type FunctionHandle, + type FunctionReference, + type RegisteredAction, } from "convex/server"; -import { Infer, v } from "convex/values"; +import { type Infer, v } from "convex/values"; import { components, internal } from "./_generated/api.js"; -import { internalMutation, MutationCtx } from "./_generated/server.js"; +import { internalMutation, type MutationCtx } from "./_generated/server.js"; import { logLevel } from "./logging.js"; import { getWorkflow } from "./model.js"; import { getDefaultLogger } from "./utils.js"; @@ -71,8 +71,11 @@ export const onComplete = internalMutation({ returns: v.null(), handler: async (ctx, args) => { const console = await getDefaultLogger(ctx); - const stepId = args.context.stepId; - if (!validate(v.id("steps"), stepId, { db: ctx.db })) { + const stepId = + "stepId" in args.context + ? ctx.db.normalizeId("steps", args.context.stepId) + : null; + if (!stepId) { // Write to failures table and return // So someone can investigate if this ever happens console.error("Invalid onComplete context", args.context); @@ -83,13 +86,12 @@ export const onComplete = internalMutation({ assert(journalEntry, `Journal entry not found: ${stepId}`); const workflowId = journalEntry.workflowId; - const error = !validate(onCompleteContext, args.context) - ? `Invalid onComplete context for workId ${args.workId}` + - JSON.stringify(args.context) - : !journalEntry.step.inProgress - ? `Journal entry not in progress: ${stepId}` - : undefined; - if (error) { + if ( + !validate(onCompleteContext, args.context, { allowUnknownFields: true }) + ) { + const error = + `Invalid onComplete context for workId ${args.workId}` + + JSON.stringify(args.context); await ctx.db.patch(workflowId, { runResult: { kind: "failed", @@ -99,6 +101,19 @@ export const onComplete = internalMutation({ return; } const { generationNumber } = args.context; + const workflow = await getWorkflow(ctx, workflowId, null); + if (workflow.generationNumber !== generationNumber) { + console.error( + `Workflow: ${workflowId} already has generation number ${workflow.generationNumber} when completing ${stepId}`, + ); + return; + } + if (!journalEntry.step.inProgress) { + console.error( + `Step finished but journal entry not in progress: ${stepId} status: ${journalEntry.step.runResult?.kind ?? "pending"}`, + ); + return; + } journalEntry.step.inProgress = false; journalEntry.step.completedAt = Date.now(); switch (args.result.kind) { @@ -123,7 +138,6 @@ export const onComplete = internalMutation({ await ctx.db.replace(journalEntry._id, journalEntry); console.debug(`Completed execution of ${stepId}`, journalEntry); - const workflow = await getWorkflow(ctx, workflowId, null); console.event("stepCompleted", { workflowId, workflowName: workflow.name, @@ -140,18 +154,13 @@ export const onComplete = internalMutation({ } return; } - if (workflow.generationNumber !== generationNumber) { - console.error( - `Workflow: ${workflowId} already has generation number ${workflow.generationNumber} when completing ${stepId}`, - ); - return; - } const workpool = await getWorkpool(ctx, args.context.workpoolOptions); await workpool.enqueueMutation( ctx, workflow.workflowHandle as FunctionHandle<"mutation">, { workflowId: workflow._id, generationNumber }, { + name: workflow.name, onComplete: internal.pool.handlerOnComplete, context: { workflowId, generationNumber }, }, @@ -188,23 +197,27 @@ export const handlerOnComplete = internalMutation({ const console = await getDefaultLogger(ctx); if (!validate(handlerOnCompleteContext, args.context)) { console.error("Invalid handlerOnComplete context", args.context); - if ( - validate(v.id("workflows"), args.context.workflowId, { db: ctx.db }) - ) { - await ctx.db.insert("onCompleteFailures", args); - await completeHandler(ctx, { - workflowId: args.context.workflowId, - generationNumber: args.context.generationNumber, - runResult: { - kind: "failed", - error: - "Invalid handlerOnComplete context: " + - JSON.stringify(args.context), - }, - }).catch((error) => { - console.error("Error calling completeHandler", error); - }); + const workflowId = ctx.db.normalizeId( + "workflows", + args.context.workflowId, + ); + await ctx.db.insert("onCompleteFailures", args); + if (!workflowId) { + console.error("Invalid workflow ID", args.context.workflowId); + return; } + await completeHandler(ctx, { + workflowId: args.context.workflowId, + generationNumber: args.context.generationNumber, + runResult: { + kind: "failed", + error: + "Invalid handlerOnComplete context: " + + JSON.stringify(args.context), + }, + }).catch((error) => { + console.error("Error calling completeHandler", error); + }); return; } const { workflowId, generationNumber } = args.context; diff --git a/src/component/schema.ts b/src/component/schema.ts index ad1443a..c96c0af 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -1,10 +1,10 @@ import { vResultValidator, - RunResult, + type RunResult, vWorkIdValidator, } from "@convex-dev/workpool"; import { defineSchema, defineTable } from "convex/server"; -import { convexToJson, Infer, v, Value } from "convex/values"; +import { convexToJson, type Infer, v, type Value } from "convex/values"; import { logLevel } from "./logging.js"; import { deprecated, literals } from "convex-helpers/validators"; @@ -98,23 +98,23 @@ const journalObject = { step, }; -export const journalDocument = v.object({ - _id: v.string(), - _creationTime: v.number(), - ...journalObject, -}); -export type JournalEntry = Infer; - export function journalEntrySize(entry: JournalEntry): number { let size = 0; - size += entry._id.length; - size += 8; // _creationTime size += entry.workflowId.length; size += 8; // stepNumber size += stepSize(entry.step); + size += entry._id.length; + size += 8; // _creationTime return size; } +export const journalDocument = v.object({ + _id: v.string(), + _creationTime: v.number(), + ...journalObject, +}); +export type JournalEntry = Infer; + export default defineSchema({ config: defineTable({ logLevel: v.optional(logLevel), diff --git a/src/component/utils.ts b/src/component/utils.ts index 3edb088..1d335c1 100644 --- a/src/component/utils.ts +++ b/src/component/utils.ts @@ -1,5 +1,5 @@ import { v } from "convex/values"; -import { internalMutation, QueryCtx } from "./_generated/server.js"; +import { internalMutation, type QueryCtx } from "./_generated/server.js"; import { createLogger, DEFAULT_LOG_LEVEL, logLevel } from "./logging.js"; export async function getDefaultLogger(ctx: QueryCtx) { diff --git a/src/component/workflow.ts b/src/component/workflow.ts index 5f59e5d..fe03b06 100644 --- a/src/component/workflow.ts +++ b/src/component/workflow.ts @@ -1,14 +1,16 @@ import { vResultValidator } from "@convex-dev/workpool"; import { assert } from "convex-helpers"; -import { FunctionHandle } from "convex/server"; -import { Infer, v } from "convex/values"; -import { mutation, MutationCtx, query } from "./_generated/server.js"; -import { Logger, logLevel } from "./logging.js"; +import type { FunctionHandle } from "convex/server"; +import { type Infer, v } from "convex/values"; +import { mutation, type MutationCtx, query } from "./_generated/server.js"; +import { type Logger, logLevel } from "./logging.js"; import { getWorkflow } from "./model.js"; import { getWorkpool } from "./pool.js"; import { journalDocument, vOnComplete, workflowDocument } from "./schema.js"; import { getDefaultLogger } from "./utils.js"; -import { WorkflowId, OnCompleteArgs } from "../types.js"; +import type { WorkflowId, OnCompleteArgs } from "../types.js"; +import { internal } from "./_generated/api.js"; +import { formatErrorWithStack } from "../shared.js"; export const create = mutation({ args: { @@ -42,6 +44,11 @@ export const create = mutation({ ctx, args.workflowHandle as FunctionHandle<"mutation">, { workflowId, generationNumber: 0 }, + { + name: args.workflowName, + onComplete: internal.pool.handlerOnComplete, + context: { workflowId, generationNumber: 0 }, + }, ); } else { // If we can't start it, may as well not create it, eh? Fail fast... @@ -107,6 +114,7 @@ export const complete = mutation({ handler: completeHandler, }); +// When the overall workflow completes (successfully or not). export async function completeHandler( ctx: MutationCtx, args: Infer, @@ -130,6 +138,7 @@ export async function completeHandler( if (workflow.runResult.kind === "canceled") { // We bump it so no in-flight steps succeed / we don't race to complete. workflow.generationNumber += 1; + // TODO: can we cancel these asynchronously if there's more than one? const inProgress = await ctx.db .query("steps") .withIndex("inProgress", (q) => @@ -162,10 +171,11 @@ export async function completeHandler( }, ); } catch (error) { - console.error("Error calling onComplete", error); + const message = formatErrorWithStack(error); + console.error("Error calling onComplete", message); await ctx.db.insert("onCompleteFailures", { ...args, - error: error instanceof Error ? error.message : String(error), + error: message, }); } } diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..bd4e39d --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,6 @@ +export function formatErrorWithStack(error: unknown): string { + if (error instanceof Error) { + return error.toString() + (error.stack ? "\n" + error.stack : ""); + } + return String(error); +} diff --git a/src/types.ts b/src/types.ts index 6e73a9c..a0fb107 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,38 +1,9 @@ -import { RunResult, WorkId } from "@convex-dev/workpool"; -import { Expand, FunctionReference } from "convex/server"; -import { GenericId, v, VString } from "convex/values"; +import type { RunResult } from "@convex-dev/workpool"; +import { v, type VString } from "convex/values"; export type WorkflowId = string & { __isWorkflowId: true }; export const vWorkflowId = v.string() as VString; -export type UseApi = Expand<{ - [mod in keyof API]: API[mod] extends FunctionReference< - infer FType, - "public", - infer FArgs, - infer FReturnType, - infer FComponentPath - > - ? FunctionReference< - FType, - "internal", - OpaqueIds, - OpaqueIds, - FComponentPath - > - : UseApi; -}>; - -export type OpaqueIds = - T extends GenericId - ? string - : T extends WorkId - ? string - : T extends (infer U)[] - ? OpaqueIds[] - : T extends object - ? { [K in keyof T]: OpaqueIds } - : T; export type OnCompleteArgs = { /** * The ID of the work that completed. diff --git a/tsconfig.build.json b/tsconfig.build.json index 7096d03..3a8941e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,11 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*"], - "exclude": [ - "src/**/*.test.*", - "../src/package.json", - "./src/vitest.config.ts" - ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.d.ts"], + "exclude": ["src/**/*.test.*", "src/vitest.config.ts"], "compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", diff --git a/tsconfig.json b/tsconfig.json index 91ec09c..3458716 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,19 +3,27 @@ "allowJs": true, "checkJs": true, "strict": true, + "jsx": "react-jsx", "target": "ESNext", - "lib": ["ES2021", "dom"], + "lib": ["ES2021", "dom", "dom.iterable"], "forceConsistentCasingInFileNames": true, "allowSyntheticDefaultImports": true, - "module": "NodeNext", + // We enforce stricter module resolution for Node16 compatibility + // But when building we use Bundler & ESNext for ESM + "module": "Node16", "moduleResolution": "NodeNext", + // Let's see how long we can go without this... + // "assumeChangesOnlyAffectDirectDependencies": false, + // "composite": true, + // "rootDir": "./src", "isolatedModules": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", + "verbatimModuleSyntax": true, "skipLibCheck": true }, "include": ["./src/**/*"]