├── .github └── workflows │ └── test-and-lint.yml ├── .gitignore ├── .knip.json ├── .prettierignore ├── .prettierrc.cjs ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api-extractor-configs ├── README.md ├── api-extractor-base.json ├── browser-api-extractor.json ├── react-api-extractor.json ├── react-auth0-api-extractor.json ├── react-clerk-api-extractor.json ├── reports │ └── server.api.md ├── server-api-extractor.json └── values-api-extractor.json ├── bin ├── main-dev └── main.js ├── browser-bundle.js ├── browser └── package.json ├── config └── rush-project.json ├── custom-vitest-environment.ts ├── eslint.config.mjs ├── jest.config.mjs ├── nextjs └── package.json ├── package.json ├── react-auth0 └── package.json ├── react-clerk └── package.json ├── react └── package.json ├── schemas └── convex.schema.json ├── scripts ├── build.cjs ├── build.py ├── bundle-server.mjs ├── checkdeps.mjs ├── checkimports.mjs ├── node-browser.mjs ├── postpack.mjs ├── prepack.mjs └── test-esm.mjs ├── server └── package.json ├── src ├── browser │ ├── http_client.ts │ ├── index.ts │ ├── logging.ts │ ├── long.ts │ ├── simple_client-node.ts │ ├── simple_client.test.ts │ ├── simple_client.ts │ └── sync │ │ ├── authentication_manager.ts │ │ ├── client.test.ts │ │ ├── client.ts │ │ ├── client_node.test.ts │ │ ├── client_node_test_helpers.ts │ │ ├── function_result.ts │ │ ├── local_state.test.ts │ │ ├── local_state.ts │ │ ├── metrics.ts │ │ ├── optimistic_query_set.test.ts │ │ ├── optimistic_updates.ts │ │ ├── optimistic_updates_impl.ts │ │ ├── protocol.test.ts │ │ ├── protocol.ts │ │ ├── remote_query_set.ts │ │ ├── request_manager.test.ts │ │ ├── request_manager.ts │ │ ├── session.ts │ │ ├── udf_path_utils.ts │ │ └── web_socket_manager.ts ├── bundler │ ├── .eslintrc.cjs │ ├── context.ts │ ├── external.ts │ ├── fs.test.ts │ ├── fs.ts │ ├── index.test.ts │ ├── index.ts │ ├── test_fixtures │ │ └── js │ │ │ ├── project01 │ │ │ ├── bar.js │ │ │ ├── file with spaces.js │ │ │ └── foo.js │ │ │ ├── project_with_https │ │ │ └── https.js │ │ │ ├── project_with_https_not_at_top_level │ │ │ ├── default.js │ │ │ └── more_code │ │ │ │ └── https.js │ │ │ └── project_with_https_without_router │ │ │ └── https.js │ └── wasm.ts ├── cli │ ├── auth.ts │ ├── codegen.ts │ ├── codegen_templates │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── api_cjs.ts │ │ ├── common.ts │ │ ├── component_api.ts │ │ ├── component_server.ts │ │ ├── dataModel.ts │ │ ├── readme.ts │ │ ├── server.ts │ │ ├── templates.test.ts │ │ ├── tsconfig.ts │ │ └── validator_helpers.ts │ ├── configure.ts │ ├── convexExport.ts │ ├── convexImport.ts │ ├── dashboard.ts │ ├── data.ts │ ├── deploy.ts │ ├── deployments.ts │ ├── dev.ts │ ├── disableLocalDev.ts │ ├── docs.ts │ ├── env.ts │ ├── functionSpec.ts │ ├── index.ts │ ├── init.ts │ ├── lib │ │ ├── api.ts │ │ ├── codegen.ts │ │ ├── command.ts │ │ ├── components.ts │ │ ├── components │ │ │ ├── constants.ts │ │ │ └── definition │ │ │ │ ├── bundle.ts │ │ │ │ └── directoryStructure.ts │ │ ├── config.test.ts │ │ ├── config.ts │ │ ├── convexExport.ts │ │ ├── convexImport.ts │ │ ├── dashboard.ts │ │ ├── data.ts │ │ ├── debugBundlePath.ts │ │ ├── deploy2.ts │ │ ├── deployApi │ │ │ ├── checkedComponent.ts │ │ │ ├── componentDefinition.ts │ │ │ ├── definitionConfig.ts │ │ │ ├── finishPush.ts │ │ │ ├── modules.ts │ │ │ ├── paths.ts │ │ │ ├── startPush.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── validator.ts │ │ ├── deployment.test.ts │ │ ├── deployment.ts │ │ ├── deploymentSelection.ts │ │ ├── dev.ts │ │ ├── env.ts │ │ ├── envvars.ts │ │ ├── fsUtils.test.ts │ │ ├── fsUtils.ts │ │ ├── functionSpec.ts │ │ ├── indexes.ts │ │ ├── init.ts │ │ ├── localDeployment │ │ │ ├── anonymous.ts │ │ │ ├── bigBrain.ts │ │ │ ├── dashboard.ts │ │ │ ├── download.ts │ │ │ ├── errors.ts │ │ │ ├── filePaths.ts │ │ │ ├── localDeployment.ts │ │ │ ├── run.test.ts │ │ │ ├── run.ts │ │ │ ├── serve.ts │ │ │ ├── upgrade.ts │ │ │ └── utils.ts │ │ ├── login.ts │ │ ├── logs.ts │ │ ├── mcp │ │ │ ├── requestContext.ts │ │ │ └── tools │ │ │ │ ├── data.ts │ │ │ │ ├── env.ts │ │ │ │ ├── functionSpec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── run.ts │ │ │ │ ├── runOneoffQuery.ts │ │ │ │ ├── status.ts │ │ │ │ └── tables.ts │ │ ├── networkTest.ts │ │ ├── push.ts │ │ ├── run.test.ts │ │ ├── run.ts │ │ ├── tracing.ts │ │ ├── typecheck.ts │ │ ├── usage.ts │ │ ├── utils │ │ │ ├── globalConfig.ts │ │ │ ├── mutex.ts │ │ │ ├── prompts.ts │ │ │ ├── sentry.ts │ │ │ └── utils.ts │ │ └── watch.ts │ ├── login.ts │ ├── logout.ts │ ├── logs.ts │ ├── mcp.ts │ ├── network_test.ts │ ├── reinit.ts │ ├── run.ts │ ├── typecheck.ts │ ├── update.ts │ └── version.ts ├── common │ ├── README.md │ ├── index.test.ts │ └── index.ts ├── index.ts ├── nextjs │ ├── index.ts │ └── nextjs.test.tsx ├── react-auth0 │ ├── ConvexProviderWithAuth0.test.tsx │ ├── ConvexProviderWithAuth0.tsx │ └── index.ts ├── react-clerk │ ├── ConvexProviderWithClerk.test.tsx │ ├── ConvexProviderWithClerk.tsx │ └── index.ts ├── react │ ├── ConvexAuthState.test.tsx │ ├── ConvexAuthState.tsx │ ├── auth_helpers.test.tsx │ ├── auth_helpers.tsx │ ├── auth_websocket.test.tsx │ ├── client.test.tsx │ ├── client.ts │ ├── hydration.tsx │ ├── index.ts │ ├── queries_observer.test.ts │ ├── queries_observer.ts │ ├── react_node.test.ts │ ├── use_paginated_query.test.tsx │ ├── use_paginated_query.ts │ ├── use_queries.test.ts │ ├── use_queries.ts │ ├── use_query.test.ts │ └── use_subscription.ts ├── server │ ├── README.md │ ├── api.test.ts │ ├── api.ts │ ├── authentication.ts │ ├── components │ │ ├── definition.ts │ │ ├── index.ts │ │ └── paths.ts │ ├── cron.ts │ ├── data_model.test.ts │ ├── data_model.ts │ ├── database.test.ts │ ├── database.ts │ ├── filter_builder.test.ts │ ├── filter_builder.ts │ ├── functionName.ts │ ├── functions.ts │ ├── impl │ │ ├── actions_impl.ts │ │ ├── authentication_impl.ts │ │ ├── database_impl.ts │ │ ├── filter_builder_impl.test.ts │ │ ├── filter_builder_impl.ts │ │ ├── index_range_builder_impl.ts │ │ ├── query_impl.test.ts │ │ ├── query_impl.ts │ │ ├── registration_impl.ts │ │ ├── scheduler_impl.ts │ │ ├── search_filter_builder_impl.ts │ │ ├── storage_impl.ts │ │ ├── syscall.ts │ │ ├── validate.ts │ │ └── vector_search_impl.ts │ ├── index.ts │ ├── index_range_builder.ts │ ├── pagination.test.ts │ ├── pagination.ts │ ├── query.test.ts │ ├── query.ts │ ├── registration.test.ts │ ├── registration.ts │ ├── router.test.ts │ ├── router.ts │ ├── scheduler.test.ts │ ├── scheduler.ts │ ├── schema.test.ts │ ├── schema.ts │ ├── search_filter_builder.ts │ ├── storage.ts │ ├── system_fields.ts │ └── vector_search.ts ├── test │ ├── fake_watch.ts │ ├── test_resolver.cjs │ └── type_testing.ts ├── type_utils.test.ts ├── type_utils.ts ├── types.d.ts └── values │ ├── base64.ts │ ├── compare.ts │ ├── compare_utf8.ts │ ├── errors.ts │ ├── index.ts │ ├── validator.test.ts │ ├── validator.ts │ ├── validators.ts │ ├── value.test.ts │ └── value.ts ├── tsconfig.json ├── values └── package.json └── vitest.config.js /.github/workflows/test-and-lint.yml: -------------------------------------------------------------------------------- 1 | name: Test and lint 2 | concurrency: 3 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 4 | cancel-in-progress: true 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: ["**"] 11 | 12 | jobs: 13 | check: 14 | name: Test and lint 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Node setup 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: "18.20.8" 25 | 26 | - name: NPM v8 27 | run: npm install -g npm@8 --registry=https://registry.npmjs.org 28 | 29 | - run: npm i 30 | 31 | - run: npm run test 32 | 33 | - run: npm run test-esm 34 | 35 | - run: npm run format-check 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | temp/ 2 | dist 3 | node_modules 4 | 5 | # The convex npm package does not use a package-lock.json. 6 | package-lock.json 7 | 8 | # Using in packaging script 9 | tmpPackage* 10 | 11 | # used while building dist 12 | tmpDist* 13 | -------------------------------------------------------------------------------- /.knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "entry": ["src/*/index.ts", "src/**/*.test.*", "scripts/*.{mjs,js,cjs}"], 4 | "project": ["src/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}", "scripts/**/*.js"] 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | temp 2 | .git/ 3 | dist/ 4 | api-extractor-configs/reports 5 | api-extractor-configs/temp 6 | tmpDist* 7 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | proseWrap: "always", 3 | trailingComma: "all", 4 | }; 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to convex-js. 4 | 5 | For anything not covered here, feel free to ask in the 6 | [Convex Discord Community](https://convex.dev/community). 7 | 8 | ### I have a question 9 | 10 | Great, please use GitHub discussions for this, or ask in Discord. 11 | 12 | ## I have a feature suggestion 13 | 14 | Great, please open a GitHub issue on this repository for this or share in 15 | Discord. 16 | 17 | ### I want to make a pull request 18 | 19 | convex-js is developed primarily by employees of Convex Inc. We're excited to 20 | provide transparency to our customers and contribute to the community by 21 | releasing this code. We can accept some pull requests from non-employee 22 | contributors, but please check in on Discord or in GitHub issues before getting 23 | into anything more than small fixes to see if it's consistent with our short 24 | term plan. We think carefully about how our APIs contribute to a cohesive 25 | product, so chatting up front goes a long way. 26 | 27 | Client tests can be run with 28 | 29 | ``` 30 | npm test 31 | ``` 32 | 33 | but be aware that there are integration tests, end-to-end tests, proptests, and 34 | more which test this code but are not located in this repository. 35 | 36 | # Directory structure notes 37 | 38 | Code generally lives in the src/ directory. 39 | 40 | There nearly-empty directories for each entry point at the top level implement 41 | the 'package-json-redirects' strategy described at 42 | https://github.com/andrewbranch/example-subpath-exports-ts-compat in an effort 43 | to make the convex npm package as compatible as possible while making the 44 | published package mirror the filesystem of this repository. 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convex 2 | 3 | TypeScript/JavaScript client libraries and CLI for Convex. 4 | 5 | Convex is the backend application platform with everything you need to build 6 | your product. 7 | 8 | Get started at [docs.convex.dev](https://docs.convex.dev)! 9 | 10 | Or see [Convex demos](https://github.com/get-convex/convex-demos). 11 | 12 | Open discussions and issues in this repository about Convex 13 | TypeScript/JavaScript clients, the Convex CLI, or the Convex platform in 14 | general. 15 | 16 | Also feel free to share feature requests, product feedback, or general questions 17 | in the [Convex Discord Community](https://convex.dev/community). 18 | 19 | # Structure 20 | 21 | This package includes several entry points for building apps on Convex: 22 | 23 | - [`convex/server`](https://docs.convex.dev/api/modules/server): Helpers for 24 | implementing Convex functions and defining a database schema. 25 | - [`convex/react`](https://docs.convex.dev/api/modules/react): Hooks and a 26 | `ConvexReactClient` for integrating Convex into React applications. 27 | - [`convex/browser`](https://docs.convex.dev/api/modules/browser): A 28 | `ConvexHttpClient` for using Convex in other browser environments. 29 | - [`convex/values`](https://docs.convex.dev/api/modules/values): Utilities for 30 | working with values stored in Convex. 31 | - [`convex/react-auth0`](https://docs.convex.dev/api/modules/react_auth0): A 32 | React component for authenticating users with Auth0. 33 | - [`convex/react-clerk`](https://docs.convex.dev/api/modules/react_clerk): A 34 | React component for authenticating users with Clerk. 35 | 36 | This package also includes [`convex`](https://docs.convex.dev/using/cli), the 37 | command-line interface for managing Convex projects. 38 | 39 | # Building 40 | 41 | `npm pack` produces a public build with internal types removed. 42 | -------------------------------------------------------------------------------- /api-extractor-configs/README.md: -------------------------------------------------------------------------------- 1 | We're starting to use api-extractor for api reports. The saved versions are 2 | committed to reports/ directory, making it possible to track evolution of the 3 | public API. 4 | 5 | Since api-extractor .d.ts rollups don't support declaration map, using these as 6 | published types break jump-to-definition in VS Code. Until 7 | [declaration map rollups](https://github.com/microsoft/rushstack/issues/1886) 8 | are implemented we compile public types with `tsc --stripInternal`, which 9 | requires marking exports as internal at the index.ts barrel file level if they 10 | need to be used in multiple files. 11 | -------------------------------------------------------------------------------- /api-extractor-configs/browser-api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./api-extractor-base.json", 3 | "mainEntryPointFilePath": "/dist/types/browser/index.d.ts", 4 | "dtsRollup": { 5 | "untrimmedFilePath": "/dist/types/browser/browser-internal.d.ts", 6 | "publicTrimmedFilePath": "/dist/types/browser/browser.d.ts" 7 | }, 8 | /** 9 | * Enable the apiReport but use the same reportFolder and tempFolder to make it a no-op. 10 | * This way forgotten exports are a warning instead of an error. 11 | */ 12 | "apiReport": { 13 | "enabled": true, 14 | "reportFileName": "browser-tmp.api.md", 15 | "reportFolder": "temp", 16 | "reportTempFolder": "temp" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api-extractor-configs/react-api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./api-extractor-base.json", 3 | "mainEntryPointFilePath": "/dist/types/react/index.d.ts", 4 | "dtsRollup": { 5 | "untrimmedFilePath": "/dist/types/react/react-internal.d.ts", 6 | "publicTrimmedFilePath": "/dist/types/react/react.d.ts" 7 | }, 8 | /** 9 | * Enable the apiReport but use the same reportFolder and tempFolder to make it a no-op. 10 | * This way forgotten exports are a warning instead of an error. 11 | */ 12 | "apiReport": { 13 | "enabled": true, 14 | "reportFileName": "react-tmp.api.md", 15 | "reportFolder": "temp", 16 | "reportTempFolder": "temp" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api-extractor-configs/react-auth0-api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./api-extractor-base.json", 3 | "mainEntryPointFilePath": "/dist/types/react-auth0/index.d.ts", 4 | "dtsRollup": { 5 | "untrimmedFilePath": "/dist/types/react-auth0/react-auth0-internal.d.ts", 6 | "publicTrimmedFilePath": "/dist/types/react-auth0/react-auth0.d.ts" 7 | }, 8 | /** 9 | * Enable the apiReport but use the same reportFolder and tempFolder to make it a no-op. 10 | * This way forgotten exports are a warning instead of an error. 11 | */ 12 | "apiReport": { 13 | "enabled": true, 14 | "reportFileName": "react-auth0-tmp.api.md", 15 | "reportFolder": "temp", 16 | "reportTempFolder": "temp" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api-extractor-configs/react-clerk-api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./api-extractor-base.json", 3 | "mainEntryPointFilePath": "/dist/types/react-clerk/index.d.ts", 4 | "dtsRollup": { 5 | "untrimmedFilePath": "/dist/types/react-clerk/react-clerk-internal.d.ts", 6 | "publicTrimmedFilePath": "/dist/types/react-clerk/react-clerk.d.ts" 7 | }, 8 | /** 9 | * Enable the apiReport but use the same reportFolder and tempFolder to make it a no-op. 10 | * This way forgotten exports are a warning instead of an error. 11 | */ 12 | "apiReport": { 13 | "enabled": true, 14 | "reportFileName": "react-clerk-tmp.api.md", 15 | "reportFolder": "temp", 16 | "reportTempFolder": "temp" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api-extractor-configs/server-api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./api-extractor-base.json", 3 | "mainEntryPointFilePath": "/dist/esm-types/server/index.d.ts", 4 | /** 5 | * Enable the apiReport but use the same reportFolder and tempFolder to make it a no-op. 6 | * This way forgotten exports are a warning instead of an error. 7 | */ 8 | "apiReport": { 9 | "enabled": true, 10 | "reportFileName": "server.api.md", 11 | "reportFolder": "reports", 12 | "reportTempFolder": "temp" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /api-extractor-configs/values-api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./api-extractor-base.json", 3 | "mainEntryPointFilePath": "/dist/types/values/index.d.ts", 4 | "dtsRollup": { 5 | "untrimmedFilePath": "/dist/types/values/values-internal.d.ts", 6 | "publicTrimmedFilePath": "/dist/types/values/values.d.ts" 7 | }, 8 | /** 9 | * Enable the apiReport but use the same reportFolder and tempFolder to make it a no-op. 10 | * This way forgotten exports are a warning instead of an error. 11 | */ 12 | "apiReport": { 13 | "enabled": true, 14 | "reportFileName": "values-tmp.api.md", 15 | "reportFolder": "temp", 16 | "reportTempFolder": "temp" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /bin/main-dev: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run the Convex CLI directly from source code. 3 | 4 | if [ "$(uname)" == "Darwin" ] || [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 5 | SCRIPTDIR="$(echo "$0" | python3 -c 'import os; print(os.path.dirname(os.path.realpath(input())))')" 6 | CONVEX_RUNNING_LIVE_IN_MONOREPO=1 "exec" "$SCRIPTDIR/../node_modules/.bin/tsx" "$SCRIPTDIR/../src/cli/index.ts" "$@" 7 | else # it's probably Windows 8 | # This doesn't follow symlinks quite as correctly as the Mac/Linux solution above 9 | CONVEXDIR="$(dirname "$(dirname "$0")")" 10 | CONVEX_RUNNING_LIVE_IN_MONOREPO=1 "exec" "$CONVEXDIR/node_modules/.bin/tsx" "$CONVEXDIR/src/cli/index.ts" "$@" 11 | fi 12 | -------------------------------------------------------------------------------- /bin/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import("../dist/cli.bundle.cjs"); 3 | -------------------------------------------------------------------------------- /browser-bundle.js: -------------------------------------------------------------------------------- 1 | // Code exposed in the browser bundle 2 | 3 | export * from "./src/browser/index.js"; 4 | export { anyApi } from "./src/server/index.js"; 5 | -------------------------------------------------------------------------------- /browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/browser/index.js", 3 | "module": "../dist/esm/browser/index.js", 4 | "types": "../dist/cjs-types/browser/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /config/rush-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "incrementalBuildIgnoredGlobs": ["temp/**"], 3 | "disableBuildCacheForProject": false, 4 | "operationSettings": [ 5 | { 6 | "operationName": "build", 7 | "outputFolderNames": ["dist"] 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /custom-vitest-environment.ts: -------------------------------------------------------------------------------- 1 | import type { Environment } from "vitest"; 2 | import { builtinEnvironments, populateGlobal } from "vitest/environments"; 3 | 4 | import ws from "ws"; 5 | const nodeWebSocket = ws as unknown as typeof WebSocket; 6 | 7 | const happy = builtinEnvironments["happy-dom"]; 8 | 9 | export default { 10 | name: "happy-dom-plus-ws", 11 | transformMode: happy.transformMode, 12 | // optional - only if you support "experimental-vm" pool 13 | async setupVM(options) { 14 | const { getVmContext: happyGetVmContext, teardown: happyTeardown } = 15 | await happy.setupVM!(options); 16 | return { 17 | getVmContext() { 18 | const context = happyGetVmContext(); 19 | return context; 20 | }, 21 | teardown() { 22 | return happyTeardown(); 23 | // called after all tests with this env have been run 24 | }, 25 | }; 26 | }, 27 | async setup(global, options) { 28 | const { teardown: happyTeardown } = await happy.setup(global, options); 29 | //populateGlobal(global, original, {}); 30 | // Add the websocket here! 31 | global.myNewGlobalVariable = 8; 32 | global.WebSocket = nodeWebSocket; 33 | 34 | // custom setup 35 | return { 36 | teardown(global) { 37 | const ret = happyTeardown(global); 38 | delete global.myNewGlobalVariable; 39 | return ret; 40 | // called after all tests with this env have been run 41 | }, 42 | }; 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: "node", 3 | testPathIgnorePatterns: ["/node_modules/", "/dist/"], 4 | // .js always uses the module type of the nearest package.json 5 | extensionsToTreatAsEsm: [".ts", ".tsx"], 6 | // This allows tests use .js extensions in imports from tests. 7 | // We could import paths without extensions in tests, but from 8 | // library code it's important to use .js import paths because 9 | // TypeScript won't change them, and published ESM code needs 10 | // to use .js file extensions. 11 | moduleNameMapper: { 12 | "^(\\.{1,2}/.*)\\.js$": "$1", 13 | }, 14 | transform: { 15 | // https://kulshekhar.github.io/ts-jest/docs/getting-started/options/ 16 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` 17 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` 18 | "^.+\\.m?[tj]sx?$": [ 19 | "ts-jest", 20 | { 21 | useESM: true, 22 | }, 23 | ], 24 | }, 25 | resolver: `./src/test/test_resolver.cjs`, 26 | }; 27 | -------------------------------------------------------------------------------- /nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/nextjs/index.js", 3 | "module": "../dist/esm/nextjs/index.js", 4 | "types": "../dist/cjs-types/nextjs/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /react-auth0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/react-auth0/index.js", 3 | "module": "../dist/esm/react-auth0/index.js", 4 | "types": "../dist/cjs-types/react-auth0/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /react-clerk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/react-clerk/index.js", 3 | "module": "../dist/esm/react-clerk/index.js", 4 | "types": "../dist/cjs-types/react-clerk/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/react/index.js", 3 | "module": "../dist/esm/react/index.js", 4 | "types": "../dist/cjs-types/react/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /schemas/convex.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "description": "Configuration schema for Convex project settings.\n\nDocumentation: https://docs.convex.dev/production/project-configuration#convexjson", 4 | "type": "object", 5 | "properties": { 6 | "$schema": { 7 | "type": "string" 8 | }, 9 | "node": { 10 | "type": "object", 11 | "description": "Node.js specific configuration for server-side packages and dependencies.", 12 | "properties": { 13 | "externalPackages": { 14 | "type": ["array"], 15 | "description": "List of packages that should be installed on the server instead of being bundled. Use this for packages that can't be bundled or need to be installed directly on the server.\n\nIf you want to mark all dependencies as external, you can use the string '*' instead of an array.\n\nDocumentation: https://docs.convex.dev/functions/bundling#external-packages", 16 | "items": { 17 | "type": "string" 18 | }, 19 | "default": ["*"], 20 | "oneOf": [ 21 | { 22 | "contains": { 23 | "const": "*" 24 | }, 25 | "maxItems": 1 26 | }, 27 | { 28 | "not": { 29 | "contains": { 30 | "const": "*" 31 | } 32 | } 33 | } 34 | ] 35 | } 36 | } 37 | }, 38 | "generateCommonJSApi": { 39 | "type": "boolean", 40 | "description": "When true, generates CommonJS-compatible API files for projects not using ES modules. Enable this if your project uses require() syntax instead of ES modules.\n\nDocumentation: https://docs.convex.dev/client/javascript/node#javascript-with-commonjs-require-syntax", 41 | "default": false 42 | }, 43 | "functions": { 44 | "type": "string", 45 | "description": "Path to the directory containing Convex functions. You can customize this to use a different location than the default 'convex/' directory.\n\nDocumentation: https://docs.convex.dev/production/project-configuration#changing-the-convex-folder-name-or-location", 46 | "default": "convex/" 47 | } 48 | }, 49 | "additionalProperties": false 50 | } 51 | -------------------------------------------------------------------------------- /scripts/bundle-server.mjs: -------------------------------------------------------------------------------- 1 | // We use an `.mjs` file instead of TypeScript so node can run the script directly. 2 | import { bundle, entryPointsByEnvironment } from "../dist/esm/bundler/index.js"; 3 | import { oneoffContext } from "../dist/esm/bundler/context.js"; 4 | import path from "path"; 5 | 6 | if (process.argv.length < 3) { 7 | throw new Error( 8 | "USAGE: node bundle-server.mjs *", 9 | ); 10 | } 11 | const systemDirs = process.argv.slice(3); 12 | const out = []; 13 | 14 | // Only bundle "setup.ts" from `udf/_system`. 15 | const udfDir = process.argv[2]; 16 | const setupPath = path.join(udfDir, "setup.ts"); 17 | const ctx = await oneoffContext({ 18 | url: undefined, 19 | adminKey: undefined, 20 | envFile: undefined, 21 | }); 22 | const setupBundles = ( 23 | await bundle(ctx, process.argv[2], [setupPath], true, "browser") 24 | ).modules; 25 | if (setupBundles.length !== 1) { 26 | throw new Error("Got more than one setup bundle?"); 27 | } 28 | out.push(...setupBundles); 29 | 30 | for (const systemDir of systemDirs) { 31 | if (path.basename(systemDir) !== "_system") { 32 | throw new Error(`Refusing to bundle non-system directory ${systemDir}`); 33 | } 34 | const entryPoints = await entryPointsByEnvironment(ctx, systemDir, false); 35 | const bundles = ( 36 | await bundle(ctx, systemDir, entryPoints.isolate, false, "browser") 37 | ).modules; 38 | out.push(...bundles); 39 | } 40 | process.stdout.write(JSON.stringify(out)); 41 | -------------------------------------------------------------------------------- /scripts/checkdeps.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Check that dependencies only used by the CLI are not present in package.json dependencies. 4 | */ 5 | 6 | import depcheck from "depcheck"; 7 | import process from "process"; 8 | import path from "path"; 9 | import * as url from "url"; 10 | 11 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 12 | const root = path.dirname(__dirname); 13 | 14 | const options = { 15 | ignorePatterns: [ 16 | "dist", 17 | "src/cli", // CLI deps are bundled, they use devDependencies 18 | "src/bundler", // Bundler is only used by the CLI 19 | ], 20 | ignoreMatches: [ 21 | "esbuild", // the only unbundled dependency of the CLI 22 | ], 23 | }; 24 | 25 | depcheck(root, options).then((unused) => { 26 | if (unused.dependencies.length) { 27 | console.log( 28 | "Some package.json dependencies are only used in CLI (or not at all):", 29 | ); 30 | console.log( 31 | "If a dependency is only used in the CLI, add it to devDependencies instead.", 32 | ); 33 | console.log(unused.dependencies); 34 | process.exit(1); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /scripts/checkimports.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { fileURLToPath } from "url"; 3 | import { dirname } from "path"; 4 | import skott from "skott"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | const __root = dirname(__dirname); 9 | 10 | async function entrypointHasCycles(entrypoint) { 11 | // Note that skott can do a lot of other things too! 12 | const { useGraph } = await skott({ 13 | entrypoint: `./dist/esm/${entrypoint}/index.js`, 14 | incremental: false, 15 | cwd: __root, 16 | includeBaseDir: true, 17 | verbose: false, 18 | }); 19 | const { findCircularDependencies } = useGraph(); 20 | 21 | const circular = findCircularDependencies(); 22 | if (circular.length) { 23 | console.log("Found import cycles by traversing", entrypoint); 24 | console.log(circular); 25 | return false; 26 | } 27 | return true; 28 | } 29 | 30 | let allOk = true; 31 | // These haven't been fixed yet so we don't fail if they have cycles. 32 | for (const entrypoint of [ 33 | "bundler", 34 | "nextjs", 35 | "react", 36 | "react-auth0", 37 | "react-clerk", 38 | "values", 39 | // don't care about cycles in CLI 40 | ]) { 41 | const ok = await entrypointHasCycles(entrypoint); 42 | allOk &&= ok; 43 | } 44 | 45 | if (!(await entrypointHasCycles("server"))) { 46 | process.exit(1); 47 | } else { 48 | console.log("No import cycles found in server."); 49 | process.exit(0); 50 | } 51 | -------------------------------------------------------------------------------- /scripts/node-browser.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Create two new entry points for convex/browser, one just for Node.js. 3 | * 4 | * The Node.js build includes in a WebSocket implementation. 5 | */ 6 | import url from "url"; 7 | import path from "path"; 8 | import fs from "fs"; 9 | 10 | const [tempDir] = process.argv 11 | .filter((arg) => arg.startsWith("tempDir=")) 12 | .map((arg) => arg.slice(8)); 13 | 14 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 15 | const convexDir = path.join(__dirname, ".."); 16 | const distDir = path.join(convexDir, tempDir); 17 | const cjsBrowserIndex = path.join(distDir, "cjs", "browser", "index.js"); 18 | const esmBrowserIndex = path.join(distDir, "esm", "browser", "index.js"); 19 | const cjsBrowserIndexNode = path.join( 20 | distDir, 21 | "cjs", 22 | "browser", 23 | "index-node.js", 24 | ); 25 | const esmBrowserIndexNode = path.join( 26 | distDir, 27 | "esm", 28 | "browser", 29 | "index-node.js", 30 | ); 31 | 32 | let output = fs.readFileSync(cjsBrowserIndex, { encoding: "utf-8" }); 33 | output = output.replace('"./simple_client.js"', '"./simple_client-node.js"'); 34 | fs.writeFileSync(cjsBrowserIndexNode, output, { encoding: "utf-8" }); 35 | 36 | output = fs.readFileSync(esmBrowserIndex, { encoding: "utf-8" }); 37 | output = output.replace('"./simple_client.js"', '"./simple_client-node.js"'); 38 | fs.writeFileSync(esmBrowserIndexNode, output, { encoding: "utf-8" }); 39 | -------------------------------------------------------------------------------- /scripts/prepack.mjs: -------------------------------------------------------------------------------- 1 | import url from "url"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | 5 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 6 | const convexDir = path.join(__dirname, ".."); 7 | 8 | assertNoTarballs(convexDir); 9 | 10 | // Remove tarballs so there's no confusion about which one was just created. 11 | // The postpack script has to guess, so let's make it explicit. 12 | function assertNoTarballs(dirname) { 13 | const files = fs.readdirSync(dirname); 14 | const tarballs = files.filter((f) => f.endsWith(".tgz")); 15 | for (const tarball of tarballs) { 16 | fs.rmSync(tarball); 17 | console.log(`tarball ${tarball} was already present, deleted.`); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/test-esm.mjs: -------------------------------------------------------------------------------- 1 | // Import everything as ESM to make sure our imports are valid ESM 2 | // (. -> ./index.js, ./foo -> ./foo.js, ./foo.ts -> ./foo.js 3 | 4 | import fs from "fs"; 5 | import path, { dirname } from "path"; 6 | import { fileURLToPath } from "url"; 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | 9 | await import("../dist/esm/index.js"); 10 | 11 | for (const dir of fs.readdirSync(path.join(__dirname, "../dist/esm"))) { 12 | if (dir.endsWith("cli")) { 13 | // CLI is tested elsewhere, importing it here exits the process 14 | continue; 15 | } 16 | 17 | const index = path.join("../dist/esm", dir, "index.js"); 18 | const indexAbsolute = path.join(__dirname, index); 19 | if (fs.existsSync(indexAbsolute)) { 20 | await import(index); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/server/index.js", 3 | "module": "../dist/esm/server/index.js", 4 | "types": "../dist/cjs-types/server/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /src/browser/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tools for accessing Convex in the browser. 3 | * 4 | * **If you are using React, use the {@link react} module instead.** 5 | * 6 | * ## Usage 7 | * 8 | * Create a {@link ConvexHttpClient} to connect to the Convex Cloud. 9 | * 10 | * ```typescript 11 | * import { ConvexHttpClient } from "convex/browser"; 12 | * // typically loaded from an environment variable 13 | * const address = "https://small-mouse-123.convex.cloud"; 14 | * const convex = new ConvexHttpClient(address); 15 | * ``` 16 | * 17 | * @module 18 | */ 19 | export { BaseConvexClient } from "./sync/client.js"; 20 | export type { 21 | BaseConvexClientOptions, 22 | MutationOptions, 23 | SubscribeOptions, 24 | ConnectionState, 25 | } from "./sync/client.js"; 26 | export type { ConvexClientOptions } from "./simple_client.js"; 27 | export { ConvexClient } from "./simple_client.js"; 28 | export type { 29 | OptimisticUpdate, 30 | OptimisticLocalStore, 31 | } from "./sync/optimistic_updates.js"; 32 | export type { QueryToken } from "./sync/udf_path_utils.js"; 33 | export { ConvexHttpClient } from "./http_client.js"; 34 | export type { QueryJournal } from "./sync/protocol.js"; 35 | /** @internal */ 36 | export type { UserIdentityAttributes } from "./sync/protocol.js"; 37 | export type { FunctionResult } from "./sync/function_result.js"; 38 | -------------------------------------------------------------------------------- /src/browser/simple_client-node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConvexClient, 3 | setDefaultWebSocketConstructor, 4 | } from "./simple_client.js"; 5 | 6 | // This file is compiled with `bundle: true` with an exception for 7 | // `./simple_client.js` so this "ws" import will be inlined. 8 | import ws from "ws"; 9 | const nodeWebSocket = ws as unknown as typeof WebSocket; 10 | 11 | setDefaultWebSocketConstructor(nodeWebSocket); 12 | 13 | export { ConvexClient }; 14 | -------------------------------------------------------------------------------- /src/browser/sync/client.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | 5 | import { test, expect } from "vitest"; 6 | 7 | import { BaseConvexClient } from "./client.js"; 8 | import { anyApi } from "../../server/api.js"; 9 | 10 | test("localQueryResult reflects optimistic results", async () => { 11 | const client = new BaseConvexClient("http://127.0.0.1:8000", () => { 12 | // ignore updates. 13 | }); 14 | 15 | expect(client.localQueryResult("myUdf", {})).toBeUndefined(); 16 | 17 | // don't wait for mutation to complete 18 | void client.mutation( 19 | "myUdf", 20 | {}, 21 | { 22 | optimisticUpdate: (localQueryStore) => { 23 | localQueryStore.setQuery(anyApi.myUdf.default, {}, true); 24 | }, 25 | }, 26 | ); 27 | expect(client.localQueryResult("myUdf", {})).toBe(true); 28 | }); 29 | 30 | test("Client warns when old clientConfig format is used", async () => { 31 | expect(() => { 32 | new BaseConvexClient( 33 | { address: "http://127.0.0.1:8000" } as any, 34 | () => null, 35 | ); 36 | }).toThrow("no longer supported"); 37 | }); 38 | -------------------------------------------------------------------------------- /src/browser/sync/function_result.ts: -------------------------------------------------------------------------------- 1 | import { Value } from "../../values/index.js"; 2 | 3 | /** 4 | * The result of running a function on the server. 5 | * 6 | * If the function hit an exception it will have an `errorMessage`. Otherwise 7 | * it will produce a `Value`. 8 | * 9 | * @public 10 | */ 11 | export type FunctionResult = FunctionSuccess | FunctionFailure; 12 | export type FunctionSuccess = { 13 | success: true; 14 | value: Value; 15 | logLines: string[]; 16 | }; 17 | export type FunctionFailure = { 18 | success: false; 19 | errorMessage: string; 20 | errorData?: Value; 21 | logLines: string[]; 22 | }; 23 | -------------------------------------------------------------------------------- /src/browser/sync/metrics.ts: -------------------------------------------------------------------------------- 1 | // Marks share a global namespace with other developer code. 2 | const markNames = [ 3 | "convexClientConstructed", 4 | "convexWebSocketOpen", 5 | "convexFirstMessageReceived", 6 | ] as const; 7 | export type MarkName = (typeof markNames)[number]; 8 | 9 | // Mark details are not reported to the server. 10 | type MarkDetail = { 11 | sessionId: string; 12 | }; 13 | 14 | // `PerformanceMark`s are efficient and show up in browser's performance 15 | // timeline. They can be cleared with `performance.clearMarks()`. 16 | // This is a memory leak, but a worthwhile one: automatic 17 | // cleanup would make in-browser debugging more difficult. 18 | export function mark(name: MarkName, sessionId: string) { 19 | const detail: MarkDetail = { sessionId }; 20 | // `performance` APIs exists in browsers, Node.js, Deno, and more but it 21 | // is not required by the Convex client. 22 | if (typeof performance === "undefined" || !performance.mark) return; 23 | performance.mark(name, { detail }); 24 | } 25 | 26 | // `PerfomanceMark` has a built-in toJSON() but the return type varies 27 | // between implementations, e.g. Node.js returns details but Chrome does not. 28 | function performanceMarkToJson(mark: PerformanceMark): MarkJson { 29 | // Remove "convex" prefix 30 | let name = mark.name.slice("convex".length); 31 | // lowercase the first letter 32 | name = name.charAt(0).toLowerCase() + name.slice(1); 33 | return { 34 | name, 35 | startTime: mark.startTime, 36 | }; 37 | } 38 | 39 | // Similar to the return type of `PerformanceMark.toJson()`. 40 | export type MarkJson = { 41 | name: string; 42 | // `startTime` is in milliseconds since the time origin like `performance.now()`. 43 | // https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp#the_time_origin 44 | startTime: number; 45 | }; 46 | 47 | export function getMarksReport(sessionId: string): MarkJson[] { 48 | if (typeof performance === "undefined" || !performance.getEntriesByName) { 49 | return []; 50 | } 51 | const allMarks: PerformanceMark[] = []; 52 | for (const name of markNames) { 53 | const marks = ( 54 | performance 55 | .getEntriesByName(name) 56 | .filter((entry) => entry.entryType === "mark") as PerformanceMark[] 57 | ).filter((mark) => mark.detail.sessionId === sessionId); 58 | allMarks.push(...marks); 59 | } 60 | return allMarks.map(performanceMarkToJson); 61 | } 62 | -------------------------------------------------------------------------------- /src/browser/sync/protocol.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | 5 | import { test, expect } from "vitest"; 6 | 7 | import { Long } from "../long.js"; 8 | import { longToU64, u64ToLong } from "./protocol.js"; 9 | 10 | test("Long serialization", async () => { 11 | expect(Long.fromNumber(89234097497)).toEqual( 12 | u64ToLong(longToU64(Long.fromNumber(89234097497))), 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /src/browser/sync/remote_query_set.ts: -------------------------------------------------------------------------------- 1 | import { jsonToConvex } from "../../values/index.js"; 2 | import { Long } from "../long.js"; 3 | import { logForFunction, Logger } from "../logging.js"; 4 | import { QueryId, StateVersion, Transition } from "./protocol.js"; 5 | import { FunctionResult } from "./function_result.js"; 6 | 7 | /** 8 | * A represention of the query results we've received on the current WebSocket 9 | * connection. 10 | */ 11 | export class RemoteQuerySet { 12 | private version: StateVersion; 13 | private readonly remoteQuerySet: Map; 14 | private readonly queryPath: (queryId: QueryId) => string | null; 15 | private readonly logger: Logger; 16 | 17 | constructor(queryPath: (queryId: QueryId) => string | null, logger: Logger) { 18 | this.version = { querySet: 0, ts: Long.fromNumber(0), identity: 0 }; 19 | this.remoteQuerySet = new Map(); 20 | this.queryPath = queryPath; 21 | this.logger = logger; 22 | } 23 | 24 | transition(transition: Transition): void { 25 | const start = transition.startVersion; 26 | if ( 27 | this.version.querySet !== start.querySet || 28 | this.version.ts.notEquals(start.ts) || 29 | this.version.identity !== start.identity 30 | ) { 31 | throw new Error( 32 | `Invalid start version: ${start.ts.toString()}:${start.querySet}`, 33 | ); 34 | } 35 | for (const modification of transition.modifications) { 36 | switch (modification.type) { 37 | case "QueryUpdated": { 38 | const queryPath = this.queryPath(modification.queryId); 39 | if (queryPath) { 40 | for (const line of modification.logLines) { 41 | logForFunction(this.logger, "info", "query", queryPath, line); 42 | } 43 | } 44 | const value = jsonToConvex(modification.value ?? null); 45 | this.remoteQuerySet.set(modification.queryId, { 46 | success: true, 47 | value, 48 | logLines: modification.logLines, 49 | }); 50 | break; 51 | } 52 | case "QueryFailed": { 53 | const queryPath = this.queryPath(modification.queryId); 54 | if (queryPath) { 55 | for (const line of modification.logLines) { 56 | logForFunction(this.logger, "info", "query", queryPath, line); 57 | } 58 | } 59 | const { errorData } = modification; 60 | this.remoteQuerySet.set(modification.queryId, { 61 | success: false, 62 | errorMessage: modification.errorMessage, 63 | errorData: 64 | errorData !== undefined ? jsonToConvex(errorData) : undefined, 65 | logLines: modification.logLines, 66 | }); 67 | break; 68 | } 69 | case "QueryRemoved": { 70 | this.remoteQuerySet.delete(modification.queryId); 71 | break; 72 | } 73 | default: { 74 | // Enforce that the switch-case is exhaustive. 75 | const _: never = modification; 76 | throw new Error(`Invalid modification ${(modification as any).type}`); 77 | } 78 | } 79 | } 80 | this.version = transition.endVersion; 81 | } 82 | 83 | remoteQueryResults(): Map { 84 | return this.remoteQuerySet; 85 | } 86 | 87 | timestamp(): Long { 88 | return this.version.ts; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/browser/sync/session.ts: -------------------------------------------------------------------------------- 1 | export function newSessionId() { 2 | return uuidv4(); 3 | } 4 | 5 | // From https://stackoverflow.com/a/2117523 6 | function uuidv4() { 7 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { 8 | const r = (Math.random() * 16) | 0, 9 | v = c === "x" ? r : (r & 0x3) | 0x8; 10 | return v.toString(16); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/browser/sync/udf_path_utils.ts: -------------------------------------------------------------------------------- 1 | import { convexToJson, Value } from "../../values/index.js"; 2 | 3 | export function canonicalizeUdfPath(udfPath: string): string { 4 | const pieces = udfPath.split(":"); 5 | let moduleName: string; 6 | let functionName: string; 7 | if (pieces.length === 1) { 8 | moduleName = pieces[0]; 9 | functionName = "default"; 10 | } else { 11 | moduleName = pieces.slice(0, pieces.length - 1).join(":"); 12 | functionName = pieces[pieces.length - 1]; 13 | } 14 | if (moduleName.endsWith(".js")) { 15 | moduleName = moduleName.slice(0, -3); 16 | } 17 | return `${moduleName}:${functionName}`; 18 | } 19 | 20 | /** 21 | * A string representing the name and arguments of a query. 22 | * 23 | * This is used by the {@link BaseConvexClient}. 24 | * 25 | * @public 26 | */ 27 | export type QueryToken = string; 28 | 29 | export function serializePathAndArgs( 30 | udfPath: string, 31 | args: Record, 32 | ): QueryToken { 33 | return JSON.stringify({ 34 | udfPath: canonicalizeUdfPath(udfPath), 35 | args: convexToJson(args), 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/bundler/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | "no-restricted-syntax": [ 4 | "error", 5 | { 6 | // Copied from `npm-packages/convex/.eslintrc.cjs` because ESLint doesn't merge 7 | // rules. 8 | 9 | // From https://github.com/typescript-eslint/typescript-eslint/issues/1391#issuecomment-1124154589 10 | // Prefer `private` ts keyword to `#private` private methods 11 | selector: 12 | ":matches(PropertyDefinition, MethodDefinition) > PrivateIdentifier.key", 13 | message: "Use `private` instead", 14 | }, 15 | { 16 | selector: "ThrowStatement", 17 | message: 18 | "Don't use `throw` if this is a developer-facing error message and this code could be called by `npx convex dev`. Instead use `ctx.crash`.", 19 | }, 20 | ], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/bundler/fs.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import fs from "fs"; 3 | import os from "os"; 4 | import path from "path"; 5 | import { nodeFs } from "./fs.js"; 6 | 7 | test("nodeFs filesystem operations behave as expected", async () => { 8 | const tmpdir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`); 9 | try { 10 | const parentDirPath = path.join(tmpdir, "testdir"); 11 | const dirPath = path.join(parentDirPath, "nestedDir"); 12 | const filePath = path.join(dirPath, "text.txt"); 13 | 14 | // Test making and listing directories. 15 | try { 16 | nodeFs.mkdir(dirPath); 17 | throw new Error( 18 | "Expected `mkdir` to fail because the containing directory doesn't exist yet.", 19 | ); 20 | } catch (e: any) { 21 | expect(e.code).toEqual("ENOENT"); 22 | } 23 | 24 | nodeFs.mkdir(parentDirPath); 25 | nodeFs.mkdir(dirPath); 26 | try { 27 | nodeFs.mkdir(dirPath); 28 | throw new Error("Expected `mkdir` to fail without allowExisting"); 29 | } catch (e: any) { 30 | expect(e.code).toEqual("EEXIST"); 31 | } 32 | nodeFs.mkdir(dirPath, { allowExisting: true }); 33 | 34 | const dirEntries = nodeFs.listDir(parentDirPath); 35 | expect(dirEntries).toHaveLength(1); 36 | expect(dirEntries[0].name).toEqual("nestedDir"); 37 | 38 | const nestedEntries = nodeFs.listDir(dirPath); 39 | expect(nestedEntries).toHaveLength(0); 40 | 41 | // Test file based methods for nonexistent paths. 42 | expect(nodeFs.exists(filePath)).toEqual(false); 43 | try { 44 | nodeFs.stat(filePath); 45 | throw new Error("Expected `stat` to fail for nonexistent paths"); 46 | } catch (e: any) { 47 | expect(e.code).toEqual("ENOENT"); 48 | } 49 | try { 50 | nodeFs.readUtf8File(filePath); 51 | throw new Error("Expected `readUtf8File` to fail for nonexistent paths"); 52 | } catch (e: any) { 53 | expect(e.code).toEqual("ENOENT"); 54 | } 55 | try { 56 | nodeFs.access(filePath); 57 | throw new Error("Expected `access` to fail for nonexistent paths"); 58 | } catch (e: any) { 59 | expect(e.code).toEqual("ENOENT"); 60 | } 61 | 62 | // Test creating a file and accessing it. 63 | const message = "it's trompo o'clock"; 64 | nodeFs.writeUtf8File(filePath, message); 65 | expect(nodeFs.exists(filePath)).toEqual(true); 66 | nodeFs.stat(filePath); 67 | expect(nodeFs.readUtf8File(filePath)).toEqual(message); 68 | nodeFs.access(filePath); 69 | 70 | // Test unlinking a file and directory. 71 | try { 72 | nodeFs.unlink(dirPath); 73 | throw new Error("Expected `unlink` to fail on a directory"); 74 | } catch (e: any) { 75 | if (os.platform() === "linux") { 76 | expect(e.code).toEqual("EISDIR"); 77 | } else { 78 | expect(e.code).toEqual("EPERM"); 79 | } 80 | } 81 | nodeFs.unlink(filePath); 82 | expect(nodeFs.exists(filePath)).toEqual(false); 83 | } finally { 84 | fs.rmSync(tmpdir, { recursive: true }); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project01/bar.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | async function _notExported() { 3 | return 0; 4 | } 5 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project01/file with spaces.js: -------------------------------------------------------------------------------- 1 | export const a = null; 2 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project01/foo.js: -------------------------------------------------------------------------------- 1 | export default async function defaultExport() { 2 | return 1; 3 | } 4 | 5 | export async function exported() { 6 | return 2; 7 | } 8 | 9 | export const notAFunction = 123; 10 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project_with_https/https.js: -------------------------------------------------------------------------------- 1 | import { httpRouter } from "convex/server"; 2 | 3 | export const val = 1; 4 | 5 | export let otherHttp = 20; 6 | 7 | const http = httpRouter(); 8 | 9 | export default http; 10 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project_with_https_not_at_top_level/default.js: -------------------------------------------------------------------------------- 1 | export default async function defaultExport() { 2 | return 1; 3 | } 4 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project_with_https_not_at_top_level/more_code/https.js: -------------------------------------------------------------------------------- 1 | import { httpRouter } from "convex/server"; 2 | 3 | const http = httpRouter(); 4 | 5 | export default http; 6 | -------------------------------------------------------------------------------- /src/bundler/test_fixtures/js/project_with_https_without_router/https.js: -------------------------------------------------------------------------------- 1 | export const val = 1; 2 | -------------------------------------------------------------------------------- /src/bundler/wasm.ts: -------------------------------------------------------------------------------- 1 | import { PluginBuild } from "esbuild"; 2 | import path from "path"; 3 | // TODO wasm contents aren't watched 4 | // eslint-disable-next-line no-restricted-imports 5 | import fs from "fs"; 6 | 7 | export const wasmPlugin = { 8 | name: "convex-wasm", 9 | setup(build: PluginBuild) { 10 | // Resolve ".wasm" files to a path with a namespace 11 | build.onResolve({ filter: /\.wasm$/ }, (args) => { 12 | // If this is the import inside the stub module, import the 13 | // binary itself. Put the path in the "wasm-binary" namespace 14 | // to tell our binary load callback to load the binary file. 15 | if (args.namespace === "wasm-stub") { 16 | return { 17 | path: args.path, 18 | namespace: "wasm-binary", 19 | }; 20 | } 21 | 22 | // Otherwise, generate the JavaScript stub module for this 23 | // ".wasm" file. Put it in the "wasm-stub" namespace to tell 24 | // our stub load callback to fill it with JavaScript. 25 | // 26 | // Resolve relative paths to absolute paths here since this 27 | // resolve callback is given "resolveDir", the directory to 28 | // resolve imports against. 29 | if (args.resolveDir === "") { 30 | return; // Ignore unresolvable paths 31 | } 32 | return { 33 | path: path.isAbsolute(args.path) 34 | ? args.path 35 | : path.join(args.resolveDir, args.path), 36 | namespace: "wasm-stub", 37 | }; 38 | }); 39 | 40 | // Virtual modules in the "wasm-stub" namespace are filled with 41 | // the JavaScript code for compiling the WebAssembly binary. The 42 | // binary itself is imported from a second virtual module. 43 | build.onLoad({ filter: /.*/, namespace: "wasm-stub" }, async (args) => ({ 44 | contents: `import wasm from ${JSON.stringify(args.path)} 45 | export default new WebAssembly.Module(wasm)`, 46 | })); 47 | 48 | // Virtual modules in the "wasm-binary" namespace contain the 49 | // actual bytes of the WebAssembly file. This uses esbuild's 50 | // built-in "binary" loader instead of manually embedding the 51 | // binary data inside JavaScript code ourselves. 52 | build.onLoad({ filter: /.*/, namespace: "wasm-binary" }, async (args) => ({ 53 | contents: await fs.promises.readFile(args.path), 54 | loader: "binary", 55 | })); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/cli/auth.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { oneoffContext } from "../bundler/context.js"; 3 | 4 | const list = new Command("list").action(async () => { 5 | const ctx = await oneoffContext({ 6 | url: undefined, 7 | adminKey: undefined, 8 | envFile: undefined, 9 | }); 10 | await ctx.crash({ 11 | exitCode: 1, 12 | errorType: "fatal", 13 | errForSentry: "Ran deprecated `convex auth list`", 14 | printedMessage: 15 | "convex auth commands were removed, see https://docs.convex.dev/auth for up to date instructions.", 16 | }); 17 | }); 18 | 19 | const rm = new Command("remove").action(async () => { 20 | const ctx = await oneoffContext({ 21 | url: undefined, 22 | adminKey: undefined, 23 | envFile: undefined, 24 | }); 25 | await ctx.crash({ 26 | exitCode: 1, 27 | errorType: "fatal", 28 | errForSentry: "Ran deprecated `convex auth remove`", 29 | printedMessage: 30 | "convex auth commands were removed, see https://docs.convex.dev/auth for up to date instructions.", 31 | }); 32 | }); 33 | 34 | const add = new Command("add") 35 | .addOption(new Option("--identity-provider-url ").hideHelp()) 36 | .addOption(new Option("--application-id ").hideHelp()) 37 | .action(async () => { 38 | const ctx = await oneoffContext({ 39 | url: undefined, 40 | adminKey: undefined, 41 | envFile: undefined, 42 | }); 43 | await ctx.crash({ 44 | exitCode: 1, 45 | errorType: "fatal", 46 | errForSentry: "Ran deprecated `convex auth add`", 47 | printedMessage: 48 | "convex auth commands were removed, see https://docs.convex.dev/auth for up to date instructions.", 49 | }); 50 | }); 51 | 52 | export const auth = new Command("auth") 53 | .addCommand(list) 54 | .addCommand(rm) 55 | .addCommand(add); 56 | -------------------------------------------------------------------------------- /src/cli/codegen.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { oneoffContext } from "../bundler/context.js"; 3 | import { runCodegen } from "./lib/components.js"; 4 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 5 | export const codegen = new Command("codegen") 6 | .summary("Generate backend type definitions") 7 | .description( 8 | "Generate types in `convex/_generated/` based on the current contents of `convex/`.", 9 | ) 10 | .allowExcessArguments(false) 11 | .option( 12 | "--dry-run", 13 | "Print out the generated configuration to stdout instead of writing to convex directory", 14 | ) 15 | .addOption(new Option("--debug").hideHelp()) 16 | .addOption( 17 | new Option( 18 | "--typecheck ", 19 | `Whether to check TypeScript files with \`tsc --noEmit\`.`, 20 | ) 21 | .choices(["enable", "try", "disable"] as const) 22 | .default("try" as const), 23 | ) 24 | .option( 25 | "--init", 26 | "Also (over-)write the default convex/README.md and convex/tsconfig.json files, otherwise only written when creating a new Convex project.", 27 | ) 28 | .addOption(new Option("--admin-key ").hideHelp()) 29 | .addOption(new Option("--url ").hideHelp()) 30 | .addOption(new Option("--live-component-sources").hideHelp()) 31 | // Experimental option 32 | .addOption( 33 | new Option( 34 | "--commonjs", 35 | "Generate CommonJS modules (CJS) instead of ECMAScript modules, the default. Bundlers typically take care of this conversion while bundling, so this setting is generally only useful for projects which do not use a bundler, typically Node.js projects. Convex functions can be written with either syntax.", 36 | ).hideHelp(), 37 | ) 38 | .action(async (options) => { 39 | const ctx = await oneoffContext(options); 40 | const deploymentSelection = await getDeploymentSelection(ctx, options); 41 | 42 | await runCodegen(ctx, deploymentSelection, { 43 | dryRun: !!options.dryRun, 44 | debug: !!options.debug, 45 | typecheck: options.typecheck, 46 | init: !!options.init, 47 | commonjs: !!options.commonjs, 48 | url: options.url, 49 | adminKey: options.adminKey, 50 | liveComponentSources: !!options.liveComponentSources, 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/api.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { importPath, moduleIdentifier } from "./api.js"; 3 | 4 | test("importPath", () => { 5 | expect(importPath("foo.ts")).toEqual("foo"); 6 | expect(importPath("foo.tsx")).toEqual("foo"); 7 | expect(importPath("foo\\bar.ts")).toEqual("foo/bar"); 8 | expect(importPath("foo/bar.ts")).toEqual("foo/bar"); 9 | }); 10 | 11 | test("moduleIdentifier", () => { 12 | expect(moduleIdentifier("foo.ts")).toEqual("foo"); 13 | // This mapping is ambiguous! This is a codegen implementation detail so 14 | // this can be changed without requiring changes beyond running codegen. 15 | expect(moduleIdentifier("foo/bar.ts")).toEqual("foo_bar"); 16 | expect(moduleIdentifier("foo_bar.ts")).toEqual("foo_bar"); 17 | expect(moduleIdentifier("foo-bar.ts")).toEqual("foo_bar"); 18 | }); 19 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/api.ts: -------------------------------------------------------------------------------- 1 | import { header } from "./common.js"; 2 | 3 | export function importPath(modulePath: string) { 4 | // Replace backslashes with forward slashes. 5 | const filePath = modulePath.replace(/\\/g, "/"); 6 | // Strip off the file extension. 7 | const lastDot = filePath.lastIndexOf("."); 8 | return filePath.slice(0, lastDot === -1 ? undefined : lastDot); 9 | } 10 | 11 | export function moduleIdentifier(modulePath: string) { 12 | // TODO: This encoding is ambiguous (`foo/bar` vs `foo_bar` vs `foo-bar`). 13 | // Also we should be renaming keywords like `delete`. 14 | let safeModulePath = importPath(modulePath) 15 | .replace(/\//g, "_") 16 | .replace(/-/g, "_"); 17 | // Escape existing variable names in this file 18 | if (["fullApi", "api", "internal", "components"].includes(safeModulePath)) { 19 | safeModulePath = `${safeModulePath}_`; 20 | } 21 | return safeModulePath; 22 | } 23 | 24 | export function apiCodegen(modulePaths: string[]) { 25 | const apiDTS = `${header("Generated `api` utility.")} 26 | import type { ApiFromModules, FilterApi, FunctionReference } from "convex/server"; 27 | ${modulePaths 28 | .map( 29 | (modulePath) => 30 | `import type * as ${moduleIdentifier(modulePath)} from "../${importPath( 31 | modulePath, 32 | )}.js";`, 33 | ) 34 | .join("\n")} 35 | 36 | /** 37 | * A utility for referencing Convex functions in your app's API. 38 | * 39 | * Usage: 40 | * \`\`\`js 41 | * const myFunctionReference = api.myModule.myFunction; 42 | * \`\`\` 43 | */ 44 | declare const fullApi: ApiFromModules<{ 45 | ${modulePaths 46 | .map( 47 | (modulePath) => 48 | `"${importPath(modulePath)}": typeof ${moduleIdentifier(modulePath)},`, 49 | ) 50 | .join("\n")} 51 | }>; 52 | export declare const api: FilterApi>; 53 | export declare const internal: FilterApi>; 54 | `; 55 | 56 | const apiJS = `${header("Generated `api` utility.")} 57 | import { anyApi } from "convex/server"; 58 | 59 | /** 60 | * A utility for referencing Convex functions in your app's API. 61 | * 62 | * Usage: 63 | * \`\`\`js 64 | * const myFunctionReference = api.myModule.myFunction; 65 | * \`\`\` 66 | */ 67 | export const api = anyApi; 68 | export const internal = anyApi; 69 | `; 70 | return { 71 | DTS: apiDTS, 72 | JS: apiJS, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/api_cjs.ts: -------------------------------------------------------------------------------- 1 | import { apiCodegen as esmApiCodegen } from "./api.js"; 2 | import { header } from "./common.js"; 3 | 4 | export function apiCjsCodegen(modulePaths: string[]) { 5 | const { DTS } = esmApiCodegen(modulePaths); 6 | const apiJS = `${header("Generated `api` utility.")} 7 | const { anyApi } = require("convex/server"); 8 | module.exports = { 9 | api: anyApi, 10 | internal: anyApi, 11 | }; 12 | `; 13 | return { 14 | DTS, 15 | JS: apiJS, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/common.ts: -------------------------------------------------------------------------------- 1 | export function header(oneLineDescription: string) { 2 | return `/* eslint-disable */ 3 | /** 4 | * ${oneLineDescription} 5 | * 6 | * THIS CODE IS AUTOMATICALLY GENERATED. 7 | * 8 | * To regenerate, run \`npx convex dev\`. 9 | * @module 10 | */ 11 | `; 12 | } 13 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/readme.ts: -------------------------------------------------------------------------------- 1 | export function readmeCodegen() { 2 | return `# Welcome to your Convex functions directory! 3 | 4 | Write your Convex functions here. 5 | See https://docs.convex.dev/functions for more. 6 | 7 | A query function that takes two arguments looks like: 8 | 9 | \`\`\`ts 10 | // functions.js 11 | import { query } from "./_generated/server"; 12 | import { v } from "convex/values"; 13 | 14 | export const myQueryFunction = query({ 15 | // Validators for arguments. 16 | args: { 17 | first: v.number(), 18 | second: v.string(), 19 | }, 20 | 21 | // Function implementation. 22 | handler: async (ctx, args) => { 23 | // Read the database as many times as you need here. 24 | // See https://docs.convex.dev/database/reading-data. 25 | const documents = await ctx.db.query("tablename").collect(); 26 | 27 | // Arguments passed from the client are properties of the args object. 28 | console.log(args.first, args.second) 29 | 30 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 31 | // remove non-public properties, or create new objects. 32 | return documents; 33 | }, 34 | }); 35 | \`\`\` 36 | 37 | Using this query function in a React component looks like: 38 | 39 | \`\`\`ts 40 | const data = useQuery(api.functions.myQueryFunction, { first: 10, second: "hello" }); 41 | \`\`\` 42 | 43 | 44 | A mutation function looks like: 45 | 46 | \`\`\`ts 47 | // functions.js 48 | import { mutation } from "./_generated/server"; 49 | import { v } from "convex/values"; 50 | 51 | export const myMutationFunction = mutation({ 52 | // Validators for arguments. 53 | args: { 54 | first: v.string(), 55 | second: v.string(), 56 | }, 57 | 58 | // Function implementation. 59 | handler: async (ctx, args) => { 60 | // Insert or modify documents in the database here. 61 | // Mutations can also read from the database like queries. 62 | // See https://docs.convex.dev/database/writing-data. 63 | const message = { body: args.first, author: args.second }; 64 | const id = await ctx.db.insert("messages", message); 65 | 66 | // Optionally, return a value from your mutation. 67 | return await ctx.db.get(id); 68 | }, 69 | }); 70 | \`\`\` 71 | 72 | Using this mutation function in a React component looks like: 73 | 74 | \`\`\`ts 75 | const mutation = useMutation(api.functions.myMutationFunction); 76 | function handleButtonPress() { 77 | // fire and forget, the most common way to use mutations 78 | mutation({ first: "Hello!", second: "me" }); 79 | // OR 80 | // use the result once the mutation has completed 81 | mutation({ first: "Hello!", second: "me" }).then(result => console.log(result)); 82 | } 83 | \`\`\` 84 | 85 | Use the Convex CLI to push your functions to a deployment. See everything 86 | the Convex CLI can do by running \`npx convex -h\` in your project root 87 | directory. To learn more, launch the docs with \`npx convex docs\`. 88 | `; 89 | } 90 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/templates.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | import { tsconfigCodegen } from "./tsconfig.js"; 3 | import { readmeCodegen } from "./readme.js"; 4 | 5 | import prettier from "prettier"; 6 | 7 | test("templates parse", async () => { 8 | await prettier.format(tsconfigCodegen(), { 9 | parser: "json", 10 | pluginSearchDirs: false, 11 | }); 12 | await prettier.format(readmeCodegen(), { 13 | parser: "markdown", 14 | pluginSearchDirs: false, 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/tsconfig.ts: -------------------------------------------------------------------------------- 1 | export function tsconfigCodegen() { 2 | return `{ 3 | /* This TypeScript project config describes the environment that 4 | * Convex functions run in and is used to typecheck them. 5 | * You can modify it, but some settings are required to use Convex. 6 | */ 7 | "compilerOptions": { 8 | /* These settings are not required by Convex and can be modified. */ 9 | "allowJs": true, 10 | "strict": true, 11 | "moduleResolution": "Bundler", 12 | "jsx": "react-jsx", 13 | "skipLibCheck": true, 14 | "allowSyntheticDefaultImports": true, 15 | 16 | /* These compiler options are required by Convex */ 17 | "target": "ESNext", 18 | "lib": ["ES2021", "dom"], 19 | "forceConsistentCasingInFileNames": true, 20 | "module": "ESNext", 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | }, 24 | "include": ["./**/*"], 25 | "exclude": ["./_generated"] 26 | }`; 27 | } 28 | -------------------------------------------------------------------------------- /src/cli/codegen_templates/validator_helpers.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { jsonToConvex, Value } from "../../values/index.js"; 3 | import { 4 | ConvexValidator, 5 | convexValidator, 6 | } from "../lib/deployApi/validator.js"; 7 | 8 | export function parseValidator( 9 | validator: string | null, 10 | ): ConvexValidator | null { 11 | if (!validator) { 12 | return null; 13 | } 14 | return z.nullable(convexValidator).parse(JSON.parse(validator)); 15 | } 16 | 17 | export function validatorToType( 18 | validator: ConvexValidator, 19 | useIdType: boolean, 20 | ): string { 21 | if (validator.type === "null") { 22 | return "null"; 23 | } else if (validator.type === "number") { 24 | return "number"; 25 | } else if (validator.type === "bigint") { 26 | return "bigint"; 27 | } else if (validator.type === "boolean") { 28 | return "boolean"; 29 | } else if (validator.type === "string") { 30 | return "string"; 31 | } else if (validator.type === "bytes") { 32 | return "ArrayBuffer"; 33 | } else if (validator.type === "any") { 34 | return "any"; 35 | } else if (validator.type === "literal") { 36 | const convexValue = jsonToConvex(validator.value); 37 | return convexValueToLiteral(convexValue); 38 | } else if (validator.type === "id") { 39 | return useIdType ? `Id<"${validator.tableName}">` : "string"; 40 | } else if (validator.type === "array") { 41 | return `Array<${validatorToType(validator.value, useIdType)}>`; 42 | } else if (validator.type === "record") { 43 | return `Record<${validatorToType(validator.keys, useIdType)}, ${validatorToType(validator.values.fieldType, useIdType)}>`; 44 | } else if (validator.type === "union") { 45 | return validator.value 46 | .map((v) => validatorToType(v, useIdType)) 47 | .join(" | "); 48 | } else if (validator.type === "object") { 49 | return objectValidatorToType(validator.value, useIdType); 50 | } else { 51 | // eslint-disable-next-line no-restricted-syntax 52 | throw new Error(`Unsupported validator type`); 53 | } 54 | } 55 | 56 | function objectValidatorToType( 57 | fields: Record, 58 | useIdType: boolean, 59 | ): string { 60 | const fieldStrings: string[] = []; 61 | for (const [fieldName, field] of Object.entries(fields)) { 62 | const fieldType = validatorToType(field.fieldType, useIdType); 63 | fieldStrings.push(`${fieldName}${field.optional ? "?" : ""}: ${fieldType}`); 64 | } 65 | return `{ ${fieldStrings.join(", ")} }`; 66 | } 67 | 68 | function convexValueToLiteral(value: Value): string { 69 | if (value === null) { 70 | return "null"; 71 | } 72 | if (typeof value === "bigint") { 73 | return `${value}n`; 74 | } 75 | if (typeof value === "number") { 76 | return `${value}`; 77 | } 78 | if (typeof value === "boolean") { 79 | return `${value}`; 80 | } 81 | if (typeof value === "string") { 82 | return `"${value}"`; 83 | } 84 | // eslint-disable-next-line no-restricted-syntax 85 | throw new Error(`Unsupported literal type`); 86 | } 87 | -------------------------------------------------------------------------------- /src/cli/convexExport.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import chalk from "chalk"; 3 | import { ensureHasConvexDependency } from "./lib/utils/utils.js"; 4 | import { oneoffContext } from "../bundler/context.js"; 5 | import { 6 | deploymentSelectionWithinProjectFromOptions, 7 | loadSelectedDeploymentCredentials, 8 | } from "./lib/api.js"; 9 | import { deploymentDashboardUrlPage } from "./lib/dashboard.js"; 10 | import { actionDescription } from "./lib/command.js"; 11 | import { exportFromDeployment } from "./lib/convexExport.js"; 12 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 13 | export const convexExport = new Command("export") 14 | .summary("Export data from your deployment to a ZIP file") 15 | .description( 16 | "Export data, and optionally file storage, from your Convex deployment to a ZIP file.\n" + 17 | "By default, this exports from your dev deployment.", 18 | ) 19 | .allowExcessArguments(false) 20 | .addExportOptions() 21 | .addDeploymentSelectionOptions(actionDescription("Export data from")) 22 | .showHelpAfterError() 23 | .action(async (options) => { 24 | const ctx = await oneoffContext(options); 25 | await ensureHasConvexDependency(ctx, "export"); 26 | 27 | const deploymentSelection = await getDeploymentSelection(ctx, options); 28 | 29 | const selectionWithinProject = 30 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 31 | 32 | const deployment = await loadSelectedDeploymentCredentials( 33 | ctx, 34 | deploymentSelection, 35 | selectionWithinProject, 36 | ); 37 | 38 | const deploymentNotice = options.prod 39 | ? ` in your ${chalk.bold("prod")} deployment` 40 | : ""; 41 | await exportFromDeployment(ctx, { 42 | ...options, 43 | deploymentUrl: deployment.url, 44 | adminKey: deployment.adminKey, 45 | deploymentNotice, 46 | snapshotExportDashboardLink: deploymentDashboardUrlPage( 47 | deployment.deploymentFields?.deploymentName ?? null, 48 | "/settings/snapshot-export", 49 | ), 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/cli/convexImport.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { ensureHasConvexDependency } from "./lib/utils/utils.js"; 3 | import { oneoffContext } from "../bundler/context.js"; 4 | import { 5 | deploymentSelectionWithinProjectFromOptions, 6 | loadSelectedDeploymentCredentials, 7 | } from "./lib/api.js"; 8 | import { Command } from "@commander-js/extra-typings"; 9 | import { actionDescription } from "./lib/command.js"; 10 | import { deploymentDashboardUrlPage } from "./lib/dashboard.js"; 11 | import { importIntoDeployment } from "./lib/convexImport.js"; 12 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 13 | 14 | export const convexImport = new Command("import") 15 | .summary("Import data from a file to your deployment") 16 | .description( 17 | "Import data from a file to your Convex deployment.\n\n" + 18 | " From a snapshot: `npx convex import snapshot.zip`\n" + 19 | " For a single table: `npx convex import --table tableName file.json`\n\n" + 20 | "By default, this imports into your dev deployment.", 21 | ) 22 | .allowExcessArguments(false) 23 | .addImportOptions() 24 | .addDeploymentSelectionOptions(actionDescription("Import data into")) 25 | .showHelpAfterError() 26 | .action(async (filePath, options) => { 27 | const ctx = await oneoffContext(options); 28 | 29 | await ensureHasConvexDependency(ctx, "import"); 30 | 31 | const selectionWithinProject = 32 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 33 | 34 | const deploymentSelection = await getDeploymentSelection(ctx, options); 35 | const deployment = await loadSelectedDeploymentCredentials( 36 | ctx, 37 | deploymentSelection, 38 | selectionWithinProject, 39 | ); 40 | 41 | const deploymentNotice = options.prod 42 | ? ` in your ${chalk.bold("prod")} deployment` 43 | : ""; 44 | 45 | await importIntoDeployment(ctx, filePath, { 46 | ...options, 47 | deploymentUrl: deployment.url, 48 | adminKey: deployment.adminKey, 49 | deploymentNotice, 50 | snapshotImportDashboardLink: snapshotImportDashboardLink( 51 | deployment.deploymentFields?.deploymentName ?? null, 52 | ), 53 | }); 54 | }); 55 | 56 | function snapshotImportDashboardLink(deploymentName: string | null) { 57 | return deploymentName === null 58 | ? "https://dashboard.convex.dev/deployment/settings/snapshots" 59 | : deploymentDashboardUrlPage(deploymentName, "/settings/snapshots"); 60 | } 61 | -------------------------------------------------------------------------------- /src/cli/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import chalk from "chalk"; 3 | import open from "open"; 4 | import { 5 | Context, 6 | logMessage, 7 | logOutput, 8 | logWarning, 9 | oneoffContext, 10 | } from "../bundler/context.js"; 11 | import { 12 | deploymentSelectionWithinProjectFromOptions, 13 | loadSelectedDeploymentCredentials, 14 | } from "./lib/api.js"; 15 | import { actionDescription } from "./lib/command.js"; 16 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 17 | import { checkIfDashboardIsRunning } from "./lib/localDeployment/dashboard.js"; 18 | import { getDashboardUrl } from "./lib/dashboard.js"; 19 | import { isAnonymousDeployment } from "./lib/deployment.js"; 20 | 21 | export const DASHBOARD_HOST = process.env.CONVEX_PROVISION_HOST 22 | ? "http://localhost:6789" 23 | : "https://dashboard.convex.dev"; 24 | 25 | export const dashboard = new Command("dashboard") 26 | .alias("dash") 27 | .description("Open the dashboard in the browser") 28 | .allowExcessArguments(false) 29 | .option( 30 | "--no-open", 31 | "Don't automatically open the dashboard in the default browser", 32 | ) 33 | .addDeploymentSelectionOptions(actionDescription("Open the dashboard for")) 34 | .showHelpAfterError() 35 | .action(async (options) => { 36 | const ctx = await oneoffContext(options); 37 | 38 | const selectionWithinProject = 39 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 40 | const deploymentSelection = await getDeploymentSelection(ctx, options); 41 | const deployment = await loadSelectedDeploymentCredentials( 42 | ctx, 43 | deploymentSelection, 44 | selectionWithinProject, 45 | { ensureLocalRunning: false }, 46 | ); 47 | 48 | if (deployment.deploymentFields === null) { 49 | const msg = `Self-hosted deployment configured.\n\`${chalk.bold("npx convex dashboard")}\` is not supported for self-hosted deployments.\nSee self-hosting instructions for how to self-host the dashboard.`; 50 | logMessage(ctx, chalk.yellow(msg)); 51 | return; 52 | } 53 | const dashboardUrl = getDashboardUrl(ctx, deployment.deploymentFields); 54 | if (isAnonymousDeployment(deployment.deploymentFields.deploymentName)) { 55 | const warningMessage = `You are not currently running the dashboard locally. Make sure \`npx convex dev\` is running and try again.`; 56 | if (dashboardUrl === null) { 57 | logWarning(ctx, warningMessage); 58 | return; 59 | } 60 | const isLocalDashboardRunning = await checkIfDashboardIsRunning(ctx); 61 | if (!isLocalDashboardRunning) { 62 | logWarning(ctx, warningMessage); 63 | return; 64 | } 65 | await logOrOpenUrl(ctx, dashboardUrl, options.open); 66 | return; 67 | } 68 | 69 | await logOrOpenUrl(ctx, dashboardUrl ?? DASHBOARD_HOST, options.open); 70 | }); 71 | 72 | async function logOrOpenUrl(ctx: Context, url: string, shouldOpen: boolean) { 73 | if (shouldOpen) { 74 | logMessage(ctx, chalk.gray(`Opening ${url} in the default browser...`)); 75 | await open(url); 76 | } else { 77 | logOutput(ctx, url); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/cli/data.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { oneoffContext } from "../bundler/context.js"; 3 | import { 4 | deploymentSelectionWithinProjectFromOptions, 5 | loadSelectedDeploymentCredentials, 6 | } from "./lib/api.js"; 7 | import { Command } from "@commander-js/extra-typings"; 8 | import { actionDescription } from "./lib/command.js"; 9 | import { dataInDeployment } from "./lib/data.js"; 10 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 11 | 12 | export const data = new Command("data") 13 | .summary("List tables and print data from your database") 14 | .description( 15 | "Inspect your Convex deployment's database.\n\n" + 16 | " List tables: `npx convex data`\n" + 17 | " List documents in a table: `npx convex data tableName`\n\n" + 18 | "By default, this inspects your dev deployment.", 19 | ) 20 | .allowExcessArguments(false) 21 | .addDataOptions() 22 | .addDeploymentSelectionOptions(actionDescription("Inspect the database in")) 23 | .showHelpAfterError() 24 | .action(async (tableName, options) => { 25 | const ctx = await oneoffContext(options); 26 | const selectionWithinProject = 27 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 28 | 29 | const deploymentSelection = await getDeploymentSelection(ctx, options); 30 | const deployment = await loadSelectedDeploymentCredentials( 31 | ctx, 32 | deploymentSelection, 33 | selectionWithinProject, 34 | ); 35 | 36 | const deploymentNotice = deployment.deploymentFields?.deploymentName 37 | ? `${chalk.bold(deployment.deploymentFields.deploymentName)} deployment's ` 38 | : ""; 39 | 40 | await dataInDeployment(ctx, { 41 | deploymentUrl: deployment.url, 42 | adminKey: deployment.adminKey, 43 | deploymentNotice, 44 | tableName, 45 | ...options, 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/cli/deployments.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { readProjectConfig } from "./lib/config.js"; 3 | import chalk from "chalk"; 4 | import { bigBrainAPI } from "./lib/utils/utils.js"; 5 | import { 6 | logError, 7 | logMessage, 8 | logOutput, 9 | oneoffContext, 10 | } from "../bundler/context.js"; 11 | 12 | type Deployment = { 13 | id: number; 14 | name: string; 15 | create_time: number; 16 | deployment_type: "dev" | "prod"; 17 | }; 18 | 19 | export const deployments = new Command("deployments") 20 | .description("List deployments associated with a project") 21 | .allowExcessArguments(false) 22 | .action(async () => { 23 | const ctx = await oneoffContext({ 24 | url: undefined, 25 | adminKey: undefined, 26 | envFile: undefined, 27 | }); 28 | const { projectConfig: config } = await readProjectConfig(ctx); 29 | 30 | const url = `teams/${config.team}/projects/${config.project}/deployments`; 31 | 32 | logMessage(ctx, `Deployments for project ${config.team}/${config.project}`); 33 | const deployments = (await bigBrainAPI({ 34 | ctx, 35 | method: "GET", 36 | url, 37 | })) as Deployment[]; 38 | logOutput(ctx, deployments); 39 | if (deployments.length === 0) { 40 | logError(ctx, chalk.yellow(`No deployments exist for project`)); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/cli/docs.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import chalk from "chalk"; 3 | import open from "open"; 4 | import { Context, logMessage, oneoffContext } from "../bundler/context.js"; 5 | import { bigBrainFetch, deprecationCheckWarning } from "./lib/utils/utils.js"; 6 | import { 7 | getDeploymentSelection, 8 | deploymentNameFromSelection, 9 | } from "./lib/deploymentSelection.js"; 10 | 11 | export const docs = new Command("docs") 12 | .description("Open the docs in the browser") 13 | .allowExcessArguments(false) 14 | .option("--no-open", "Print docs URL instead of opening it in your browser") 15 | .action(async (options) => { 16 | const ctx = await oneoffContext({ 17 | url: undefined, 18 | adminKey: undefined, 19 | envFile: undefined, 20 | }); 21 | const deploymentSelection = await getDeploymentSelection(ctx, { 22 | url: undefined, 23 | adminKey: undefined, 24 | envFile: undefined, 25 | }); 26 | const configuredDeployment = 27 | deploymentNameFromSelection(deploymentSelection); 28 | if (configuredDeployment === null) { 29 | await openDocs(ctx, options.open); 30 | return; 31 | } 32 | const getCookieUrl = `get_cookie/${configuredDeployment}`; 33 | const fetch = await bigBrainFetch(ctx); 34 | try { 35 | const res = await fetch(getCookieUrl); 36 | deprecationCheckWarning(ctx, res); 37 | const { cookie } = await res.json(); 38 | await openDocs(ctx, options.open, cookie); 39 | } catch { 40 | await openDocs(ctx, options.open); 41 | } 42 | }); 43 | 44 | async function openDocs(ctx: Context, toOpen: boolean, cookie?: string) { 45 | let docsUrl = "https://docs.convex.dev"; 46 | if (cookie !== undefined) { 47 | docsUrl += "/?t=" + cookie; 48 | } 49 | if (toOpen) { 50 | await open(docsUrl); 51 | logMessage(ctx, chalk.green("Docs have launched! Check your browser.")); 52 | } else { 53 | logMessage(ctx, chalk.green(`Find Convex docs here: ${docsUrl}`)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/cli/functionSpec.ts: -------------------------------------------------------------------------------- 1 | import { oneoffContext } from "../bundler/context.js"; 2 | import { 3 | deploymentSelectionWithinProjectFromOptions, 4 | loadSelectedDeploymentCredentials, 5 | } from "./lib/api.js"; 6 | import { Command, Option } from "@commander-js/extra-typings"; 7 | import { actionDescription } from "./lib/command.js"; 8 | import { functionSpecForDeployment } from "./lib/functionSpec.js"; 9 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 10 | export const functionSpec = new Command("function-spec") 11 | .summary("List function metadata from your deployment") 12 | .description( 13 | "List argument and return values to your Convex functions.\n\n" + 14 | "By default, this inspects your dev deployment.", 15 | ) 16 | .allowExcessArguments(false) 17 | .addOption(new Option("--file", "Output as JSON to a file.")) 18 | .addDeploymentSelectionOptions( 19 | actionDescription("Read function metadata from"), 20 | ) 21 | .showHelpAfterError() 22 | .action(async (options) => { 23 | const ctx = await oneoffContext(options); 24 | const deploymentSelection = await getDeploymentSelection(ctx, options); 25 | const selectionWithinProject = 26 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 27 | const { adminKey, url: deploymentUrl } = 28 | await loadSelectedDeploymentCredentials( 29 | ctx, 30 | deploymentSelection, 31 | selectionWithinProject, 32 | ); 33 | 34 | await functionSpecForDeployment(ctx, { 35 | deploymentUrl, 36 | adminKey, 37 | file: !!options.file, 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/cli/init.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import path from "path"; 3 | import { oneoffContext } from "../bundler/context.js"; 4 | 5 | const cwd = path.basename(process.cwd()); 6 | 7 | // Initialize a new Convex project. 8 | // This command is deprecated and hidden from the command help. 9 | // `npx convex dev --once --configure=new` replaces it. 10 | export const init = new Command("init") 11 | .description("Initialize a new Convex project in the current directory") 12 | .allowExcessArguments(false) 13 | .addOption( 14 | new Option( 15 | "--project ", 16 | `Name of the project to create. Defaults to \`${cwd}\` (the current directory)`, 17 | ), 18 | ) 19 | .addOption( 20 | new Option( 21 | "--team ", 22 | "Slug identifier for the team this project will belong to.", 23 | ), 24 | ) 25 | .action(async (_options) => { 26 | return ( 27 | await oneoffContext({ 28 | url: undefined, 29 | adminKey: undefined, 30 | envFile: undefined, 31 | }) 32 | ).crash({ 33 | exitCode: 1, 34 | errorType: "fatal", 35 | errForSentry: 36 | "The `init` command is deprecated. Use `npx convex dev --once --configure=new` instead.", 37 | printedMessage: 38 | "The `init` command is deprecated. Use `npx convex dev --once --configure=new` instead.", 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/cli/lib/components/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFINITION_FILENAME_TS = "convex.config.ts"; 2 | export const DEFINITION_FILENAME_JS = "convex.config.js"; 3 | -------------------------------------------------------------------------------- /src/cli/lib/config.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, test, expect } from "vitest"; 2 | import { parseProjectConfig } from "./config.js"; 3 | import { logFailure, oneoffContext } from "../../bundler/context.js"; 4 | import stripAnsi from "strip-ansi"; 5 | 6 | test("parseProjectConfig", async () => { 7 | // Make a context that throws on crashes so we can detect them. 8 | const originalContext = await oneoffContext({ 9 | url: undefined, 10 | adminKey: undefined, 11 | envFile: undefined, 12 | }); 13 | const ctx = { 14 | ...originalContext, 15 | crash: (args: { printedMessage: string | null }) => { 16 | if (args.printedMessage !== null) { 17 | logFailure(originalContext, args.printedMessage); 18 | } 19 | throw new Error(); 20 | }, 21 | }; 22 | const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => { 23 | // Do nothing 24 | return true; 25 | }); 26 | const assertParses = async (inp: any) => { 27 | expect(await parseProjectConfig(ctx, inp)).toEqual(inp); 28 | }; 29 | const assertParseError = async (inp: any, err: string) => { 30 | await expect(parseProjectConfig(ctx, inp)).rejects.toThrow(); 31 | const calledWith = stderrSpy.mock.calls as string[][]; 32 | expect(stripAnsi(calledWith[0][0])).toEqual(err); 33 | }; 34 | 35 | await assertParses({ 36 | team: "team", 37 | project: "proj", 38 | prodUrl: "prodUrl", 39 | functions: "functions/", 40 | }); 41 | 42 | await assertParses({ 43 | team: "team", 44 | project: "proj", 45 | prodUrl: "prodUrl", 46 | functions: "functions/", 47 | authInfos: [], 48 | }); 49 | 50 | await assertParses({ 51 | team: "team", 52 | project: "proj", 53 | prodUrl: "prodUrl", 54 | functions: "functions/", 55 | authInfos: [ 56 | { 57 | applicationID: "hello", 58 | domain: "world", 59 | }, 60 | ], 61 | }); 62 | 63 | await assertParseError( 64 | { 65 | team: "team", 66 | project: "proj", 67 | prodUrl: "prodUrl", 68 | functions: "functions/", 69 | authInfo: [{}], 70 | }, 71 | "✖ Expected `authInfo` in `convex.json` to be type AuthInfo[]\n", 72 | ); 73 | }); 74 | -------------------------------------------------------------------------------- /src/cli/lib/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "../../bundler/context.js"; 2 | import { DeploymentType } from "./api.js"; 3 | import { dashboardUrl as localDashboardUrl } from "./localDeployment/dashboard.js"; 4 | 5 | export const DASHBOARD_HOST = process.env.CONVEX_PROVISION_HOST 6 | ? "http://localhost:6789" 7 | : "https://dashboard.convex.dev"; 8 | 9 | export function getDashboardUrl( 10 | ctx: Context, 11 | { 12 | deploymentName, 13 | deploymentType, 14 | }: { 15 | deploymentName: string; 16 | deploymentType: DeploymentType; 17 | }, 18 | ): string | null { 19 | switch (deploymentType) { 20 | case "anonymous": { 21 | return localDashboardUrl(ctx, deploymentName); 22 | } 23 | case "local": 24 | case "dev": 25 | case "prod": 26 | case "preview": 27 | return deploymentDashboardUrlPage(deploymentName, ""); 28 | default: { 29 | const _exhaustiveCheck: never = deploymentType; 30 | return _exhaustiveCheck; 31 | } 32 | } 33 | } 34 | 35 | export function deploymentDashboardUrlPage( 36 | configuredDeployment: string | null, 37 | page: string, 38 | ): string { 39 | return `${DASHBOARD_HOST}/d/${configuredDeployment}${page}`; 40 | } 41 | 42 | export function deploymentDashboardUrl( 43 | team: string, 44 | project: string, 45 | deploymentName: string, 46 | ) { 47 | return `${projectDashboardUrl(team, project)}/${deploymentName}`; 48 | } 49 | 50 | export function projectDashboardUrl(team: string, project: string) { 51 | return `${teamDashboardUrl(team)}/${project}`; 52 | } 53 | 54 | export function teamDashboardUrl(team: string) { 55 | return `${DASHBOARD_HOST}/t/${team}`; 56 | } 57 | -------------------------------------------------------------------------------- /src/cli/lib/debugBundlePath.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { Context } from "../../bundler/context.js"; 3 | import { Config } from "./config.js"; 4 | 5 | export async function handleDebugBundlePath( 6 | ctx: Context, 7 | debugBundleDir: string, 8 | config: Config, 9 | ) { 10 | if (!ctx.fs.exists(debugBundleDir)) { 11 | ctx.fs.mkdir(debugBundleDir); 12 | } else if (!ctx.fs.stat(debugBundleDir).isDirectory()) { 13 | return await ctx.crash({ 14 | exitCode: 1, 15 | errorType: "fatal", 16 | printedMessage: `Path \`${debugBundleDir}\` is not a directory. Please choose an empty directory for \`--debug-bundle-path\`.`, 17 | }); 18 | } else if (ctx.fs.listDir(debugBundleDir).length !== 0) { 19 | await ctx.crash({ 20 | exitCode: 1, 21 | errorType: "fatal", 22 | printedMessage: `Directory \`${debugBundleDir}\` is not empty. Please remove it or choose an empty directory for \`--debug-bundle-path\`.`, 23 | }); 24 | } 25 | ctx.fs.writeUtf8File( 26 | path.join(debugBundleDir, "fullConfig.json"), 27 | JSON.stringify(config), 28 | ); 29 | 30 | for (const moduleInfo of config.modules) { 31 | const trimmedPath = moduleInfo.path.endsWith(".js") 32 | ? moduleInfo.path.slice(0, moduleInfo.path.length - ".js".length) 33 | : moduleInfo.path; 34 | const environmentDir = path.join(debugBundleDir, moduleInfo.environment); 35 | ctx.fs.mkdir(path.dirname(path.join(environmentDir, `${trimmedPath}.js`)), { 36 | allowExisting: true, 37 | recursive: true, 38 | }); 39 | ctx.fs.writeUtf8File( 40 | path.join(environmentDir, `${trimmedPath}.js`), 41 | moduleInfo.source, 42 | ); 43 | if (moduleInfo.sourceMap !== undefined) { 44 | ctx.fs.writeUtf8File( 45 | path.join(environmentDir, `${trimmedPath}.js.map`), 46 | moduleInfo.sourceMap, 47 | ); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/checkedComponent.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | componentDefinitionPath, 4 | componentFunctionPath, 5 | ComponentDefinitionPath, 6 | ComponentPath, 7 | componentPath, 8 | } from "./paths.js"; 9 | import { Identifier, identifier } from "./types.js"; 10 | import { looseObject } from "./utils.js"; 11 | 12 | export const resource = z.union([ 13 | looseObject({ type: z.literal("value"), value: z.string() }), 14 | looseObject({ 15 | type: z.literal("function"), 16 | path: componentFunctionPath, 17 | }), 18 | ]); 19 | export type Resource = z.infer; 20 | 21 | export type CheckedExport = 22 | | { type: "branch"; children: Record } 23 | | { type: "leaf"; resource: Resource }; 24 | export const checkedExport: z.ZodType = z.lazy(() => 25 | z.union([ 26 | looseObject({ 27 | type: z.literal("branch"), 28 | children: z.record(identifier, checkedExport), 29 | }), 30 | looseObject({ 31 | type: z.literal("leaf"), 32 | resource, 33 | }), 34 | ]), 35 | ); 36 | 37 | export const httpActionRoute = looseObject({ 38 | method: z.string(), 39 | path: z.string(), 40 | }); 41 | 42 | export const checkedHttpRoutes = looseObject({ 43 | httpModuleRoutes: z.nullable(z.array(httpActionRoute)), 44 | mounts: z.array(z.string()), 45 | }); 46 | export type CheckedHttpRoutes = z.infer; 47 | 48 | export type CheckedComponent = { 49 | definitionPath: ComponentDefinitionPath; 50 | componentPath: ComponentPath; 51 | args: Record; 52 | childComponents: Record; 53 | }; 54 | export const checkedComponent: z.ZodType = z.lazy(() => 55 | looseObject({ 56 | definitionPath: componentDefinitionPath, 57 | componentPath, 58 | args: z.record(identifier, resource), 59 | childComponents: z.record(identifier, checkedComponent), 60 | httpRoutes: checkedHttpRoutes, 61 | exports: z.record(identifier, checkedExport), 62 | }), 63 | ); 64 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/definitionConfig.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { componentDefinitionPath } from "./paths.js"; 3 | import { moduleConfig } from "./modules.js"; 4 | import { looseObject } from "./utils.js"; 5 | 6 | export const appDefinitionConfig = looseObject({ 7 | definition: z.nullable(moduleConfig), 8 | dependencies: z.array(componentDefinitionPath), 9 | schema: z.nullable(moduleConfig), 10 | functions: z.array(moduleConfig), 11 | udfServerVersion: z.string(), 12 | }); 13 | export type AppDefinitionConfig = z.infer; 14 | 15 | export const componentDefinitionConfig = looseObject({ 16 | definitionPath: componentDefinitionPath, 17 | definition: moduleConfig, 18 | dependencies: z.array(componentDefinitionPath), 19 | schema: z.nullable(moduleConfig), 20 | functions: z.array(moduleConfig), 21 | udfServerVersion: z.string(), 22 | }); 23 | export type ComponentDefinitionConfig = z.infer< 24 | typeof componentDefinitionConfig 25 | >; 26 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/finishPush.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { looseObject } from "./utils.js"; 3 | 4 | export const authDiff = looseObject({ 5 | added: z.array(z.string()), 6 | removed: z.array(z.string()), 7 | }); 8 | export type AuthDiff = z.infer; 9 | 10 | export const componentDefinitionDiff = looseObject({}); 11 | export type ComponentDefinitionDiff = z.infer; 12 | 13 | export const componentDiffType = z.discriminatedUnion("type", [ 14 | looseObject({ 15 | type: z.literal("create"), 16 | }), 17 | looseObject({ 18 | type: z.literal("modify"), 19 | }), 20 | looseObject({ 21 | type: z.literal("unmount"), 22 | }), 23 | looseObject({ 24 | type: z.literal("remount"), 25 | }), 26 | ]); 27 | export type ComponentDiffType = z.infer; 28 | 29 | export const moduleDiff = looseObject({ 30 | added: z.array(z.string()), 31 | removed: z.array(z.string()), 32 | }); 33 | export type ModuleDiff = z.infer; 34 | 35 | export const udfConfigDiff = looseObject({ 36 | previous_version: z.string(), 37 | next_version: z.string(), 38 | }); 39 | export type UdfConfigDiff = z.infer; 40 | 41 | export const cronDiff = looseObject({ 42 | added: z.array(z.string()), 43 | updated: z.array(z.string()), 44 | deleted: z.array(z.string()), 45 | }); 46 | export type CronDiff = z.infer; 47 | 48 | const developerIndexConfig = z.discriminatedUnion("type", [ 49 | looseObject({ 50 | name: z.string(), 51 | type: z.literal("database"), 52 | fields: z.array(z.string()), 53 | }), 54 | looseObject({ 55 | name: z.string(), 56 | type: z.literal("search"), 57 | searchField: z.string(), 58 | filterFields: z.array(z.string()), 59 | }), 60 | looseObject({ 61 | name: z.string(), 62 | type: z.literal("vector"), 63 | dimensions: z.number(), 64 | vectorField: z.string(), 65 | filterFields: z.array(z.string()), 66 | }), 67 | ]); 68 | export type DeveloperIndexConfig = z.infer; 69 | 70 | export const indexDiff = looseObject({ 71 | added_indexes: z.array(developerIndexConfig), 72 | removed_indexes: z.array(developerIndexConfig), 73 | }); 74 | export type IndexDiff = z.infer; 75 | 76 | export const schemaDiff = looseObject({ 77 | previous_schema: z.nullable(z.string()), 78 | next_schema: z.nullable(z.string()), 79 | }); 80 | export type SchemaDiff = z.infer; 81 | 82 | export const componentDiff = looseObject({ 83 | diffType: componentDiffType, 84 | moduleDiff, 85 | udfConfigDiff: z.nullable(udfConfigDiff), 86 | cronDiff, 87 | indexDiff, 88 | schemaDiff: z.nullable(schemaDiff), 89 | }); 90 | export type ComponentDiff = z.infer; 91 | 92 | export const finishPushDiff = looseObject({ 93 | authDiff, 94 | definitionDiffs: z.record(z.string(), componentDefinitionDiff), 95 | componentDiffs: z.record(z.string(), componentDiff), 96 | }); 97 | export type FinishPushDiff = z.infer; 98 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/modules.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { looseObject } from "./utils.js"; 3 | 4 | export const moduleEnvironment = z.union([ 5 | z.literal("isolate"), 6 | z.literal("node"), 7 | ]); 8 | export type ModuleEnvironment = z.infer; 9 | 10 | export const moduleConfig = looseObject({ 11 | path: z.string(), 12 | source: z.string(), 13 | sourceMap: z.optional(z.string()), 14 | environment: moduleEnvironment, 15 | }); 16 | export type ModuleConfig = z.infer; 17 | 18 | export const nodeDependency = looseObject({ 19 | name: z.string(), 20 | version: z.string(), 21 | }); 22 | export type NodeDependency = z.infer; 23 | 24 | export const udfConfig = looseObject({ 25 | serverVersion: z.string(), 26 | // RNG seed encoded as Convex bytes in JSON. 27 | importPhaseRngSeed: z.any(), 28 | // Timestamp encoded as a Convex Int64 in JSON. 29 | importPhaseUnixTimestamp: z.any(), 30 | }); 31 | export type UdfConfig = z.infer; 32 | 33 | export const sourcePackage = z.any(); 34 | export type SourcePackage = z.infer; 35 | 36 | export const visibility = z.union([ 37 | looseObject({ kind: z.literal("public") }), 38 | looseObject({ kind: z.literal("internal") }), 39 | ]); 40 | export type Visibility = z.infer; 41 | 42 | export const analyzedFunction = looseObject({ 43 | name: z.string(), 44 | pos: z.any(), 45 | udfType: z.union([ 46 | z.literal("Query"), 47 | z.literal("Mutation"), 48 | z.literal("Action"), 49 | ]), 50 | visibility: z.nullable(visibility), 51 | args: z.nullable(z.string()), 52 | returns: z.nullable(z.string()), 53 | }); 54 | export type AnalyzedFunction = z.infer; 55 | 56 | export const analyzedModule = looseObject({ 57 | functions: z.array(analyzedFunction), 58 | httpRoutes: z.any(), 59 | cronSpecs: z.any(), 60 | sourceMapped: z.any(), 61 | }); 62 | export type AnalyzedModule = z.infer; 63 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/paths.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { looseObject } from "./utils.js"; 3 | 4 | // TODO share some of these types, to distinguish between encodedComponentDefinitionPaths etc. 5 | export const componentDefinitionPath = z.string(); 6 | export type ComponentDefinitionPath = z.infer; 7 | 8 | export const componentPath = z.string(); 9 | export type ComponentPath = z.infer; 10 | 11 | export const canonicalizedModulePath = z.string(); 12 | export type CanonicalizedModulePath = z.infer; 13 | 14 | export const componentFunctionPath = looseObject({ 15 | component: z.string(), 16 | udfPath: z.string(), 17 | }); 18 | export type ComponentFunctionPath = z.infer; 19 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/startPush.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { componentDefinitionPath, componentPath } from "./paths.js"; 3 | import { nodeDependency, sourcePackage } from "./modules.js"; 4 | import { checkedComponent } from "./checkedComponent.js"; 5 | import { evaluatedComponentDefinition } from "./componentDefinition.js"; 6 | import { 7 | appDefinitionConfig, 8 | componentDefinitionConfig, 9 | } from "./definitionConfig.js"; 10 | import { authInfo } from "./types.js"; 11 | import { looseObject } from "./utils.js"; 12 | 13 | export const startPushRequest = looseObject({ 14 | adminKey: z.string(), 15 | dryRun: z.boolean(), 16 | 17 | functions: z.string(), 18 | 19 | appDefinition: appDefinitionConfig, 20 | componentDefinitions: z.array(componentDefinitionConfig), 21 | 22 | nodeDependencies: z.array(nodeDependency), 23 | }); 24 | export type StartPushRequest = z.infer; 25 | 26 | export const schemaChange = looseObject({ 27 | allocatedComponentIds: z.any(), 28 | schemaIds: z.any(), 29 | }); 30 | export type SchemaChange = z.infer; 31 | 32 | export const startPushResponse = looseObject({ 33 | environmentVariables: z.record(z.string(), z.string()), 34 | 35 | externalDepsId: z.nullable(z.string()), 36 | componentDefinitionPackages: z.record(componentDefinitionPath, sourcePackage), 37 | 38 | appAuth: z.array(authInfo), 39 | analysis: z.record(componentDefinitionPath, evaluatedComponentDefinition), 40 | 41 | app: checkedComponent, 42 | 43 | schemaChange, 44 | }); 45 | export type StartPushResponse = z.infer; 46 | 47 | export const componentSchemaStatus = looseObject({ 48 | schemaValidationComplete: z.boolean(), 49 | indexesComplete: z.number(), 50 | indexesTotal: z.number(), 51 | }); 52 | export type ComponentSchemaStatus = z.infer; 53 | 54 | export const schemaStatus = z.union([ 55 | looseObject({ 56 | type: z.literal("inProgress"), 57 | components: z.record(componentPath, componentSchemaStatus), 58 | }), 59 | looseObject({ 60 | type: z.literal("failed"), 61 | error: z.string(), 62 | componentPath, 63 | tableName: z.nullable(z.string()), 64 | }), 65 | looseObject({ 66 | type: z.literal("raceDetected"), 67 | }), 68 | looseObject({ 69 | type: z.literal("complete"), 70 | }), 71 | ]); 72 | export type SchemaStatus = z.infer; 73 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const reference = z.string(); 4 | export type Reference = z.infer; 5 | 6 | // These validators parse the response from the backend so although 7 | // they roughly correspond with convex/auth.config.ts providers they 8 | // have been processed. 9 | 10 | // Passthrough so old CLIs can operate on new backend formats. 11 | const Oidc = z 12 | .object({ 13 | applicationID: z.string(), 14 | domain: z.string(), 15 | }) 16 | .passthrough(); 17 | const CustomJwt = z 18 | .object({ 19 | type: z.literal("customJwt"), 20 | applicationID: z.string().nullable(), 21 | issuer: z.string(), 22 | jwks: z.string(), 23 | algorithm: z.string(), 24 | }) 25 | .passthrough(); 26 | 27 | export const authInfo = z.union([CustomJwt, Oidc]); 28 | 29 | export type AuthInfo = z.infer; 30 | 31 | export const identifier = z.string(); 32 | export type Identifier = z.infer; 33 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * Convenience wrapper for z.object(...).passthrough(). 5 | * 6 | * This object validator allows extra properties and passes them through. 7 | * This is useful for forwards compatibility if the server adds extra unknown 8 | * fields. 9 | */ 10 | export function looseObject( 11 | shape: T, 12 | params?: z.RawCreateParams, 13 | ): z.ZodObject< 14 | T, 15 | "passthrough", 16 | z.ZodTypeAny, 17 | { 18 | [k_1 in keyof z.objectUtil.addQuestionMarks< 19 | z.baseObjectOutputType, 20 | { 21 | [k in keyof z.baseObjectOutputType]: undefined extends z.baseObjectOutputType[k] 22 | ? never 23 | : k; 24 | }[keyof T] 25 | >]: z.objectUtil.addQuestionMarks< 26 | z.baseObjectOutputType, 27 | { 28 | [k in keyof z.baseObjectOutputType]: undefined extends z.baseObjectOutputType[k] 29 | ? never 30 | : k; 31 | }[keyof T] 32 | >[k_1]; 33 | }, 34 | { [k_2 in keyof z.baseObjectInputType]: z.baseObjectInputType[k_2] } 35 | > { 36 | return z.object(shape, params).passthrough(); 37 | } 38 | -------------------------------------------------------------------------------- /src/cli/lib/deployApi/validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { looseObject } from "./utils.js"; 3 | 4 | const baseConvexValidator = z.discriminatedUnion("type", [ 5 | looseObject({ type: z.literal("null") }), 6 | looseObject({ type: z.literal("number") }), 7 | looseObject({ type: z.literal("bigint") }), 8 | looseObject({ type: z.literal("boolean") }), 9 | looseObject({ type: z.literal("string") }), 10 | looseObject({ type: z.literal("bytes") }), 11 | looseObject({ type: z.literal("any") }), 12 | looseObject({ type: z.literal("literal"), value: z.any() }), 13 | looseObject({ type: z.literal("id"), tableName: z.string() }), 14 | ]); 15 | export type ConvexValidator = 16 | | z.infer 17 | | { type: "array"; value: ConvexValidator } 18 | | { 19 | type: "record"; 20 | keys: ConvexValidator; 21 | values: { fieldType: ConvexValidator; optional: false }; 22 | } 23 | | { type: "union"; value: ConvexValidator[] } 24 | | { 25 | type: "object"; 26 | value: Record; 27 | }; 28 | export const convexValidator: z.ZodType = z.lazy(() => 29 | z.union([ 30 | baseConvexValidator, 31 | looseObject({ type: z.literal("array"), value: convexValidator }), 32 | looseObject({ 33 | type: z.literal("record"), 34 | keys: convexValidator, 35 | values: z.object({ 36 | fieldType: convexValidator, 37 | optional: z.literal(false), 38 | }), 39 | }), 40 | looseObject({ 41 | type: z.literal("union"), 42 | value: z.array(convexValidator), 43 | }), 44 | looseObject({ 45 | type: z.literal("object"), 46 | value: z.record( 47 | looseObject({ 48 | fieldType: convexValidator, 49 | optional: z.boolean(), 50 | }), 51 | ), 52 | }), 53 | ]), 54 | ); 55 | -------------------------------------------------------------------------------- /src/cli/lib/deployment.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { changesToEnvVarFile, changesToGitIgnore } from "./deployment.js"; 3 | 4 | const DEPLOYMENT = { 5 | team: "snoops", 6 | project: "earth", 7 | deploymentName: "tall-bar", 8 | }; 9 | 10 | test("env var changes", () => { 11 | expect(changesToEnvVarFile(null, "prod", DEPLOYMENT)).toEqual( 12 | "# Deployment used by `npx convex dev`\n" + 13 | "CONVEX_DEPLOYMENT=prod:tall-bar # team: snoops, project: earth\n", 14 | ); 15 | 16 | expect(changesToEnvVarFile("CONVEX_DEPLOYMENT=", "prod", DEPLOYMENT)).toEqual( 17 | "CONVEX_DEPLOYMENT=prod:tall-bar # team: snoops, project: earth", 18 | ); 19 | 20 | expect( 21 | changesToEnvVarFile("CONVEX_DEPLOYMENT=foo", "prod", DEPLOYMENT), 22 | ).toEqual("CONVEX_DEPLOYMENT=prod:tall-bar # team: snoops, project: earth"); 23 | 24 | expect(changesToEnvVarFile("RAD_DEPLOYMENT=foo", "prod", DEPLOYMENT)).toEqual( 25 | "RAD_DEPLOYMENT=foo\n" + 26 | "\n" + 27 | "# Deployment used by `npx convex dev`\n" + 28 | "CONVEX_DEPLOYMENT=prod:tall-bar # team: snoops, project: earth\n", 29 | ); 30 | 31 | expect( 32 | changesToEnvVarFile( 33 | "RAD_DEPLOYMENT=foo\n" + "CONVEX_DEPLOYMENT=foo", 34 | "prod", 35 | DEPLOYMENT, 36 | ), 37 | ).toEqual( 38 | "RAD_DEPLOYMENT=foo\n" + 39 | "CONVEX_DEPLOYMENT=prod:tall-bar # team: snoops, project: earth", 40 | ); 41 | 42 | expect( 43 | changesToEnvVarFile( 44 | "CONVEX_DEPLOYMENT=\n" + "RAD_DEPLOYMENT=foo", 45 | "prod", 46 | DEPLOYMENT, 47 | ), 48 | ).toEqual( 49 | "CONVEX_DEPLOYMENT=prod:tall-bar # team: snoops, project: earth\n" + 50 | "RAD_DEPLOYMENT=foo", 51 | ); 52 | }); 53 | 54 | test("git ignore changes", () => { 55 | // Handle additions 56 | expect(changesToGitIgnore(null)).toEqual(".env.local\n"); 57 | expect(changesToGitIgnore("")).toEqual("\n.env.local\n"); 58 | expect(changesToGitIgnore(".env")).toEqual(".env\n.env.local\n"); 59 | expect(changesToGitIgnore("# .env.local")).toEqual( 60 | "# .env.local\n.env.local\n", 61 | ); 62 | 63 | // Handle existing 64 | expect(changesToGitIgnore(".env.local")).toEqual(null); 65 | expect(changesToGitIgnore(".env.*")).toEqual(null); 66 | expect(changesToGitIgnore(".env*")).toEqual(null); 67 | 68 | expect(changesToGitIgnore(".env*.local")).toEqual(null); 69 | expect(changesToGitIgnore("*.local")).toEqual(null); 70 | expect(changesToGitIgnore("# convex env\n.env.local")).toEqual(null); 71 | 72 | // Handle Windows 73 | expect(changesToGitIgnore(".env.local\r")).toEqual(null); 74 | expect(changesToGitIgnore("foo\r\n.env.local")).toEqual(null); 75 | expect(changesToGitIgnore("foo\r\n.env.local\r\n")).toEqual(null); 76 | expect(changesToGitIgnore("foo\r\n.env.local\r\nbar")).toEqual(null); 77 | 78 | // Handle trailing whitespace 79 | expect(changesToGitIgnore(" .env.local ")).toEqual(null); 80 | 81 | // Add .env.local (even if it's negated) to guide the user to solve the problem 82 | expect(changesToGitIgnore("!.env.local")).toEqual( 83 | "!.env.local\n.env.local\n", 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/cli/lib/fsUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe, beforeEach, afterEach } from "vitest"; 2 | import { oneoffContext, Context } from "../../bundler/context.js"; 3 | import fs from "fs"; 4 | import os from "os"; 5 | import path from "path"; 6 | import { recursivelyDelete } from "./fsUtils.js"; 7 | 8 | describe("fsUtils", async () => { 9 | let tmpDir: string; 10 | let ctx: Context; 11 | 12 | beforeEach(async () => { 13 | ctx = await oneoffContext({ 14 | url: undefined, 15 | adminKey: undefined, 16 | envFile: undefined, 17 | }); 18 | tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`); 19 | }); 20 | 21 | describe("recursivelyDelete", () => { 22 | test("deletes file", () => { 23 | const file = path.join(tmpDir, "file"); 24 | ctx.fs.writeUtf8File(file, "contents"); 25 | expect(ctx.fs.exists(file)).toBe(true); 26 | 27 | recursivelyDelete(ctx, file); 28 | expect(ctx.fs.exists(file)).toBe(false); 29 | }); 30 | 31 | test("throws an error on non-existent file", () => { 32 | const nonexistentFile = path.join(tmpDir, "nonexistent_file"); 33 | expect(() => { 34 | recursivelyDelete(ctx, nonexistentFile); 35 | }).toThrow("ENOENT: no such file or directory"); 36 | }); 37 | 38 | test("does not throw error if `force` is used", () => { 39 | const nonexistentFile = path.join(tmpDir, "nonexistent_file"); 40 | recursivelyDelete(ctx, nonexistentFile, { force: true }); 41 | }); 42 | 43 | test("recursively deletes a directory", () => { 44 | const dir = path.join(tmpDir, "dir"); 45 | ctx.fs.mkdir(dir); 46 | const nestedFile = path.join(dir, "nested_file"); 47 | ctx.fs.writeUtf8File(nestedFile, "content"); 48 | const nestedDir = path.join(dir, "nested_dir"); 49 | ctx.fs.mkdir(nestedDir); 50 | 51 | expect(ctx.fs.exists(dir)).toBe(true); 52 | 53 | recursivelyDelete(ctx, dir); 54 | expect(ctx.fs.exists(dir)).toBe(false); 55 | }); 56 | 57 | test("`recursive` and `force` work together", () => { 58 | const nonexistentDir = path.join(tmpDir, "nonexistent_dir"); 59 | // Shouldn't throw an exception. 60 | recursivelyDelete(ctx, nonexistentDir, { force: true }); 61 | }); 62 | }); 63 | 64 | afterEach(() => { 65 | fs.rmSync(tmpDir, { recursive: true }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/cli/lib/fsUtils.ts: -------------------------------------------------------------------------------- 1 | import { Context, logOutput } from "../../bundler/context.js"; 2 | import path from "path"; 3 | import { NodeFs } from "../../bundler/fs.js"; 4 | 5 | export function recursivelyDelete( 6 | ctx: Context, 7 | deletePath: string, 8 | opts?: { force?: boolean; dryRun?: boolean }, 9 | ) { 10 | const dryRun = !!opts?.dryRun; 11 | let st; 12 | try { 13 | st = ctx.fs.stat(deletePath); 14 | } catch (err: any) { 15 | if (err.code === "ENOENT" && opts?.force) { 16 | return; 17 | } 18 | // eslint-disable-next-line no-restricted-syntax 19 | throw err; 20 | } 21 | if (st.isDirectory()) { 22 | for (const entry of ctx.fs.listDir(deletePath)) { 23 | recursivelyDelete(ctx, path.join(deletePath, entry.name), opts); 24 | } 25 | if (dryRun) { 26 | logOutput(ctx, `Command would delete directory: ${deletePath}`); 27 | return; 28 | } 29 | try { 30 | ctx.fs.rmdir(deletePath); 31 | } catch (err: any) { 32 | if (err.code !== "ENOENT") { 33 | // eslint-disable-next-line no-restricted-syntax 34 | throw err; 35 | } 36 | } 37 | } else { 38 | if (dryRun) { 39 | logOutput(ctx, `Command would delete file: ${deletePath}`); 40 | return; 41 | } 42 | try { 43 | ctx.fs.unlink(deletePath); 44 | } catch (err: any) { 45 | if (err.code !== "ENOENT") { 46 | // eslint-disable-next-line no-restricted-syntax 47 | throw err; 48 | } 49 | } 50 | } 51 | } 52 | 53 | export async function recursivelyCopy( 54 | ctx: Context, 55 | nodeFs: NodeFs, 56 | src: string, 57 | dest: string, 58 | ) { 59 | const st = nodeFs.stat(src); 60 | if (st.isDirectory()) { 61 | nodeFs.mkdir(dest, { recursive: true }); 62 | for (const entry of nodeFs.listDir(src)) { 63 | await recursivelyCopy( 64 | ctx, 65 | nodeFs, 66 | path.join(src, entry.name), 67 | path.join(dest, entry.name), 68 | ); 69 | } 70 | } else { 71 | // Don't use writeUtf8File to allow copying arbitrary files 72 | await nodeFs.writeFileStream(dest, nodeFs.createReadStream(src, {})); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/cli/lib/functionSpec.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { logOutput } from "../../bundler/context.js"; 3 | import { runSystemQuery } from "./run.js"; 4 | import { Context } from "../../bundler/context.js"; 5 | 6 | export async function functionSpecForDeployment( 7 | ctx: Context, 8 | options: { 9 | deploymentUrl: string; 10 | adminKey: string; 11 | file: boolean; 12 | }, 13 | ) { 14 | const functions = (await runSystemQuery(ctx, { 15 | deploymentUrl: options.deploymentUrl, 16 | adminKey: options.adminKey, 17 | functionName: "_system/cli/modules:apiSpec", 18 | componentPath: undefined, 19 | args: {}, 20 | })) as any[]; 21 | const url = (await runSystemQuery(ctx, { 22 | deploymentUrl: options.deploymentUrl, 23 | adminKey: options.adminKey, 24 | functionName: "_system/cli/convexUrl:cloudUrl", 25 | componentPath: undefined, 26 | args: {}, 27 | })) as string; 28 | 29 | const output = JSON.stringify({ url, functions }, null, 2); 30 | 31 | if (options.file) { 32 | const fileName = `function_spec_${Date.now().valueOf()}.json`; 33 | ctx.fs.writeUtf8File(fileName, output); 34 | logOutput(ctx, chalk.green(`Wrote function spec to ${fileName}`)); 35 | } else { 36 | logOutput(ctx, output); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/cli/lib/init.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { Context, logFinishedStep, logMessage } from "../../bundler/context.js"; 3 | import { DeploymentType } from "./api.js"; 4 | import { writeConvexUrlToEnvFile } from "./envvars.js"; 5 | import { getDashboardUrl } from "./dashboard.js"; 6 | 7 | export async function finalizeConfiguration( 8 | ctx: Context, 9 | options: { 10 | functionsPath: string; 11 | deploymentType: DeploymentType; 12 | deploymentName: string; 13 | url: string; 14 | wroteToGitIgnore: boolean; 15 | changedDeploymentEnvVar: boolean; 16 | }, 17 | ) { 18 | const envVarWrite = await writeConvexUrlToEnvFile(ctx, options.url); 19 | if (envVarWrite !== null) { 20 | logFinishedStep( 21 | ctx, 22 | `${messageForDeploymentType(options.deploymentType, options.url)} and saved its:\n` + 23 | ` name as CONVEX_DEPLOYMENT to .env.local\n` + 24 | ` URL as ${envVarWrite.envVar} to ${envVarWrite.envFile}`, 25 | ); 26 | } else if (options.changedDeploymentEnvVar) { 27 | logFinishedStep( 28 | ctx, 29 | `${messageForDeploymentType(options.deploymentType, options.url)} and saved its name as CONVEX_DEPLOYMENT to .env.local`, 30 | ); 31 | } 32 | if (options.wroteToGitIgnore) { 33 | logMessage(ctx, chalk.gray(` Added ".env.local" to .gitignore`)); 34 | } 35 | if (options.deploymentType === "anonymous") { 36 | logMessage( 37 | ctx, 38 | `Run \`npx convex login\` at any time to create an account and link this deployment.`, 39 | ); 40 | } 41 | 42 | const anyChanges = 43 | options.wroteToGitIgnore || 44 | options.changedDeploymentEnvVar || 45 | envVarWrite !== null; 46 | if (anyChanges) { 47 | const dashboardUrl = getDashboardUrl(ctx, { 48 | deploymentName: options.deploymentName, 49 | deploymentType: options.deploymentType, 50 | }); 51 | logMessage( 52 | ctx, 53 | `\nWrite your Convex functions in ${chalk.bold(options.functionsPath)}\n` + 54 | "Give us feedback at https://convex.dev/community or support@convex.dev\n" + 55 | `View the Convex dashboard at ${dashboardUrl}\n`, 56 | ); 57 | } 58 | } 59 | 60 | function messageForDeploymentType(deploymentType: DeploymentType, url: string) { 61 | switch (deploymentType) { 62 | case "anonymous": 63 | return `Started running a deployment locally at ${url}`; 64 | case "local": 65 | return `Started running a deployment locally at ${url}`; 66 | case "dev": 67 | case "prod": 68 | case "preview": 69 | return `Provisioned a ${deploymentType} deployment`; 70 | default: { 71 | const _exhaustiveCheck: never = deploymentType; 72 | return `Provisioned a ${deploymentType as any} deployment`; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/cli/lib/localDeployment/bigBrain.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "../../../bundler/context.js"; 2 | import { bigBrainAPI } from "../utils/utils.js"; 3 | 4 | export async function bigBrainStart( 5 | ctx: Context, 6 | data: { 7 | // cloud port 8 | port: number; 9 | projectSlug: string; 10 | teamSlug: string; 11 | instanceName: string | null; 12 | }, 13 | ): Promise<{ deploymentName: string; adminKey: string }> { 14 | return bigBrainAPI({ 15 | ctx, 16 | method: "POST", 17 | url: "/api/local_deployment/start", 18 | data, 19 | }); 20 | } 21 | 22 | export async function bigBrainPause( 23 | ctx: Context, 24 | data: { 25 | projectSlug: string; 26 | teamSlug: string; 27 | }, 28 | ): Promise { 29 | return bigBrainAPI({ 30 | ctx, 31 | method: "POST", 32 | url: "/api/local_deployment/pause", 33 | data, 34 | }); 35 | } 36 | 37 | export async function bigBrainRecordActivity( 38 | ctx: Context, 39 | data: { 40 | instanceName: string; 41 | }, 42 | ) { 43 | return bigBrainAPI({ 44 | ctx, 45 | method: "POST", 46 | url: "/api/local_deployment/record_activity", 47 | data, 48 | }); 49 | } 50 | 51 | export async function bigBrainEnableFeatureMetadata( 52 | ctx: Context, 53 | ): Promise<{ totalProjects: { kind: "none" | "one" | "multiple" } }> { 54 | return bigBrainAPI({ 55 | ctx, 56 | method: "POST", 57 | url: "/api/local_deployment/enable_feature_metadata", 58 | data: {}, 59 | }); 60 | } 61 | 62 | export async function bigBrainGenerateAdminKeyForAnonymousDeployment( 63 | ctx: Context, 64 | data: { 65 | instanceName: string; 66 | instanceSecret: string; 67 | }, 68 | ) { 69 | return bigBrainAPI({ 70 | ctx, 71 | method: "POST", 72 | url: "/api/local_deployment/generate_admin_key", 73 | data, 74 | }); 75 | } 76 | /** Whether a project already has a cloud dev deployment for this user. */ 77 | export async function projectHasExistingCloudDev( 78 | ctx: Context, 79 | { 80 | projectSlug, 81 | teamSlug, 82 | }: { 83 | projectSlug: string; 84 | teamSlug: string; 85 | }, 86 | ) { 87 | const response = await bigBrainAPI< 88 | | { 89 | kind: "Exists"; 90 | } 91 | | { 92 | kind: "DoesNotExist"; 93 | } 94 | >({ 95 | ctx, 96 | method: "POST", 97 | url: "/api/deployment/existing_dev", 98 | data: { projectSlug, teamSlug }, 99 | }); 100 | if (response.kind === "Exists") { 101 | return true; 102 | } else if (response.kind === "DoesNotExist") { 103 | return false; 104 | } 105 | return await ctx.crash({ 106 | exitCode: 1, 107 | errorType: "fatal", 108 | printedMessage: `Unexpected /api/deployment/existing_dev response: ${JSON.stringify(response, null, 2)}`, 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /src/cli/lib/localDeployment/errors.ts: -------------------------------------------------------------------------------- 1 | import { logFailure, logMessage, Context } from "../../../bundler/context.js"; 2 | 3 | export class LocalDeploymentError extends Error {} 4 | 5 | export function printLocalDeploymentOnError(ctx: Context) { 6 | // Note: Not printing the error message here since it should already be printed by 7 | // ctx.crash. 8 | logFailure(ctx, `Hit an error while running local deployment.`); 9 | logMessage( 10 | ctx, 11 | "Your error has been reported to our team, and we'll be working on it.", 12 | ); 13 | logMessage( 14 | ctx, 15 | "To opt out, run `npx convex disable-local-deployments`. Then re-run your original command.", 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/lib/localDeployment/utils.ts: -------------------------------------------------------------------------------- 1 | import { Context, logMessage } from "../../../bundler/context.js"; 2 | import { detect } from "detect-port"; 3 | import crypto from "crypto"; 4 | import chalk from "chalk"; 5 | 6 | export async function choosePorts( 7 | ctx: Context, 8 | { 9 | count, 10 | requestedPorts, 11 | startPort, 12 | }: { 13 | count: number; 14 | requestedPorts?: Array; 15 | startPort: number; 16 | }, 17 | ): Promise> { 18 | const ports: Array = []; 19 | for (let i = 0; i < count; i++) { 20 | const requestedPort = requestedPorts?.[i]; 21 | if (requestedPort !== null) { 22 | const port = await detect(requestedPort); 23 | if (port !== requestedPort) { 24 | return ctx.crash({ 25 | exitCode: 1, 26 | errorType: "fatal", 27 | printedMessage: "Requested port is not available", 28 | }); 29 | } 30 | ports.push(port); 31 | } else { 32 | const portToTry = 33 | ports.length > 0 ? ports[ports.length - 1] + 1 : startPort; 34 | const port = await detect(portToTry); 35 | ports.push(port); 36 | } 37 | } 38 | return ports; 39 | } 40 | 41 | export async function isOffline(): Promise { 42 | // TODO(ENG-7080) -- implement this for real 43 | return false; 44 | } 45 | 46 | export function printLocalDeploymentWelcomeMessage(ctx: Context) { 47 | logMessage( 48 | ctx, 49 | chalk.cyan("You're trying out the beta local deployment feature!"), 50 | ); 51 | logMessage( 52 | ctx, 53 | chalk.cyan( 54 | "To learn more, read the docs: https://docs.convex.dev/cli/local-deployments", 55 | ), 56 | ); 57 | logMessage( 58 | ctx, 59 | chalk.cyan( 60 | "To opt out at any time, run `npx convex disable-local-deployments`", 61 | ), 62 | ); 63 | } 64 | 65 | export function generateInstanceSecret(): string { 66 | return crypto.randomBytes(32).toString("hex"); 67 | } 68 | 69 | export const LOCAL_BACKEND_INSTANCE_SECRET = 70 | "4361726e697461732c206c69746572616c6c79206d65616e696e6720226c6974"; 71 | -------------------------------------------------------------------------------- /src/cli/lib/mcp/tools/data.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { runSystemQuery } from "../../run.js"; 3 | import { ConvexTool } from "./index.js"; 4 | import { PaginationResult } from "../../../../server/pagination.js"; 5 | import { loadSelectedDeploymentCredentials } from "../../api.js"; 6 | import { getDeploymentSelection } from "../../deploymentSelection.js"; 7 | 8 | const inputSchema = z.object({ 9 | deploymentSelector: z 10 | .string() 11 | .describe("Deployment selector (from the status tool) to read data from."), 12 | tableName: z.string().describe("The name of the table to read from."), 13 | order: z.enum(["asc", "desc"]).describe("The order to sort the results in."), 14 | cursor: z.string().optional().describe("The cursor to start reading from."), 15 | limit: z 16 | .number() 17 | .max(1000) 18 | .optional() 19 | .describe("The maximum number of results to return, defaults to 100."), 20 | }); 21 | 22 | const outputSchema = z.object({ 23 | page: z.array(z.any()), 24 | isDone: z.boolean(), 25 | continueCursor: z.string(), 26 | }); 27 | 28 | const description = ` 29 | Read a page of data from a table in the project's Convex deployment. 30 | 31 | Output: 32 | - page: A page of results from the table. 33 | - isDone: Whether there are more results to read. 34 | - continueCursor: The cursor to use to read the next page of results. 35 | `.trim(); 36 | 37 | export const DataTool: ConvexTool = { 38 | name: "data", 39 | description, 40 | inputSchema, 41 | outputSchema, 42 | handler: async (ctx, args) => { 43 | const { projectDir, deployment } = await ctx.decodeDeploymentSelector( 44 | args.deploymentSelector, 45 | ); 46 | process.chdir(projectDir); 47 | const deploymentSelection = await getDeploymentSelection(ctx, ctx.options); 48 | const credentials = await loadSelectedDeploymentCredentials( 49 | ctx, 50 | deploymentSelection, 51 | deployment, 52 | ); 53 | const paginationResult = (await runSystemQuery(ctx, { 54 | deploymentUrl: credentials.url, 55 | adminKey: credentials.adminKey, 56 | functionName: "_system/cli/tableData", 57 | componentPath: undefined, 58 | args: { 59 | table: args.tableName, 60 | order: args.order, 61 | paginationOpts: { 62 | numItems: args.limit ?? 100, 63 | cursor: args.cursor ?? null, 64 | }, 65 | }, 66 | })) as unknown as PaginationResult; 67 | return { 68 | page: paginationResult.page, 69 | isDone: paginationResult.isDone, 70 | continueCursor: paginationResult.continueCursor, 71 | }; 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/cli/lib/mcp/tools/functionSpec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { ConvexTool } from "./index.js"; 3 | import { loadSelectedDeploymentCredentials } from "../../api.js"; 4 | import { runSystemQuery } from "../../run.js"; 5 | import { getDeploymentSelection } from "../../deploymentSelection.js"; 6 | 7 | const inputSchema = z.object({ 8 | deploymentSelector: z 9 | .string() 10 | .describe( 11 | "Deployment selector (from the status tool) to get function metadata from.", 12 | ), 13 | }); 14 | 15 | const outputSchema = z 16 | .any() 17 | .describe("Function metadata including arguments and return values"); 18 | 19 | const description = ` 20 | Get the function metadata from a Convex deployment. 21 | 22 | Returns an array of structured objects for each function the deployment. Each function's 23 | metadata contains its identifier (which is its path within the convex/ folder joined 24 | with its exported name), its argument validator, its return value validator, its type 25 | (i.e. is it a query, mutation, or action), and its visibility (i.e. is it public or 26 | internal). 27 | `.trim(); 28 | 29 | export const FunctionSpecTool: ConvexTool< 30 | typeof inputSchema, 31 | typeof outputSchema 32 | > = { 33 | name: "functionSpec", 34 | description, 35 | inputSchema, 36 | outputSchema, 37 | handler: async (ctx, args) => { 38 | const { projectDir, deployment } = await ctx.decodeDeploymentSelector( 39 | args.deploymentSelector, 40 | ); 41 | process.chdir(projectDir); 42 | const deploymentSelection = await getDeploymentSelection(ctx, ctx.options); 43 | const credentials = await loadSelectedDeploymentCredentials( 44 | ctx, 45 | deploymentSelection, 46 | deployment, 47 | ); 48 | const functions = await runSystemQuery(ctx, { 49 | deploymentUrl: credentials.url, 50 | adminKey: credentials.adminKey, 51 | functionName: "_system/cli/modules:apiSpec", 52 | componentPath: undefined, 53 | args: {}, 54 | }); 55 | return functions; 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/cli/lib/mcp/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { ToolSchema } from "@modelcontextprotocol/sdk/types"; 2 | import { Tool } from "@modelcontextprotocol/sdk/types"; 3 | import { RequestContext } from "../requestContext.js"; 4 | import { ZodTypeAny, z } from "zod"; 5 | import zodToJsonSchema from "zod-to-json-schema"; 6 | import { TablesTool } from "./tables.js"; 7 | import { DataTool } from "./data.js"; 8 | import { StatusTool } from "./status.js"; 9 | import { FunctionSpecTool } from "./functionSpec.js"; 10 | import { RunTool } from "./run.js"; 11 | import { EnvListTool, EnvGetTool, EnvSetTool, EnvRemoveTool } from "./env.js"; 12 | import { RunOneoffQueryTool } from "./runOneoffQuery.js"; 13 | 14 | export type ConvexTool = { 15 | name: string; 16 | description: string; 17 | inputSchema: Input; 18 | outputSchema: Output; 19 | handler: ( 20 | ctx: RequestContext, 21 | input: z.infer, 22 | ) => Promise>; 23 | }; 24 | 25 | type ToolInput = z.infer<(typeof ToolSchema)["shape"]["inputSchema"]>; 26 | 27 | export function mcpTool(tool: ConvexTool): Tool { 28 | return { 29 | name: tool.name, 30 | description: tool.description, 31 | inputSchema: zodToJsonSchema(tool.inputSchema) as ToolInput, 32 | }; 33 | } 34 | 35 | export const convexTools: ConvexTool[] = [ 36 | StatusTool, 37 | DataTool, 38 | TablesTool, 39 | FunctionSpecTool, 40 | RunTool, 41 | EnvListTool, 42 | EnvGetTool, 43 | EnvSetTool, 44 | EnvRemoveTool, 45 | RunOneoffQueryTool, 46 | ]; 47 | -------------------------------------------------------------------------------- /src/cli/lib/mcp/tools/run.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { ConvexTool } from "./index.js"; 3 | import { loadSelectedDeploymentCredentials } from "../../api.js"; 4 | import { parseArgs, parseFunctionName } from "../../run.js"; 5 | import { readProjectConfig } from "../../config.js"; 6 | import { ConvexHttpClient } from "../../../../browser/index.js"; 7 | import { Value } from "../../../../values/index.js"; 8 | import { Logger } from "../../../../browser/logging.js"; 9 | import { getDeploymentSelection } from "../../deploymentSelection.js"; 10 | const inputSchema = z.object({ 11 | deploymentSelector: z 12 | .string() 13 | .describe( 14 | "Deployment selector (from the status tool) to run the function on.", 15 | ), 16 | functionName: z 17 | .string() 18 | .describe( 19 | "The name of the function to run (e.g. 'path/to/my/module.js:myFunction').", 20 | ), 21 | args: z 22 | .string() 23 | .describe( 24 | "The argument object to pass to the function, JSON-encoded as a string.", 25 | ), 26 | }); 27 | 28 | const outputSchema = z.object({ 29 | result: z.any().describe("The result returned by the function"), 30 | logLines: z 31 | .array(z.string()) 32 | .describe("The log lines generated by the function"), 33 | }); 34 | 35 | const description = ` 36 | Run a Convex function (query, mutation, or action) on your deployment. 37 | 38 | Returns the result and any log lines generated by the function. 39 | `.trim(); 40 | 41 | export const RunTool: ConvexTool = { 42 | name: "run", 43 | description, 44 | inputSchema, 45 | outputSchema, 46 | handler: async (ctx, args) => { 47 | const { projectDir, deployment } = await ctx.decodeDeploymentSelector( 48 | args.deploymentSelector, 49 | ); 50 | process.chdir(projectDir); 51 | const metadata = await getDeploymentSelection(ctx, ctx.options); 52 | const credentials = await loadSelectedDeploymentCredentials( 53 | ctx, 54 | metadata, 55 | deployment, 56 | ); 57 | const parsedArgs = await parseArgs(ctx, args.args); 58 | const { projectConfig } = await readProjectConfig(ctx); 59 | const parsedFunctionName = await parseFunctionName( 60 | ctx, 61 | args.functionName, 62 | projectConfig.functions, 63 | ); 64 | const logger = new Logger({ verbose: true }); 65 | const logLines: string[] = []; 66 | logger.addLogLineListener((level, ...args) => { 67 | logLines.push(`${level}: ${args.join(" ")}`); 68 | }); 69 | const client = new ConvexHttpClient(credentials.url, { 70 | logger: logger, 71 | }); 72 | client.setAdminAuth(credentials.adminKey); 73 | let result: Value; 74 | try { 75 | result = await client.function(parsedFunctionName, undefined, parsedArgs); 76 | } catch (err) { 77 | return await ctx.crash({ 78 | exitCode: 1, 79 | errorType: "invalid filesystem or env vars", 80 | printedMessage: `Failed to run function "${args.functionName}":\n${(err as Error).toString().trim()}`, 81 | }); 82 | } 83 | return { 84 | result, 85 | logLines, 86 | }; 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /src/cli/lib/mcp/tools/tables.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { ConvexTool } from "./index.js"; 3 | import { loadSelectedDeploymentCredentials } from "../../api.js"; 4 | import { runSystemQuery } from "../../run.js"; 5 | import { deploymentFetch } from "../../utils/utils.js"; 6 | import { getDeploymentSelection } from "../../deploymentSelection.js"; 7 | 8 | const inputSchema = z.object({ 9 | deploymentSelector: z 10 | .string() 11 | .describe( 12 | "Deployment selector (from the status tool) to read tables from.", 13 | ), 14 | }); 15 | 16 | const outputSchema = z.object({ 17 | tables: z.record( 18 | z.string(), 19 | z.object({ 20 | schema: z.any().optional(), 21 | inferredSchema: z.any().optional(), 22 | }), 23 | ), 24 | }); 25 | 26 | export const TablesTool: ConvexTool = { 27 | name: "tables", 28 | description: 29 | "List all tables in a particular Convex deployment and their inferred and declared schema.", 30 | inputSchema, 31 | outputSchema, 32 | handler: async (ctx, args) => { 33 | const { projectDir, deployment } = await ctx.decodeDeploymentSelector( 34 | args.deploymentSelector, 35 | ); 36 | process.chdir(projectDir); 37 | const deploymentSelection = await getDeploymentSelection(ctx, ctx.options); 38 | const credentials = await loadSelectedDeploymentCredentials( 39 | ctx, 40 | deploymentSelection, 41 | deployment, 42 | ); 43 | const schemaResponse: any = await runSystemQuery(ctx, { 44 | deploymentUrl: credentials.url, 45 | adminKey: credentials.adminKey, 46 | functionName: "_system/frontend/getSchemas", 47 | componentPath: undefined, 48 | args: {}, 49 | }); 50 | const schema: Record> = {}; 51 | if (schemaResponse.active) { 52 | const parsed = activeSchema.parse(JSON.parse(schemaResponse.active)); 53 | for (const table of parsed.tables) { 54 | schema[table.tableName] = table; 55 | } 56 | } 57 | const fetch = deploymentFetch(ctx, { 58 | deploymentUrl: credentials.url, 59 | adminKey: credentials.adminKey, 60 | }); 61 | const response = await fetch("/api/shapes2", {}); 62 | const shapesResult: Record = await response.json(); 63 | 64 | const allTablesSet = new Set([ 65 | ...Object.keys(shapesResult), 66 | ...Object.keys(schema), 67 | ]); 68 | const allTables = Array.from(allTablesSet); 69 | allTables.sort(); 70 | 71 | const result: z.infer["tables"] = {}; 72 | for (const table of allTables) { 73 | result[table] = { 74 | schema: schema[table], 75 | inferredSchema: shapesResult[table], 76 | }; 77 | } 78 | return { tables: result }; 79 | }, 80 | }; 81 | 82 | const activeSchemaEntry = z.object({ 83 | tableName: z.string(), 84 | indexes: z.array(z.any()), 85 | searchIndexes: z.array(z.any()), 86 | vectorIndexes: z.array(z.any()), 87 | documentType: z.any(), 88 | }); 89 | 90 | const activeSchema = z.object({ tables: z.array(activeSchemaEntry) }); 91 | -------------------------------------------------------------------------------- /src/cli/lib/run.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { parseFunctionName } from "./run.js"; 3 | import { oneoffContext } from "../../bundler/context.js"; 4 | 5 | test("parseFunctionName", async () => { 6 | const originalContext = await oneoffContext({ 7 | url: undefined, 8 | adminKey: undefined, 9 | envFile: undefined, 10 | }); 11 | const files = new Set(); 12 | const ctx = { 13 | ...originalContext, 14 | fs: { 15 | ...originalContext.fs, 16 | exists: (file: string) => files.has(file), 17 | }, 18 | }; 19 | 20 | files.add("convex/foo/bar.ts"); 21 | files.add("convex/convex/bar/baz.ts"); 22 | files.add("src/convex/foo/bar.ts"); 23 | 24 | expect(await parseFunctionName(ctx, "api.foo.bar", "convex/")).toEqual( 25 | "foo:bar", 26 | ); 27 | expect(await parseFunctionName(ctx, "internal.foo.bar", "convex/")).toEqual( 28 | "foo:bar", 29 | ); 30 | expect(await parseFunctionName(ctx, "foo/bar", "convex/")).toEqual( 31 | "foo/bar:default", 32 | ); 33 | expect(await parseFunctionName(ctx, "foo/bar:baz", "convex/")).toEqual( 34 | "foo/bar:baz", 35 | ); 36 | expect(await parseFunctionName(ctx, "convex/foo/bar", "convex/")).toEqual( 37 | "foo/bar:default", 38 | ); 39 | expect(await parseFunctionName(ctx, "convex/foo/bar.ts", "convex/")).toEqual( 40 | "foo/bar:default", 41 | ); 42 | expect( 43 | await parseFunctionName(ctx, "convex/foo/bar.ts:baz", "convex/"), 44 | ).toEqual("foo/bar:baz"); 45 | expect(await parseFunctionName(ctx, "convex/bar/baz", "convex/")).toEqual( 46 | "convex/bar/baz:default", 47 | ); 48 | expect( 49 | await parseFunctionName(ctx, "src/convex/foo/bar", "src/convex/"), 50 | ).toEqual("foo/bar:default"); 51 | expect(await parseFunctionName(ctx, "foo/bar", "src/convex/")).toEqual( 52 | "foo/bar:default", 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /src/cli/lib/utils/mutex.ts: -------------------------------------------------------------------------------- 1 | export class Mutex { 2 | currentlyRunning: Promise | null = null; 3 | waiting: Array<() => Promise> = []; 4 | 5 | async runExclusive(fn: () => Promise): Promise { 6 | const outerPromise = new Promise((resolve, reject) => { 7 | const wrappedCallback: () => Promise = () => { 8 | return fn() 9 | .then((v: T) => resolve(v)) 10 | .catch((e: any) => reject(e)); 11 | }; 12 | this.enqueueCallbackForMutex(wrappedCallback); 13 | }); 14 | return outerPromise; 15 | } 16 | 17 | private enqueueCallbackForMutex(callback: () => Promise) { 18 | if (this.currentlyRunning === null) { 19 | this.currentlyRunning = callback().finally(() => { 20 | const nextCb = this.waiting.shift(); 21 | if (nextCb === undefined) { 22 | this.currentlyRunning = null; 23 | } else { 24 | this.enqueueCallbackForMutex(nextCb); 25 | } 26 | }); 27 | this.waiting.length = 0; 28 | } else { 29 | this.waiting.push(callback); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/cli/lib/utils/sentry.ts: -------------------------------------------------------------------------------- 1 | import "@sentry/tracing"; 2 | import { productionProvisionHost, provisionHost } from "../config.js"; 3 | import stripAnsi from "strip-ansi"; 4 | import * as Sentry from "@sentry/node"; 5 | import { version } from "../../../index.js"; 6 | 7 | export const SENTRY_DSN = 8 | "https://f9fa0306e3d540079cf40ce8c2ad9644@o1192621.ingest.sentry.io/6390839"; 9 | 10 | export function initSentry() { 11 | if (!process.env.CI && provisionHost === productionProvisionHost) { 12 | Sentry.init({ 13 | dsn: SENTRY_DSN, 14 | release: "cli@" + version, 15 | tracesSampleRate: 0.2, 16 | beforeBreadcrumb: (breadcrumb) => { 17 | // Strip ANSI color codes from log lines that are sent as breadcrumbs. 18 | if (breadcrumb.message) { 19 | breadcrumb.message = stripAnsi(breadcrumb.message); 20 | } 21 | return breadcrumb; 22 | }, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/logout.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { logFinishedStep, oneoffContext } from "../bundler/context.js"; 3 | import { recursivelyDelete } from "./lib/fsUtils.js"; 4 | import { globalConfigPath } from "./lib/utils/globalConfig.js"; 5 | 6 | export const logout = new Command("logout") 7 | .description("Log out of Convex on this machine") 8 | .allowExcessArguments(false) 9 | .action(async () => { 10 | const ctx = await oneoffContext({ 11 | url: undefined, 12 | adminKey: undefined, 13 | envFile: undefined, 14 | }); 15 | 16 | if (ctx.fs.exists(globalConfigPath())) { 17 | recursivelyDelete(ctx, globalConfigPath()); 18 | } 19 | 20 | logFinishedStep( 21 | ctx, 22 | "You have been logged out of Convex.\n Run `npx convex dev` to log in.", 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /src/cli/logs.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { oneoffContext } from "../bundler/context.js"; 3 | import { 4 | deploymentSelectionWithinProjectFromOptions, 5 | loadSelectedDeploymentCredentials, 6 | } from "./lib/api.js"; 7 | import { actionDescription } from "./lib/command.js"; 8 | import { logsForDeployment } from "./lib/logs.js"; 9 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 10 | 11 | export const logs = new Command("logs") 12 | .summary("Watch logs from your deployment") 13 | .description( 14 | "Stream function logs from your Convex deployment.\nBy default, this streams from your project's dev deployment.", 15 | ) 16 | .allowExcessArguments(false) 17 | .addLogsOptions() 18 | .addDeploymentSelectionOptions(actionDescription("Watch logs from")) 19 | .showHelpAfterError() 20 | .action(async (cmdOptions) => { 21 | const ctx = await oneoffContext(cmdOptions); 22 | 23 | const selectionWithinProject = 24 | await deploymentSelectionWithinProjectFromOptions(ctx, cmdOptions); 25 | const deploymentSelection = await getDeploymentSelection(ctx, cmdOptions); 26 | const deployment = await loadSelectedDeploymentCredentials( 27 | ctx, 28 | deploymentSelection, 29 | selectionWithinProject, 30 | ); 31 | const deploymentName = deployment.deploymentFields?.deploymentName 32 | ? ` ${deployment.deploymentFields.deploymentName}` 33 | : ""; 34 | const deploymentNotice = ` for ${cmdOptions.prod ? "production" : "dev"} deployment${deploymentName}`; 35 | await logsForDeployment(ctx, deployment, { 36 | history: cmdOptions.history, 37 | success: cmdOptions.success, 38 | deploymentNotice, 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/cli/network_test.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { 3 | deploymentSelectionWithinProjectFromOptions, 4 | loadSelectedDeploymentCredentials, 5 | } from "./lib/api.js"; 6 | import { 7 | Context, 8 | oneoffContext, 9 | showSpinner, 10 | logMessage, 11 | } from "../bundler/context.js"; 12 | import chalk from "chalk"; 13 | import { actionDescription } from "./lib/command.js"; 14 | import { runNetworkTestOnUrl, withTimeout } from "./lib/networkTest.js"; 15 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 16 | 17 | export const networkTest = new Command("network-test") 18 | .description("Run a network test to Convex's servers") 19 | .allowExcessArguments(false) 20 | .addNetworkTestOptions() 21 | .addDeploymentSelectionOptions( 22 | actionDescription("Perform the network test on"), 23 | ) 24 | .option("--url ") // unhide help 25 | .action(async (options) => { 26 | const ctx = await oneoffContext(options); 27 | const timeoutSeconds = options.timeout 28 | ? Number.parseFloat(options.timeout) 29 | : 30; 30 | await withTimeout( 31 | ctx, 32 | "Network test", 33 | timeoutSeconds * 1000, 34 | runNetworkTest(ctx, options), 35 | ); 36 | }); 37 | 38 | async function runNetworkTest( 39 | ctx: Context, 40 | options: { 41 | prod?: boolean | undefined; 42 | previewName?: string | undefined; 43 | deploymentName?: string | undefined; 44 | url?: string | undefined; 45 | adminKey?: string | undefined; 46 | ipFamily?: string; 47 | speedTest?: boolean; 48 | }, 49 | ) { 50 | showSpinner(ctx, "Performing network test..."); 51 | // Try to fetch the URL following the usual paths, but special case the 52 | // `--url` argument in case the developer doesn't have network connectivity. 53 | let url: string; 54 | let adminKey: string | null; 55 | if (options.url !== undefined && options.adminKey !== undefined) { 56 | url = options.url; 57 | adminKey = options.adminKey; 58 | } else if (options.url !== undefined) { 59 | url = options.url; 60 | adminKey = null; 61 | } else { 62 | const selectionWithinProject = 63 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 64 | const deploymentSelection = await getDeploymentSelection(ctx, options); 65 | const credentials = await loadSelectedDeploymentCredentials( 66 | ctx, 67 | deploymentSelection, 68 | selectionWithinProject, 69 | ); 70 | url = credentials.url; 71 | adminKey = credentials.adminKey; 72 | } 73 | logMessage(ctx, `${chalk.green(`✔`)} Deployment URL: ${url}`); 74 | await runNetworkTestOnUrl(ctx, { url, adminKey }, options); 75 | } 76 | -------------------------------------------------------------------------------- /src/cli/reinit.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { oneoffContext } from "../bundler/context.js"; 3 | 4 | // Reinitialize an existing Convex project. 5 | // This command is deprecated and hidden from the command help. 6 | // `npx convex dev --once --configure=existing` replaces it. 7 | export const reinit = new Command("reinit") 8 | .description( 9 | "Reinitialize a Convex project in the local directory if you've lost your convex.json file", 10 | ) 11 | .allowExcessArguments(false) 12 | .addOption( 13 | new Option( 14 | "--team ", 15 | "The identifier of the team the project belongs to.", 16 | ), 17 | ) 18 | .addOption( 19 | new Option( 20 | "--project ", 21 | "The identifier of the project you'd like to reinitialize.", 22 | ), 23 | ) 24 | .action(async (_options) => { 25 | return ( 26 | await oneoffContext({ 27 | url: undefined, 28 | adminKey: undefined, 29 | envFile: undefined, 30 | }) 31 | ).crash({ 32 | exitCode: 1, 33 | errorType: "fatal", 34 | errForSentry: 35 | "The `reinit` command is deprecated. Use `npx convex dev --once --configure=existing` instead.", 36 | printedMessage: 37 | "The `reinit` command is deprecated. Use `npx convex dev --once --configure=existing` instead.", 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/cli/run.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { oneoffContext } from "../bundler/context.js"; 3 | import { 4 | deploymentSelectionWithinProjectFromOptions, 5 | loadSelectedDeploymentCredentials, 6 | } from "./lib/api.js"; 7 | import { actionDescription } from "./lib/command.js"; 8 | import { runInDeployment } from "./lib/run.js"; 9 | import { ensureHasConvexDependency } from "./lib/utils/utils.js"; 10 | import { getDeploymentSelection } from "./lib/deploymentSelection.js"; 11 | 12 | export const run = new Command("run") 13 | .description("Run a function (query, mutation, or action) on your deployment") 14 | .allowExcessArguments(false) 15 | .addRunOptions() 16 | .addDeploymentSelectionOptions(actionDescription("Run the function on")) 17 | .showHelpAfterError() 18 | .action(async (functionName, argsString, options) => { 19 | const ctx = await oneoffContext(options); 20 | await ensureHasConvexDependency(ctx, "run"); 21 | const selectionWithinProject = 22 | await deploymentSelectionWithinProjectFromOptions(ctx, options); 23 | const deploymentSelection = await getDeploymentSelection(ctx, options); 24 | const deployment = await loadSelectedDeploymentCredentials( 25 | ctx, 26 | deploymentSelection, 27 | selectionWithinProject, 28 | ); 29 | 30 | if ( 31 | deployment.deploymentFields?.deploymentType === "prod" && 32 | options.push 33 | ) { 34 | return await ctx.crash({ 35 | exitCode: 1, 36 | errorType: "fatal", 37 | printedMessage: 38 | `\`convex run\` doesn't support pushing functions to prod deployments. ` + 39 | `Remove the --push flag. To push to production use \`npx convex deploy\`.`, 40 | }); 41 | } 42 | 43 | await runInDeployment(ctx, { 44 | deploymentUrl: deployment.url, 45 | adminKey: deployment.adminKey, 46 | deploymentName: deployment.deploymentFields?.deploymentName ?? null, 47 | functionName, 48 | argsString: argsString ?? "{}", 49 | componentPath: options.component, 50 | identityString: options.identity, 51 | push: !!options.push, 52 | watch: !!options.watch, 53 | typecheck: options.typecheck, 54 | typecheckComponents: options.typecheckComponents, 55 | codegen: options.codegen === "enable", 56 | liveComponentSources: !!options.liveComponentSources, 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/cli/typecheck.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { functionsDir, ensureHasConvexDependency } from "./lib/utils/utils.js"; 3 | import { Command } from "@commander-js/extra-typings"; 4 | import { readConfig } from "./lib/config.js"; 5 | import { typeCheckFunctions } from "./lib/typecheck.js"; 6 | import { 7 | logFinishedStep, 8 | logMessage, 9 | oneoffContext, 10 | } from "../bundler/context.js"; 11 | 12 | // Experimental (it's going to fail sometimes) TypeScript type checking. 13 | // Includes a separate command to help users debug their TypeScript configs. 14 | 15 | export type TypecheckResult = "cantTypeCheck" | "success" | "typecheckFailed"; 16 | 17 | /** Run the TypeScript compiler, as configured during */ 18 | export const typecheck = new Command("typecheck") 19 | .description( 20 | "Run TypeScript typechecking on your Convex functions with `tsc --noEmit`.", 21 | ) 22 | .allowExcessArguments(false) 23 | .action(async () => { 24 | const ctx = await oneoffContext({ 25 | url: undefined, 26 | adminKey: undefined, 27 | envFile: undefined, 28 | }); 29 | const { configPath, config: localConfig } = await readConfig(ctx, false); 30 | await ensureHasConvexDependency(ctx, "typecheck"); 31 | await typeCheckFunctions( 32 | ctx, 33 | functionsDir(configPath, localConfig.projectConfig), 34 | async (typecheckResult, logSpecificError, runOnError) => { 35 | logSpecificError?.(); 36 | if (typecheckResult === "typecheckFailed") { 37 | logMessage(ctx, chalk.gray("Typecheck failed")); 38 | try { 39 | await runOnError?.(); 40 | // If runOnError doesn't throw then it worked the second time. 41 | // No errors to report, but it's still a failure. 42 | } catch { 43 | // As expected, `runOnError` threw 44 | } 45 | return await ctx.crash({ 46 | exitCode: 1, 47 | errorType: "invalid filesystem data", 48 | printedMessage: null, 49 | }); 50 | } else if (typecheckResult === "cantTypeCheck") { 51 | logMessage( 52 | ctx, 53 | chalk.gray("Unable to typecheck; is TypeScript installed?"), 54 | ); 55 | return await ctx.crash({ 56 | exitCode: 1, 57 | errorType: "invalid filesystem data", 58 | printedMessage: null, 59 | }); 60 | } else { 61 | logFinishedStep( 62 | ctx, 63 | "Typecheck passed: `tsc --noEmit` completed with exit code 0.", 64 | ); 65 | return await ctx.flushAndExit(0); 66 | } 67 | }, 68 | ); 69 | }); 70 | -------------------------------------------------------------------------------- /src/cli/update.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { Command } from "@commander-js/extra-typings"; 3 | import { logMessage, oneoffContext } from "../bundler/context.js"; 4 | import { loadPackageJson } from "./lib/utils/utils.js"; 5 | 6 | export const update = new Command("update") 7 | .description("Print instructions for updating the convex package") 8 | .allowExcessArguments(false) 9 | .action(async () => { 10 | const ctx = await oneoffContext({ 11 | url: undefined, 12 | adminKey: undefined, 13 | envFile: undefined, 14 | }); 15 | let updateInstructions = "npm install convex@latest\n"; 16 | const packages = await loadPackageJson(ctx); 17 | const oldPackageNames = Object.keys(packages).filter((name) => 18 | name.startsWith("@convex-dev"), 19 | ); 20 | for (const pkg of oldPackageNames) { 21 | updateInstructions += `npm uninstall ${pkg}\n`; 22 | } 23 | 24 | logMessage( 25 | ctx, 26 | chalk.green( 27 | `To view the Convex changelog, go to https://news.convex.dev/tag/releases/\nWhen you are ready to upgrade, run the following commands:\n${updateInstructions}`, 28 | ), 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /src/cli/version.ts: -------------------------------------------------------------------------------- 1 | import { version as versionInner } from "../index.js"; 2 | 3 | export const version = process.env.CONVEX_VERSION_OVERRIDE || versionInner; 4 | -------------------------------------------------------------------------------- /src/common/README.md: -------------------------------------------------------------------------------- 1 | Code in this common/ folder is not publicly exposed, there is no 'convex/common' 2 | export. Code here is used from other entry points. 3 | -------------------------------------------------------------------------------- /src/common/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, expect } from "vitest"; 2 | import { validateDeploymentUrl } from "./index.js"; 3 | 4 | describe("validateDeploymentUrl", () => { 5 | test("localhost is valid", () => { 6 | validateDeploymentUrl("http://127.0.0.1:8000"); 7 | validateDeploymentUrl("http://localhost:8000"); 8 | validateDeploymentUrl("http://0.0.0.0:8000"); 9 | }); 10 | test("real URLs are valid", () => { 11 | validateDeploymentUrl("https://small-mouse-123.convex.cloud"); 12 | }); 13 | 14 | test("vanity domain works", () => { 15 | validateDeploymentUrl("https://tshirts.com"); 16 | }); 17 | 18 | test("wrong protocol throws", () => { 19 | expect(() => 20 | validateDeploymentUrl("ws://small-mouse-123.convex.cloud"), 21 | ).toThrow("Invalid deployment address"); 22 | }); 23 | 24 | test("invalid url throws", () => { 25 | expect(() => validateDeploymentUrl("https://:small-mouse-123:")).toThrow( 26 | "Invalid deployment address", 27 | ); 28 | }); 29 | 30 | test(".convex.site domain throws", () => { 31 | expect(() => 32 | validateDeploymentUrl("https://small-mouse-123.convex.site"), 33 | ).toThrow("Invalid deployment address"); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import type { Value } from "../values/value.js"; 2 | 3 | /** 4 | * Validate that the arguments to a Convex function are an object, defaulting 5 | * `undefined` to `{}`. 6 | */ 7 | export function parseArgs( 8 | args: Record | undefined, 9 | ): Record { 10 | if (args === undefined) { 11 | return {}; 12 | } 13 | if (!isSimpleObject(args)) { 14 | throw new Error( 15 | `The arguments to a Convex function must be an object. Received: ${ 16 | args as any 17 | }`, 18 | ); 19 | } 20 | return args; 21 | } 22 | 23 | export function validateDeploymentUrl(deploymentUrl: string) { 24 | // Don't use things like `new URL(deploymentUrl).hostname` since these aren't 25 | // supported by React Native's JS environment 26 | if (typeof deploymentUrl === "undefined") { 27 | throw new Error( 28 | `Client created with undefined deployment address. If you used an environment variable, check that it's set.`, 29 | ); 30 | } 31 | if (typeof deploymentUrl !== "string") { 32 | throw new Error( 33 | `Invalid deployment address: found ${deploymentUrl as any}".`, 34 | ); 35 | } 36 | if ( 37 | !(deploymentUrl.startsWith("http:") || deploymentUrl.startsWith("https:")) 38 | ) { 39 | throw new Error( 40 | `Invalid deployment address: Must start with "https://" or "http://". Found "${deploymentUrl}".`, 41 | ); 42 | } 43 | 44 | // Most clients should connect to ".convex.cloud". But we also support localhost and 45 | // custom custom. We validate the deployment url is a valid url, which is the most 46 | // common failure pattern. 47 | try { 48 | new URL(deploymentUrl); 49 | } catch { 50 | throw new Error( 51 | `Invalid deployment address: "${deploymentUrl}" is not a valid URL. If you believe this URL is correct, use the \`skipConvexDeploymentUrlCheck\` option to bypass this.`, 52 | ); 53 | } 54 | 55 | // If a user uses .convex.site, this is very likely incorrect. 56 | if (deploymentUrl.endsWith(".convex.site")) { 57 | throw new Error( 58 | `Invalid deployment address: "${deploymentUrl}" ends with .convex.site, which is used for HTTP Actions. Convex deployment URLs typically end with .convex.cloud? If you believe this URL is correct, use the \`skipConvexDeploymentUrlCheck\` option to bypass this.`, 59 | ); 60 | } 61 | } 62 | 63 | /** 64 | * Check whether a value is a plain old JavaScript object. 65 | */ 66 | export function isSimpleObject(value: unknown) { 67 | const isObject = typeof value === "object"; 68 | const prototype = Object.getPrototypeOf(value); 69 | const isSimple = 70 | prototype === null || 71 | prototype === Object.prototype || 72 | // Objects generated from other contexts (e.g. across Node.js `vm` modules) will not satisfy the previous 73 | // conditions but are still simple objects. 74 | prototype?.constructor?.name === "Object"; 75 | return isObject && isSimple; 76 | } 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export const version = "1.24.6"; 2 | -------------------------------------------------------------------------------- /src/nextjs/nextjs.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | import { vi, expect, test, describe, beforeEach, afterEach } from "vitest"; 5 | 6 | import { renderHook } from "@testing-library/react"; 7 | import React from "react"; 8 | import { ConvexProvider, ConvexReactClient } from "../react/client.js"; 9 | import { usePreloadedQuery } from "../react/hydration.js"; 10 | import { anyApi } from "../server/api.js"; 11 | import { convexToJson } from "../values/value.js"; 12 | import { preloadQuery, preloadedQueryResult } from "./index.js"; 13 | 14 | const address = "https://127.0.0.1:3001"; 15 | 16 | describe("env setup", () => { 17 | test("requires NEXT_PUBLIC_CONVEX_URL", async () => { 18 | await expect(preloadQuery(anyApi.myQuery.default)).rejects.toThrow( 19 | "Environment variable NEXT_PUBLIC_CONVEX_URL is not set.", 20 | ); 21 | }); 22 | }); 23 | 24 | describe("preloadQuery and usePreloadedQuery", () => { 25 | beforeEach(() => { 26 | global.process.env.NEXT_PUBLIC_CONVEX_URL = address; 27 | global.fetch = vi.fn().mockResolvedValue({ 28 | ok: true, 29 | json: () => 30 | Promise.resolve({ status: "success", value: convexToJson({ x: 42 }) }), 31 | } as never) as any; 32 | }); 33 | 34 | afterEach(() => { 35 | delete global.process.env.NEXT_PUBLIC_CONVEX_URL; 36 | }); 37 | 38 | test("returns server result before client loads data", async () => { 39 | const preloaded = await preloadQuery(anyApi.myQuery.default, { 40 | arg: "something", 41 | }); 42 | const serverResult = preloadedQueryResult(preloaded); 43 | 44 | expect(fetch).toHaveBeenCalledWith( 45 | expect.anything(), 46 | expect.objectContaining({ 47 | cache: "no-store", 48 | }), 49 | ); 50 | 51 | expect(serverResult).toStrictEqual({ x: 42 }); 52 | 53 | const client = new ConvexReactClient(address); 54 | const wrapper = ({ children }: any) => ( 55 | {children} 56 | ); 57 | const { result: hydrationResult } = renderHook( 58 | () => usePreloadedQuery(preloaded), 59 | { wrapper }, 60 | ); 61 | expect(hydrationResult.current).toStrictEqual({ x: 42 }); 62 | }); 63 | 64 | test("returns client result after client loads data", async () => { 65 | const preloaded = await preloadQuery(anyApi.myQuery.default, { 66 | arg: "something", 67 | }); 68 | const client = new ConvexReactClient(address); 69 | // Use an optimistic update to set up a query to have a result. 70 | void client.mutation( 71 | anyApi.myMutation.default, 72 | {}, 73 | { 74 | optimisticUpdate: (localStore) => { 75 | localStore.setQuery( 76 | anyApi.myQuery.default, 77 | { arg: "something" }, 78 | // Simplest value to return, and make sure we're correctly 79 | // handling it. 80 | null, 81 | ); 82 | }, 83 | }, 84 | ); 85 | const wrapper = ({ children }: any) => ( 86 | {children} 87 | ); 88 | const { result: clientResult } = renderHook( 89 | () => usePreloadedQuery(preloaded), 90 | { wrapper }, 91 | ); 92 | expect(clientResult.current).toStrictEqual(null); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/react-auth0/ConvexProviderWithAuth0.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | import { test } from "vitest"; 5 | import React from "react"; 6 | import { ConvexProviderWithAuth0 } from "./ConvexProviderWithAuth0.js"; 7 | import { ConvexReactClient } from "../react/index.js"; 8 | 9 | test("Helpers are valid children", () => { 10 | const convex = new ConvexReactClient("https://localhost:3001"); 11 | 12 | const _ = ( 13 | 14 | Hello world 15 | 16 | ); 17 | }); 18 | -------------------------------------------------------------------------------- /src/react-auth0/ConvexProviderWithAuth0.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import React from "react"; 3 | 4 | import { ReactNode, useCallback, useMemo } from "react"; 5 | import { AuthTokenFetcher } from "../browser/sync/client.js"; 6 | import { ConvexProviderWithAuth } from "../react/ConvexAuthState.js"; 7 | 8 | // Until we can import from our own entry points (requires TypeScript 4.7), 9 | // just describe the interface enough to help users pass the right type. 10 | type IConvexReactClient = { 11 | setAuth(fetchToken: AuthTokenFetcher): void; 12 | clearAuth(): void; 13 | }; 14 | 15 | /** 16 | * A wrapper React component which provides a {@link react.ConvexReactClient} 17 | * authenticated with Auth0. 18 | * 19 | * It must be wrapped by a configured `Auth0Provider` from `@auth0/auth0-react`. 20 | * 21 | * See [Convex Auth0](https://docs.convex.dev/auth/auth0) on how to set up 22 | * Convex with Auth0. 23 | * 24 | * @public 25 | */ 26 | export function ConvexProviderWithAuth0({ 27 | children, 28 | client, 29 | }: { 30 | children: ReactNode; 31 | client: IConvexReactClient; 32 | }) { 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | } 39 | 40 | function useAuthFromAuth0() { 41 | const { isLoading, isAuthenticated, getAccessTokenSilently } = useAuth0(); 42 | const fetchAccessToken = useCallback( 43 | async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => { 44 | try { 45 | const response = await getAccessTokenSilently({ 46 | detailedResponse: true, 47 | cacheMode: forceRefreshToken ? "off" : "on", 48 | }); 49 | return response.id_token as string; 50 | } catch { 51 | return null; 52 | } 53 | }, 54 | [getAccessTokenSilently], 55 | ); 56 | return useMemo( 57 | () => ({ isLoading, isAuthenticated, fetchAccessToken }), 58 | [isLoading, isAuthenticated, fetchAccessToken], 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/react-auth0/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * React login component for use with Auth0. 3 | * 4 | * @module 5 | */ 6 | export { ConvexProviderWithAuth0 } from "./ConvexProviderWithAuth0.js"; 7 | -------------------------------------------------------------------------------- /src/react-clerk/ConvexProviderWithClerk.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | import { test } from "vitest"; 5 | import React from "react"; 6 | import { ConvexProviderWithClerk } from "./ConvexProviderWithClerk.js"; 7 | import { ConvexReactClient } from "../react/index.js"; 8 | import { useAuth } from "@clerk/clerk-react"; 9 | 10 | test("Helpers are valid children", () => { 11 | const convex = new ConvexReactClient("https://localhost:3001"); 12 | 13 | const _ = ( 14 | 15 | Hello world 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /src/react-clerk/ConvexProviderWithClerk.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ReactNode, useCallback, useMemo } from "react"; 4 | import { AuthTokenFetcher } from "../browser/sync/client.js"; 5 | import { ConvexProviderWithAuth } from "../react/ConvexAuthState.js"; 6 | 7 | // Until we can import from our own entry points (requires TypeScript 4.7), 8 | // just describe the interface enough to help users pass the right type. 9 | type IConvexReactClient = { 10 | setAuth(fetchToken: AuthTokenFetcher): void; 11 | clearAuth(): void; 12 | }; 13 | 14 | // https://clerk.com/docs/reference/clerk-react/useauth 15 | type UseAuth = () => { 16 | isLoaded: boolean; 17 | isSignedIn: boolean | undefined; 18 | getToken: (options: { 19 | template?: "convex"; 20 | skipCache?: boolean; 21 | }) => Promise; 22 | // We don't use these properties but they should trigger a new token fetch. 23 | orgId: string | undefined | null; 24 | orgRole: string | undefined | null; 25 | }; 26 | 27 | /** 28 | * A wrapper React component which provides a {@link react.ConvexReactClient} 29 | * authenticated with Clerk. 30 | * 31 | * It must be wrapped by a configured `ClerkProvider`, from 32 | * `@clerk/clerk-react`, `@clerk/clerk-expo`, `@clerk/nextjs` or 33 | * another React-based Clerk client library and have the corresponding 34 | * `useAuth` hook passed in. 35 | * 36 | * See [Convex Clerk](https://docs.convex.dev/auth/clerk) on how to set up 37 | * Convex with Clerk. 38 | * 39 | * @public 40 | */ 41 | export function ConvexProviderWithClerk({ 42 | children, 43 | client, 44 | useAuth, 45 | }: { 46 | children: ReactNode; 47 | client: IConvexReactClient; 48 | useAuth: UseAuth; // useAuth from Clerk 49 | }) { 50 | const useAuthFromClerk = useUseAuthFromClerk(useAuth); 51 | return ( 52 | 53 | {children} 54 | 55 | ); 56 | } 57 | 58 | function useUseAuthFromClerk(useAuth: UseAuth) { 59 | return useMemo( 60 | () => 61 | function useAuthFromClerk() { 62 | const { isLoaded, isSignedIn, getToken, orgId, orgRole } = useAuth(); 63 | const fetchAccessToken = useCallback( 64 | async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => { 65 | try { 66 | return getToken({ 67 | template: "convex", 68 | skipCache: forceRefreshToken, 69 | }); 70 | } catch { 71 | return null; 72 | } 73 | }, 74 | // Build a new fetchAccessToken to trigger setAuth() whenever these change. 75 | // Anything else from the JWT Clerk wants to be reactive goes here too. 76 | // Clerk's Expo useAuth hook is not memoized so we don't include getToken. 77 | // eslint-disable-next-line react-hooks/exhaustive-deps 78 | [orgId, orgRole], 79 | ); 80 | return useMemo( 81 | () => ({ 82 | isLoading: !isLoaded, 83 | isAuthenticated: isSignedIn ?? false, 84 | fetchAccessToken, 85 | }), 86 | [isLoaded, isSignedIn, fetchAccessToken], 87 | ); 88 | }, 89 | [useAuth], 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/react-clerk/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * React login component for use with Clerk. 3 | * 4 | * @module 5 | */ 6 | export { ConvexProviderWithClerk } from "./ConvexProviderWithClerk.js"; 7 | -------------------------------------------------------------------------------- /src/react/auth_helpers.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | import { test } from "vitest"; 5 | import React from "react"; 6 | import { Authenticated, AuthLoading, Unauthenticated } from "./auth_helpers.js"; 7 | 8 | test("Helpers are valid children", () => { 9 | const _element = ( 10 |
11 | Yay 12 | Nay 13 | ??? 14 |
15 | ); 16 | }); 17 | 18 | test("Helpers can take many children", () => { 19 | const _element = ( 20 |
21 | 22 |
Yay
23 |
Yay again
24 |
25 | 26 |
Yay
27 |
Yay again
28 |
29 | 30 |
Yay
31 |
Yay again
32 |
33 |
34 | ); 35 | }); 36 | -------------------------------------------------------------------------------- /src/react/auth_helpers.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactNode } from "react"; 3 | import { useConvexAuth } from "./ConvexAuthState.js"; 4 | 5 | /** 6 | * Renders children if the client is authenticated. 7 | * 8 | * @public 9 | */ 10 | export function Authenticated({ children }: { children: ReactNode }) { 11 | const { isLoading, isAuthenticated } = useConvexAuth(); 12 | if (isLoading || !isAuthenticated) { 13 | return null; 14 | } 15 | return <>{children}; 16 | } 17 | 18 | /** 19 | * Renders children if the client is using authentication but is not authenticated. 20 | * 21 | * @public 22 | */ 23 | export function Unauthenticated({ children }: { children: ReactNode }) { 24 | const { isLoading, isAuthenticated } = useConvexAuth(); 25 | if (isLoading || isAuthenticated) { 26 | return null; 27 | } 28 | return <>{children}; 29 | } 30 | 31 | /** 32 | * Renders children if the client isn't using authentication or is in the process 33 | * of authenticating. 34 | * 35 | * @public 36 | */ 37 | export function AuthLoading({ children }: { children: ReactNode }) { 38 | const { isLoading } = useConvexAuth(); 39 | if (!isLoading) { 40 | return null; 41 | } 42 | return <>{children}; 43 | } 44 | -------------------------------------------------------------------------------- /src/react/hydration.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useQuery } from "../react/client.js"; 3 | import { FunctionReference, makeFunctionReference } from "../server/api.js"; 4 | import { jsonToConvex } from "../values/index.js"; 5 | 6 | /** 7 | * The preloaded query payload, which should be passed to a client component 8 | * and passed to {@link usePreloadedQuery}. 9 | * 10 | * @public 11 | */ 12 | export type Preloaded> = { 13 | __type: Query; 14 | _name: string; 15 | _argsJSON: string; 16 | _valueJSON: string; 17 | }; 18 | 19 | /** 20 | * Load a reactive query within a React component using a `Preloaded` payload 21 | * from a Server Component returned by {@link nextjs.preloadQuery}. 22 | * 23 | * This React hook contains internal state that will cause a rerender 24 | * whenever the query result changes. 25 | * 26 | * Throws an error if not used under {@link ConvexProvider}. 27 | * 28 | * @param preloadedQuery - The `Preloaded` query payload from a Server Component. 29 | * @returns the result of the query. Initially returns the result fetched 30 | * by the Server Component. Subsequently returns the result fetched by the client. 31 | * 32 | * @public 33 | */ 34 | export function usePreloadedQuery>( 35 | preloadedQuery: Preloaded, 36 | ): Query["_returnType"] { 37 | const args = useMemo( 38 | () => jsonToConvex(preloadedQuery._argsJSON), 39 | [preloadedQuery._argsJSON], 40 | ) as Query["_args"]; 41 | const preloadedResult = useMemo( 42 | () => jsonToConvex(preloadedQuery._valueJSON), 43 | [preloadedQuery._valueJSON], 44 | ); 45 | const result = useQuery( 46 | makeFunctionReference(preloadedQuery._name) as Query, 47 | args, 48 | ); 49 | return result === undefined ? preloadedResult : result; 50 | } 51 | -------------------------------------------------------------------------------- /src/react/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tools to integrate Convex into React applications. 3 | * 4 | * This module contains: 5 | * 1. {@link ConvexReactClient}, a client for using Convex in React. 6 | * 2. {@link ConvexProvider}, a component that stores this client in React context. 7 | * 3. {@link Authenticated}, {@link Unauthenticated} and {@link AuthLoading} helper auth components. 8 | * 4. Hooks {@link useQuery}, {@link useMutation}, {@link useAction} and more for accessing this 9 | * client from your React components. 10 | * 11 | * ## Usage 12 | * 13 | * ### Creating the client 14 | * 15 | * ```typescript 16 | * import { ConvexReactClient } from "convex/react"; 17 | * 18 | * // typically loaded from an environment variable 19 | * const address = "https://small-mouse-123.convex.cloud" 20 | * const convex = new ConvexReactClient(address); 21 | * ``` 22 | * 23 | * ### Storing the client in React Context 24 | * 25 | * ```typescript 26 | * import { ConvexProvider } from "convex/react"; 27 | * 28 | * 29 | * 30 | * 31 | * ``` 32 | * 33 | * ### Using the auth helpers 34 | * 35 | * ```typescript 36 | * import { Authenticated, Unauthenticated, AuthLoading } from "convex/react"; 37 | * 38 | * 39 | * Logged in 40 | * 41 | * 42 | * Logged out 43 | * 44 | * 45 | * Still loading 46 | * 47 | * ``` 48 | * 49 | * ### Using React hooks 50 | * 51 | * ```typescript 52 | * import { useQuery, useMutation } from "convex/react"; 53 | * import { api } from "../convex/_generated/api"; 54 | * 55 | * function App() { 56 | * const counter = useQuery(api.getCounter.default); 57 | * const increment = useMutation(api.incrementCounter.default); 58 | * // Your component here! 59 | * } 60 | * ``` 61 | * @module 62 | */ 63 | export * from "./use_paginated_query.js"; 64 | export { useQueries, type RequestForQueries } from "./use_queries.js"; 65 | export type { AuthTokenFetcher } from "../browser/sync/client.js"; 66 | export * from "./auth_helpers.js"; 67 | export * from "./ConvexAuthState.js"; 68 | export * from "./hydration.js"; 69 | /* @internal */ 70 | export { useSubscription } from "./use_subscription.js"; 71 | export { 72 | type ReactMutation, 73 | type ReactAction, 74 | type Watch, 75 | type WatchQueryOptions, 76 | type MutationOptions, 77 | type ConvexReactClientOptions, 78 | type OptionalRestArgsOrSkip, 79 | ConvexReactClient, 80 | useConvex, 81 | ConvexProvider, 82 | useQuery, 83 | useMutation, 84 | useAction, 85 | } from "./client.js"; 86 | -------------------------------------------------------------------------------- /src/react/react_node.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { Long } from "../browser/long.js"; 3 | 4 | import { ConvexReactClient } from "./client.js"; 5 | import { 6 | ClientMessage, 7 | QuerySetModification, 8 | ServerMessage, 9 | } from "../browser/sync/protocol.js"; 10 | import { 11 | nodeWebSocket, 12 | withInMemoryWebSocket, 13 | } from "../browser/sync/client_node_test_helpers.js"; 14 | import { anyApi } from "../server/api.js"; 15 | 16 | const testReactClient = (address: string) => 17 | new ConvexReactClient(address, { 18 | webSocketConstructor: nodeWebSocket, 19 | unsavedChangesWarning: false, 20 | }); 21 | 22 | test("ConvexReactClient ends subscriptions on close", async () => { 23 | await withInMemoryWebSocket(async ({ address, receive, send }) => { 24 | const client = testReactClient(address); 25 | const watch = client.watchQuery(anyApi.myQuery.default, {}); 26 | let timesCallbackRan = 0; 27 | watch.onUpdate(() => timesCallbackRan++); 28 | 29 | expect((await receive()).type).toEqual("Connect"); 30 | const modify = expectQuerySetModification(await receive()); 31 | expect(modify.modifications).toEqual([ 32 | { 33 | args: [{}], 34 | queryId: 0, 35 | type: "Add", 36 | udfPath: "myQuery:default", 37 | }, 38 | ]); 39 | expect(timesCallbackRan).toEqual(0); 40 | 41 | send(transition()); 42 | 43 | // After the callback has been registered but before the callback has been 44 | // run, close the client. 45 | const closePromise = client.close(); 46 | 47 | expect(timesCallbackRan).toEqual(0); 48 | 49 | // After the internal client has closed, same nothing. 50 | await closePromise; 51 | expect(timesCallbackRan).toEqual(0); 52 | }); 53 | }); 54 | 55 | const expectQuerySetModification = ( 56 | message: ClientMessage, 57 | ): QuerySetModification => { 58 | expect(message.type).toEqual("ModifyQuerySet"); 59 | if (message.type !== "ModifyQuerySet") throw new Error("Wrong message!"); 60 | return message; 61 | }; 62 | 63 | function transition(): ServerMessage { 64 | return { 65 | type: "Transition", 66 | startVersion: { querySet: 0, identity: 0, ts: Long.fromNumber(0) }, 67 | endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(1) }, 68 | modifications: [ 69 | { 70 | type: "QueryUpdated", 71 | queryId: 0, 72 | value: 0.0, 73 | logLines: [], 74 | journal: null, 75 | }, 76 | ], 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/react/use_query.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment custom-vitest-environment.ts 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/no-unused-vars */ 6 | import { test, describe, expect } from "vitest"; 7 | import { anyApi } from "../server/api.js"; 8 | 9 | import { ApiFromModules, QueryBuilder } from "../server/index.js"; 10 | import { useQuery as useQueryReal } from "./client.js"; 11 | 12 | // Intentional noop, we're just testing types. 13 | const useQuery = (() => {}) as unknown as typeof useQueryReal; 14 | 15 | const query: QueryBuilder = (() => { 16 | // Intentional noop. We're only testing the type 17 | }) as any; 18 | 19 | const module = { 20 | noArgs: query(() => "result"), 21 | args: query((_ctx, { _arg }: { _arg: string }) => "result"), 22 | /* 23 | // TODO some of these may be worth testing or proving 24 | // that they produce the same function reference types. 25 | untypedArgs: query((_ctx, _args) => "result"), 26 | unpackedUntypedArgs: query((_ctx, { _arg }) => "result"), 27 | configNoArgs: query({ 28 | handler: () => "result", 29 | }), 30 | configEmptyArgs: query({ 31 | args: {}, 32 | handler: () => "result", 33 | }), 34 | configArgs: query({ 35 | args: { _arg: v.string() }, 36 | handler: (args) => "result", 37 | }), 38 | */ 39 | }; 40 | type API = ApiFromModules<{ module: typeof module }>; 41 | const api = anyApi as unknown as API; 42 | 43 | // Test the existing behavior of useQuery types. 44 | // The change to consider is adding an options object. 45 | // These rely on OptionalRestArgs / OptionalRestArgsOrSkip 46 | // see https://github.com/get-convex/convex/pull/13978 47 | describe("useQuery types", () => { 48 | test("Queries with arguments", () => { 49 | useQuery(api.module.args, { _arg: "asdf" }); 50 | 51 | // @ts-expect-error extra args is an error 52 | useQuery(api.module.args, { _arg: "asdf", arg2: 123 }); 53 | 54 | // @ts-expect-error wrong arg type is an error 55 | useQuery(api.module.args, { _arg: 1 }); 56 | 57 | // @ts-expect-error eliding args object is an error 58 | useQuery(api.module.args); 59 | }); 60 | 61 | test("Queries without arguments", () => { 62 | // empty args are allowed 63 | useQuery(api.module.noArgs, {}); 64 | 65 | // eliding args object is allowed 66 | useQuery(api.module.noArgs); 67 | 68 | // @ts-expect-error adding args is not allowed 69 | useQuery(api.module.noArgs, { _arg: 1 }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/server/README.md: -------------------------------------------------------------------------------- 1 | # Server 2 | 3 | This is the entry point for all of the code for use within query and mutation 4 | functions. 5 | 6 | This directory uses an "interface-impl" pattern where: 7 | 8 | - The main directory has all interfaces to define the types of the various 9 | abstractions. These are parameterized of the developers `DataModel` type and 10 | carefully written to only allow valid usage. 11 | - The `impl/` subdirectory has implementations of all of these interfaces. These 12 | implementations are sloppier about their types and **not parameterized over 13 | `DataModel`**. This simplifies their implementation and only gives up a bit of 14 | type safety. The `DataModel` type is built to help developers write correct 15 | code, not to check that our internal structures are correct. 16 | -------------------------------------------------------------------------------- /src/server/components/definition.ts: -------------------------------------------------------------------------------- 1 | // These reflect server types. 2 | export type ComponentDefinitionExport = { 3 | name: string; 4 | // how will we figure this out? 5 | path: string; 6 | definitionType: { 7 | type: "childComponent"; 8 | name: string; 9 | args: [string, { type: "value"; value: string }][]; 10 | }; 11 | childComponents: []; 12 | exports: { type: "branch"; branch: [] }; 13 | }; 14 | 15 | // These reflect server types. 16 | // type ComponentDefinitionType 17 | export type ComponentDefinitionType = { 18 | type: "childComponent"; 19 | name: string; 20 | args: [string, { type: "value"; value: string }][]; 21 | }; 22 | export type AppDefinitionType = { type: "app" }; 23 | 24 | type ComponentInstantiation = { 25 | name: string; 26 | // This is a ComponentPath. 27 | path: string; 28 | args: [string, { type: "value"; value: string }][]; 29 | }; 30 | 31 | export type HttpMount = string; 32 | 33 | type ComponentExport = 34 | | { type: "branch"; branch: [string, ComponentExport][] } 35 | | { type: "leaf"; leaf: string }; 36 | 37 | // The type expected from the internal .export() 38 | // method of a component or app definition. 39 | export type ComponentDefinitionAnalysis = { 40 | name: string; 41 | definitionType: ComponentDefinitionType; 42 | childComponents: ComponentInstantiation[]; 43 | httpMounts: Record; 44 | exports: ComponentExport; 45 | }; 46 | export type AppDefinitionAnalysis = { 47 | definitionType: AppDefinitionType; 48 | childComponents: ComponentInstantiation[]; 49 | httpMounts: Record; 50 | exports: ComponentExport; 51 | }; 52 | -------------------------------------------------------------------------------- /src/server/components/paths.ts: -------------------------------------------------------------------------------- 1 | import { functionName } from "../functionName.js"; 2 | 3 | export const toReferencePath = Symbol.for("toReferencePath"); 4 | 5 | // Multiple instances of the same Symbol.for() are equal at runtime but not 6 | // at type-time, so `[toReferencePath]` properties aren't used in types. 7 | // Use this function to set the property invisibly. 8 | export function setReferencePath(obj: T, value: string) { 9 | (obj as any)[toReferencePath] = value; 10 | } 11 | 12 | export function extractReferencePath(reference: any): string | null { 13 | return reference[toReferencePath] ?? null; 14 | } 15 | 16 | export function isFunctionHandle(s: string): boolean { 17 | return s.startsWith("function://"); 18 | } 19 | 20 | export function getFunctionAddress(functionReference: any) { 21 | // The `run*` syscalls expect either a UDF path at "name" or a serialized 22 | // reference at "reference". Dispatch on `functionReference` to coerce 23 | // it to one or the other. 24 | let functionAddress; 25 | 26 | // Legacy path for passing in UDF paths directly as function references. 27 | if (typeof functionReference === "string") { 28 | if (isFunctionHandle(functionReference)) { 29 | functionAddress = { functionHandle: functionReference }; 30 | } else { 31 | functionAddress = { name: functionReference }; 32 | } 33 | } 34 | // Path for passing in a `FunctionReference`, either from `api` or directly 35 | // created from a UDF path with `makeFunctionReference`. 36 | else if (functionReference[functionName]) { 37 | functionAddress = { name: functionReference[functionName] }; 38 | } 39 | // Reference to a component's function derived from `app` or `component`. 40 | else { 41 | const referencePath = extractReferencePath(functionReference); 42 | if (!referencePath) { 43 | throw new Error(`${functionReference} is not a functionReference`); 44 | } 45 | functionAddress = { reference: referencePath }; 46 | } 47 | return functionAddress; 48 | } 49 | -------------------------------------------------------------------------------- /src/server/filter_builder.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { GenericId } from "../values/index.js"; 3 | import { test } from "vitest"; 4 | import { assert, Equals } from "../test/type_testing.js"; 5 | import { Expression, FilterBuilder } from "./filter_builder.js"; 6 | 7 | type Document = { 8 | _id: GenericId<"tableName">; 9 | numberField: number; 10 | bigintField: bigint; 11 | nestedObject: { 12 | numberField: number; 13 | }; 14 | }; 15 | 16 | type TableInfo = { 17 | document: Document; 18 | fieldPaths: 19 | | "_id" 20 | | "numberField" 21 | | "bigintField" 22 | | "nestedObject" 23 | | "nestedObject.numberField"; 24 | indexes: {}; 25 | searchIndexes: {}; 26 | vectorIndexes: {}; 27 | }; 28 | 29 | type FB = FilterBuilder; 30 | 31 | test("eq must have the same input types", () => { 32 | // This breaks because we're comparing a string and a number. 33 | function brokenEq(q: FB) { 34 | // @ts-expect-error Using this directive to assert this is an error. 35 | return q.eq("string", 123); 36 | } 37 | 38 | function eq(q: FB) { 39 | return q.eq("string", "another string"); 40 | } 41 | type Result = ReturnType; 42 | type Expected = Expression; 43 | assert>(); 44 | }); 45 | 46 | test("neq must have the same input types", () => { 47 | // This breaks because we're comparing a string and a number. 48 | function brokenNeq(q: FB) { 49 | // @ts-expect-error Using this directive to assert this is an error. 50 | return q.neq("string", 123); 51 | } 52 | 53 | function neq(q: FB) { 54 | return q.neq("string", "another string"); 55 | } 56 | type Result = ReturnType; 57 | type Expected = Expression; 58 | assert>(); 59 | }); 60 | 61 | test("neg returns number when number is passed in", () => { 62 | function negNumber(q: FB) { 63 | return q.neg(q.field("numberField")); 64 | } 65 | type Result = ReturnType; 66 | type Expected = Expression; 67 | assert>(); 68 | }); 69 | 70 | test("neg returns bigint when bigint is passed in", () => { 71 | function negBigint(q: FB) { 72 | return q.neg(q.field("bigintField")); 73 | } 74 | type Result = ReturnType; 75 | type Expected = Expression; 76 | assert>(); 77 | }); 78 | 79 | test("field doesn't compile on invalid field paths", () => { 80 | function broken(q: FB) { 81 | // @ts-expect-error Using this directive to assert this is an error. 82 | return q.field("notAField"); 83 | } 84 | }); 85 | 86 | test("field determines field type", () => { 87 | function idField(q: FB) { 88 | return q.field("_id"); 89 | } 90 | type Result = ReturnType; 91 | type Expected = Expression>; 92 | assert>(); 93 | }); 94 | 95 | test("field determines field type in nested field", () => { 96 | function nestedField(q: FB) { 97 | return q.field("nestedObject.numberField"); 98 | } 99 | type Result = ReturnType; 100 | type Expected = Expression; 101 | assert>(); 102 | }); 103 | -------------------------------------------------------------------------------- /src/server/functionName.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A symbol for accessing the name of a {@link FunctionReference} at runtime. 3 | */ 4 | export const functionName = Symbol.for("functionName"); 5 | -------------------------------------------------------------------------------- /src/server/functions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A symbol for accessing the name of a {@link FunctionReference} at runtime. 3 | */ 4 | export const functionName = Symbol.for("functionName"); 5 | -------------------------------------------------------------------------------- /src/server/impl/actions_impl.ts: -------------------------------------------------------------------------------- 1 | import { convexToJson, jsonToConvex, Value } from "../../values/index.js"; 2 | import { version } from "../../index.js"; 3 | import { performAsyncSyscall } from "./syscall.js"; 4 | import { parseArgs } from "../../common/index.js"; 5 | import { FunctionReference } from "../../server/api.js"; 6 | import { getFunctionAddress } from "../components/paths.js"; 7 | 8 | function syscallArgs( 9 | requestId: string, 10 | functionReference: any, 11 | args?: Record, 12 | ) { 13 | const address = getFunctionAddress(functionReference); 14 | return { 15 | ...address, 16 | args: convexToJson(parseArgs(args)), 17 | version, 18 | requestId, 19 | }; 20 | } 21 | 22 | export function setupActionCalls(requestId: string) { 23 | return { 24 | runQuery: async ( 25 | query: FunctionReference<"query", "public" | "internal">, 26 | args?: Record, 27 | ): Promise => { 28 | const result = await performAsyncSyscall( 29 | "1.0/actions/query", 30 | syscallArgs(requestId, query, args), 31 | ); 32 | return jsonToConvex(result); 33 | }, 34 | runMutation: async ( 35 | mutation: FunctionReference<"mutation", "public" | "internal">, 36 | args?: Record, 37 | ): Promise => { 38 | const result = await performAsyncSyscall( 39 | "1.0/actions/mutation", 40 | syscallArgs(requestId, mutation, args), 41 | ); 42 | return jsonToConvex(result); 43 | }, 44 | runAction: async ( 45 | action: FunctionReference<"action", "public" | "internal">, 46 | args?: Record, 47 | ): Promise => { 48 | const result = await performAsyncSyscall( 49 | "1.0/actions/action", 50 | syscallArgs(requestId, action, args), 51 | ); 52 | return jsonToConvex(result); 53 | }, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/server/impl/authentication_impl.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from "../authentication.js"; 2 | import { performAsyncSyscall } from "./syscall.js"; 3 | 4 | export function setupAuth(requestId: string): Auth { 5 | return { 6 | getUserIdentity: async () => { 7 | return await performAsyncSyscall("1.0/getUserIdentity", { 8 | requestId, 9 | }); 10 | }, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/server/impl/filter_builder_impl.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { filterBuilderImpl } from "./filter_builder_impl.js"; 3 | 4 | test("Serialize expression with literals", () => { 5 | const predicate = filterBuilderImpl.and( 6 | filterBuilderImpl.eq(filterBuilderImpl.field("test"), 3), 7 | true as any, 8 | ); 9 | const expected = { 10 | $and: [{ $eq: [{ $field: "test" }, { $literal: 3 }] }, { $literal: true }], 11 | }; 12 | expect((predicate as any).serialize()).toEqual(expected); 13 | }); 14 | -------------------------------------------------------------------------------- /src/server/impl/index_range_builder_impl.ts: -------------------------------------------------------------------------------- 1 | import { convexToJson, JSONValue, Value } from "../../values/index.js"; 2 | import { convexOrUndefinedToJson } from "../../values/value.js"; 3 | import { GenericDocument, GenericIndexFields } from "../data_model.js"; 4 | import { 5 | IndexRange, 6 | IndexRangeBuilder, 7 | LowerBoundIndexRangeBuilder, 8 | UpperBoundIndexRangeBuilder, 9 | } from "../index_range_builder.js"; 10 | 11 | export type SerializedRangeExpression = { 12 | type: "Eq" | "Gt" | "Gte" | "Lt" | "Lte"; 13 | fieldPath: string; 14 | value: JSONValue; 15 | }; 16 | 17 | export class IndexRangeBuilderImpl 18 | extends IndexRange 19 | implements 20 | IndexRangeBuilder, 21 | LowerBoundIndexRangeBuilder, 22 | UpperBoundIndexRangeBuilder 23 | { 24 | private rangeExpressions: ReadonlyArray; 25 | private isConsumed: boolean; 26 | private constructor( 27 | rangeExpressions: ReadonlyArray, 28 | ) { 29 | super(); 30 | this.rangeExpressions = rangeExpressions; 31 | this.isConsumed = false; 32 | } 33 | 34 | static new(): IndexRangeBuilderImpl { 35 | return new IndexRangeBuilderImpl([]); 36 | } 37 | 38 | private consume() { 39 | if (this.isConsumed) { 40 | throw new Error( 41 | "IndexRangeBuilder has already been used! Chain your method calls like `q => q.eq(...).eq(...)`. See https://docs.convex.dev/using/indexes", 42 | ); 43 | } 44 | this.isConsumed = true; 45 | } 46 | 47 | eq(fieldName: string, value: Value) { 48 | this.consume(); 49 | return new IndexRangeBuilderImpl( 50 | this.rangeExpressions.concat({ 51 | type: "Eq", 52 | fieldPath: fieldName, 53 | value: convexOrUndefinedToJson(value), 54 | }), 55 | ); 56 | } 57 | 58 | gt(fieldName: string, value: Value) { 59 | this.consume(); 60 | return new IndexRangeBuilderImpl( 61 | this.rangeExpressions.concat({ 62 | type: "Gt", 63 | fieldPath: fieldName, 64 | value: convexToJson(value), 65 | }), 66 | ); 67 | } 68 | gte(fieldName: string, value: Value) { 69 | this.consume(); 70 | return new IndexRangeBuilderImpl( 71 | this.rangeExpressions.concat({ 72 | type: "Gte", 73 | fieldPath: fieldName, 74 | value: convexToJson(value), 75 | }), 76 | ); 77 | } 78 | lt(fieldName: string, value: Value) { 79 | this.consume(); 80 | return new IndexRangeBuilderImpl( 81 | this.rangeExpressions.concat({ 82 | type: "Lt", 83 | fieldPath: fieldName, 84 | value: convexToJson(value), 85 | }), 86 | ); 87 | } 88 | lte(fieldName: string, value: Value) { 89 | this.consume(); 90 | return new IndexRangeBuilderImpl( 91 | this.rangeExpressions.concat({ 92 | type: "Lte", 93 | fieldPath: fieldName, 94 | value: convexToJson(value), 95 | }), 96 | ); 97 | } 98 | 99 | export() { 100 | this.consume(); 101 | return this.rangeExpressions; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/server/impl/query_impl.test.ts: -------------------------------------------------------------------------------- 1 | import { QueryImpl } from "./query_impl.js"; 2 | import { test, expect } from "vitest"; 3 | 4 | // Mock to prevent 5 | // "The Convex database and auth objects are being used outside of a Convex backend..." errors 6 | (globalThis as any).Convex = { 7 | syscall: (_op: string, _jsonArgs: string) => { 8 | return "{}"; 9 | }, 10 | asyncSyscall: async (_op: string, _jsonArgs: string) => { 11 | return new Promise((resolve) => { 12 | resolve('{ "done": true, "value": null }'); 13 | }); 14 | }, 15 | }; 16 | 17 | function newQuery() { 18 | return new QueryImpl({ 19 | source: { 20 | type: "FullTableScan", 21 | tableName: "messages", 22 | order: null, 23 | }, 24 | operators: [], 25 | }); 26 | } 27 | 28 | test("take does not throw if passed a non-negative integer", async () => { 29 | await newQuery().take(1); 30 | }); 31 | 32 | test("take throws a TypeError if passed a float", async () => { 33 | const t = () => { 34 | return newQuery().take(1.5); 35 | }; 36 | await expect(t).rejects.toThrow(TypeError); 37 | await expect(t).rejects.toThrow(/must be a non-negative integer/); 38 | }); 39 | 40 | test("take throws a TypeError if passed a negative integer", async () => { 41 | const t = () => { 42 | return newQuery().take(-1); 43 | }; 44 | await expect(t).rejects.toThrow(TypeError); 45 | await expect(t).rejects.toThrow(/must be a non-negative integer/); 46 | }); 47 | -------------------------------------------------------------------------------- /src/server/impl/search_filter_builder_impl.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue, convexOrUndefinedToJson } from "../../values/value.js"; 2 | import { 3 | FieldTypeFromFieldPath, 4 | GenericDocument, 5 | GenericSearchIndexConfig, 6 | } from "../data_model.js"; 7 | import { 8 | SearchFilter, 9 | SearchFilterBuilder, 10 | SearchFilterFinalizer, 11 | } from "../search_filter_builder.js"; 12 | import { validateArg } from "./validate.js"; 13 | 14 | export type SerializedSearchFilter = 15 | | { 16 | type: "Search"; 17 | fieldPath: string; 18 | value: string; 19 | } 20 | | { 21 | type: "Eq"; 22 | fieldPath: string; 23 | value: JSONValue; 24 | }; 25 | 26 | export class SearchFilterBuilderImpl 27 | extends SearchFilter 28 | implements 29 | SearchFilterBuilder, 30 | SearchFilterFinalizer 31 | { 32 | private filters: ReadonlyArray; 33 | private isConsumed: boolean; 34 | private constructor(filters: ReadonlyArray) { 35 | super(); 36 | this.filters = filters; 37 | this.isConsumed = false; 38 | } 39 | 40 | static new(): SearchFilterBuilderImpl { 41 | return new SearchFilterBuilderImpl([]); 42 | } 43 | 44 | private consume() { 45 | if (this.isConsumed) { 46 | throw new Error( 47 | "SearchFilterBuilder has already been used! Chain your method calls like `q => q.search(...).eq(...)`.", 48 | ); 49 | } 50 | this.isConsumed = true; 51 | } 52 | 53 | search( 54 | fieldName: string, 55 | query: string, 56 | ): SearchFilterFinalizer { 57 | validateArg(fieldName, 1, "search", "fieldName"); 58 | validateArg(query, 2, "search", "query"); 59 | this.consume(); 60 | return new SearchFilterBuilderImpl( 61 | this.filters.concat({ 62 | type: "Search", 63 | fieldPath: fieldName, 64 | value: query, 65 | }), 66 | ); 67 | } 68 | eq( 69 | fieldName: FieldName, 70 | value: FieldTypeFromFieldPath, 71 | ): SearchFilterFinalizer { 72 | validateArg(fieldName, 1, "eq", "fieldName"); 73 | // when `undefined` is passed explicitly, it is allowed. 74 | if (arguments.length !== 2) { 75 | validateArg(value, 2, "search", "value"); 76 | } 77 | this.consume(); 78 | return new SearchFilterBuilderImpl( 79 | this.filters.concat({ 80 | type: "Eq", 81 | fieldPath: fieldName, 82 | value: convexOrUndefinedToJson(value), 83 | }), 84 | ); 85 | } 86 | 87 | export() { 88 | this.consume(); 89 | return this.filters; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/server/impl/storage_impl.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileMetadata, 3 | StorageActionWriter, 4 | FileStorageId, 5 | StorageReader, 6 | StorageWriter, 7 | } from "../storage.js"; 8 | import { version } from "../../index.js"; 9 | import { performAsyncSyscall, performJsSyscall } from "./syscall.js"; 10 | import { validateArg } from "./validate.js"; 11 | 12 | export function setupStorageReader(requestId: string): StorageReader { 13 | return { 14 | getUrl: async (storageId: FileStorageId) => { 15 | validateArg(storageId, 1, "getUrl", "storageId"); 16 | return await performAsyncSyscall("1.0/storageGetUrl", { 17 | requestId, 18 | version, 19 | storageId, 20 | }); 21 | }, 22 | getMetadata: async (storageId: FileStorageId): Promise => { 23 | return await performAsyncSyscall("1.0/storageGetMetadata", { 24 | requestId, 25 | version, 26 | storageId, 27 | }); 28 | }, 29 | }; 30 | } 31 | 32 | export function setupStorageWriter(requestId: string): StorageWriter { 33 | const reader = setupStorageReader(requestId); 34 | return { 35 | generateUploadUrl: async () => { 36 | return await performAsyncSyscall("1.0/storageGenerateUploadUrl", { 37 | requestId, 38 | version, 39 | }); 40 | }, 41 | delete: async (storageId: FileStorageId) => { 42 | await performAsyncSyscall("1.0/storageDelete", { 43 | requestId, 44 | version, 45 | storageId, 46 | }); 47 | }, 48 | getUrl: reader.getUrl, 49 | getMetadata: reader.getMetadata, 50 | }; 51 | } 52 | 53 | export function setupStorageActionWriter( 54 | requestId: string, 55 | ): StorageActionWriter { 56 | const writer = setupStorageWriter(requestId); 57 | return { 58 | ...writer, 59 | store: async (blob: Blob, options?: { sha256?: string }) => { 60 | return await performJsSyscall("storage/storeBlob", { 61 | requestId, 62 | version, 63 | blob, 64 | options, 65 | }); 66 | }, 67 | get: async (storageId: FileStorageId) => { 68 | return await performJsSyscall("storage/getBlob", { 69 | requestId, 70 | version, 71 | storageId, 72 | }); 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/server/impl/syscall.ts: -------------------------------------------------------------------------------- 1 | import { ConvexError } from "../../values/errors.js"; 2 | import { jsonToConvex } from "../../values/value.js"; 3 | 4 | declare const Convex: { 5 | syscall: (op: string, jsonArgs: string) => string; 6 | asyncSyscall: (op: string, jsonArgs: string) => Promise; 7 | jsSyscall: (op: string, args: Record) => any; 8 | }; 9 | /** 10 | * Perform a syscall, taking in a JSON-encodable object as an argument, serializing with 11 | * JSON.stringify, calling into Rust, and then parsing the response as a JSON-encodable 12 | * value. If one of your arguments is a Convex value, you must call `convexToJson` on it 13 | * before passing it to this function, and if the return value has a Convex value, you're 14 | * also responsible for calling `jsonToConvex`: This layer only deals in JSON. 15 | */ 16 | 17 | export function performSyscall(op: string, arg: Record): any { 18 | if (typeof Convex === "undefined" || Convex.syscall === undefined) { 19 | throw new Error( 20 | "The Convex database and auth objects are being used outside of a Convex backend. " + 21 | "Did you mean to use `useQuery` or `useMutation` to call a Convex function?", 22 | ); 23 | } 24 | const resultStr = Convex.syscall(op, JSON.stringify(arg)); 25 | return JSON.parse(resultStr); 26 | } 27 | 28 | export async function performAsyncSyscall( 29 | op: string, 30 | arg: Record, 31 | ): Promise { 32 | if (typeof Convex === "undefined" || Convex.asyncSyscall === undefined) { 33 | throw new Error( 34 | "The Convex database and auth objects are being used outside of a Convex backend. " + 35 | "Did you mean to use `useQuery` or `useMutation` to call a Convex function?", 36 | ); 37 | } 38 | let resultStr; 39 | try { 40 | resultStr = await Convex.asyncSyscall(op, JSON.stringify(arg)); 41 | } catch (e: any) { 42 | // Rethrow the exception to attach stack trace starting from here. 43 | // If the error came from JS it will include its own stack trace in the message. 44 | // If it came from Rust it won't. 45 | 46 | // This only happens if we're propagating ConvexErrors 47 | if (e.data !== undefined) { 48 | const rethrown = new ConvexError(e.message); 49 | rethrown.data = jsonToConvex(e.data); 50 | throw rethrown; 51 | } 52 | throw new Error(e.message); 53 | } 54 | return JSON.parse(resultStr); 55 | } 56 | 57 | /** 58 | * Call into a "JS" syscall. Like `performSyscall`, this calls a dynamically linked 59 | * function set up in the Convex function execution. Unlike `performSyscall`, the 60 | * arguments do not need to be JSON-encodable and neither does the return value. 61 | * 62 | * @param op 63 | * @param arg 64 | * @returns 65 | */ 66 | export function performJsSyscall(op: string, arg: Record): any { 67 | if (typeof Convex === "undefined" || Convex.jsSyscall === undefined) { 68 | throw new Error( 69 | "The Convex database and auth objects are being used outside of a Convex backend. " + 70 | "Did you mean to use `useQuery` or `useMutation` to call a Convex function?", 71 | ); 72 | } 73 | return Convex.jsSyscall(op, arg); 74 | } 75 | -------------------------------------------------------------------------------- /src/server/impl/validate.ts: -------------------------------------------------------------------------------- 1 | export function validateArg( 2 | arg: any, 3 | idx: number, 4 | method: string, 5 | argName: string, 6 | ) { 7 | if (arg === undefined) { 8 | throw new TypeError( 9 | `Must provide arg ${idx} \`${argName}\` to \`${method}\``, 10 | ); 11 | } 12 | } 13 | 14 | export function validateArgIsInteger( 15 | arg: any, 16 | idx: number, 17 | method: string, 18 | argName: string, 19 | ) { 20 | if (!Number.isInteger(arg)) { 21 | throw new TypeError( 22 | `Arg ${idx} \`${argName}\` to \`${method}\` must be an integer`, 23 | ); 24 | } 25 | } 26 | 27 | export function validateArgIsNonNegativeInteger( 28 | arg: any, 29 | idx: number, 30 | method: string, 31 | argName: string, 32 | ) { 33 | if (!Number.isInteger(arg) || arg < 0) { 34 | throw new TypeError( 35 | `Arg ${idx} \`${argName}\` to \`${method}\` must be a non-negative integer`, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/server/pagination.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "../test/type_testing.js"; 2 | import { test } from "vitest"; 3 | import { PaginationOptions, paginationOptsValidator } from "./pagination.js"; 4 | import { Infer } from "../values/validator.js"; 5 | 6 | test("paginationOptsValidator matches the paginationOpts type", () => { 7 | type validatorType = Infer; 8 | assert(); 9 | // All optional fields exist and have the correct type. 10 | assert< 11 | Required extends Required ? true : false 12 | >(); 13 | }); 14 | -------------------------------------------------------------------------------- /src/server/scheduler.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | import { ApiFromModules, FunctionReference, justSchedulable } from "./api.js"; 3 | import { assert, Equals } from "../test/type_testing.js"; 4 | import { 5 | actionGeneric, 6 | mutationGeneric, 7 | queryGeneric, 8 | } from "./impl/registration_impl.js"; 9 | import { EmptyObject } from "./registration.js"; 10 | 11 | const _myModule = { 12 | query: queryGeneric((_) => false), 13 | action: actionGeneric((_) => "result"), 14 | mutation: mutationGeneric((_) => 123), 15 | }; 16 | 17 | type API = ApiFromModules<{ 18 | myModule: typeof _myModule; 19 | }>; 20 | 21 | type SchedulableAPI = ReturnType>; 22 | 23 | test("SchedulableFunctionNames", () => { 24 | type Expected = { 25 | myModule: { 26 | action: FunctionReference<"action", "public", EmptyObject, string>; 27 | mutation: FunctionReference<"mutation", "public", EmptyObject, number>; 28 | }; 29 | }; 30 | assert>(); 31 | }); 32 | -------------------------------------------------------------------------------- /src/server/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { FunctionReference, OptionalRestArgs } from "../server/api.js"; 2 | import { Id } from "../values/value.js"; 3 | 4 | /** 5 | * A {@link FunctionReference} that can be scheduled to run in the future. 6 | * 7 | * Schedulable functions are mutations and actions that are public or internal. 8 | * 9 | * @public 10 | */ 11 | export type SchedulableFunctionReference = FunctionReference< 12 | "mutation" | "action", 13 | "public" | "internal" 14 | >; 15 | 16 | /** 17 | * An interface to schedule Convex functions. 18 | * 19 | * You can schedule either mutations or actions. Mutations are guaranteed to execute 20 | * exactly once - they are automatically retried on transient errors and either execute 21 | * successfully or fail deterministically due to developer error in defining the 22 | * function. Actions execute at most once - they are not retried and might fail 23 | * due to transient errors. 24 | * 25 | * Consider using an {@link internalMutation} or {@link internalAction} to enforce that 26 | * these functions cannot be called directly from a Convex client. 27 | * 28 | * @public 29 | */ 30 | export interface Scheduler { 31 | /** 32 | * Schedule a function to execute after a delay. 33 | * 34 | * @param delayMs - Delay in milliseconds. Must be non-negative. If the delay 35 | * is zero, the scheduled function will be due to execute immediately after the 36 | * scheduling one completes. 37 | * @param functionReference - A {@link FunctionReference} for the function 38 | * to schedule. 39 | * @param args - Arguments to call the scheduled functions with. 40 | **/ 41 | runAfter( 42 | delayMs: number, 43 | functionReference: FuncRef, 44 | ...args: OptionalRestArgs 45 | ): Promise>; 46 | 47 | /** 48 | * Schedule a function to execute at a given timestamp. 49 | * 50 | * @param timestamp - A Date or a timestamp (milliseconds since the epoch). 51 | * If the timestamp is in the past, the scheduled function will be due to 52 | * execute immediately after the scheduling one completes. The timestamp can't 53 | * be more than five years in the past or more than five years in the future. 54 | * @param functionReference - A {@link FunctionReference} for the function 55 | * to schedule. 56 | * @param args - arguments to call the scheduled functions with. 57 | **/ 58 | runAt( 59 | timestamp: number | Date, 60 | functionReference: FuncRef, 61 | ...args: OptionalRestArgs 62 | ): Promise>; 63 | 64 | /** 65 | * Cancels a previously scheduled function if it has not started yet. If the 66 | * scheduled function is already in progress, it will continue running but 67 | * any new functions that it tries to schedule will be canceled. 68 | * 69 | * @param id 70 | */ 71 | cancel(id: Id<"_scheduled_functions">): Promise; 72 | } 73 | -------------------------------------------------------------------------------- /src/server/search_filter_builder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldTypeFromFieldPath, 3 | GenericDocument, 4 | GenericSearchIndexConfig, 5 | } from "./data_model.js"; 6 | 7 | /** 8 | * Builder for defining search filters. 9 | * 10 | * A search filter is a chained list of: 11 | * 1. One search expression constructed with `.search`. 12 | * 2. Zero or more equality expressions constructed with `.eq`. 13 | * 14 | * The search expression must search for text in the index's `searchField`. The 15 | * filter expressions can use any of the `filterFields` defined in the index. 16 | * 17 | * For all other filtering use {@link OrderedQuery.filter}. 18 | * 19 | * To learn about full text search, see [Indexes](https://docs.convex.dev/text-search). 20 | * @public 21 | */ 22 | export interface SearchFilterBuilder< 23 | Document extends GenericDocument, 24 | SearchIndexConfig extends GenericSearchIndexConfig, 25 | > { 26 | /** 27 | * Search for the terms in `query` within `doc[fieldName]`. 28 | * 29 | * This will do a full text search that returns results where any word of of 30 | * `query` appears in the field. 31 | * 32 | * Documents will be returned based on their relevance to the query. This 33 | * takes into account: 34 | * - How many words in the query appear in the text? 35 | * - How many times do they appear? 36 | * - How long is the text field? 37 | * 38 | * @param fieldName - The name of the field to search in. This must be listed 39 | * as the index's `searchField`. 40 | * @param query - The query text to search for. 41 | */ 42 | search( 43 | fieldName: SearchIndexConfig["searchField"], 44 | query: string, 45 | ): SearchFilterFinalizer; 46 | } 47 | 48 | /** 49 | * Builder to define equality expressions as part of a search filter. 50 | * 51 | * See {@link SearchFilterBuilder}. 52 | * 53 | * @public 54 | */ 55 | export interface SearchFilterFinalizer< 56 | Document extends GenericDocument, 57 | SearchIndexConfig extends GenericSearchIndexConfig, 58 | > extends SearchFilter { 59 | /** 60 | * Restrict this query to documents where `doc[fieldName] === value`. 61 | * 62 | * @param fieldName - The name of the field to compare. This must be listed in 63 | * the search index's `filterFields`. 64 | * @param value - The value to compare against. 65 | */ 66 | eq( 67 | fieldName: FieldName, 68 | value: FieldTypeFromFieldPath, 69 | ): SearchFilterFinalizer; 70 | } 71 | 72 | /** 73 | * An expression representing a search filter created by 74 | * {@link SearchFilterBuilder}. 75 | * 76 | * @public 77 | */ 78 | export abstract class SearchFilter { 79 | // Property for nominal type support. 80 | private _isSearchFilter: undefined; 81 | 82 | /** 83 | * @internal 84 | */ 85 | constructor() { 86 | // only defining the constructor so we can mark it as internal and keep 87 | // it out of the docs. 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/server/system_fields.ts: -------------------------------------------------------------------------------- 1 | import { GenericId } from "../values/index.js"; 2 | import { BetterOmit, Expand } from "../type_utils.js"; 3 | import { GenericDocument } from "./data_model.js"; 4 | 5 | /** 6 | * The fields that Convex automatically adds to documents, not including `_id`. 7 | * 8 | * This is an object type mapping field name to field type. 9 | * @public 10 | */ 11 | export type SystemFields = { 12 | _creationTime: number; 13 | }; 14 | 15 | /** 16 | * The `_id` field that Convex automatically adds to documents. 17 | * @public 18 | */ 19 | export type IdField = { 20 | _id: GenericId; 21 | }; 22 | 23 | /** 24 | * A Convex document with the system fields like `_id` and `_creationTime` omitted. 25 | * 26 | * @public 27 | */ 28 | export type WithoutSystemFields = Expand< 29 | BetterOmit 30 | >; 31 | 32 | /** 33 | * A Convex document with the system fields like `_id` and `_creationTime` optional. 34 | * 35 | * @public 36 | */ 37 | export type WithOptionalSystemFields = Expand< 38 | WithoutSystemFields & 39 | Partial> 40 | >; 41 | 42 | /** 43 | * The indexes that Convex automatically adds to every table. 44 | * 45 | * This is an object mapping index names to index field paths. 46 | * @public 47 | */ 48 | export type SystemIndexes = { 49 | // Note `db.get(id)` is simpler and equivalent to a query on `by_id`. 50 | // Unless the query is being built dynamically, or doing manual pagination. 51 | by_id: ["_id"]; 52 | 53 | by_creation_time: ["_creationTime"]; 54 | }; 55 | 56 | /** 57 | * Convex automatically appends "_creationTime" to the end of every index to 58 | * break ties if all of the other fields are identical. 59 | * @public 60 | */ 61 | export type IndexTiebreakerField = "_creationTime"; 62 | -------------------------------------------------------------------------------- /src/test/fake_watch.ts: -------------------------------------------------------------------------------- 1 | import { QueryJournal } from "../browser/sync/protocol.js"; 2 | import { Watch } from "../react/client.js"; 3 | 4 | export default class FakeWatch implements Watch { 5 | callbacks: Set<() => void>; 6 | value: T | undefined; 7 | journalValue: QueryJournal | undefined; 8 | 9 | constructor() { 10 | this.callbacks = new Set(); 11 | this.value = undefined; 12 | this.journalValue = undefined; 13 | } 14 | 15 | setValue(newValue: T | undefined) { 16 | this.value = newValue; 17 | for (const callback of this.callbacks) { 18 | callback(); 19 | } 20 | } 21 | 22 | setJournal(journal: QueryJournal | undefined) { 23 | this.journalValue = journal; 24 | } 25 | 26 | numCallbacks(): number { 27 | return this.callbacks.size; 28 | } 29 | 30 | onUpdate(callback: () => void) { 31 | this.callbacks.add(callback); 32 | return () => { 33 | this.callbacks.delete(callback); 34 | 35 | // If no one is subscribed anymore, drop our journal like the real 36 | // client would. 37 | if (this.numCallbacks() === 0) { 38 | this.journalValue = undefined; 39 | } 40 | }; 41 | } 42 | 43 | localQueryResult(): T | undefined { 44 | return this.value; 45 | } 46 | 47 | localQueryLogs(): string[] | undefined { 48 | return undefined; 49 | } 50 | 51 | journal(): QueryJournal | undefined { 52 | return this.journalValue; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/test_resolver.cjs: -------------------------------------------------------------------------------- 1 | module.exports = (path, options) => { 2 | // Call the defaultResolver, so we leverage its cache, error handling, etc. 3 | return options.defaultResolver(path, { 4 | ...options, 5 | // Use packageFilter to process parsed `package.json` before the resolution (see https://www.npmjs.com/package/resolve#resolveid-opts-cb) 6 | packageFilter: (pkg) => { 7 | // Force the `ws` import to use CJS in both the jest jsdom browser environment and the 8 | // jest node environment. 9 | // 10 | // jest-environment-jsdom 28+ tries to use browser exports instead of default exports, 11 | // but since we have a file that is imported from both types of tests (jsdom and node), 12 | // we need to make sure we're importing the CJS one in both cases. 13 | // 14 | // This workaround prevents Jest from considering ws's module-based exports at all; 15 | // it falls back to ws's CommonJS+node "main" property. 16 | // 17 | // Inspired by https://github.com/microsoft/accessibility-insights-web/pull/5421#issuecomment-1109168149 18 | // This can go away once we improve `client_node_test_helpers.ts` to have different behavior 19 | // in node vs jsdom. But we can't do this until WS publishes types for both (or unifies their behavior) 20 | if (pkg.name === "ws") { 21 | delete pkg["exports"]; 22 | delete pkg["module"]; 23 | } 24 | return pkg; 25 | }, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/test/type_testing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests if two types are exactly the same. 3 | * Taken from https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650 4 | * (Apache Version 2.0, January 2004) 5 | */ 6 | export type Equals = 7 | (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 8 | ? true 9 | : false; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | export function assert() { 13 | // no need to do anything! we're just asserting at compile time that the type 14 | // parameter is true. 15 | } 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 | export function assertFalse() { 19 | // no need to do anything! we're just asserting at compile time that the type 20 | // parameter is false. 21 | } 22 | -------------------------------------------------------------------------------- /src/type_utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | import { assert, Equals } from "./test/type_testing.js"; 3 | import { BetterOmit } from "./type_utils.js"; 4 | 5 | describe("BetterOmit", () => { 6 | test("Basic object type", () => { 7 | type ObjectUnion = { 8 | property1: string; 9 | property2: string; 10 | }; 11 | 12 | type Expected = { 13 | property2: string; 14 | }; 15 | type Actual = BetterOmit; 16 | 17 | assert>; 18 | }); 19 | 20 | test("Union", () => { 21 | type ObjectUnion = 22 | | { 23 | type: "left"; 24 | sharedField: string; 25 | leftField: string; 26 | } 27 | | { 28 | type: "right"; 29 | sharedField: string; 30 | rightField: string; 31 | }; 32 | 33 | type Expected = 34 | | { 35 | type: "left"; 36 | leftField: string; 37 | } 38 | | { 39 | type: "right"; 40 | rightField: string; 41 | }; 42 | type Actual = BetterOmit; 43 | 44 | assert>; 45 | }); 46 | 47 | test("Index signature", () => { 48 | type ObjectUnion = { 49 | property1: string; 50 | property2: string; 51 | [propertyName: string]: any; 52 | }; 53 | 54 | type Expected = { 55 | property2: string; 56 | [propertyName: string]: any; 57 | }; 58 | type Actual = BetterOmit; 59 | 60 | assert>; 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/type_utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Common utilities for manipulating TypeScript types. 3 | * @module 4 | */ 5 | 6 | /** 7 | * Hack! This type causes TypeScript to simplify how it renders object types. 8 | * 9 | * It is functionally the identity for object types, but in practice it can 10 | * simplify expressions like `A & B`. 11 | */ 12 | export type Expand> = 13 | ObjectType extends Record 14 | ? { 15 | [Key in keyof ObjectType]: ObjectType[Key]; 16 | } 17 | : never; 18 | 19 | /** 20 | * An `Omit<>` type that: 21 | * 1. Applies to each element of a union. 22 | * 2. Preserves the index signature of the underlying type. 23 | */ 24 | export type BetterOmit = { 25 | [Property in keyof T as Property extends K ? never : Property]: T[Property]; 26 | }; 27 | 28 | /** 29 | * Convert a union type like `A | B | C` into an intersection type like 30 | * `A & B & C`. 31 | */ 32 | export type UnionToIntersection = ( 33 | UnionType extends any ? (k: UnionType) => void : never 34 | ) extends (k: infer I) => void 35 | ? I 36 | : never; 37 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "inquirer-search-list" { 2 | const mod: import("inquirer").prompts.PromptConstructor; 3 | export = mod; 4 | } 5 | -------------------------------------------------------------------------------- /src/values/errors.ts: -------------------------------------------------------------------------------- 1 | import { Value, stringifyValueForError } from "./value.js"; 2 | 3 | const IDENTIFYING_FIELD = Symbol.for("ConvexError"); 4 | 5 | export class ConvexError extends Error { 6 | name = "ConvexError"; 7 | data: TData; 8 | [IDENTIFYING_FIELD] = true; 9 | 10 | constructor(data: TData) { 11 | super(typeof data === "string" ? data : stringifyValueForError(data)); 12 | this.data = data; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/values/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities for working with values stored in Convex. 3 | * 4 | * You can see the full set of supported types at 5 | * [Types](https://docs.convex.dev/using/types). 6 | * @module 7 | */ 8 | 9 | export { convexToJson, jsonToConvex } from "./value.js"; 10 | export type { 11 | Id as GenericId, 12 | JSONValue, 13 | Value, 14 | NumericValue, 15 | } from "./value.js"; 16 | export { v, asObjectValidator } from "./validator.js"; 17 | export type { 18 | AsObjectValidator, 19 | GenericValidator, 20 | ObjectType, 21 | PropertyValidators, 22 | } from "./validator.js"; 23 | export type { 24 | ValidatorJSON, 25 | RecordKeyValidatorJSON, 26 | RecordValueValidatorJSON, 27 | ObjectFieldType, 28 | Validator, 29 | OptionalProperty, 30 | VId, 31 | VFloat64, 32 | VInt64, 33 | VBoolean, 34 | VBytes, 35 | VString, 36 | VNull, 37 | VAny, 38 | VObject, 39 | VLiteral, 40 | VArray, 41 | VRecord, 42 | VUnion, 43 | VOptional, 44 | } from "./validators.js"; 45 | import * as Base64 from "./base64.js"; 46 | export { Base64 }; 47 | export type { Infer } from "./validator.js"; 48 | export * from "./errors.js"; 49 | export { compareValues } from "./compare.js"; 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", ".eslintrc.cjs"], 3 | "compileOnSave": true, 4 | "compilerOptions": { 5 | /* Basic Options */ 6 | "target": "es2022", 7 | "module": "es2020", 8 | "declaration": true, 9 | "declarationMap": true, 10 | "emitDeclarationOnly": true, 11 | "stripInternal": true, 12 | "outDir": "./dist/types", 13 | "rootDir": "./src", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | /* Strict Type-Checking Options */ 18 | "strict": true, 19 | /* Module Resolution Options */ 20 | "esModuleInterop": true, 21 | /* Advanced Options */ 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "jsx": "react" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /values/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/cjs/values/index.js", 3 | "module": "../dist/esm/values/index.js", 4 | "types": "../dist/cjs-types/values/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | alias: { 6 | "@jest/globals": "vitest", 7 | }, 8 | isolate: true, 9 | watch: false, 10 | //environment: "node", // "node" is the default 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------