├── .github └── workflows │ ├── cla.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── .vscode └── convex.code-snippets ├── CONTRIBUTING.md ├── Justfile ├── LICENSE ├── README.md ├── backendHarness.js ├── convex.sh ├── convex ├── README.md ├── _generated │ ├── api.d.ts │ ├── api.js │ ├── dataModel.d.ts │ ├── server.d.ts │ └── server.js ├── addIt.ts ├── counter.ts ├── example.test.ts ├── http.ts ├── migrationsExample.ts ├── presence.ts ├── relationshipsExample.ts ├── retriesExample.ts ├── rlsExample.ts ├── schema.ts ├── sessionsExample.ts ├── testingFunctions.ts ├── triggersExample.ts ├── tsconfig.json └── vitest.config.mts ├── index.html ├── package-lock.json ├── package.json ├── packages └── convex-helpers │ ├── .npmignore │ ├── README.md │ ├── browser.test.ts │ ├── browser.ts │ ├── cli │ ├── functions.test.ts │ ├── index.ts │ ├── openApiSpec.test.ts │ ├── openApiSpec.ts │ ├── tsApiSpec.test.ts │ ├── tsApiSpec.ts │ └── utils.ts │ ├── generate-exports.mjs │ ├── index.test.ts │ ├── index.ts │ ├── package.json │ ├── react.ts │ ├── react │ ├── cache.ts │ ├── cache │ │ ├── hooks.ts │ │ └── provider.tsx │ ├── sessions.test.ts │ └── sessions.ts │ ├── server.ts │ ├── server │ ├── _generated │ │ └── _ignore.ts │ ├── compare.ts │ ├── cors.test.http.ts │ ├── cors.test.ts │ ├── cors.ts │ ├── crud.test.ts │ ├── crud.ts │ ├── customFunctions.test.ts │ ├── customFunctions.ts │ ├── filter.test.ts │ ├── filter.ts │ ├── hono.ts │ ├── migrations.ts │ ├── pagination.test.ts │ ├── pagination.ts │ ├── rateLimit.test.ts │ ├── rateLimit.ts │ ├── relationships.ts │ ├── retries.ts │ ├── rowLevelSecurity.test.ts │ ├── rowLevelSecurity.ts │ ├── sessions.ts │ ├── setup.test.ts │ ├── stream.test.ts │ ├── stream.ts │ ├── table.test.ts │ ├── triggers.test.ts │ ├── triggers.ts │ ├── validators.test.ts │ ├── zod.test.ts │ └── zod.ts │ ├── standardSchema.test.ts │ ├── standardSchema.ts │ ├── testing.ts │ ├── tsconfig.json │ ├── validators.ts │ └── vitest.config.mts ├── publish.sh ├── renovate.json ├── scripts └── check_cla.py ├── src ├── App.tsx ├── components │ ├── CacheExample.tsx │ ├── Counter.injection.test.tsx │ ├── Counter.mock.test.tsx │ ├── Counter.tsx │ ├── Facepile.tsx │ ├── HonoExample.tsx │ ├── RelationshipExample.tsx │ └── SessionsExample.tsx ├── fakeConvexClient │ ├── fakeConvexClient.d.ts │ └── fakeConvexClient.js ├── hooks │ ├── useLatestValue.ts │ ├── usePresence.ts │ ├── useSingleFlight.ts │ ├── useStableQuery.ts │ └── useTypingIndicator.ts ├── index.css ├── main.tsx └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.test.json ├── vite.config.mts └── vitest.workspace.ts /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: Check CLA in PR description 2 | 3 | on: 4 | pull_request: 5 | # Run when PRs are opened, reopened, edited or updated with new commits 6 | types: [opened, reopened, synchronize, edited] 7 | 8 | jobs: 9 | check-cla: 10 | name: Check CLA in PR description 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | 16 | - name: Validate PR has CLA confirmation 17 | # Exclude automated PRs from bots 18 | if: ${{ (startsWith(github.head_ref, 'dependabot') || startsWith(github.head_ref, 'renovate')) == false }} 19 | env: 20 | PR_DESCRIPTION: ${{ github.event.pull_request.body }} 21 | run: | 22 | ./scripts/check_cla.py 23 | -------------------------------------------------------------------------------- /.github/workflows/test.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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 20 | 21 | - name: Node setup 22 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 23 | with: 24 | node-version: "18.20.8" 25 | 26 | - run: | 27 | npm i 28 | npm run lint 29 | npm run build 30 | npm run test 31 | npx convex codegen && git diff --exit-code 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .npmrc 2 | 3 | # Or our local env 4 | .env.local 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | 83 | # Next.js build output 84 | .next 85 | 86 | # Nuxt.js build / generate output 87 | .nuxt 88 | dist 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | tsconfig.tsbuildinfo 111 | convex-local-backend* 112 | .mypy_cache 113 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/convex.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Convex Imports": { 3 | "prefix": "convex:imports", 4 | "body": [ 5 | "import { v } from \"convex/values\";", 6 | "import { api, internal } from \"./_generated/api\";", 7 | "import { Doc, Id } from \"./_generated/dataModel\";", 8 | "import {", 9 | " action,", 10 | " internalAction,", 11 | " internalMutation,", 12 | " internalQuery,", 13 | " mutation,", 14 | " query,", 15 | "} from \"./_generated/server\";", 16 | ], 17 | "scope": "javascript,typescript", 18 | "isFileTemplate": true, 19 | }, 20 | 21 | "Convex Query": { 22 | "prefix": "convex:query", 23 | "body": [ 24 | "export const $1 = query({", 25 | " args: {$2},", 26 | " handler: async (ctx, args) => {", 27 | " $0", 28 | " },", 29 | "});", 30 | ], 31 | "scope": "javascript,typescript", 32 | }, 33 | 34 | "Convex Internal Query": { 35 | "prefix": "convex:internalQuery", 36 | "body": [ 37 | "export const $1 = internalQuery({", 38 | " args: {$2},", 39 | " handler: async (ctx, args) => {", 40 | " $0", 41 | " },", 42 | "});", 43 | ], 44 | "scope": "javascript,typescript", 45 | }, 46 | 47 | "Convex Mutation": { 48 | "prefix": "convex:mutation", 49 | "body": [ 50 | "export const $1 = mutation({", 51 | " args: {$2},", 52 | " handler: async (ctx, args) => {", 53 | " $0", 54 | " },", 55 | "});", 56 | ], 57 | "scope": "javascript,typescript", 58 | }, 59 | 60 | "Convex Internal Mutation": { 61 | "prefix": "convex:internalMutation", 62 | "body": [ 63 | "export const $1 = internalMutation({", 64 | " args: {$2},", 65 | " handler: async (ctx, args) => {", 66 | " $0", 67 | " },", 68 | "});", 69 | ], 70 | "scope": "javascript,typescript", 71 | }, 72 | 73 | "Convex Action": { 74 | "prefix": "convex:action", 75 | "body": [ 76 | "export const $1 = action({", 77 | " args: {$2},", 78 | " handler: async (ctx, args) => {", 79 | " $0", 80 | " },", 81 | "});", 82 | ], 83 | "scope": "javascript,typescript", 84 | }, 85 | 86 | "Convex Internal Action": { 87 | "prefix": "convex:internalAction", 88 | "body": [ 89 | "export const $1 = internalAction({", 90 | " args: {$2},", 91 | " handler: async (ctx, args) => {", 92 | " $0", 93 | " },", 94 | "});", 95 | ], 96 | "scope": "javascript,typescript", 97 | }, 98 | 99 | "Convex Crons": { 100 | "prefix": "convex:crons", 101 | "body": [ 102 | "import { cronJobs } from \"convex/server\";", 103 | "import { internal } from \"./_generated/api\";", 104 | "import { internalMutation } from \"./_generated/server\";", 105 | "import { v } from \"convex/values\";", 106 | "", 107 | "const crons = cronJobs();", 108 | "", 109 | "export const $1 = internalMutation({", 110 | " args: {},", 111 | " handler: async (ctx, args) => {", 112 | " $0", 113 | " },", 114 | "});", 115 | "", 116 | "crons.interval(", 117 | " \"$1\",", 118 | " { seconds: $2 },", 119 | " internal.crons.$1,", 120 | ");", 121 | "", 122 | "", 123 | "export default crons;", 124 | ], 125 | "scope": "javascript,typescript", 126 | }, 127 | 128 | "Convex Schema": { 129 | "prefix": "convex:schema", 130 | "body": [ 131 | "import { defineTable, defineSchema } from \"convex/server\";", 132 | "import { v } from \"convex/values\";", 133 | "", 134 | "export default defineSchema({", 135 | " $1: defineTable({", 136 | " $0", 137 | " }),", 138 | "});", 139 | ], 140 | "scope": "javascript,typescript", 141 | }, 142 | 143 | "Convex HTTP": { 144 | "prefix": "convex:http", 145 | "body": [ 146 | "import { httpRouter } from \"convex/server\";", 147 | "import { httpAction } from \"./_generated/server\";", 148 | "", 149 | "const http = httpRouter();", 150 | "", 151 | "http.route({", 152 | " path: \"/echo\",", 153 | " method: \"GET\",", 154 | " handler: httpAction(async (ctx, request) => {", 155 | " const t = await request.text();", 156 | " return new Response(\"Received: \" + t);", 157 | " }),", 158 | "});", 159 | "", 160 | "// Convex expects the router to be the default export of `convex/http.js`.", 161 | "export default http;", 162 | ], 163 | "scope": "javascript,typescript", 164 | }, 165 | 166 | "Convex Test": { 167 | "prefix": "convex:test", 168 | "body": [ 169 | "import { convexTest } from \"convex-test\";", 170 | "import { v } from \"convex/values\";", 171 | "import { expect, test } from \"vitest\";", 172 | "import { api, internal } from \"./_generated/api\";", 173 | "import { Doc, Id } from \"./_generated/dataModel\";", 174 | "import {", 175 | " action,", 176 | " internalAction,", 177 | " internalMutation,", 178 | " internalQuery,", 179 | " mutation,", 180 | " query,", 181 | "} from \"./_generated/server\";", 182 | "import schema from \"./schema\";", 183 | "", 184 | "test(\"$1\", async () => {", 185 | " const t = convexTest(schema);", 186 | " $0", 187 | "});", 188 | ], 189 | "scope": "javascript,typescript", 190 | }, 191 | } 192 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to convex-helpers 2 | 3 | ## Adding a helper 4 | 5 | Adding helpers usually involves: 6 | 7 | 1. Adding code (and corresponding .test.ts file) to: 8 | 9 | - ./server/ if it helps write server-side code (imported in convex/) 10 | - ./react/ for client-side code. In the future beyond react/ there can be other framework-specific client-side helpers. 11 | - ./ if it's truly generic - can be imported client or server-side 12 | 13 | 2. Adding the file to [the root package.json](./package.json) 14 | or 15 | in the following places: 16 | 17 | 1. exports in [the npm library package.json](./packages/convex-helpers/package.json) 18 | using `node generate-exports.mjs`. 19 | 2. scripts: Update the `dev:helpers` script if it isn't being included by the existing 20 | globs, and the `build` command if it's not included in the `cp` command. 21 | 22 | 3. [package README.md](./packages/convex-helpers/README.md) blurb on how to use it, and a link in the TOC. 23 | 4. [root README.md](./README.md) link in the TOC. 24 | 5. Adding an example of usage in the root of this repo. 25 | 26 | 1. convex/fooExample.ts for server-side code 27 | 1. src/components/FooExample.tsx for client-side code, added in App.tsx 28 | 29 | 6. A [Stack](https://stack.convex.dev) post on it - what problem it solves, 30 | a blurb on how to use it. Update this README with the link when it's live. 31 | 32 | ## Recommendations 33 | 34 | 1. Include a block comment at the top of the file about how to use it. 35 | 2. Include jsdoc comments on exported functions etc. about how to use them. 36 | 3. Include motivation for **why** someone would use this helper, for browsing. 37 | 4. Avoid introducing too many files. Things are more discoverable within a file. 38 | 39 | ## Releasing 40 | 41 | Run commands from this folder (root of repo). 42 | 43 | **NOTE**: make sure you aren't running `npm run dev` anywhere when you're 44 | publishing to avoid races with re-generating files while publishing. 45 | 46 | In general you can run `./publish.sh` to go through the publish workflow, or 47 | `npm run release` to do a release. 48 | It will prompt you for a new version. If you've already adjusted the version, 49 | you can just hit enter. 50 | 51 | When it shows the publish preview, ensure the files all look like they're there. 52 | After you confirm to publish, it will publish to npm, make a git commit, 53 | tag the commit with the version, and push the current branch & that tag. 54 | 55 | ### Alpha releases 56 | 57 | For alpha releases, you can run `./publish.sh alpha` or `npm run alpha`. 58 | 59 | Or run this beforehand to bump the version: 60 | `npm version prerelease --preid alpha && git add package*`. 61 | Only use alpha, otherwise npm won't tag it correctly and it might suggest it as 62 | `convex-helpers@latest` instead of just as `convex-helpers@alpha`. 63 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set fallback := true 2 | set shell := ["bash", "-uc"] 3 | set windows-shell := ["sh", "-uc"] 4 | 5 | # `just --list` (or just `just`) will print all the recipes in 6 | # the current Justfile. `just RECIPE` will run the macro/job. 7 | # 8 | # In several places there are recipes for running common scripts or commands. 9 | # Instead of `Makefile`s, Convex uses Justfiles, which are similar, but avoid 10 | # several footguns associated with Makefiles, since using make as a macro runner 11 | # can sometimes conflict with Makefiles desire to have some rudimentary 12 | # understanding of build artifacts and associated dependencies. 13 | # 14 | # Read up on just here: https://github.com/casey/just 15 | 16 | _default: 17 | @just --list 18 | 19 | set positional-arguments 20 | 21 | reset-local-backend: 22 | rm -rf convex_local_storage && rm -f convex_local_backend.sqlite3 23 | 24 | # (*) Run the open source convex backend, downloading first if necessary. 25 | run-local-backend: 26 | #!/usr/bin/env sh 27 | if [ ! -x ./convex-local-backend ]; then 28 | if [ "$(uname)" == "Darwin" ]; then 29 | if [ "$(uname -m)" == "arm64" ]; then 30 | pkg=convex-local-backend-aarch64-apple-darwin.zip 31 | elif [ "$(uname -m)" == "x86_64" ]; then 32 | pkg=convex-local-backend-x86_64-apple-darwin.zip 33 | fi 34 | elif [ "$(uname -m)" == "x86_64" ]; then 35 | pkg=convex-local-backend-x86_64-unknown-linux-gnu.zip 36 | fi 37 | if [ -z "$pkg" ]; then 38 | echo "Download or build the convex-local-backend: https://github.com/get-convex/convex-backend" 39 | exit 1 40 | fi 41 | curl -L -O "https://github.com/get-convex/convex-backend/releases/latest/download/$pkg" 42 | unzip "$pkg" 43 | fi 44 | ./convex-local-backend 45 | 46 | # Taken from https://github.com/get-convex/convex-backend/blob/main/Justfile 47 | # (*) Run convex CLI commands like `convex dev` against local backend from `just run-local-backend`. 48 | # This uses the default admin key for local backends, which is safe as long as the backend is 49 | # running locally. 50 | convex *ARGS: 51 | npx convex "$@" --admin-key 0135d8598650f8f5cb0f30c34ec2e2bb62793bc28717c8eb6fb577996d50be5f4281b59181095065c5d0f86a2c31ddbe9b597ec62b47ded69782cd --url "http://127.0.0.1:3210" 52 | 53 | # Clears a table in the cloud backend. 54 | clear-table *ARGS: 55 | npx convex import --table "$1" --replace --format jsonLines /dev/null "${@:2}" 56 | -------------------------------------------------------------------------------- /backendHarness.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const { spawn, exec, execSync } = require("child_process"); 3 | 4 | // Run a command against a fresh local backend, handling setting up and tearing down the backend. 5 | 6 | // Checks for a local backend running on port 3210. 7 | const parsedUrl = new URL("http://127.0.0.1:3210"); 8 | 9 | async function isBackendRunning(backendUrl) { 10 | return new Promise((resolve) => { 11 | http 12 | .request( 13 | { 14 | hostname: backendUrl.hostname, 15 | port: backendUrl.port, 16 | path: "/version", 17 | method: "GET", 18 | }, 19 | (res) => { 20 | resolve(res.statusCode === 200); 21 | }, 22 | ) 23 | .on("error", () => { 24 | resolve(false); 25 | }) 26 | .end(); 27 | }); 28 | } 29 | 30 | function sleep(ms) { 31 | return new Promise((resolve) => setTimeout(resolve, ms)); 32 | } 33 | 34 | const waitForLocalBackendRunning = async (backendUrl) => { 35 | let isRunning = await isBackendRunning(backendUrl); 36 | let i = 0; 37 | while (!isRunning) { 38 | if (i % 10 === 0) { 39 | // Progress messages every ~5 seconds 40 | console.log("Waiting for backend to be running..."); 41 | } 42 | await sleep(500); 43 | isRunning = await isBackendRunning(backendUrl); 44 | i += 1; 45 | } 46 | return; 47 | }; 48 | 49 | let backendProcess = null; 50 | 51 | function cleanup() { 52 | if (backendProcess !== null) { 53 | console.log("Cleaning up running backend"); 54 | backendProcess.kill("SIGTERM"); 55 | execSync("just reset-local-backend"); 56 | } 57 | } 58 | 59 | async function runWithLocalBackend(command, backendUrl) { 60 | const isRunning = await isBackendRunning(backendUrl); 61 | if (isRunning) { 62 | console.error( 63 | "Looks like local backend is already running. Cancel it and restart this command.", 64 | ); 65 | process.exit(1); 66 | } 67 | execSync("just reset-local-backend"); 68 | backendProcess = exec("CONVEX_TRACE_FILE=1 just run-local-backend"); 69 | await waitForLocalBackendRunning(backendUrl); 70 | console.log("Backend running! Logs can be found in convex-local-backend.log"); 71 | const innerCommand = new Promise((resolve) => { 72 | const c = spawn(command, { 73 | shell: true, 74 | stdio: "pipe", 75 | env: { ...process.env, FORCE_COLOR: true }, 76 | }); 77 | c.stdout.on("data", (data) => { 78 | process.stdout.write(data); 79 | }); 80 | 81 | c.stderr.on("data", (data) => { 82 | process.stderr.write(data); 83 | }); 84 | 85 | c.on("exit", (code) => { 86 | console.log("inner command exited with code " + code.toString()); 87 | resolve(code); 88 | }); 89 | }); 90 | return innerCommand; 91 | } 92 | 93 | runWithLocalBackend(process.argv[2], parsedUrl) 94 | .then((code) => { 95 | cleanup(); 96 | process.exit(code); 97 | }) 98 | .catch(() => { 99 | cleanup(); 100 | process.exit(1); 101 | }); 102 | -------------------------------------------------------------------------------- /convex.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | set -e 3 | 4 | if grep -q -E '^\s*VITE_CONVEX_URL\s*=\s*https://.*\.convex\.cloud' .env.local; then 5 | npx convex "$@" 6 | else 7 | npx convex "$@" --admin-key 0135d8598650f8f5cb0f30c34ec2e2bb62793bc28717c8eb6fb577996d50be5f4281b59181095065c5d0f86a2c31ddbe9b597ec62b47ded69782cd --url "http://127.0.0.1:3210" 8 | fi 9 | -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. 4 | See https://docs.convex.dev/functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | handler: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | handler: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result), 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | ApiFromModules, 13 | FilterApi, 14 | FunctionReference, 15 | } from "convex/server"; 16 | import type * as addIt from "../addIt.js"; 17 | import type * as counter from "../counter.js"; 18 | import type * as http from "../http.js"; 19 | import type * as migrationsExample from "../migrationsExample.js"; 20 | import type * as presence from "../presence.js"; 21 | import type * as relationshipsExample from "../relationshipsExample.js"; 22 | import type * as retriesExample from "../retriesExample.js"; 23 | import type * as rlsExample from "../rlsExample.js"; 24 | import type * as sessionsExample from "../sessionsExample.js"; 25 | import type * as testingFunctions from "../testingFunctions.js"; 26 | import type * as triggersExample from "../triggersExample.js"; 27 | 28 | /** 29 | * A utility for referencing Convex functions in your app's API. 30 | * 31 | * Usage: 32 | * ```js 33 | * const myFunctionReference = api.myModule.myFunction; 34 | * ``` 35 | */ 36 | declare const fullApi: ApiFromModules<{ 37 | addIt: typeof addIt; 38 | counter: typeof counter; 39 | http: typeof http; 40 | migrationsExample: typeof migrationsExample; 41 | presence: typeof presence; 42 | relationshipsExample: typeof relationshipsExample; 43 | retriesExample: typeof retriesExample; 44 | rlsExample: typeof rlsExample; 45 | sessionsExample: typeof sessionsExample; 46 | testingFunctions: typeof testingFunctions; 47 | triggersExample: typeof triggersExample; 48 | }>; 49 | export declare const api: FilterApi< 50 | typeof fullApi, 51 | FunctionReference 52 | >; 53 | export declare const internal: FilterApi< 54 | typeof fullApi, 55 | FunctionReference 56 | >; 57 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | ActionBuilder, 13 | HttpActionBuilder, 14 | MutationBuilder, 15 | QueryBuilder, 16 | GenericActionCtx, 17 | GenericMutationCtx, 18 | GenericQueryCtx, 19 | GenericDatabaseReader, 20 | GenericDatabaseWriter, 21 | } from "convex/server"; 22 | import type { DataModel } from "./dataModel.js"; 23 | 24 | /** 25 | * Define a query in this Convex app's public API. 26 | * 27 | * This function will be allowed to read your Convex database and will be accessible from the client. 28 | * 29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 31 | */ 32 | export declare const query: QueryBuilder; 33 | 34 | /** 35 | * Define a query that is only accessible from other Convex functions (but not from the client). 36 | * 37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 38 | * 39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 41 | */ 42 | export declare const internalQuery: QueryBuilder; 43 | 44 | /** 45 | * Define a mutation in this Convex app's public API. 46 | * 47 | * This function will be allowed to modify your Convex database and will be accessible from the client. 48 | * 49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 51 | */ 52 | export declare const mutation: MutationBuilder; 53 | 54 | /** 55 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 56 | * 57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 58 | * 59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 61 | */ 62 | export declare const internalMutation: MutationBuilder; 63 | 64 | /** 65 | * Define an action in this Convex app's public API. 66 | * 67 | * An action is a function which can execute any JavaScript code, including non-deterministic 68 | * code and code with side-effects, like calling third-party services. 69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 71 | * 72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 74 | */ 75 | export declare const action: ActionBuilder; 76 | 77 | /** 78 | * Define an action that is only accessible from other Convex functions (but not from the client). 79 | * 80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 82 | */ 83 | export declare const internalAction: ActionBuilder; 84 | 85 | /** 86 | * Define an HTTP action. 87 | * 88 | * This function will be used to respond to HTTP requests received by a Convex 89 | * deployment if the requests matches the path and method where this action 90 | * is routed. Be sure to route your action in `convex/http.js`. 91 | * 92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 93 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 94 | */ 95 | export declare const httpAction: HttpActionBuilder; 96 | 97 | /** 98 | * A set of services for use within Convex query functions. 99 | * 100 | * The query context is passed as the first argument to any Convex query 101 | * function run on the server. 102 | * 103 | * This differs from the {@link MutationCtx} because all of the services are 104 | * read-only. 105 | */ 106 | export type QueryCtx = GenericQueryCtx; 107 | 108 | /** 109 | * A set of services for use within Convex mutation functions. 110 | * 111 | * The mutation context is passed as the first argument to any Convex mutation 112 | * function run on the server. 113 | */ 114 | export type MutationCtx = GenericMutationCtx; 115 | 116 | /** 117 | * A set of services for use within Convex action functions. 118 | * 119 | * The action context is passed as the first argument to any Convex action 120 | * function run on the server. 121 | */ 122 | export type ActionCtx = GenericActionCtx; 123 | 124 | /** 125 | * An interface to read from the database within Convex query functions. 126 | * 127 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 128 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 129 | * building a query. 130 | */ 131 | export type DatabaseReader = GenericDatabaseReader; 132 | 133 | /** 134 | * An interface to read from and write to the database within Convex mutation 135 | * functions. 136 | * 137 | * Convex guarantees that all writes within a single mutation are 138 | * executed atomically, so you never have to worry about partial writes leaving 139 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 140 | * for the guarantees Convex provides your functions. 141 | */ 142 | export type DatabaseWriter = GenericDatabaseWriter; 143 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | } from "convex/server"; 20 | 21 | /** 22 | * Define a query in this Convex app's public API. 23 | * 24 | * This function will be allowed to read your Convex database and will be accessible from the client. 25 | * 26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 28 | */ 29 | export const query = queryGeneric; 30 | 31 | /** 32 | * Define a query that is only accessible from other Convex functions (but not from the client). 33 | * 34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export const internalQuery = internalQueryGeneric; 40 | 41 | /** 42 | * Define a mutation in this Convex app's public API. 43 | * 44 | * This function will be allowed to modify your Convex database and will be accessible from the client. 45 | * 46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export const mutation = mutationGeneric; 50 | 51 | /** 52 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 53 | * 54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export const internalMutation = internalMutationGeneric; 60 | 61 | /** 62 | * Define an action in this Convex app's public API. 63 | * 64 | * An action is a function which can execute any JavaScript code, including non-deterministic 65 | * code and code with side-effects, like calling third-party services. 66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 68 | * 69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const action = actionGeneric; 73 | 74 | /** 75 | * Define an action that is only accessible from other Convex functions (but not from the client). 76 | * 77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 79 | */ 80 | export const internalAction = internalActionGeneric; 81 | 82 | /** 83 | * Define a Convex HTTP action. 84 | * 85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 86 | * as its second. 87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 88 | */ 89 | export const httpAction = httpActionGeneric; 90 | -------------------------------------------------------------------------------- /convex/addIt.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { query } from "./_generated/server"; 3 | 4 | export const addItUp = query({ 5 | args: { 6 | top: v.number(), 7 | }, 8 | handler: async (ctx, args) => { 9 | // throw "Test Error"; 10 | let sum = 0; 11 | for (let i = 0; i <= args.top; i++) { 12 | sum += i * 2; 13 | } 14 | return sum; 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /convex/counter.ts: -------------------------------------------------------------------------------- 1 | import { action, query } from "./_generated/server"; 2 | // Using mutation from triggersExample so any changes will run triggers 3 | import { mutation } from "./triggersExample"; 4 | import { v } from "convex/values"; 5 | 6 | export const getCounter = query({ 7 | args: { counterName: v.string() }, 8 | returns: v.number(), 9 | handler: async (ctx, { counterName }) => { 10 | const counterDoc = await ctx.db 11 | .query("counter_table") 12 | .filter((q) => q.eq(q.field("name"), counterName)) 13 | .first(); 14 | return counterDoc === null ? 0 : counterDoc.counter; 15 | }, 16 | }); 17 | 18 | export const getCounters = query({ 19 | args: {}, 20 | handler: async ({ db }) => { 21 | return db.query("counter_table").collect(); 22 | }, 23 | }); 24 | 25 | export const getCounterOrThrow = query( 26 | async (ctx, { counterName }: { counterName: string }): Promise => { 27 | const counterDoc = await ctx.db 28 | .query("counter_table") 29 | .filter((q) => q.eq(q.field("name"), counterName)) 30 | .first(); 31 | if (counterDoc === null) { 32 | throw new Error("Counter not found"); 33 | } 34 | return counterDoc.counter; 35 | }, 36 | ); 37 | 38 | export const upload = action({ 39 | args: { data: v.any() }, 40 | handler: async (ctx, args) => { 41 | const id = await ctx.storage.store(args.data); 42 | console.log(id); 43 | return id; 44 | }, 45 | }); 46 | 47 | export const incrementCounter = mutation({ 48 | args: { counterName: v.string(), increment: v.number() }, 49 | handler: async ( 50 | ctx, 51 | { counterName, increment }: { counterName: string; increment: number }, 52 | ) => { 53 | const counterDoc = await ctx.db 54 | .query("counter_table") 55 | .filter((q) => q.eq(q.field("name"), counterName)) 56 | .first(); 57 | if (counterDoc === null) { 58 | await ctx.db.insert("counter_table", { 59 | name: counterName, 60 | counter: increment, 61 | }); 62 | } else { 63 | counterDoc.counter += increment; 64 | await ctx.db.replace(counterDoc._id, counterDoc); 65 | } 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /convex/example.test.ts: -------------------------------------------------------------------------------- 1 | import { api } from "./_generated/api"; 2 | import { ConvexTestingHelper } from "convex-helpers/testing"; 3 | 4 | describe("testingExample", () => { 5 | let t: ConvexTestingHelper; 6 | 7 | beforeEach(() => { 8 | t = new ConvexTestingHelper(); 9 | }); 10 | 11 | afterEach(async () => { 12 | await t.mutation(api.testingFunctions.clearAll, {}); 13 | await t.close(); 14 | }); 15 | 16 | test("counter", async () => { 17 | expect(await t.query(api.counter.getCounter, { counterName: "foo" })).toBe( 18 | 0, 19 | ); 20 | expect(() => 21 | t.query(api.counter.getCounterOrThrow, { counterName: "foo" }), 22 | ).rejects.toThrow(/Counter not found/); 23 | expect(() => 24 | t.query(api.counter.getCounterOrThrow, { counterName: "bar" }), 25 | ).rejects.toThrow(/Counter not found/); 26 | await t.mutation(api.counter.incrementCounter, { 27 | counterName: "foo", 28 | increment: 10, 29 | }); 30 | expect( 31 | await t.query(api.counter.getCounterOrThrow, { counterName: "foo" }), 32 | ).toBe(10); 33 | expect(await t.query(api.counter.getCounter, { counterName: "foo" })).toBe( 34 | 10, 35 | ); 36 | expect(await t.query(api.counter.getCounter, { counterName: "bar" })).toBe( 37 | 0, 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /convex/http.ts: -------------------------------------------------------------------------------- 1 | import { HonoWithConvex, HttpRouterWithHono } from "convex-helpers/server/hono"; 2 | import { Hono } from "hono"; 3 | import { cors } from "hono/cors"; 4 | import { ActionCtx, query } from "./_generated/server"; 5 | 6 | const app: HonoWithConvex = new Hono(); 7 | 8 | app.use("/*", cors()); 9 | // See the [guide on Stack](https://stack.convex.dev/hono-with-convex) 10 | // for tips on using Hono for HTTP endpoints. 11 | app.get("/", async (c) => { 12 | return c.json("Hello world!"); 13 | }); 14 | 15 | export default new HttpRouterWithHono(app); 16 | 17 | /** 18 | * Helper for testing. 19 | */ 20 | export const siteUrl = query({ 21 | args: {}, 22 | handler: async () => { 23 | return process.env.CONVEX_SITE_URL; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /convex/migrationsExample.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cancelMigration, 3 | getStatus, 4 | makeMigration, 5 | MigrationStatus, 6 | startMigration, 7 | startMigrationsSerially, 8 | } from "convex-helpers/server/migrations"; 9 | import { internalQuery } from "./_generated/server"; 10 | import { internal } from "./_generated/api"; 11 | import { v } from "convex/values"; 12 | // Importing from triggers so any changes here will run triggers 13 | import { internalMutation } from "./triggersExample"; 14 | 15 | const migration = makeMigration(internalMutation, { 16 | migrationTable: "migrations", 17 | }); 18 | 19 | export const increment = migration({ 20 | table: "counter_table", 21 | migrateOne: (ctx, doc) => ({ 22 | counter: doc.counter + 1, 23 | }), 24 | customRange: (q) => 25 | q.withIndex("by_creation_time", (q) => 26 | q.lt("_creationTime", Date.now() - 1_000_000), 27 | ), 28 | }); 29 | 30 | export const cleanUpBrokenRefs = migration({ 31 | table: "join_table_example", 32 | migrateOne: async (ctx, doc) => { 33 | const user = await ctx.db.get(doc.userId); 34 | const presence = await ctx.db.get(doc.presenceId); 35 | if (!user || !presence) { 36 | await ctx.db.delete(doc._id); 37 | } 38 | }, 39 | }); 40 | 41 | export const callOneDirectly = internalMutation({ 42 | args: {}, 43 | handler: async (ctx, args) => { 44 | // can run a migration directly within a function: 45 | await startMigration(ctx, internal.migrationsExample.increment, { 46 | startCursor: null, 47 | batchSize: 10, 48 | }); 49 | }, 50 | }); 51 | 52 | // Or run all specified ones from a general script 53 | const standardMigrations = [ 54 | internal.migrationsExample.increment, 55 | internal.migrationsExample.cleanUpBrokenRefs, 56 | ]; 57 | 58 | export const status = internalQuery( 59 | async (ctx): Promise[]> => { 60 | return await getStatus(ctx, { migrationTable: "migrations" }); 61 | }, 62 | ); 63 | 64 | export const cancel = internalMutation({ 65 | args: { fn: v.string() }, 66 | handler: async (ctx, { fn }) => { 67 | return await cancelMigration(ctx, "migrations", fn); 68 | }, 69 | }); 70 | 71 | // Incorporate into some general setup script 72 | // Call from CLI: `npx convex run migrationsExample` 73 | // As part of a deploy script: 74 | // `npx convex deploy && npx convex run --prod migrationsExample` 75 | export default internalMutation(async (ctx) => { 76 | await startMigrationsSerially(ctx, standardMigrations); 77 | }); 78 | -------------------------------------------------------------------------------- /convex/presence.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions related to reading & writing presence data. 3 | * 4 | * Note: this file does not currently implement authorization. 5 | * That is left as an exercise to the reader. Some suggestions for a production 6 | * app: 7 | * - Use Convex `auth` to authenticate users rather than passing up a "user" 8 | * - Check that the user is allowed to be in a given room. 9 | */ 10 | import { query, mutation } from "./_generated/server"; 11 | 12 | const LIST_LIMIT = 20; 13 | 14 | /** 15 | * Overwrites the presence data for a given user in a room. 16 | * 17 | * It will also set the "updated" timestamp to now, and create the presence 18 | * document if it doesn't exist yet. 19 | * 20 | * @param room - The location associated with the presence data. Examples: 21 | * page, chat channel, game instance. 22 | * @param user - The user associated with the presence data. 23 | */ 24 | export const update = mutation( 25 | async ( 26 | { db }, 27 | { room, user, data }: { room: string; user: string; data: any }, 28 | ) => { 29 | const existing = await db 30 | .query("presence") 31 | .withIndex("user_room", (q) => q.eq("user", user).eq("room", room)) 32 | .unique(); 33 | if (existing) { 34 | await db.patch(existing._id, { data, updated: Date.now() }); 35 | } else { 36 | await db.insert("presence", { 37 | user, 38 | data, 39 | room, 40 | updated: Date.now(), 41 | }); 42 | } 43 | }, 44 | ); 45 | 46 | /** 47 | * Updates the "updated" timestamp for a given user's presence in a room. 48 | * 49 | * @param room - The location associated with the presence data. Examples: 50 | * page, chat channel, game instance. 51 | * @param user - The user associated with the presence data. 52 | */ 53 | export const heartbeat = mutation( 54 | async ({ db }, { room, user }: { room: string; user: string }) => { 55 | const existing = await db 56 | .query("presence") 57 | .withIndex("user_room", (q) => q.eq("user", user).eq("room", room)) 58 | .unique(); 59 | if (existing) { 60 | await db.patch(existing._id, { updated: Date.now() }); 61 | } 62 | }, 63 | ); 64 | 65 | /** 66 | * Lists the presence data for N users in a room, ordered by recent update. 67 | * 68 | * @param room - The location associated with the presence data. Examples: 69 | * page, chat channel, game instance. 70 | * @returns A list of presence objects, ordered by recent update, limited to 71 | * the most recent N. 72 | */ 73 | export const list = query(async ({ db }, { room }: { room: string }) => { 74 | const presence = await db 75 | .query("presence") 76 | .withIndex("room_updated", (q) => q.eq("room", room)) 77 | .order("desc") 78 | .take(LIST_LIMIT); 79 | return presence.map(({ _creationTime, updated, user, data }) => ({ 80 | created: _creationTime, 81 | updated, 82 | user, 83 | data, 84 | })); 85 | }); 86 | -------------------------------------------------------------------------------- /convex/retriesExample.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { internal } from "./_generated/api"; 3 | import { internalAction, mutation } from "./_generated/server"; 4 | 5 | import { makeActionRetrier } from "convex-helpers/server/retries"; 6 | 7 | // Export the helpers, with the name of the retry function. 8 | export const { runWithRetries, retry } = makeActionRetrier( 9 | "retriesExample:retry", 10 | { retryBackoff: 1000, log: console.warn }, // options for demo purposes. 11 | ); 12 | 13 | // This is a sample action will fail randomly based on the `failureRate` 14 | // argument. It's safe to retry since it doesn't have any side effects. 15 | export const unreliableAction = internalAction({ 16 | args: { failureRate: v.number() }, // 0.0 - 1.0 17 | handler: async (_ctx, { failureRate }) => { 18 | console.log("Running an action with failure rate " + failureRate); 19 | if (Math.random() < failureRate) { 20 | throw new Error("action failed."); 21 | } 22 | console.log("action succeded."); 23 | }, 24 | }); 25 | 26 | // This calls the `unreliableAction` function with retries. 27 | // Try it for yourself with: 28 | // e.g. `npx convex run retriesExample:runUnreliableActionWithRetries` 29 | export const runUnreliableActionWithRetries = mutation({ 30 | args: {}, 31 | handler: async (ctx, args) => { 32 | await runWithRetries(ctx, internal.retriesExample.unreliableAction, { 33 | failureRate: 0.8, 34 | }); 35 | // Possibly do something else besides scheduling the action, 36 | // for instance check if the logged in user has permission to run it. 37 | // If an error is thrown in a mutation, the transaction will be rolled back 38 | // and the action will not be scheduled to run. 39 | }, 40 | }); 41 | 42 | // Calling an action with retries from an action works too. 43 | export const runFromAnAction = internalAction({ 44 | args: {}, 45 | handler: async (ctx) => { 46 | const email = { to: "user@example.com", subject: "Hello", body: "World" }; 47 | await runWithRetries( 48 | ctx, 49 | internal.retriesExample.sendWelcomeEmail, 50 | { email }, 51 | { 52 | // You can limit the number of retries and wait longer between attempts 53 | // if the action is not time sensitive and generally reliable. 54 | // Note: generally the default options are fine. 55 | 56 | // After 5 failures, give up. 57 | maxFailures: 5, 58 | // Wait 10 seconds before retrying the first time. 59 | retryBackoff: 10_000, 60 | // This is the multiplier for the next wait time. 61 | // e.g. if base is 10, the next wait time will be 10 times the previous. 62 | base: 10, 63 | // Wait time is then retryBackoff * base^(retryNumber). 64 | // This will result in waiting: 65 | // 10 seconds before retrying the first time, 66 | // 100 seconds before retrying the second time (~1.7 minutes), 67 | // 1000 seconds before retrying the third time (~16.7 minutes), 68 | // 10000 seconds before retrying the fourth and final time (~2.7 hours), 69 | // giving the action 5 chances to succeed over ~3 hours. 70 | }, 71 | ); 72 | // Unlike in mutations, in an action the scheduler will immediately be 73 | // given the action & parameters to run, even if there's an exception thrown 74 | // afterwards. 75 | }, 76 | }); 77 | 78 | // This is a pretend email sending function. 79 | export const sendWelcomeEmail = internalAction({ 80 | args: { 81 | email: v.object({ to: v.string(), subject: v.string(), body: v.string() }), 82 | }, 83 | handler: async () => { 84 | // If the email provider is down, it could fail. 85 | // Hopefully it will be back up within minutes, but if it still isn't up 86 | // hours later, it's ok to skip sending this non-essential. 87 | // Note: If we wanted to ensure we don't send the same email twice, 88 | // we would need something like an idempotency key to pass to the provider. 89 | }, 90 | }); 91 | -------------------------------------------------------------------------------- /convex/rlsExample.ts: -------------------------------------------------------------------------------- 1 | import { crud } from "convex-helpers/server/crud"; 2 | import { 3 | customCtx, 4 | customMutation, 5 | customQuery, 6 | } from "convex-helpers/server/customFunctions"; 7 | import { 8 | Rules, 9 | wrapDatabaseReader, 10 | wrapDatabaseWriter, 11 | } from "convex-helpers/server/rowLevelSecurity"; 12 | import { v } from "convex/values"; 13 | import { DataModel } from "./_generated/dataModel"; 14 | import { mutation, query, QueryCtx } from "./_generated/server"; 15 | import schema from "./schema"; 16 | 17 | async function rlsRules(ctx: QueryCtx) { 18 | const identity = await ctx.auth.getUserIdentity(); 19 | return { 20 | users: { 21 | read: async (_, user) => { 22 | // Unauthenticated users can only read users over 18 23 | if (!identity && user.age < 18) return false; 24 | return true; 25 | }, 26 | insert: async (_, user) => { 27 | return true; 28 | }, 29 | modify: async (_, user) => { 30 | if (!identity) 31 | throw new Error("Must be authenticated to modify a user"); 32 | // Users can only modify their own user 33 | return user.tokenIdentifier === identity.tokenIdentifier; 34 | }, 35 | }, 36 | } satisfies Rules; 37 | } 38 | 39 | const queryWithRLS = customQuery( 40 | query, 41 | customCtx(async (ctx) => ({ 42 | db: wrapDatabaseReader(ctx, ctx.db, await rlsRules(ctx)), 43 | })), 44 | ); 45 | 46 | const mutationWithRLS = customMutation( 47 | mutation, 48 | customCtx(async (ctx) => ({ 49 | db: wrapDatabaseWriter(ctx, ctx.db, await rlsRules(ctx)), 50 | })), 51 | ); 52 | 53 | // exposing a CRUD interface for the users table. 54 | export const { create, read, update, destroy } = crud( 55 | schema, 56 | "users", 57 | queryWithRLS, 58 | mutationWithRLS, 59 | ); 60 | 61 | // Example functions that use the RLS rules transparently 62 | export const getMyUser = queryWithRLS(async (ctx) => { 63 | const identity = await ctx.auth.getUserIdentity(); 64 | if (!identity) return null; 65 | const me = await ctx.db 66 | .query("users") 67 | .withIndex("tokenIdentifier", (q) => 68 | q.eq("tokenIdentifier", identity.tokenIdentifier), 69 | ) 70 | .unique(); 71 | return me; 72 | }); 73 | 74 | export const updateName = mutationWithRLS({ 75 | // Note: it's generally a bad idea to pass your own user's ID 76 | // instead, you should just pull the user from the auth context 77 | // but this is just an example to show that this is safe, since the RLS rules 78 | // will prevent you from modifying other users. 79 | args: { name: v.string(), userId: v.id("users") }, 80 | handler: async (ctx, { name, userId }) => { 81 | await ctx.db.patch(userId, { name }); 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | import { migrationsTable } from "convex-helpers/server/migrations"; 4 | 5 | export default defineSchema({ 6 | users: defineTable({ 7 | name: v.string(), 8 | age: v.number(), 9 | tokenIdentifier: v.string(), 10 | }).index("tokenIdentifier", ["tokenIdentifier"]), 11 | join_table_example: defineTable({ 12 | userId: v.id("users"), 13 | presenceId: v.id("presence"), 14 | }).index("by_userId", ["userId"]), 15 | join_storage_example: defineTable({ 16 | userId: v.id("users"), 17 | storageId: v.id("_storage"), 18 | }) 19 | .index("storageId", ["storageId"]) 20 | .index("userId_storageId", ["userId", "storageId"]), 21 | presence: defineTable({ 22 | user: v.string(), 23 | room: v.string(), 24 | updated: v.number(), 25 | data: v.any(), 26 | }) 27 | // Index for fetching presence data 28 | .index("room_updated", ["room", "updated"]) 29 | // Index for updating presence data 30 | .index("user_room", ["user", "room"]), 31 | counter_table: defineTable({ name: v.string(), counter: v.number() }), 32 | sum_table: defineTable({ sum: v.number() }), 33 | notes: defineTable({ session: v.string(), note: v.string() }), 34 | migrations: migrationsTable, 35 | }); 36 | -------------------------------------------------------------------------------- /convex/testingFunctions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | customAction, 3 | customMutation, 4 | customQuery, 5 | } from "convex-helpers/server/customFunctions"; 6 | import { action, mutation, query } from "./_generated/server"; 7 | import schema from "./schema"; 8 | 9 | // Wrappers to use for function that should only be called from tests 10 | export const testingQuery = customQuery(query, { 11 | args: {}, 12 | input: async (_ctx, _args) => { 13 | if (process.env.IS_TEST === undefined) { 14 | throw new Error( 15 | "Calling a test only function in an unexpected environment", 16 | ); 17 | } 18 | return { ctx: {}, args: {} }; 19 | }, 20 | }); 21 | 22 | export const testingMutation = customMutation(mutation, { 23 | args: {}, 24 | input: async (_ctx, _args) => { 25 | if (process.env.IS_TEST === undefined) { 26 | throw new Error( 27 | "Calling a test only function in an unexpected environment", 28 | ); 29 | } 30 | return { ctx: {}, args: {} }; 31 | }, 32 | }); 33 | 34 | export const testingAction = customAction(action, { 35 | args: {}, 36 | input: async (_ctx, _args) => { 37 | if (process.env.IS_TEST === undefined) { 38 | throw new Error( 39 | "Calling a test only function in an unexpected environment", 40 | ); 41 | } 42 | return { ctx: {}, args: {} }; 43 | }, 44 | }); 45 | 46 | export const clearAll = testingMutation(async ({ db, scheduler, storage }) => { 47 | for (const table of Object.keys(schema.tables)) { 48 | const docs = await db.query(table as any).collect(); 49 | await Promise.all(docs.map((doc) => db.delete(doc._id))); 50 | } 51 | const scheduled = await db.system.query("_scheduled_functions").collect(); 52 | await Promise.all(scheduled.map((s) => scheduler.cancel(s._id))); 53 | const storedFiles = await db.system.query("_storage").collect(); 54 | await Promise.all(storedFiles.map((s) => storage.delete(s._id))); 55 | }); 56 | -------------------------------------------------------------------------------- /convex/triggersExample.ts: -------------------------------------------------------------------------------- 1 | import { 2 | customCtx, 3 | customMutation, 4 | } from "convex-helpers/server/customFunctions"; 5 | import { DataModel, Id } from "./_generated/dataModel"; 6 | import { 7 | MutationCtx, 8 | query, 9 | mutation as rawMutation, 10 | internalMutation as rawInternalMutation, 11 | } from "./_generated/server"; 12 | import { Triggers } from "convex-helpers/server/triggers"; 13 | import { v } from "convex/values"; 14 | import { internal } from "./_generated/api"; 15 | 16 | const triggers = new Triggers(); 17 | 18 | // Example of a trigger that rounds up every counter to a multiple of 10, 19 | // demonstrating that triggers can trigger themselves. 20 | triggers.register("counter_table", async (ctx, change) => { 21 | if (change.operation === "insert") { 22 | console.log("Counter created", change.newDoc); 23 | } 24 | if (change.newDoc && change.newDoc.counter % 10 !== 0) { 25 | // Round up to the nearest multiple of 10, one at a time. 26 | // This demonstrates that triggers can trigger themselves. 27 | console.log("Incrementing counter to", change.newDoc.counter + 1); 28 | await ctx.db.patch(change.newDoc._id, { 29 | counter: change.newDoc.counter + 1, 30 | }); 31 | } 32 | }); 33 | 34 | // Call logCounterChange async after the mutation completes. 35 | let scheduledJobId: Id<"_scheduled_functions"> | null = null; 36 | triggers.register("counter_table", async (ctx, change) => { 37 | if (scheduledJobId !== null) { 38 | await ctx.scheduler.cancel(scheduledJobId); 39 | } 40 | if (change.newDoc) { 41 | console.log("scheduling logCounterChange", change.newDoc.counter); 42 | scheduledJobId = await ctx.scheduler.runAfter( 43 | 0, 44 | internal.triggersExample.logCounterChange, 45 | { name: change.newDoc.name, counter: change.newDoc.counter }, 46 | ); 47 | } 48 | }); 49 | 50 | // Track denormalized sum of all counters. 51 | triggers.register("counter_table", async (ctx, change) => { 52 | const sum = await ctx.db.query("sum_table").first(); 53 | if (!sum) { 54 | await ctx.db.insert("sum_table", { sum: 0 }); 55 | } 56 | const sumDoc = (await ctx.db.query("sum_table").first())!; 57 | if (change.operation === "insert") { 58 | await ctx.db.patch(sumDoc._id, { sum: sumDoc.sum + change.newDoc.counter }); 59 | } else if (change.operation === "update") { 60 | await ctx.db.patch(sumDoc._id, { 61 | sum: sumDoc.sum + change.newDoc.counter - change.oldDoc.counter, 62 | }); 63 | } else if (change.operation === "delete") { 64 | await ctx.db.patch(sumDoc._id, { sum: sumDoc.sum - change.oldDoc.counter }); 65 | } 66 | }); 67 | 68 | export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB)); 69 | export const internalMutation = customMutation( 70 | rawInternalMutation, 71 | customCtx(triggers.wrapDB), 72 | ); 73 | 74 | export const logCounterChange = internalMutation({ 75 | args: { name: v.string(), counter: v.number() }, 76 | handler: async (_ctx, { name, counter }) => { 77 | console.log(`Counter ${name} changed to ${counter}`); 78 | }, 79 | }); 80 | 81 | // This checks that many triggers happening in parallel won't race with each 82 | // other. whatever the value ends up being, that will be the change to the sum. 83 | export const incrementCounterRace = mutation({ 84 | args: {}, 85 | handler: async ({ db }) => { 86 | const firstCounter = await db.query("counter_table").first(); 87 | if (!firstCounter) { 88 | throw new Error("No counters"); 89 | } 90 | await Promise.all([ 91 | db.patch(firstCounter._id, { counter: firstCounter.counter + 1 }), 92 | db.patch(firstCounter._id, { counter: firstCounter.counter + 2 }), 93 | db.patch(firstCounter._id, { counter: firstCounter.counter + 3 }), 94 | ]); 95 | }, 96 | }); 97 | 98 | export const getSum = query({ 99 | args: {}, 100 | handler: async (ctx, args) => { 101 | return ctx.db.query("sum_table").first(); 102 | }, 103 | }); 104 | 105 | /// Example of using triggers to implement the write side of row-level security, 106 | /// with a precomputed value `viewer` passed in to every trigger through the ctx. 107 | 108 | const triggersWithRLS = new Triggers< 109 | DataModel, 110 | MutationCtx & { viewer: string | null } 111 | >(); 112 | 113 | triggersWithRLS.register("users", async (ctx, change) => { 114 | const oldTokenIdentifier = change?.oldDoc?.tokenIdentifier; 115 | if (oldTokenIdentifier && oldTokenIdentifier !== ctx.viewer) { 116 | throw new Error(`You can only modify your own user`); 117 | } 118 | const newTokenIdentifier = change?.oldDoc?.tokenIdentifier; 119 | if (newTokenIdentifier && newTokenIdentifier !== ctx.viewer) { 120 | throw new Error(`You can only modify your own user`); 121 | } 122 | }); 123 | 124 | const mutationWithRLS = customMutation( 125 | rawMutation, 126 | customCtx(async (ctx) => { 127 | const viewer = (await ctx.auth.getUserIdentity())?.tokenIdentifier ?? null; 128 | // Note: you can add more things to the ctx than the registered triggers 129 | // require, and the types will flow through. 130 | return triggersWithRLS.wrapDB({ ...ctx, viewer, foo: "bar" }); 131 | }), 132 | ); 133 | 134 | export const updateName = mutationWithRLS({ 135 | // Note: it's generally a bad idea to pass your own user's ID 136 | // instead, you should just pull the user from the auth context 137 | // but this is just an example to show that this is safe, since the RLS rules 138 | // will prevent you from modifying other users. 139 | args: { name: v.string(), userId: v.id("users") }, 140 | handler: async (ctx, { name, userId }) => { 141 | // The extra type from above still comes through 142 | console.log(ctx.foo); 143 | await ctx.db.patch(userId, { name }); 144 | }, 145 | }); 146 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | 11 | /* These compiler options are required by Convex */ 12 | "target": "ESNext", 13 | "lib": ["ES2021", "dom"], 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "isolatedModules": true, 19 | "skipLibCheck": true, 20 | "noEmit": true 21 | }, 22 | "include": ["./**/*"], 23 | "exclude": ["./_generated"] 24 | } 25 | -------------------------------------------------------------------------------- /convex/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | // https://vitejs.dev/config/ 4 | export default defineConfig({ 5 | test: { 6 | environment: "edge-runtime", 7 | exclude: [], 8 | passWithNoTests: true, 9 | 10 | // Only run one suite at a time because all of our tests are running against 11 | // the same backend and we don't want to leak state. 12 | maxWorkers: 1, 13 | minWorkers: 1, 14 | globals: true, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Convex Helpers 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convex-helpers-base", 3 | "private": "true", 4 | "description": "Home to [packages](./packages/) to complement the official convex package.", 5 | "scripts": { 6 | "init": "convex dev --until-success", 7 | "dev": "npm-run-all --parallel dev:backend dev:frontend dev:helpers", 8 | "dev:backend": "convex dev", 9 | "dev:frontend": "vite", 10 | "dev:helpers": "cd packages/convex-helpers && npm run dev", 11 | "predev": "npm run build", 12 | "format": "prettier --write .", 13 | "lint": "tsc --project tsconfig.test.json && prettier --check .", 14 | "build": "cd packages/convex-helpers && npm run build", 15 | "clean": "rm -rf packages/convex-helpers/dist", 16 | "pack": "cd packages/convex-helpers/dist && npm pack --pack-destination ../../..", 17 | "test": "vitest run", 18 | "test:watch": "vitest", 19 | "test:debug": "vitest --inspect-brk --no-file-parallelism", 20 | "test:coverage": "vitest run --coverage --coverage.reporter=text", 21 | "testFunctionsExistingBackend": "just convex deploy && just convex env set IS_TEST true && vitest --run convex/example.test.ts", 22 | "testFunctions": "node backendHarness.js 'npm run testFunctionsExistingBackend'", 23 | "arethetypeswrong": "cd packages/convex-helpers/dist && attw $(npm pack) --ignore-rules cjs-resolves-to-esm", 24 | "alpha": "./publish.sh alpha", 25 | "release": "./publish.sh" 26 | }, 27 | "dependencies": { 28 | "classnames": "^2.3.2", 29 | "convex": "^1.22.0", 30 | "convex-helpers": "file:packages/convex-helpers/dist", 31 | "hono": "^4.3.6", 32 | "react": "^19.0.0", 33 | "react-dom": "^19.0.0", 34 | "usehooks-ts": "^3.1.0", 35 | "zod": "^3.24.0" 36 | }, 37 | "devDependencies": { 38 | "@arethetypeswrong/cli": "0.18.1", 39 | "@edge-runtime/vm": "5.0.0", 40 | "@redocly/cli": "1.34.3", 41 | "@standard-schema/spec": "1.0.0", 42 | "@testing-library/react": "16.3.0", 43 | "@types/babel__core": "7.20.5", 44 | "@types/jest": "29.5.14", 45 | "@types/node": "22.13.10", 46 | "@types/react": "19.1.6", 47 | "@types/react-dom": "19.1.5", 48 | "@vitejs/plugin-react": "4.5.0", 49 | "@vitest/coverage-v8": "3.1.4", 50 | "chokidar-cli": "3.0.0", 51 | "convex-test": "0.0.37", 52 | "eslint": "9.28.0", 53 | "jsdom": "26.1.0", 54 | "npm-run-all2": "8.0.4", 55 | "typescript": "5.8.3", 56 | "vite": "6.3.5", 57 | "vitest": "3.1.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/convex-helpers/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | *.tgz 4 | *.tsbuildinfo 5 | -------------------------------------------------------------------------------- /packages/convex-helpers/browser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from "vitest"; 2 | import type { FunctionReference } from "convex/server"; 3 | import type { ConvexClient } from "convex/browser"; 4 | import { withArgs } from "./browser"; 5 | 6 | describe("withArgs", () => { 7 | let mockClient: ConvexClient; 8 | 9 | beforeEach(() => { 10 | mockClient = { 11 | query: vi.fn().mockResolvedValue("query-result"), 12 | mutation: vi.fn().mockResolvedValue("mutation-result"), 13 | action: vi.fn().mockResolvedValue("action-result"), 14 | } as unknown as ConvexClient; 15 | }); 16 | 17 | it("should inject args into query", async () => { 18 | const query = {} as FunctionReference< 19 | "query", 20 | "public", 21 | { injected: string; extra: number }, 22 | any 23 | >; 24 | const injectedArgs = { injected: "foo" }; 25 | const extraArgs = { extra: 123 }; 26 | 27 | const client = withArgs(mockClient, injectedArgs); 28 | const result = await client.query(query, extraArgs); 29 | 30 | expect(mockClient.query).toHaveBeenCalledWith(query, { 31 | ...extraArgs, 32 | ...injectedArgs, 33 | }); 34 | expect(result).toBe("query-result"); 35 | }); 36 | 37 | it("should inject args into mutation", async () => { 38 | const mutation = {} as FunctionReference< 39 | "mutation", 40 | "public", 41 | { injected: string; extra: number }, 42 | any 43 | >; 44 | const injectedArgs = { injected: "foo" }; 45 | const extraArgs = { extra: 123 }; 46 | 47 | const client = withArgs(mockClient, injectedArgs); 48 | const result = await client.mutation(mutation, extraArgs); 49 | 50 | expect(mockClient.mutation).toHaveBeenCalledWith(mutation, { 51 | ...extraArgs, 52 | ...injectedArgs, 53 | }); 54 | expect(result).toBe("mutation-result"); 55 | }); 56 | 57 | it("should inject args into action", async () => { 58 | const action = {} as FunctionReference< 59 | "action", 60 | "public", 61 | { injected: string; extra: number }, 62 | any 63 | >; 64 | const injectedArgs = { injected: "foo" }; 65 | const extraArgs = { extra: 123 }; 66 | 67 | const client = withArgs(mockClient, injectedArgs); 68 | const result = await client.action(action, extraArgs); 69 | 70 | expect(mockClient.action).toHaveBeenCalledWith(action, { 71 | ...extraArgs, 72 | ...injectedArgs, 73 | }); 74 | expect(result).toBe("action-result"); 75 | }); 76 | 77 | it("should handle case when only injected args are needed", async () => { 78 | const query = {} as FunctionReference< 79 | "query", 80 | "public", 81 | { injected: string }, 82 | any 83 | >; 84 | const injectedArgs = { injected: "foo" }; 85 | 86 | const client = withArgs(mockClient, injectedArgs); 87 | const result = await client.query(query, {}); 88 | 89 | expect(mockClient.query).toHaveBeenCalledWith(query, injectedArgs); 90 | expect(result).toBe("query-result"); 91 | }); 92 | 93 | it("should handle case when no extra args are provided", async () => { 94 | const query = {} as FunctionReference< 95 | "query", 96 | "public", 97 | { injected: string }, 98 | any 99 | >; 100 | const injectedArgs = { injected: "foo" }; 101 | 102 | const client = withArgs(mockClient, injectedArgs); 103 | const result = await client.query(query); 104 | 105 | expect(mockClient.query).toHaveBeenCalledWith(query, injectedArgs); 106 | expect(result).toBe("query-result"); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /packages/convex-helpers/browser.ts: -------------------------------------------------------------------------------- 1 | import type { BetterOmit, EmptyObject } from "./index.js"; 2 | import type { ConvexClient, ConvexHttpClient } from "convex/browser"; 3 | import type { FunctionArgs, FunctionReturnType } from "convex/server"; 4 | import type { FunctionReference } from "convex/server"; 5 | import type { Value } from "convex/values"; 6 | 7 | export type ArgsArray< 8 | Injected extends Record, 9 | FullArgs extends Injected, 10 | > = keyof FullArgs extends keyof Injected 11 | ? [args?: EmptyObject] 12 | : [args: BetterOmit]; 13 | 14 | /** 15 | * Inject arguments into a Convex client's calls. 16 | * 17 | * Useful when you want to pass an API key or session ID on many calls and don't 18 | * want to pass the value around and add it to the arguments explicitly. 19 | * 20 | * e.g. 21 | * ```ts 22 | * const client = new ConvexClient(process.env.CONVEX_URL!); 23 | * const apiClient = withArgs(client, { apiKey: process.env.API_KEY! }); 24 | * 25 | * const result = await apiClient.query(api.foo.bar, { ...other args }); 26 | * ``` 27 | * 28 | * @param client A ConvexClient instance 29 | * @param injectedArgs Arguments to inject into each query/mutation/action call. 30 | * @returns { query, mutation, action } functions with the injected arguments 31 | */ 32 | export function withArgs>( 33 | client: ConvexClient | ConvexHttpClient, 34 | injectedArgs: A, 35 | ) { 36 | return { 37 | query>( 38 | query: Query, 39 | ...args: ArgsArray> 40 | ): Promise>> { 41 | return client.query(query, { 42 | ...(args[0] ?? {}), 43 | ...injectedArgs, 44 | } as FunctionArgs); 45 | }, 46 | mutation>( 47 | mutation: Mutation, 48 | ...args: ArgsArray> 49 | ): Promise>> { 50 | return client.mutation(mutation, { 51 | ...(args[0] ?? {}), 52 | ...injectedArgs, 53 | } as FunctionArgs); 54 | }, 55 | action>( 56 | action: Action, 57 | ...args: ArgsArray> 58 | ): Promise>> { 59 | return client.action(action, { 60 | ...(args[0] ?? {}), 61 | ...injectedArgs, 62 | } as FunctionArgs); 63 | }, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /packages/convex-helpers/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from "commander"; 4 | import { openApiSpec } from "./openApiSpec.js"; 5 | import { tsApiSpec } from "./tsApiSpec.js"; 6 | 7 | async function main() { 8 | const program = new Command(); 9 | program 10 | .name("convex-helpers") 11 | .usage(" [options]") 12 | .description("Run scripts in the convex-helpers library.") 13 | .addCommand(openApiSpec) 14 | .addCommand(tsApiSpec); 15 | 16 | await program.parseAsync(process.argv); 17 | process.exit(); 18 | } 19 | 20 | void main(); 21 | -------------------------------------------------------------------------------- /packages/convex-helpers/cli/openApiSpec.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { expect, test } from "vitest"; 3 | import { generateOpenApiSpec } from "./openApiSpec"; 4 | import { execSync } from "child_process"; 5 | import { FUNCTIONS_JSON, OPEN_API_SPEC } from "./functions.test"; 6 | 7 | // If this test fails, it means you changed the generated OpenAPI spec. Confirm that these changes are 8 | // intentional by looking at the diff and update the string we compare against. 9 | test("openApiSpecMatches", () => { 10 | const apiSpec = generateOpenApiSpec(JSON.parse(FUNCTIONS_JSON), true); 11 | 12 | expect( 13 | apiSpec, 14 | "The generated spec has changed. Confirm that these changes are intentional\ 15 | by looking at the diff and update the comparison string if necessary.", 16 | ).toEqual(OPEN_API_SPEC); 17 | }); 18 | 19 | // If this test fails, it means the generated OpenAPI spec is no longer valid. 20 | test("generateValidSpec", async () => { 21 | const apiSpec = generateOpenApiSpec(JSON.parse(FUNCTIONS_JSON), true); 22 | 23 | const testFileName = "openApiSpec.test.yaml"; 24 | fs.writeFileSync(testFileName, apiSpec, "utf-8"); 25 | 26 | let output = execSync(`npx redocly lint ${testFileName} --format='json'`); 27 | 28 | fs.unlinkSync(testFileName); 29 | 30 | expect(JSON.parse(output.toString())["totals"]).toHaveProperty("errors", 0); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/convex-helpers/cli/tsApiSpec.test.ts: -------------------------------------------------------------------------------- 1 | import * as ts from "typescript"; 2 | import { expect, test } from "vitest"; 3 | import { FUNCTIONS_JSON, JS_API } from "./functions.test"; 4 | import { generateApiSpec } from "./tsApiSpec"; 5 | 6 | // If this test fails, it means the generated code changed. Confirm that these changes are 7 | // intentional by looking at the diff and update the string we compare against. 8 | test("generatedCodeMatches", () => { 9 | const tsCode = generateApiSpec(JSON.parse(FUNCTIONS_JSON), false); 10 | expect( 11 | tsCode, 12 | "The generated code has changed. Confirm that these changes are intentional\ 13 | by looking at the diff and update the comparison string if necessary.", 14 | ).toEqual(JS_API); 15 | }); 16 | 17 | // If this test fails, you made the generated code invalid typescript. 18 | test("generatedCodeIsValid", () => { 19 | const tsCode = generateApiSpec(JSON.parse(FUNCTIONS_JSON), false); 20 | const result = ts.transpileModule(tsCode, { 21 | compilerOptions: { 22 | module: ts.ModuleKind.CommonJS, 23 | target: ts.ScriptTarget.ESNext, 24 | }, 25 | }); 26 | 27 | const jsCode = result.outputText; 28 | 29 | // Asserts that the generated code is valid typescript. This will fail if 30 | // the generated code is invalid. 31 | eval(jsCode); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/convex-helpers/cli/tsApiSpec.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { Command, Option } from "commander"; 3 | import type { ValidatorJSON, RecordKeyValidatorJSON } from "convex/values"; 4 | import chalk from "chalk"; 5 | import type { FunctionSpec } from "./utils.js"; 6 | import { getFunctionSpec } from "./utils.js"; 7 | import prettier from "prettier"; 8 | 9 | export const tsApiSpec = new Command("ts-api-spec") 10 | .summary( 11 | "Generate a TypeScript API spec similar to `convex/_generated/api.d.ts` from a Convex function definition.", 12 | ) 13 | .addOption( 14 | new Option( 15 | "--input-file ", 16 | "The file name of the Convex function definition. If this argument is not provided, we will " + 17 | "\nretrieve the function spec from your configured Convex deployment.\n" + 18 | "The file name defaults to `convexApi{msSinceEpoch}`.", 19 | ), 20 | ) 21 | .addOption( 22 | new Option( 23 | "--output-file ", 24 | "Specify the output file name for your spec.", 25 | ).default(undefined), 26 | ) 27 | .addOption( 28 | new Option( 29 | "--prod", 30 | "Get the function spec for your configured project's prod deployment.", 31 | ).default(undefined), 32 | ) 33 | .addOption( 34 | new Option( 35 | "--include-internal", 36 | "Include internal functions from your Convex deployment.", 37 | ).default(false), 38 | ) 39 | .action(async (options) => { 40 | let content = getFunctionSpec(options.prod, options.inputFile); 41 | const outputPath = 42 | (options.outputFile ?? `convexApi${Date.now().valueOf()}`) + ".ts"; 43 | 44 | try { 45 | const apiSpec = generateApiSpec( 46 | JSON.parse(content), 47 | options.includeInternal, 48 | ); 49 | const formattedSpec = await prettier.format(apiSpec, { 50 | parser: "typescript", 51 | }); 52 | fs.writeFileSync(outputPath, formattedSpec, "utf-8"); 53 | } catch (e) { 54 | console.error("Failed to generate TypeScript API spec: ", e); 55 | process.exit(1); 56 | } 57 | 58 | console.log(chalk.green("Wrote JavaScript API spec to " + outputPath)); 59 | }); 60 | 61 | function generateArgsType(argsJson: ValidatorJSON): string { 62 | switch (argsJson.type) { 63 | case "null": 64 | return "null"; 65 | case "number": 66 | return "number"; 67 | case "bigint": 68 | return "bigint"; 69 | case "boolean": 70 | return "boolean"; 71 | case "string": 72 | return "string"; 73 | case "bytes": 74 | return "ArrayBuffer"; 75 | case "any": 76 | return "any"; 77 | case "literal": 78 | if (typeof argsJson.value === "string") { 79 | return `"${argsJson.value}"` as string; 80 | } else { 81 | return argsJson.value!.toString(); 82 | } 83 | case "id": 84 | return `Id<"${argsJson.tableName}">`; 85 | case "array": 86 | return `Array<${generateArgsType(argsJson.value)}>`; 87 | case "record": { 88 | const keyType = generateRecordKeyType(argsJson.keys); 89 | const valueType = generateArgsType(argsJson.values.fieldType); 90 | return `Record<${keyType}, ${valueType}>`; 91 | } 92 | case "object": { 93 | const members: string[] = Object.entries(argsJson.value).map( 94 | ([key, value]) => { 95 | return `${key}${value.optional ? "?" : ""}: ${generateArgsType( 96 | value.fieldType, 97 | )},`; 98 | }, 99 | ); 100 | if (members.length === 0) { 101 | // special case empty object 102 | return "Record"; 103 | } 104 | return `{ ${members.join("\n")} }`; 105 | } 106 | case "union": { 107 | const members: string[] = argsJson.value.map((v) => generateArgsType(v)); 108 | return members.join(" | "); 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Generates a TypeScript-compatible key type for a record. 115 | */ 116 | function generateRecordKeyType(keys: RecordKeyValidatorJSON): string { 117 | switch (keys.type) { 118 | case "string": 119 | return "string"; 120 | case "id": 121 | return `Id<"${keys.tableName}">`; 122 | case "union": 123 | return keys.value.map(generateRecordKeyType).join(" | "); 124 | default: 125 | return "any"; 126 | } 127 | } 128 | 129 | function generateApiType(tree: Record) { 130 | const isFunction = tree.functionType !== undefined; 131 | if (isFunction) { 132 | const output = 133 | tree.returns === null || tree.returns === undefined 134 | ? "any" 135 | : generateArgsType(tree.returns); 136 | return `FunctionReference<"${(tree.functionType as string).toLowerCase()}", "${ 137 | tree.visibility.kind 138 | }", ${generateArgsType(tree.args)}, ${output}>`; 139 | } 140 | const members: string[] = Object.entries(tree).map(([key, value]) => { 141 | return `${key}: ${generateApiType(value)}`; 142 | }); 143 | return `{ ${members.join("\n")} }`; 144 | } 145 | 146 | export function generateApiSpec( 147 | functionSpec: FunctionSpec, 148 | includeInternal: boolean, 149 | ) { 150 | if (functionSpec.functions === undefined || functionSpec.url === undefined) { 151 | console.error( 152 | chalk.red( 153 | "Incorrect function spec provided. Confirm that you have Convex 1.15.0 or greater installed.", 154 | ), 155 | ); 156 | process.exit(1); 157 | } 158 | 159 | const publicFunctionTree: Record = {}; 160 | const internalFunctionTree: Record = {}; 161 | for (const fn of functionSpec.functions) { 162 | // Skip http actions because they go to a different url and we don't have argument/return types 163 | if (fn.functionType === "HttpAction") { 164 | continue; 165 | } 166 | const [modulePath, functionName] = fn.identifier.split(":"); 167 | const withoutExtension = modulePath!.slice(0, modulePath!.length - 3); 168 | const pathParts = withoutExtension!.split("/"); 169 | let treeNode = 170 | fn.visibility.kind === "internal" 171 | ? internalFunctionTree 172 | : publicFunctionTree; 173 | for (let i = 0; i < pathParts.length; i += 1) { 174 | const pathPart = pathParts[i]!; 175 | if (treeNode[pathPart] === undefined) { 176 | treeNode[pathPart] = {}; 177 | } 178 | treeNode = treeNode[pathPart]; 179 | } 180 | treeNode[functionName!] = fn; 181 | } 182 | const apiType = generateApiType(publicFunctionTree); 183 | const internalApiType = generateApiType( 184 | includeInternal ? internalFunctionTree : {}, 185 | ); 186 | return ` 187 | import { FunctionReference, anyApi } from "convex/server" 188 | import { GenericId as Id } from "convex/values" 189 | 190 | export const api: PublicApiType = anyApi as unknown as PublicApiType; 191 | export const internal: InternalApiType = anyApi as unknown as InternalApiType; 192 | 193 | export type PublicApiType = ${apiType} 194 | export type InternalApiType = ${internalApiType} 195 | `; 196 | } 197 | -------------------------------------------------------------------------------- /packages/convex-helpers/cli/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { spawnSync } from "child_process"; 3 | import chalk from "chalk"; 4 | import type { ValidatorJSON } from "convex/values"; 5 | import path from "path"; 6 | import os from "os"; 7 | 8 | type Visibility = { kind: "public" } | { kind: "internal" }; 9 | 10 | type FunctionType = "Action" | "Mutation" | "Query" | "HttpAction"; 11 | 12 | export type FunctionSpec = { 13 | url: string; 14 | functions: AnalyzedFunction[]; 15 | }; 16 | 17 | export type AnalyzedFunction = { 18 | identifier: string; 19 | functionType: FunctionType; 20 | visibility: Visibility; 21 | args: ValidatorJSON | null; 22 | returns: ValidatorJSON | null; 23 | }; 24 | 25 | export function getFunctionSpec(prod?: boolean, filePath?: string) { 26 | if (filePath && prod) { 27 | console.error(`To use the prod flag, you can't provide a file path`); 28 | process.exit(1); 29 | } 30 | let content: string; 31 | if (filePath && !fs.existsSync(filePath)) { 32 | console.error(chalk.red(`File ${filePath} not found`)); 33 | process.exit(1); 34 | } 35 | if (filePath) { 36 | content = fs.readFileSync(filePath, "utf-8"); 37 | } else { 38 | const tempFile = path.join(os.tmpdir(), `function-spec-${Date.now()}.json`); 39 | 40 | try { 41 | const outputFd = fs.openSync(tempFile, "w"); 42 | const flags = prod ? ["--prod"] : []; 43 | const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; 44 | const extraOpts = process.platform === "win32" ? { shell: true } : {}; 45 | const result = spawnSync(npxCmd, ["convex", "function-spec", ...flags], { 46 | stdio: ["inherit", outputFd, "pipe"], 47 | encoding: "utf-8", 48 | ...extraOpts, 49 | }); 50 | 51 | fs.closeSync(outputFd); 52 | 53 | if (result.status !== 0) { 54 | throw new Error(result.stderr || "Failed without error message"); 55 | } 56 | 57 | content = fs.readFileSync(tempFile, "utf-8"); 58 | } catch (e) { 59 | if (e instanceof Error) { 60 | console.error(e.message); 61 | } 62 | console.error( 63 | chalk.red( 64 | "\nError retrieving function spec from your Convex deployment. " + 65 | "Confirm that you \nare running this command from within a Convex project.\n", 66 | ), 67 | ); 68 | throw e; 69 | } finally { 70 | try { 71 | fs.unlinkSync(tempFile); 72 | } catch (error) { 73 | console.warn( 74 | chalk.yellow( 75 | `Warning: Failed to delete temporary file ${tempFile}:`, 76 | error instanceof Error ? error.message : "Unknown error", 77 | ), 78 | ); 79 | } 80 | } 81 | } 82 | 83 | return content; 84 | } 85 | -------------------------------------------------------------------------------- /packages/convex-helpers/generate-exports.mjs: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import process from "node:process"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))); 7 | 8 | function directoryContents(dirname) { 9 | return fs 10 | .readdirSync(path.join(__dirname, dirname)) 11 | .filter((filename) => filename.endsWith(".ts") || filename.endsWith(".tsx")) 12 | .filter((filename) => !filename.includes(".test")) 13 | .map((filename) => path.join(dirname, filename)); 14 | } 15 | 16 | const EntryPointDirectories = ["react", "react/cache", "server"]; 17 | function entryPointFiles() { 18 | return [ 19 | "./index.ts", 20 | "./browser.ts", 21 | "./testing.ts", 22 | "./validators.ts", 23 | "./server.ts", 24 | "./standardSchema.ts", 25 | "./react.ts", 26 | ...EntryPointDirectories.map(directoryContents).flat(), 27 | ]; 28 | } 29 | 30 | function entryPointFromFile(source) { 31 | let entryPoint = path.join(path.parse(source).dir, path.parse(source).name); 32 | 33 | if (path.parse(source).name === "index") { 34 | entryPoint = path.parse(source).dir; 35 | } 36 | 37 | if (!entryPoint.startsWith(".")) { 38 | entryPoint = `./${entryPoint}`; 39 | } 40 | 41 | return entryPoint; 42 | } 43 | 44 | function generateExport(source) { 45 | let extensionless = path.join( 46 | path.parse(source).dir, 47 | path.parse(source).name, 48 | ); 49 | 50 | return { 51 | types: `./${extensionless}.d.ts`, 52 | default: `./${extensionless}.js`, 53 | }; 54 | } 55 | 56 | function generateExports() { 57 | const obj = {}; 58 | for (const entryPoint of entryPointFiles()) { 59 | obj[entryPointFromFile(entryPoint)] = generateExport(entryPoint); 60 | } 61 | for (const entryPointDir of EntryPointDirectories) { 62 | obj[`./${entryPointDir}/*`] = { 63 | types: `./${entryPointDir}/*.d.ts`, 64 | default: `./${entryPointDir}/*.js`, 65 | }; 66 | } 67 | return obj; 68 | } 69 | 70 | function checkPackageJsonExports() { 71 | const packageJson = JSON.parse( 72 | fs.readFileSync(path.join(__dirname, "package.json")), 73 | ); 74 | const actual = packageJson.exports; 75 | const expected = generateExports(); 76 | if (JSON.stringify(actual) !== JSON.stringify(expected)) { 77 | packageJson.exports = expected; 78 | fs.writeFileSync( 79 | path.join(__dirname, "package.json"), 80 | JSON.stringify(packageJson, null, 2) + "\n", 81 | ); 82 | console.error( 83 | "`package.json` exports are not correct and have been updated. Review and commit the changes", 84 | ); 85 | process.exit(1); 86 | } 87 | } 88 | 89 | checkPackageJsonExports(); 90 | -------------------------------------------------------------------------------- /packages/convex-helpers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { withoutSystemFields } from "./index.js"; 3 | import { test, expect, expectTypeOf } from "vitest"; 4 | 5 | test("withoutSystemFields", () => { 6 | const obj = { _id: "1", _creationTime: 1, a: "a" }; 7 | const without = withoutSystemFields(obj); 8 | expect(without).toEqual({ a: "a" }); 9 | }); 10 | 11 | test("withoutSystemFields when fields aren't present", () => { 12 | const obj = { a: "a" }; 13 | const without = withoutSystemFields(obj); 14 | expect(without).toEqual({ a: "a" }); 15 | }); 16 | 17 | test("withoutSystemFields type when it's a union", () => { 18 | const obj = { a: "a" } as 19 | | { a: string } 20 | | { _id: string; _creationTime: number }; 21 | const without = withoutSystemFields(obj); 22 | expect(without).toEqual({ a: "a" }); 23 | expectTypeOf(without).toEqualTypeOf<{ a: string } | {}>(); 24 | const obj2 = { _id: "1", _creationTime: 1, a: "a" } as 25 | | { _id: string; _creationTime: number; a: string } 26 | | { _id: string; _creationTime: number; b: string }; 27 | const without2 = withoutSystemFields(obj2); 28 | expect(without2).toEqual({ a: "a" }); 29 | expectTypeOf(without2).toEqualTypeOf<{ a: string } | { b: string }>(); 30 | }); 31 | 32 | test("withoutSystemFields works on validators too", () => { 33 | const validator = v.object({ 34 | _id: v.string(), 35 | _creationTime: v.number(), 36 | a: v.string(), 37 | }); 38 | const { _id, _creationTime, ...rest } = validator.fields; 39 | const without = withoutSystemFields(validator.fields); 40 | expectTypeOf(without).toEqualTypeOf(); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/convex-helpers/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * asyncMap returns the results of applying an async function over an list. 3 | * 4 | * The list can even be a promise, or an iterable like a Set. 5 | * @param list - Iterable object of items, e.g. an Array, Set, Object.keys 6 | * @param asyncTransform 7 | * @returns 8 | */ 9 | export async function asyncMap( 10 | list: Iterable | Promise>, 11 | asyncTransform: (item: FromType, index: number) => Promise, 12 | ): Promise { 13 | const promises: Promise[] = []; 14 | let index = 0; 15 | list = await list; 16 | for (const item of list) { 17 | promises.push(asyncTransform(item, index)); 18 | index += 1; 19 | } 20 | return Promise.all(promises); 21 | } 22 | 23 | /** 24 | * Filters out null elements from an array. 25 | * @param list List of elements that might be null. 26 | * @returns List of elements with nulls removed. 27 | */ 28 | export function pruneNull(list: (T | null)[]): T[] { 29 | return list.filter((i) => i !== null) as T[]; 30 | } 31 | 32 | export class NullDocumentError extends Error {} 33 | 34 | /** 35 | * Throws if there is a null element in the array. 36 | * @param list List of elements that might have a null element. 37 | * @returns Same list of elements with a refined type. 38 | */ 39 | export function nullThrows(doc: T | null, message?: string): T { 40 | if (doc === null) { 41 | throw new NullDocumentError(message ?? "Unexpected null document."); 42 | } 43 | return doc; 44 | } 45 | 46 | /** 47 | * pick helps you pick keys from an object more concisely. 48 | * 49 | * e.g. `pick({a: v.string(), b: v.number()}, ["a"])` is equivalent to 50 | * `{a: v.string()}` 51 | * The alternative could be something like: 52 | * ```js 53 | * const obj = { a: v.string(), b: v.number() }; 54 | * // pick does the following 55 | * const { a } = obj; 56 | * const onlyA = { a }; 57 | * ``` 58 | * 59 | * @param obj The object to pick from. Often like { a: v.string() } 60 | * @param keys The keys to pick from the object. 61 | * @returns A new object with only the keys you picked and their values. 62 | */ 63 | export function pick, Keys extends (keyof T)[]>( 64 | obj: T, 65 | keys: Keys, 66 | ) { 67 | return Object.fromEntries( 68 | Object.entries(obj).filter(([k]) => keys.includes(k as Keys[number])), 69 | ) as { 70 | [K in Keys[number]]: T[K]; 71 | }; 72 | } 73 | 74 | /** 75 | * omit helps you omit keys from an object more concisely. 76 | * 77 | * e.g. `omit({a: v.string(), b: v.number()}, ["a"])` is equivalent to 78 | * `{b: v.number()}` 79 | * 80 | * The alternative could be something like: 81 | * ```js 82 | * const obj = { a: v.string(), b: v.number() }; 83 | * // omit does the following 84 | * const { a, ...rest } = obj; 85 | * const withoutA = rest; 86 | * ``` 87 | * 88 | * @param obj The object to return a copy of without the specified keys. 89 | * @param keys The keys to omit from the object. 90 | * @returns A new object with the keys you omitted removed. 91 | */ 92 | export function omit, Keys extends (keyof T)[]>( 93 | obj: T, 94 | keys: Keys, 95 | ) { 96 | return Object.fromEntries( 97 | Object.entries(obj).filter(([k]) => !keys.includes(k as Keys[number])), 98 | ) as Expand>; 99 | } 100 | 101 | /** 102 | * Removes the _id and _creationTime fields from an object. 103 | * This enables easily cloning a Convex document like: 104 | * ```ts 105 | * const doc = await db.get(id); 106 | * const clone = withoutSystemFields(doc); 107 | * await db.insert(table, clone); 108 | * ``` 109 | * @param obj The object to remove the _id and _creationTime fields from. 110 | * @returns A new object with the _id and _creationTime fields removed. 111 | */ 112 | export function withoutSystemFields>(obj: T) { 113 | return omit(obj, ["_id", "_creationTime"]); 114 | } 115 | 116 | // Type utils: 117 | const error = Symbol(); 118 | export type ErrorMessage = Reason & { 119 | __error: typeof error; 120 | }; 121 | 122 | // Copied from convex/server since it wasn't exported 123 | export type EmptyObject = Record; 124 | /** 125 | * An `Omit<>` type that: 126 | * 1. Applies to each element of a union. 127 | * 2. Preserves the index signature of the underlying type. 128 | */ 129 | export type BetterOmit = { 130 | [Property in keyof T as Property extends K ? never : Property]: T[Property]; 131 | }; 132 | 133 | /** 134 | * Hack! This type causes TypeScript to simplify how it renders object types. 135 | * 136 | * It is functionally the identity for object types, but in practice it can 137 | * simplify expressions like `A & B`. 138 | */ 139 | export type Expand> = 140 | ObjectType extends Record 141 | ? { 142 | [Key in keyof ObjectType]: ObjectType[Key]; 143 | } 144 | : never; 145 | 146 | /** 147 | * TESTS 148 | */ 149 | /** 150 | * Tests if two types are exactly the same. 151 | * Taken from https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650 152 | * (Apache Version 2.0, January 2004) 153 | */ 154 | export type Equals = 155 | (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 156 | ? true 157 | : false; 158 | 159 | /** 160 | * A utility to validate truthiness at runtime, providing a type guard 161 | * 162 | * @example 163 | * ```ts 164 | * const x: string | null = getValue(); 165 | * assert(x); 166 | * // x is now of type string 167 | * ``` 168 | * @param arg A value to assert the truthiness of. 169 | * @param message An optional message to throw if the value is not truthy. 170 | */ 171 | export function assert(value: unknown, message?: string): asserts value { 172 | if (!value) { 173 | throw new Error(message); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /packages/convex-helpers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convex-helpers", 3 | "version": "0.1.89", 4 | "description": "A collection of useful code to complement the official convex package.", 5 | "type": "module", 6 | "bin": { 7 | "convex-helpers": "./bin.cjs" 8 | }, 9 | "exports": { 10 | ".": { 11 | "types": "./index.d.ts", 12 | "default": "./index.js" 13 | }, 14 | "./browser": { 15 | "types": "./browser.d.ts", 16 | "default": "./browser.js" 17 | }, 18 | "./testing": { 19 | "types": "./testing.d.ts", 20 | "default": "./testing.js" 21 | }, 22 | "./validators": { 23 | "types": "./validators.d.ts", 24 | "default": "./validators.js" 25 | }, 26 | "./server": { 27 | "types": "./server.d.ts", 28 | "default": "./server.js" 29 | }, 30 | "./standardSchema": { 31 | "types": "./standardSchema.d.ts", 32 | "default": "./standardSchema.js" 33 | }, 34 | "./react": { 35 | "types": "./react.d.ts", 36 | "default": "./react.js" 37 | }, 38 | "./react/cache": { 39 | "types": "./react/cache.d.ts", 40 | "default": "./react/cache.js" 41 | }, 42 | "./react/sessions": { 43 | "types": "./react/sessions.d.ts", 44 | "default": "./react/sessions.js" 45 | }, 46 | "./react/cache/hooks": { 47 | "types": "./react/cache/hooks.d.ts", 48 | "default": "./react/cache/hooks.js" 49 | }, 50 | "./react/cache/provider": { 51 | "types": "./react/cache/provider.d.ts", 52 | "default": "./react/cache/provider.js" 53 | }, 54 | "./server/compare": { 55 | "types": "./server/compare.d.ts", 56 | "default": "./server/compare.js" 57 | }, 58 | "./server/cors": { 59 | "types": "./server/cors.d.ts", 60 | "default": "./server/cors.js" 61 | }, 62 | "./server/crud": { 63 | "types": "./server/crud.d.ts", 64 | "default": "./server/crud.js" 65 | }, 66 | "./server/customFunctions": { 67 | "types": "./server/customFunctions.d.ts", 68 | "default": "./server/customFunctions.js" 69 | }, 70 | "./server/filter": { 71 | "types": "./server/filter.d.ts", 72 | "default": "./server/filter.js" 73 | }, 74 | "./server/hono": { 75 | "types": "./server/hono.d.ts", 76 | "default": "./server/hono.js" 77 | }, 78 | "./server/migrations": { 79 | "types": "./server/migrations.d.ts", 80 | "default": "./server/migrations.js" 81 | }, 82 | "./server/pagination": { 83 | "types": "./server/pagination.d.ts", 84 | "default": "./server/pagination.js" 85 | }, 86 | "./server/rateLimit": { 87 | "types": "./server/rateLimit.d.ts", 88 | "default": "./server/rateLimit.js" 89 | }, 90 | "./server/relationships": { 91 | "types": "./server/relationships.d.ts", 92 | "default": "./server/relationships.js" 93 | }, 94 | "./server/retries": { 95 | "types": "./server/retries.d.ts", 96 | "default": "./server/retries.js" 97 | }, 98 | "./server/rowLevelSecurity": { 99 | "types": "./server/rowLevelSecurity.d.ts", 100 | "default": "./server/rowLevelSecurity.js" 101 | }, 102 | "./server/sessions": { 103 | "types": "./server/sessions.d.ts", 104 | "default": "./server/sessions.js" 105 | }, 106 | "./server/stream": { 107 | "types": "./server/stream.d.ts", 108 | "default": "./server/stream.js" 109 | }, 110 | "./server/triggers": { 111 | "types": "./server/triggers.d.ts", 112 | "default": "./server/triggers.js" 113 | }, 114 | "./server/zod": { 115 | "types": "./server/zod.d.ts", 116 | "default": "./server/zod.js" 117 | }, 118 | "./react/*": { 119 | "types": "./react/*.d.ts", 120 | "default": "./react/*.js" 121 | }, 122 | "./react/cache/*": { 123 | "types": "./react/cache/*.d.ts", 124 | "default": "./react/cache/*.js" 125 | }, 126 | "./server/*": { 127 | "types": "./server/*.d.ts", 128 | "default": "./server/*.js" 129 | } 130 | }, 131 | "scripts": { 132 | "build:bin": "esbuild ./cli/index.ts --bundle --platform=node --external:prettier --format=cjs --outfile=dist/bin.cjs", 133 | "build": "node generate-exports.mjs && mkdir -p dist && npm run build:bin && cp -r *.ts server react ./package.json ./tsconfig.json ./README.md ../../LICENSE ./.npmignore dist/ && cd dist/ && rm **/*.test.* && rm -r server/_generated && tsc", 134 | "dev": "chokidar '*.ts' 'server/**/*.ts' 'react/**/*.ts*' 'tsconfig*.json' 'package.json' -c 'cd ../.. && npm run build' --initial" 135 | }, 136 | "repository": { 137 | "type": "git", 138 | "url": "git+https://github.com/get-convex/convex-helpers.git", 139 | "directory": "packages/convex-helpers" 140 | }, 141 | "keywords": [ 142 | "convex", 143 | "backend", 144 | "migrations", 145 | "ratelimit", 146 | "database", 147 | "react" 148 | ], 149 | "author": "Ian Macartney ", 150 | "license": "Apache-2.0", 151 | "bugs": { 152 | "url": "https://github.com/get-convex/convex-helpers/issues" 153 | }, 154 | "homepage": "https://github.com/get-convex/convex-helpers/tree/main/packages/convex-helpers/README.md", 155 | "devDependencies": { 156 | "chalk": "5.4.1", 157 | "commander": "14.0.0" 158 | }, 159 | "peerDependencies": { 160 | "@standard-schema/spec": "^1.0.0", 161 | "convex": "^1.13.0", 162 | "hono": "^4.0.5", 163 | "react": "^17.0.2 || ^18.0.0 || ^19.0.0", 164 | "typescript": "^5.5", 165 | "zod": "^3.22.4" 166 | }, 167 | "peerDependenciesMeta": { 168 | "@standard-schema/spec": { 169 | "optional": true 170 | }, 171 | "hono": { 172 | "optional": true 173 | }, 174 | "react": { 175 | "optional": true 176 | }, 177 | "typescript": { 178 | "optional": true 179 | }, 180 | "zod": { 181 | "optional": true 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /packages/convex-helpers/react.ts: -------------------------------------------------------------------------------- 1 | import type { OptionalRestArgsOrSkip } from "convex/react"; 2 | import { useQueries, useQuery as useQueryOriginal } from "convex/react"; 3 | import type { FunctionReference, FunctionReturnType } from "convex/server"; 4 | import { getFunctionName } from "convex/server"; 5 | import { useMemo } from "react"; 6 | import type { EmptyObject } from "./index.js"; 7 | 8 | /** 9 | * Use in place of `useQuery` from "convex/react" to fetch data from a query 10 | * function but instead returns `{ status, data, error, isSuccess, isPending, isError}`. 11 | * 12 | * Want a different name? Use `makeUseQueryWithStatus` to create a custom hook: 13 | * ```ts 14 | * import { useQueries } from "convex/react"; 15 | * import { makeUseQueryWithStatus } from "convex-helpers/react"; 16 | * export const useQuery = makeUseQueryWithStatus(useQueries); 17 | * ``` 18 | * 19 | * Status is one of "success", "pending", or "error". 20 | * Docs copied from {@link useQueryOriginal} until `returns` block: 21 | * 22 | * Load a reactive query within a React component. 23 | * 24 | * This React hook contains internal state that will cause a rerender 25 | * whenever the query result changes. 26 | * 27 | * Throws an error if not used under {@link ConvexProvider}. 28 | * 29 | * @param query - a {@link server.FunctionReference} for the public query to run 30 | * like `api.dir1.dir2.filename.func`. 31 | * @param args - The arguments to the query function or the string "skip" if the 32 | * query should not be loaded. 33 | * @returns {status, data, error, isSuccess, isPending, isError} where: 34 | * - `status` is one of "success", "pending", or "error" 35 | * - `data` is the result of the query function, if it loaded successfully, 36 | * - `error` is an `Error` if the query threw an exception. 37 | * - `isSuccess` is `true` if the query loaded successfully. 38 | * - `isPending` is `true` if the query is still loading or "skip" was passed. 39 | * - `isError` is `true` if the query threw an exception. 40 | */ 41 | export const useQuery = makeUseQueryWithStatus(useQueries); 42 | 43 | /** 44 | * Makes a hook to use in place of `useQuery` from "convex/react" to fetch data from a query 45 | * function but instead returns `{ status, data, error, isSuccess, isPending, isError}`. 46 | * 47 | * You can pass in any hook that matches the signature of {@link useQueries} from "convex/react". 48 | * For instance: 49 | * 50 | * ```ts 51 | * import { useQueries } from "convex-helpers/react/cache/hooks"; 52 | * import { makeUseQueryWithStatus } from "convex-helpers/react"; 53 | * const useQuery = makeUseQueryWithStatus(useQueries); 54 | * ``` 55 | * 56 | * Status is one of "success", "pending", or "error". 57 | * Docs copied from {@link useQueryOriginal} until `returns` block: 58 | * 59 | * Load a reactive query within a React component. 60 | * 61 | * This React hook contains internal state that will cause a rerender 62 | * whenever the query result changes. 63 | * 64 | * Throws an error if not used under {@link ConvexProvider}. 65 | * 66 | * @param query - a {@link server.FunctionReference} for the public query to run 67 | * like `api.dir1.dir2.filename.func`. 68 | * @param args - The arguments to the query function or the string "skip" if the 69 | * query should not be loaded. 70 | * @returns {status, data, error, isSuccess, isPending, isError} where: 71 | * - `status` is one of "success", "pending", or "error" 72 | * - `data` is the result of the query function, if it loaded successfully, 73 | * - `error` is an `Error` if the query threw an exception. 74 | * - `isSuccess` is `true` if the query loaded successfully. 75 | * - `isPending` is `true` if the query is still loading or "skip" was passed. 76 | * - `isError` is `true` if the query threw an exception. 77 | * 78 | * @param useQueries Something matching the signature of {@link useQueries} from "convex/react". 79 | * @returns 80 | * @returns A useQuery function that returns an object with status, data, error, isSuccess, isPending, isError. 81 | */ 82 | export function makeUseQueryWithStatus(useQueriesHook: typeof useQueries) { 83 | return function useQuery>( 84 | query: Query, 85 | ...queryArgs: OptionalRestArgsOrSkip 86 | ): 87 | | { 88 | status: "success"; 89 | data: FunctionReturnType; 90 | error: undefined; 91 | isSuccess: true; 92 | isPending: false; 93 | isError: false; 94 | } 95 | | { 96 | status: "pending"; 97 | data: undefined; 98 | error: undefined; 99 | isSuccess: false; 100 | isPending: true; 101 | isError: false; 102 | } 103 | | { 104 | status: "error"; 105 | data: undefined; 106 | error: Error; 107 | isSuccess: false; 108 | isPending: false; 109 | isError: true; 110 | } { 111 | const args = queryArgs[0] ?? {}; 112 | const queries = useMemo(() => { 113 | if (args === "skip") { 114 | return {} as EmptyObject; 115 | } 116 | return { 117 | data: { 118 | query, 119 | args, 120 | }, 121 | }; 122 | // eslint-disable-next-line react-hooks/exhaustive-deps 123 | }, [getFunctionName(query), JSON.stringify(args)]); 124 | const result = useQueriesHook(queries); 125 | if (args === "skip") { 126 | return { 127 | status: "pending", 128 | data: undefined, 129 | error: undefined, 130 | isSuccess: false, 131 | isPending: true, 132 | isError: false, 133 | }; 134 | } 135 | if (result.data instanceof Error) { 136 | return { 137 | status: "error", 138 | data: undefined, 139 | error: result.data, 140 | isSuccess: false, 141 | isPending: false, 142 | isError: true, 143 | }; 144 | } 145 | const { data } = result; 146 | if (data === undefined) { 147 | return { 148 | status: "pending", 149 | data, 150 | error: undefined, 151 | isSuccess: false, 152 | isPending: true, 153 | isError: false, 154 | }; 155 | } 156 | return { 157 | status: "success", 158 | data, 159 | error: undefined, 160 | isSuccess: true, 161 | isPending: false, 162 | isError: false, 163 | }; 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /packages/convex-helpers/react/cache.ts: -------------------------------------------------------------------------------- 1 | export { ConvexQueryCacheProvider } from "./cache/provider.js"; 2 | export { useQuery, useQueries } from "./cache/hooks.js"; 3 | -------------------------------------------------------------------------------- /packages/convex-helpers/react/cache/hooks.ts: -------------------------------------------------------------------------------- 1 | import type { OptionalRestArgsOrSkip, RequestForQueries } from "convex/react"; 2 | import { ConvexProvider, useQueries as useQueriesCore } from "convex/react"; 3 | import type { 4 | FunctionArgs, 5 | FunctionReference, 6 | FunctionReturnType, 7 | } from "convex/server"; 8 | import { getFunctionName } from "convex/server"; 9 | import { useContext, useEffect, useMemo } from "react"; 10 | import { ConvexQueryCacheContext } from "./provider.js"; 11 | import { convexToJson } from "convex/values"; 12 | 13 | const uuid = 14 | typeof crypto !== "undefined" && crypto.randomUUID 15 | ? crypto.randomUUID.bind(crypto) 16 | : () => 17 | Math.random().toString(36).substring(2) + 18 | Math.random().toString(36).substring(2); 19 | 20 | /** 21 | * Load a variable number of reactive Convex queries, utilizing 22 | * the query cache. 23 | * 24 | * `useQueries` is similar to {@link useQuery} but it allows 25 | * loading multiple queries which can be useful for loading a dynamic number 26 | * of queries without violating the rules of React hooks. 27 | * 28 | * This hook accepts an object whose keys are identifiers for each query and the 29 | * values are objects of `{ query: FunctionReference, args: Record }`. The 30 | * `query` is a FunctionReference for the Convex query function to load, and the `args` are 31 | * the arguments to that function. 32 | * 33 | * The hook returns an object that maps each identifier to the result of the query, 34 | * `undefined` if the query is still loading, or an instance of `Error` if the query 35 | * threw an exception. 36 | * 37 | * For example if you loaded a query like: 38 | * ```typescript 39 | * const results = useQueries({ 40 | * messagesInGeneral: { 41 | * query: "listMessages", 42 | * args: { channel: "#general" } 43 | * } 44 | * }); 45 | * ``` 46 | * then the result would look like: 47 | * ```typescript 48 | * { 49 | * messagesInGeneral: [{ 50 | * channel: "#general", 51 | * body: "hello" 52 | * _id: ..., 53 | * _creationTime: ... 54 | * }] 55 | * } 56 | * ``` 57 | * 58 | * This React hook contains internal state that will cause a rerender 59 | * whenever any of the query results change. 60 | * 61 | * Throws an error if not used under {@link ConvexProvider}. 62 | * 63 | * @param queries - An object mapping identifiers to objects of 64 | * `{query: string, args: Record }` describing which query 65 | * functions to fetch. 66 | * @returns An object with the same keys as the input. The values are the result 67 | * of the query function, `undefined` if it's still loading, or an `Error` if 68 | * it threw an exception. 69 | * 70 | * @public 71 | */ 72 | export function useQueries( 73 | queries: RequestForQueries, 74 | ): Record { 75 | const { registry } = useContext(ConvexQueryCacheContext); 76 | if (registry === null) { 77 | throw new Error( 78 | "Could not find `ConvexQueryCacheContext`! This `useQuery` implementation must be used in the React component " + 79 | "tree under `ConvexQueryCacheProvider`. Did you forget it? ", 80 | ); 81 | } 82 | const queryKeys: Record = {}; 83 | for (const [key, { query, args }] of Object.entries(queries)) { 84 | queryKeys[key] = createQueryKey(query, args); 85 | } 86 | 87 | useEffect( 88 | () => { 89 | const ids: string[] = []; 90 | for (const [key, { query, args }] of Object.entries(queries)) { 91 | const id = uuid(); 92 | registry.start(id, queryKeys[key]!, query, args); 93 | ids.push(id); 94 | } 95 | return () => { 96 | for (const id of ids) { 97 | registry.end(id); 98 | } 99 | }; 100 | }, 101 | // Safe to ignore query and args since queryKey is derived from them 102 | // eslint-disable-next-line react-hooks/exhaustive-deps 103 | [registry, JSON.stringify(queryKeys)], 104 | ); 105 | const memoizedQueries = useMemo(() => queries, [JSON.stringify(queryKeys)]); 106 | return useQueriesCore(memoizedQueries); 107 | } 108 | 109 | /** 110 | * Load a reactive query within a React component. 111 | * 112 | * This React hook contains internal state that will cause a rerender 113 | * whenever the query result changes. 114 | * 115 | * Throws an error if not used under {@link ConvexProvider} and {@link ConvexQueryCacheProvider}. 116 | * 117 | * @param query - a {@link FunctionReference} for the public query to run 118 | * like `api.dir1.dir2.filename.func`. 119 | * @param args - The arguments to the query function or the string "skip" if the 120 | * query should not be loaded. 121 | * @returns the result of the query. If the query is loading returns `undefined`. 122 | * 123 | * @public 124 | */ 125 | export function useQuery>( 126 | query: Query, 127 | ...queryArgs: OptionalRestArgsOrSkip 128 | ): FunctionReturnType | undefined { 129 | const args = queryArgs[0] ?? {}; 130 | // Unlike the regular useQuery implementation, we don't need to memoize 131 | // the params here, since the cached useQueries will handle that. 132 | const results = useQueries( 133 | args === "skip" 134 | ? {} // Use queries doesn't support skip. 135 | : { 136 | _default: { query, args }, 137 | }, 138 | ); 139 | 140 | // This may be undefined either because the upstream 141 | // value is actually undefined, or because the value 142 | // was not sent to `useQueries` due to "skip". 143 | const result = results._default; 144 | if (result instanceof Error) { 145 | throw result; 146 | } 147 | return result; 148 | } 149 | 150 | /** 151 | * Generate a query key from a query function and its arguments. 152 | * @param query Query function reference like api.foo.bar 153 | * @param args Arguments to the function, like { foo: "bar" } 154 | * @returns A string key that uniquely identifies the query and its arguments. 155 | */ 156 | function createQueryKey>( 157 | query: Query, 158 | args: FunctionArgs, 159 | ): string { 160 | const queryString = getFunctionName(query); 161 | const key = [queryString, convexToJson(args)]; 162 | const queryKey = JSON.stringify(key); 163 | return queryKey; 164 | } 165 | -------------------------------------------------------------------------------- /packages/convex-helpers/react/cache/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useConvex, ConvexReactClient } from "convex/react"; 3 | import type { FunctionArgs, FunctionReference } from "convex/server"; 4 | import type { FC, PropsWithChildren } from "react"; 5 | import { createContext, useMemo } from "react"; 6 | 7 | export const ConvexQueryCacheContext = createContext({ 8 | registry: null as CacheRegistry | null, 9 | }); 10 | 11 | /** 12 | * A provider that establishes a query cache context in the React render 13 | * tree so that cached `useQuery` calls can be used. 14 | * 15 | * @component 16 | * @param {ConvexQueryCacheOptions} props.options - Options for the query cache 17 | * @returns {Element} 18 | */ 19 | export const ConvexQueryCacheProvider: FC< 20 | PropsWithChildren 21 | > = ({ children, debug, expiration, maxIdleEntries }) => { 22 | const convex = useConvex(); 23 | if (convex === undefined) { 24 | throw new Error( 25 | "Could not find Convex client! `ConvexQueryCacheProvider` must be used in the React component " + 26 | "tree under `ConvexProvider`. Did you forget it? " + 27 | "See https://docs.convex.dev/quick-start#set-up-convex-in-your-react-app", 28 | ); 29 | } 30 | const registry = useMemo( 31 | () => new CacheRegistry(convex, { debug, expiration, maxIdleEntries }), 32 | [convex, debug, expiration, maxIdleEntries], 33 | ); 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | }; 40 | 41 | const DEFAULT_EXPIRATION_MS = 300_000; // 5 minutes 42 | const DEFAULT_MAX_ENTRIES = 250; 43 | 44 | export type ConvexQueryCacheOptions = { 45 | /** 46 | * How long, in milliseconds, to keep the subscription to the convex 47 | * query alive even after all references in the app have been dropped. 48 | * 49 | * @default 300000 50 | */ 51 | expiration?: number; 52 | 53 | /** 54 | * How many "extra" idle query subscriptions are allowed to remain 55 | * connected to your convex backend. 56 | * 57 | * @default Infinity 58 | */ 59 | maxIdleEntries?: number; 60 | 61 | /** 62 | * A debug flag that will cause information about the query cache 63 | * to be logged to the console every 3 seconds. 64 | * 65 | * @default false 66 | */ 67 | debug?: boolean; 68 | }; 69 | 70 | /** 71 | * Implementation of the query cache. 72 | */ 73 | 74 | type SubKey = string; 75 | type QueryKey = string; 76 | type CachedQuery = { 77 | refs: Set; 78 | evictTimer: number | null; // SetTimeout 79 | unsub: () => void; 80 | }; 81 | 82 | // Core caching structure. 83 | class CacheRegistry { 84 | queries: Map; 85 | subs: Map; 86 | convex: ConvexReactClient; 87 | timeout: number; 88 | maxIdleEntries: number; 89 | idle: number; 90 | 91 | constructor(convex: ConvexReactClient, options: ConvexQueryCacheOptions) { 92 | this.queries = new Map(); 93 | this.subs = new Map(); 94 | this.convex = convex; 95 | this.idle = 0; 96 | this.timeout = options.expiration ?? DEFAULT_EXPIRATION_MS; 97 | this.maxIdleEntries = options.maxIdleEntries ?? DEFAULT_MAX_ENTRIES; 98 | if (options.debug ?? false) { 99 | const weakThis = new WeakRef(this); 100 | const debugInterval = setInterval(() => { 101 | const r = weakThis.deref(); 102 | if (r === undefined) { 103 | clearInterval(debugInterval); 104 | } else { 105 | r.debug(); 106 | } 107 | }, 3000); 108 | } 109 | } 110 | 111 | // Enable a new subscription. 112 | start>( 113 | id: string, 114 | queryKey: string, 115 | query: Query, 116 | args: FunctionArgs, 117 | ): void { 118 | let entry = this.queries.get(queryKey); 119 | this.subs.set(id, queryKey); 120 | if (entry === undefined) { 121 | entry = { 122 | refs: new Set(), 123 | evictTimer: null, 124 | // We only need to hold open subscriptions, we don't care about updates. 125 | unsub: this.convex.watchQuery(query, args).onUpdate(() => {}), 126 | }; 127 | this.queries.set(queryKey, entry); 128 | } else if (entry.evictTimer !== null) { 129 | this.idle -= 1; 130 | clearTimeout(entry.evictTimer); 131 | entry.evictTimer = null; 132 | } 133 | entry.refs.add(id); 134 | } 135 | // End a previous subscription. 136 | end(id: string) { 137 | const queryKey = this.subs.get(id); 138 | if (queryKey) { 139 | this.subs.delete(id); 140 | const cq = this.queries.get(queryKey); 141 | cq?.refs.delete(id); 142 | // None left? 143 | if (cq?.refs.size === 0) { 144 | const remove = () => { 145 | cq.unsub(); 146 | this.queries.delete(queryKey); 147 | }; 148 | if (this.idle == this.maxIdleEntries) { 149 | remove(); 150 | } else { 151 | this.idle += 1; 152 | const evictTimer = window.setTimeout(() => { 153 | this.idle -= 1; 154 | remove(); 155 | }, this.timeout); 156 | cq.evictTimer = evictTimer; 157 | } 158 | } 159 | } 160 | } 161 | debug() { 162 | console.log("DEBUG CACHE"); 163 | console.log(`IDLE = ${this.idle}`); 164 | console.log(" SUBS"); 165 | for (const [k, v] of this.subs.entries()) { 166 | console.log(` ${k} => ${v}`); 167 | } 168 | console.log(" QUERIES"); 169 | for (const [k, v] of this.queries.entries()) { 170 | console.log(` ${k} => ${v.refs.size} refs, evict = ${v.evictTimer}`); 171 | } 172 | console.log("~~~~~~~~~~~~~~~~~~~~~~"); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/convex-helpers/react/sessions.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expectTypeOf, 3 | test, 4 | vi, 5 | describe, 6 | it, 7 | expect, 8 | beforeEach, 9 | } from "vitest"; 10 | import type { FunctionReference } from "convex/server"; 11 | import type { SessionArgsArray, SessionQueryArgsArray } from "./sessions"; 12 | import type { EmptyObject } from ".."; 13 | import type { SessionId } from "../server/sessions"; 14 | import { ConvexReactSessionClient } from "convex-helpers/react/sessions"; 15 | 16 | test("noop", () => {}); 17 | 18 | expectTypeOf< 19 | SessionQueryArgsArray< 20 | FunctionReference< 21 | "query", 22 | "public", 23 | { arg: string; sessionId: SessionId | null }, 24 | any 25 | > 26 | > 27 | >().toEqualTypeOf<[{ arg: string } | "skip"]>(); 28 | 29 | expectTypeOf< 30 | SessionQueryArgsArray< 31 | FunctionReference<"query", "public", { sessionId: SessionId | null }, any> 32 | > 33 | >().toEqualTypeOf<[args?: EmptyObject | "skip" | undefined]>(); 34 | 35 | expectTypeOf< 36 | SessionArgsArray< 37 | FunctionReference< 38 | "mutation", 39 | "public", 40 | { arg: string; sessionId: SessionId }, 41 | any 42 | > 43 | > 44 | >().toEqualTypeOf<[{ arg: string }]>(); 45 | 46 | expectTypeOf< 47 | SessionArgsArray< 48 | FunctionReference<"mutation", "public", { sessionId: SessionId }, any> 49 | > 50 | >().toEqualTypeOf<[args?: EmptyObject | undefined]>(); 51 | 52 | expectTypeOf< 53 | SessionArgsArray< 54 | FunctionReference< 55 | "query", 56 | "public", 57 | { arg: string; sessionId: SessionId }, 58 | any 59 | > 60 | > 61 | >().toEqualTypeOf<[{ arg: string }]>(); 62 | 63 | expectTypeOf< 64 | SessionArgsArray< 65 | FunctionReference<"query", "public", { sessionId: SessionId }, any> 66 | > 67 | >().toEqualTypeOf<[args?: EmptyObject | undefined]>(); 68 | 69 | describe("ConvexSessionClient", () => { 70 | let mockClient: { query: any; mutation: any; action: any }; 71 | let sessionClient: ConvexReactSessionClient; 72 | const sessionId = "test-session-id" as SessionId; 73 | 74 | beforeEach(() => { 75 | mockClient = { 76 | query: vi.fn().mockResolvedValue("query-result"), 77 | mutation: vi.fn().mockResolvedValue("mutation-result"), 78 | action: vi.fn().mockResolvedValue("action-result"), 79 | }; 80 | sessionClient = new ConvexReactSessionClient("http://localhost:3000", { 81 | sessionId, 82 | }); 83 | sessionClient.query = mockClient.query; 84 | sessionClient.mutation = mockClient.mutation; 85 | sessionClient.action = mockClient.action; 86 | }); 87 | 88 | it("should inject sessionId into query args", async () => { 89 | const query = {} as FunctionReference< 90 | "query", 91 | "public", 92 | { arg: string; sessionId: SessionId | null }, 93 | any 94 | >; 95 | const args = { arg: " foo" }; 96 | 97 | const result = await sessionClient.sessionQuery(query, args); 98 | 99 | expect(mockClient.query).toHaveBeenCalledWith(query, { 100 | arg: " foo", 101 | sessionId, 102 | }); 103 | expect(result).toBe("query-result"); 104 | }); 105 | 106 | it("should inject sessionId into mutation args", async () => { 107 | const mutation = {} as FunctionReference< 108 | "mutation", 109 | "public", 110 | { arg: string; sessionId: SessionId }, 111 | any 112 | >; 113 | const args = { arg: "foo" }; 114 | 115 | const result = await sessionClient.sessionMutation(mutation, args); 116 | 117 | expect(mockClient.mutation).toHaveBeenCalledWith( 118 | mutation, 119 | { ...args, sessionId }, 120 | // options, e.g. for optimistic updates 121 | undefined, 122 | ); 123 | expect(result).toBe("mutation-result"); 124 | }); 125 | 126 | it("should inject sessionId into action args", async () => { 127 | const action = {} as FunctionReference< 128 | "action", 129 | "public", 130 | { arg: string; sessionId: SessionId }, 131 | any 132 | >; 133 | const args = { arg: "foo" }; 134 | 135 | const result = await sessionClient.sessionAction(action, args); 136 | 137 | expect(mockClient.action).toHaveBeenCalledWith(action, { 138 | ...args, 139 | sessionId, 140 | }); 141 | expect(result).toBe("action-result"); 142 | }); 143 | 144 | it("should allow changing the sessionId", async () => { 145 | const newSessionId = "new-session-id" as SessionId; 146 | const query = {} as FunctionReference< 147 | "query", 148 | "public", 149 | { arg: string; sessionId: SessionId }, 150 | any 151 | >; 152 | 153 | sessionClient.setSessionId(newSessionId); 154 | 155 | await sessionClient.sessionQuery(query, { arg: "foo" }); 156 | 157 | expect(mockClient.query).toHaveBeenCalledWith(query, { 158 | arg: "foo", 159 | sessionId: newSessionId, 160 | }); 161 | expect(sessionClient.getSessionId()).toBe(newSessionId); 162 | }); 163 | 164 | it("should allow omitting args if the only arg is sessionId", async () => { 165 | const query = {} as FunctionReference< 166 | "query", 167 | "public", 168 | { sessionId: SessionId }, 169 | any 170 | >; 171 | 172 | expect(await sessionClient.sessionQuery(query)).toBe("query-result"); 173 | expect(mockClient.query).toHaveBeenCalledWith(query, { 174 | sessionId, 175 | }); 176 | 177 | const mutation = {} as FunctionReference< 178 | "mutation", 179 | "public", 180 | { sessionId: SessionId }, 181 | any 182 | >; 183 | 184 | expect(await sessionClient.sessionMutation(mutation)).toBe( 185 | "mutation-result", 186 | ); 187 | expect(mockClient.mutation).toHaveBeenCalledWith( 188 | mutation, 189 | { sessionId }, 190 | undefined, 191 | ); 192 | 193 | const action = {} as FunctionReference< 194 | "action", 195 | "public", 196 | { sessionId: SessionId }, 197 | any 198 | >; 199 | 200 | expect(await sessionClient.sessionAction(action)).toBe("action-result"); 201 | expect(mockClient.action).toHaveBeenCalledWith(action, { 202 | sessionId, 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/_generated/_ignore.ts: -------------------------------------------------------------------------------- 1 | // This is only here so convex-test can detect a _generated folder 2 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/compare.ts: -------------------------------------------------------------------------------- 1 | import type { Value } from "convex/values"; 2 | 3 | // Returns -1 if k1 < k2 4 | // Returns 0 if k1 === k2 5 | // Returns 1 if k1 > k2 6 | export function compareValues(k1: Value | undefined, k2: Value | undefined) { 7 | return compareAsTuples(makeComparable(k1), makeComparable(k2)); 8 | } 9 | 10 | function compareAsTuples(a: [number, T], b: [number, T]): number { 11 | if (a[0] === b[0]) { 12 | return compareSameTypeValues(a[1], b[1]); 13 | } else if (a[0] < b[0]) { 14 | return -1; 15 | } 16 | return 1; 17 | } 18 | 19 | function compareSameTypeValues(v1: T, v2: T): number { 20 | if (v1 === undefined || v1 === null) { 21 | return 0; 22 | } 23 | if ( 24 | typeof v1 === "bigint" || 25 | typeof v1 === "number" || 26 | typeof v1 === "boolean" || 27 | typeof v1 === "string" 28 | ) { 29 | return v1 < v2 ? -1 : v1 === v2 ? 0 : 1; 30 | } 31 | if (!Array.isArray(v1) || !Array.isArray(v2)) { 32 | throw new Error(`Unexpected type ${v1 as any}`); 33 | } 34 | for (let i = 0; i < v1.length && i < v2.length; i++) { 35 | const cmp = compareAsTuples(v1[i], v2[i]); 36 | if (cmp !== 0) { 37 | return cmp; 38 | } 39 | } 40 | if (v1.length < v2.length) { 41 | return -1; 42 | } 43 | if (v1.length > v2.length) { 44 | return 1; 45 | } 46 | return 0; 47 | } 48 | 49 | // Returns an array which can be compared to other arrays as if they were tuples. 50 | // For example, [1, null] < [2, 1n] means null sorts before all bigints 51 | // And [3, 5] < [3, 6] means floats sort as expected 52 | // And [7, [[5, "a"]]] < [7, [[5, "a"], [5, "b"]]] means arrays sort as expected 53 | function makeComparable(v: Value | undefined): [number, any] { 54 | if (v === undefined) { 55 | return [0, undefined]; 56 | } 57 | if (v === null) { 58 | return [1, null]; 59 | } 60 | if (typeof v === "bigint") { 61 | return [2, v]; 62 | } 63 | if (typeof v === "number") { 64 | if (isNaN(v)) { 65 | // Consider all NaNs to be equal. 66 | return [3.5, 0]; 67 | } 68 | return [3, v]; 69 | } 70 | if (typeof v === "boolean") { 71 | return [4, v]; 72 | } 73 | if (typeof v === "string") { 74 | return [5, v]; 75 | } 76 | if (v instanceof ArrayBuffer) { 77 | return [6, Array.from(new Uint8Array(v)).map(makeComparable)]; 78 | } 79 | if (Array.isArray(v)) { 80 | return [7, v.map(makeComparable)]; 81 | } 82 | // Otherwise, it's an POJO. 83 | const keys = Object.keys(v).sort(); 84 | const pojo: Value[] = keys.map((k) => [k, v[k]!]); 85 | return [8, pojo.map(makeComparable)]; 86 | } 87 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/cors.test.http.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used to define the HTTP routes for the cors.test.ts file. 3 | * It does not contain any tests, but the .test path both excludes it from the 4 | * generated API spec and indicates its intent. 5 | */ 6 | import { httpRouter, httpActionGeneric } from "convex/server"; 7 | import { corsRouter } from "./cors.js"; 8 | 9 | const everythingHandler = httpActionGeneric(async () => { 10 | return new Response(JSON.stringify([{ fact: "Hello, world!" }]), { 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | }); 15 | }); 16 | 17 | const http = httpRouter(); 18 | const cors = corsRouter(http, { 19 | allowedOrigins: ["*"], 20 | }); 21 | 22 | /** 23 | * Exact routes will match /fact exactly 24 | */ 25 | cors.route({ 26 | path: "/fact", 27 | method: "GET", 28 | handler: everythingHandler, 29 | }); 30 | 31 | cors.route({ 32 | path: "/fact", 33 | method: "POST", 34 | handler: everythingHandler, 35 | }); 36 | 37 | cors.route({ 38 | path: "/fact", 39 | method: "PATCH", 40 | handler: everythingHandler, 41 | }); 42 | 43 | cors.route({ 44 | path: "/fact", 45 | method: "DELETE", 46 | handler: everythingHandler, 47 | }); 48 | 49 | /** 50 | * Non-CORS routes 51 | */ 52 | http.route({ 53 | path: "/nocors/fact", 54 | method: "GET", 55 | handler: everythingHandler, 56 | }); 57 | 58 | http.route({ 59 | path: "/nocors/fact", 60 | method: "POST", 61 | handler: everythingHandler, 62 | }); 63 | 64 | /** 65 | * Prefix routes will match /dynamicFact/123 and /dynamicFact/456 etc. 66 | */ 67 | cors.route({ 68 | pathPrefix: "/dynamicFact/", 69 | method: "GET", 70 | handler: everythingHandler, 71 | }); 72 | 73 | cors.route({ 74 | pathPrefix: "/dynamicFact/", 75 | method: "PATCH", 76 | handler: everythingHandler, 77 | }); 78 | 79 | /** 80 | * Per-path "allowedOrigins" will override the default "allowedOrigins" for that route 81 | */ 82 | cors.route({ 83 | path: "/specialRouteOnlyForThisOrigin", 84 | method: "GET", 85 | handler: httpActionGeneric(async () => { 86 | return new Response( 87 | JSON.stringify({ message: "Custom allowed origins! Wow!" }), 88 | { 89 | status: 200, 90 | headers: { 91 | "Content-Type": "application/json", 92 | }, 93 | }, 94 | ); 95 | }), 96 | allowedOrigins: ["http://localhost:3000"], 97 | }); 98 | 99 | /** 100 | * Disable CORS for this route 101 | */ 102 | http.route({ 103 | path: "/routeWithoutCors", 104 | method: "GET", 105 | handler: httpActionGeneric(async () => { 106 | return new Response( 107 | JSON.stringify({ message: "No CORS allowed here, pal." }), 108 | { 109 | status: 200, 110 | headers: { 111 | "Content-Type": "application/json", 112 | }, 113 | }, 114 | ); 115 | }), 116 | }); 117 | 118 | /** 119 | * Test that allowed headers are correctly set. 120 | */ 121 | cors.route({ 122 | path: "/allowedHeaders", 123 | method: "GET", 124 | handler: everythingHandler, 125 | allowedHeaders: ["X-Custom-Header"], 126 | }); 127 | 128 | /** 129 | * Test that the exposed headers are correctly set. 130 | */ 131 | cors.route({ 132 | path: "/exposedHeaders", 133 | method: "GET", 134 | handler: everythingHandler, 135 | exposedHeaders: ["X-Custom-Header"], 136 | }); 137 | 138 | /** 139 | * Test that the browser cache max age is correctly set. 140 | */ 141 | cors.route({ 142 | path: "/browserCacheMaxAge", 143 | method: "GET", 144 | handler: everythingHandler, 145 | browserCacheMaxAge: 60, 146 | }); 147 | 148 | /** 149 | * Test that allow credentials works with *. 150 | */ 151 | cors.route({ 152 | path: "/allowCredentials", 153 | method: "GET", 154 | handler: everythingHandler, 155 | allowCredentials: true, 156 | }); 157 | 158 | /** 159 | * Test that allow credentials works with a specific origin. 160 | */ 161 | cors.route({ 162 | path: "/allowCredentialsWithOrigin", 163 | method: "GET", 164 | handler: everythingHandler, 165 | allowCredentials: true, 166 | allowedOrigins: ["http://localhost:3000"], 167 | }); 168 | 169 | export default http; 170 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/crud.test.ts: -------------------------------------------------------------------------------- 1 | import { convexTest } from "convex-test"; 2 | import { expect, test } from "vitest"; 3 | import { crud } from "./crud.js"; 4 | import type { 5 | ApiFromModules, 6 | DataModelFromSchemaDefinition, 7 | MutationBuilder, 8 | QueryBuilder, 9 | } from "convex/server"; 10 | import { anyApi, defineSchema, defineTable } from "convex/server"; 11 | import { v } from "convex/values"; 12 | import { internalQueryGeneric, internalMutationGeneric } from "convex/server"; 13 | import { modules } from "./setup.test.js"; 14 | import { customCtx, customMutation, customQuery } from "./customFunctions.js"; 15 | 16 | const ExampleFields = { 17 | foo: v.string(), 18 | bar: v.union(v.object({ n: v.optional(v.number()) }), v.null()), 19 | baz: v.optional(v.boolean()), 20 | }; 21 | const CrudTable = "crud_example"; 22 | 23 | const schema = defineSchema({ 24 | [CrudTable]: defineTable(ExampleFields), 25 | }); 26 | type DataModel = DataModelFromSchemaDefinition; 27 | const internalQuery = internalQueryGeneric as QueryBuilder< 28 | DataModel, 29 | "internal" 30 | >; 31 | const internalMutation = internalMutationGeneric as MutationBuilder< 32 | DataModel, 33 | "internal" 34 | >; 35 | 36 | export const { create, read, paginate, update, destroy } = crud( 37 | schema, 38 | CrudTable, 39 | ); 40 | 41 | const testApi: ApiFromModules<{ 42 | fns: { 43 | create: typeof create; 44 | read: typeof read; 45 | update: typeof update; 46 | paginate: typeof paginate; 47 | destroy: typeof destroy; 48 | }; 49 | }>["fns"] = anyApi["crud.test"] as any; 50 | 51 | test("crud for table", async () => { 52 | const t = convexTest(schema, modules); 53 | const doc = await t.mutation(testApi.create, { foo: "", bar: null }); 54 | expect(doc).toMatchObject({ foo: "", bar: null }); 55 | const read = await t.query(testApi.read, { id: doc._id }); 56 | expect(read).toMatchObject(doc); 57 | await t.mutation(testApi.update, { 58 | id: doc._id, 59 | patch: { foo: "new", bar: { n: 42 }, baz: true }, 60 | }); 61 | expect(await t.query(testApi.read, { id: doc._id })).toMatchObject({ 62 | foo: "new", 63 | bar: { n: 42 }, 64 | baz: true, 65 | }); 66 | await t.mutation(testApi.destroy, { id: doc._id }); 67 | expect(await t.query(testApi.read, { id: doc._id })).toBe(null); 68 | }); 69 | 70 | /** 71 | * Custom function tests 72 | */ 73 | 74 | const customQ = customQuery( 75 | internalQuery, 76 | customCtx((ctx) => ({ foo: "bar" })), 77 | ); 78 | const customM = customMutation( 79 | internalMutation, 80 | customCtx((ctx) => ({})), 81 | ); 82 | 83 | const customCrud = crud(schema, CrudTable, customQ, customM); 84 | 85 | const customTestApi: ApiFromModules<{ 86 | fns: { 87 | create: typeof customCrud.create; 88 | read: typeof customCrud.read; 89 | update: typeof customCrud.update; 90 | paginate: typeof customCrud.paginate; 91 | destroy: typeof customCrud.destroy; 92 | }; 93 | }>["fns"] = anyApi["crud.test"] as any; 94 | 95 | test("custom crud for table", async () => { 96 | const t = convexTest(schema, modules); 97 | const doc = await t.mutation(customTestApi.create, { foo: "", bar: null }); 98 | expect(doc).toMatchObject({ foo: "", bar: null }); 99 | const read = await t.query(customTestApi.read, { id: doc._id }); 100 | expect(read).toMatchObject(doc); 101 | await t.mutation(customTestApi.update, { 102 | id: doc._id, 103 | patch: { foo: "new", bar: { n: 42 }, baz: true }, 104 | }); 105 | expect(await t.query(customTestApi.read, { id: doc._id })).toMatchObject({ 106 | foo: "new", 107 | bar: { n: 42 }, 108 | baz: true, 109 | }); 110 | await t.mutation(customTestApi.destroy, { id: doc._id }); 111 | expect(await t.query(customTestApi.read, { id: doc._id })).toBe(null); 112 | }); 113 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/crud.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | QueryBuilder, 3 | MutationBuilder, 4 | WithoutSystemFields, 5 | DocumentByName, 6 | RegisteredMutation, 7 | RegisteredQuery, 8 | FunctionVisibility, 9 | PaginationResult, 10 | SchemaDefinition, 11 | GenericSchema, 12 | TableNamesInDataModel, 13 | DataModelFromSchemaDefinition, 14 | } from "convex/server"; 15 | import { 16 | paginationOptsValidator, 17 | internalQueryGeneric, 18 | internalMutationGeneric, 19 | } from "convex/server"; 20 | import type { GenericId, Infer } from "convex/values"; 21 | import { v } from "convex/values"; 22 | import { partial } from "../validators.js"; 23 | /** 24 | * Create CRUD operations for a table. 25 | * You can expose these operations in your API. For example, in convex/users.ts: 26 | * 27 | * ```ts 28 | * // in convex/users.ts 29 | * import { crud } from "convex-helpers/server/crud"; 30 | * import schema from "./schema"; 31 | * 32 | * export const { create, read, update, destroy } = crud(schema, "users"); 33 | * ``` 34 | * 35 | * Then you can access the functions like `internal.users.create` from actions. 36 | * 37 | * To expose these functions publicly, you can pass in custom query and 38 | * mutation arguments. Be careful what you expose publicly: you wouldn't want 39 | * any client to be able to delete users, for example. 40 | * 41 | * @param schema Your project's schema. 42 | * @param table The table name to create CRUD operations for. 43 | * @param query The query to use - use internalQuery or query from 44 | * "./convex/_generated/server" or a customQuery. 45 | * @param mutation The mutation to use - use internalMutation or mutation from 46 | * "./convex/_generated/server" or a customMutation. 47 | * @returns An object with create, read, update, and delete functions. 48 | * You must export these functions at the top level of your file to use them. 49 | */ 50 | export function crud< 51 | Schema extends GenericSchema, 52 | TableName extends TableNamesInDataModel< 53 | DataModelFromSchemaDefinition> 54 | >, 55 | QueryVisibility extends FunctionVisibility = "internal", 56 | MutationVisibility extends FunctionVisibility = "internal", 57 | >( 58 | schema: SchemaDefinition, 59 | table: TableName, 60 | query: QueryBuilder< 61 | DataModelFromSchemaDefinition>, 62 | QueryVisibility 63 | > = internalQueryGeneric as any, 64 | mutation: MutationBuilder< 65 | DataModelFromSchemaDefinition>, 66 | MutationVisibility 67 | > = internalMutationGeneric as any, 68 | ) { 69 | type DataModel = DataModelFromSchemaDefinition>; 70 | const systemFields = { 71 | _id: v.id(table), 72 | _creationTime: v.number(), 73 | }; 74 | const validator = schema.tables[table]?.validator; 75 | if (!validator) { 76 | throw new Error( 77 | `Table ${table} not found in schema. Did you define it in defineSchema?`, 78 | ); 79 | } 80 | if (validator.kind !== "object") { 81 | throw new Error( 82 | `CRUD only supports simple tables ${table} is a ${validator.type}`, 83 | ); 84 | } 85 | 86 | return { 87 | create: mutation({ 88 | args: { 89 | ...validator.fields, 90 | ...partial(systemFields), 91 | }, 92 | handler: async (ctx, args) => { 93 | if ("_id" in args) delete args._id; 94 | if ("_creationTime" in args) delete args._creationTime; 95 | const id = await ctx.db.insert( 96 | table, 97 | args as unknown as WithoutSystemFields< 98 | DocumentByName 99 | >, 100 | ); 101 | return (await ctx.db.get(id))!; 102 | }, 103 | }) as RegisteredMutation< 104 | MutationVisibility, 105 | WithoutSystemFields>, 106 | Promise> 107 | >, 108 | read: query({ 109 | args: { id: v.id(table) }, 110 | handler: async (ctx, args) => { 111 | return await ctx.db.get(args.id); 112 | }, 113 | }) as RegisteredQuery< 114 | QueryVisibility, 115 | { id: GenericId }, 116 | Promise | null> 117 | >, 118 | paginate: query({ 119 | args: { 120 | paginationOpts: paginationOptsValidator, 121 | }, 122 | handler: async (ctx, args) => { 123 | return ctx.db.query(table).paginate(args.paginationOpts); 124 | }, 125 | }) as RegisteredQuery< 126 | QueryVisibility, 127 | { paginationOpts: Infer }, 128 | Promise>> 129 | >, 130 | update: mutation({ 131 | args: { 132 | id: v.id(table), 133 | // this could be partial(table.withSystemFields) but keeping 134 | // the api less coupled to Table 135 | patch: v.object({ 136 | ...partial(validator.fields), 137 | ...partial(systemFields), 138 | }), 139 | }, 140 | handler: async (ctx, args) => { 141 | await ctx.db.patch( 142 | args.id, 143 | args.patch as Partial>, 144 | ); 145 | }, 146 | }) as RegisteredMutation< 147 | MutationVisibility, 148 | { 149 | id: GenericId; 150 | patch: Partial< 151 | WithoutSystemFields> 152 | >; 153 | }, 154 | Promise 155 | >, 156 | destroy: mutation({ 157 | args: { id: v.id(table) }, 158 | handler: async (ctx, args) => { 159 | const old = await ctx.db.get(args.id); 160 | if (old) { 161 | await ctx.db.delete(args.id); 162 | } 163 | return old; 164 | }, 165 | }) as RegisteredMutation< 166 | MutationVisibility, 167 | { id: GenericId }, 168 | Promise> 169 | >, 170 | }; 171 | } 172 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { filter } from "./filter.js"; 2 | import { convexTest } from "convex-test"; 3 | import { v } from "convex/values"; 4 | import { expect, test } from "vitest"; 5 | import { defineSchema, defineTable } from "convex/server"; 6 | import { modules } from "./setup.test.js"; 7 | 8 | const schema = defineSchema({ 9 | tableA: defineTable({ 10 | count: v.number(), 11 | }), 12 | tableB: defineTable({ 13 | tableAId: v.id("tableA"), 14 | name: v.string(), 15 | }), 16 | }); 17 | 18 | test("filter", async () => { 19 | const t = convexTest(schema, modules); 20 | await t.run(async (ctx) => { 21 | for (let i = 0; i < 10; i++) { 22 | const tableAId = await ctx.db.insert("tableA", { count: i }); 23 | await ctx.db.insert("tableB", { tableAId, name: String(i) }); 24 | } 25 | }); 26 | const evens = await t.run((ctx) => 27 | filter(ctx.db.query("tableA"), (c) => c.count % 2 === 0).collect(), 28 | ); 29 | expect(evens).toMatchObject([ 30 | { count: 0 }, 31 | { count: 2 }, 32 | { count: 4 }, 33 | { count: 6 }, 34 | { count: 8 }, 35 | ]); 36 | // For comparison, even filters that were possible before, it's much more 37 | // readable to use the JavaScript filter. 38 | const evensBuiltin = await t.run((ctx) => 39 | ctx.db 40 | .query("tableA") 41 | .filter((q) => q.eq(q.mod(q.field("count"), 2), 0)) 42 | .collect(), 43 | ); 44 | expect(evens).toMatchObject(evensBuiltin); 45 | 46 | const withLookup = await t.run((ctx) => 47 | filter( 48 | ctx.db.query("tableB"), 49 | async (c) => ((await ctx.db.get(c.tableAId))?.count ?? 0) > 5, 50 | ).collect(), 51 | ); 52 | expect(withLookup).toMatchObject([ 53 | { name: "6" }, 54 | { name: "7" }, 55 | { name: "8" }, 56 | { name: "9" }, 57 | ]); 58 | 59 | // Check that ordering works, before or after the filter. 60 | const withOrder = await t.run((ctx) => 61 | filter(ctx.db.query("tableA").order("desc"), (c) => c.count > 5).collect(), 62 | ); 63 | expect(withOrder).toMatchObject([ 64 | { count: 9 }, 65 | { count: 8 }, 66 | { count: 7 }, 67 | { count: 6 }, 68 | ]); 69 | 70 | const withOrderAfter = await t.run((ctx) => 71 | filter(ctx.db.query("tableA"), (c) => c.count > 5) 72 | .order("desc") 73 | .collect(), 74 | ); 75 | expect(withOrderAfter).toMatchObject([ 76 | { count: 9 }, 77 | { count: 8 }, 78 | { count: 7 }, 79 | { count: 6 }, 80 | ]); 81 | }); 82 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/filter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines a function `filter` that wraps a query, attaching a 3 | * JavaScript/TypeScript function that filters results just like 4 | * `db.query(...).filter(...)` but with more generality. 5 | * 6 | */ 7 | 8 | import type { 9 | DocumentByInfo, 10 | GenericTableInfo, 11 | PaginationOptions, 12 | QueryInitializer, 13 | PaginationResult, 14 | FilterBuilder, 15 | Expression, 16 | OrderedQuery, 17 | IndexRange, 18 | IndexRangeBuilder, 19 | Indexes, 20 | NamedIndex, 21 | NamedSearchIndex, 22 | Query, 23 | SearchFilterBuilder, 24 | SearchIndexes, 25 | } from "convex/server"; 26 | 27 | import { SearchFilter } from "convex/server"; 28 | 29 | async function asyncFilter( 30 | arr: T[], 31 | predicate: (d: T) => Promise | boolean, 32 | ): Promise { 33 | const results = await Promise.all(arr.map(predicate)); 34 | return arr.filter((_v, index) => results[index]); 35 | } 36 | 37 | class QueryWithFilter 38 | implements QueryInitializer 39 | { 40 | // q actually is only guaranteed to implement OrderedQuery, 41 | // but we forward all QueryInitializer methods to it and if they fail they fail. 42 | q: QueryInitializer; 43 | p: Predicate; 44 | iterator?: AsyncIterator; 45 | 46 | constructor(q: OrderedQuery, p: Predicate) { 47 | this.q = q as QueryInitializer; 48 | this.p = p; 49 | } 50 | filter(predicate: (q: FilterBuilder) => Expression): this { 51 | return new QueryWithFilter(this.q.filter(predicate), this.p) as this; 52 | } 53 | order(order: "asc" | "desc"): QueryWithFilter { 54 | return new QueryWithFilter(this.q.order(order), this.p); 55 | } 56 | async paginate( 57 | paginationOpts: PaginationOptions, 58 | ): Promise>> { 59 | const result = await this.q.paginate(paginationOpts); 60 | return { ...result, page: await asyncFilter(result.page, this.p) }; 61 | } 62 | async collect(): Promise[]> { 63 | const results = await this.q.collect(); 64 | return await asyncFilter(results, this.p); 65 | } 66 | async take(n: number): Promise[]> { 67 | const results: DocumentByInfo[] = []; 68 | for await (const result of this) { 69 | results.push(result); 70 | if (results.length >= n) { 71 | break; 72 | } 73 | } 74 | return results; 75 | } 76 | async first(): Promise | null> { 77 | for await (const result of this) { 78 | return result; 79 | } 80 | return null; 81 | } 82 | async unique(): Promise | null> { 83 | let uniqueResult: DocumentByInfo | null = null; 84 | for await (const result of this) { 85 | if (uniqueResult === null) { 86 | uniqueResult = result; 87 | } else { 88 | throw new Error( 89 | `unique() query returned more than one result: 90 | [${uniqueResult._id}, ${result._id}, ...]`, 91 | ); 92 | } 93 | } 94 | return uniqueResult; 95 | } 96 | [Symbol.asyncIterator](): AsyncIterator, any, undefined> { 97 | this.iterator = this.q[Symbol.asyncIterator](); 98 | return this; 99 | } 100 | async next(): Promise> { 101 | for (;;) { 102 | const { value, done } = await this.iterator!.next(); 103 | if (value && (await this.p(value))) { 104 | return { value, done }; 105 | } 106 | if (done) { 107 | return { value: null, done: true }; 108 | } 109 | } 110 | } 111 | return() { 112 | return this.iterator!.return!(); 113 | } 114 | 115 | // Implement the remainder of QueryInitializer. 116 | fullTableScan(): QueryWithFilter { 117 | return new QueryWithFilter(this.q.fullTableScan(), this.p); 118 | } 119 | withIndex>( 120 | indexName: IndexName, 121 | indexRange?: 122 | | (( 123 | q: IndexRangeBuilder, NamedIndex, 0>, 124 | ) => IndexRange) 125 | | undefined, 126 | ): Query { 127 | return new QueryWithFilter(this.q.withIndex(indexName, indexRange), this.p); 128 | } 129 | withSearchIndex>( 130 | indexName: IndexName, 131 | searchFilter: ( 132 | q: SearchFilterBuilder, NamedSearchIndex>, 133 | ) => SearchFilter, 134 | ): OrderedQuery { 135 | return new QueryWithFilter( 136 | this.q.withSearchIndex(indexName, searchFilter), 137 | this.p, 138 | ); 139 | } 140 | } 141 | 142 | export type Predicate = ( 143 | doc: DocumentByInfo, 144 | ) => Promise | boolean; 145 | 146 | type QueryTableInfo = Q extends OrderedQuery ? T : never; 147 | 148 | /** 149 | * Applies a filter to a database query, just like `.filter((q) => ...)` but 150 | * supporting arbitrary JavaScript/TypeScript. 151 | * Performance is roughly the same as `.filter((q) => ...)`. If you want better 152 | * performance, use an index to narrow down the results before filtering. 153 | * 154 | * Examples: 155 | * 156 | * // Full table scan, filtered to short messages. 157 | * return await filter( 158 | * ctx.db.query("messages"), 159 | * async (message) => message.body.length < 10, 160 | * ).collect(); 161 | * 162 | * // Short messages by author, paginated. 163 | * return await filter( 164 | * ctx.db.query("messages").withIndex("by_author", q=>q.eq("author", args.author)), 165 | * async (message) => message.body.length < 10, 166 | * ).paginate(args.paginationOpts); 167 | * 168 | * // Same behavior as above: Short messages by author, paginated. 169 | * // Note the filter can wrap any part of the query pipeline, and it is applied 170 | * // at the end. This is how RowLevelSecurity works. 171 | * const shortMessages = await filter( 172 | * ctx.db.query("messages"), 173 | * async (message) => message.body.length < 10, 174 | * ); 175 | * return await shortMessages 176 | * .withIndex("by_author", q=>q.eq("author", args.author)) 177 | * .paginate(args.paginationOpts); 178 | * 179 | * // Also works with `order()`, `take()`, `unique()`, and `first()`. 180 | * return await filter( 181 | * ctx.db.query("messages").order("desc"), 182 | * async (message) => message.body.length < 10, 183 | * ).first(); 184 | * 185 | * @param query The query to filter. 186 | * @param predicate Async function to run on each document before it is yielded 187 | * from the query pipeline. 188 | * @returns A new query with the filter applied. 189 | */ 190 | export function filter>( 191 | query: Q, 192 | predicate: Predicate>, 193 | ): Q { 194 | return new QueryWithFilter>( 195 | query as unknown as OrderedQuery>, 196 | predicate, 197 | ) as any as Q; 198 | } 199 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/rowLevelSecurity.test.ts: -------------------------------------------------------------------------------- 1 | import { convexTest } from "convex-test"; 2 | import { v } from "convex/values"; 3 | import { describe, expect, test } from "vitest"; 4 | import { wrapDatabaseWriter } from "./rowLevelSecurity.js"; 5 | import type { 6 | Auth, 7 | DataModelFromSchemaDefinition, 8 | GenericDatabaseWriter, 9 | MutationBuilder, 10 | } from "convex/server"; 11 | import { 12 | defineSchema, 13 | defineTable, 14 | mutationGeneric, 15 | queryGeneric, 16 | } from "convex/server"; 17 | import { modules } from "./setup.test.js"; 18 | import { customCtx, customMutation } from "./customFunctions.js"; 19 | import { crud } from "./crud.js"; 20 | 21 | const schema = defineSchema({ 22 | users: defineTable({ 23 | tokenIdentifier: v.string(), 24 | }), 25 | notes: defineTable({ 26 | note: v.string(), 27 | userId: v.id("users"), 28 | }), 29 | }); 30 | 31 | type DataModel = DataModelFromSchemaDefinition; 32 | type DatabaseWriter = GenericDatabaseWriter; 33 | 34 | const withRLS = async (ctx: { db: DatabaseWriter; auth: Auth }) => { 35 | const tokenIdentifier = (await ctx.auth.getUserIdentity())?.tokenIdentifier; 36 | if (!tokenIdentifier) throw new Error("Unauthenticated"); 37 | return { 38 | ...ctx, 39 | db: wrapDatabaseWriter({ tokenIdentifier }, ctx.db, { 40 | notes: { 41 | read: async ({ tokenIdentifier }, doc) => { 42 | const author = await ctx.db.get(doc.userId); 43 | return tokenIdentifier === author?.tokenIdentifier; 44 | }, 45 | }, 46 | }), 47 | }; 48 | }; 49 | 50 | describe("row level security", () => { 51 | test("can only read own notes", async () => { 52 | const t = convexTest(schema, modules); 53 | await t.run(async (ctx) => { 54 | const aId = await ctx.db.insert("users", { tokenIdentifier: "Person A" }); 55 | const bId = await ctx.db.insert("users", { tokenIdentifier: "Person B" }); 56 | await ctx.db.insert("notes", { 57 | note: "Hello from Person A", 58 | userId: aId, 59 | }); 60 | await ctx.db.insert("notes", { 61 | note: "Hello from Person B", 62 | userId: bId, 63 | }); 64 | }); 65 | const asA = t.withIdentity({ tokenIdentifier: "Person A" }); 66 | const asB = t.withIdentity({ tokenIdentifier: "Person B" }); 67 | const notesA = await asA.run(async (ctx) => { 68 | const rls = await withRLS(ctx); 69 | return await rls.db.query("notes").collect(); 70 | }); 71 | expect(notesA).toMatchObject([{ note: "Hello from Person A" }]); 72 | 73 | const notesB = await asB.run(async (ctx) => { 74 | const rls = await withRLS(ctx); 75 | return await rls.db.query("notes").collect(); 76 | }); 77 | expect(notesB).toMatchObject([{ note: "Hello from Person B" }]); 78 | }); 79 | 80 | test("cannot delete someone else's note", async () => { 81 | const t = convexTest(schema, modules); 82 | const noteId = await t.run(async (ctx) => { 83 | const aId = await ctx.db.insert("users", { tokenIdentifier: "Person A" }); 84 | await ctx.db.insert("users", { tokenIdentifier: "Person B" }); 85 | return ctx.db.insert("notes", { 86 | note: "Hello from Person A", 87 | userId: aId, 88 | }); 89 | }); 90 | const asA = t.withIdentity({ tokenIdentifier: "Person A" }); 91 | const asB = t.withIdentity({ tokenIdentifier: "Person B" }); 92 | await expect(() => 93 | asB.run(async (ctx) => { 94 | const rls = await withRLS(ctx); 95 | return rls.db.delete(noteId); 96 | }), 97 | ).rejects.toThrow(/no read access/); 98 | await asA.run(async (ctx) => { 99 | const rls = await withRLS(ctx); 100 | return rls.db.delete(noteId); 101 | }); 102 | }); 103 | }); 104 | 105 | const mutation = mutationGeneric as MutationBuilder; 106 | 107 | const mutationWithRLS = customMutation( 108 | mutation, 109 | customCtx((ctx) => withRLS(ctx)), 110 | ); 111 | 112 | customMutation( 113 | mutationWithRLS, 114 | customCtx((ctx) => ({ foo: "bar" })), 115 | ) satisfies typeof mutation; 116 | 117 | crud(schema, "users", queryGeneric, mutationWithRLS); 118 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/sessions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Allows you to persist state server-side, associated with a sessionId stored 3 | * on the client (in localStorage, e.g.). 4 | * 5 | * You can define your function to take in a sessionId parameter of type 6 | * SessionId, which is just a branded string to help avoid errors. 7 | * The validator is vSessionId, or you can spread the argument in for your 8 | * function like this: 9 | * ```ts 10 | * const myMutation = mutation({ 11 | * args: { 12 | * arg1: v.number(), // whatever other args you want 13 | * ...SessionIdArg, 14 | * }, 15 | * handler: async (ctx, args) => { 16 | * // args.sessionId is a SessionId 17 | * }) 18 | * }); 19 | * ``` 20 | * 21 | * Then, on the client side, you can use {@link useSessionMutation} to call 22 | * your function with the sessionId automatically passed in, like: 23 | * ```ts 24 | * const myMutation = useSessionMutation(api.myModule.myMutation); 25 | * ... 26 | * await myMutation({ arg1: 123 }); 27 | * ``` 28 | * 29 | * To codify the sessionId parameter, you can use the customFunction module to 30 | * create a custom mutation or query, like: 31 | * ```ts 32 | * export const sessionMutation = customMutation(mutation, { 33 | * args: { ...SessionIdArg }, 34 | * input: (ctx, { sessionId }) => { 35 | * const anonUser = await getAnonymousUser(ctx, sessionId); 36 | * return { ctx: { anonUser }, args: {} }; 37 | * }, 38 | * }); 39 | * ``` 40 | * 41 | * Then you can define functions like: 42 | * ```ts 43 | * export const myMutation = sessionMutation({ 44 | * args: { arg1: v.number() }, // whatever other args you want 45 | * handler: async (ctx, args) => { 46 | * // ctx.anonUser exists and has a type from getAnonymousUser. 47 | * // args is { arg1: number } 48 | * }) 49 | * }); 50 | * ``` 51 | * 52 | * See the associated [Stack post](https://stack.convex.dev/track-sessions-without-cookies) 53 | * for more information. 54 | */ 55 | import type { 56 | FunctionArgs, 57 | FunctionReference, 58 | FunctionReturnType, 59 | GenericActionCtx, 60 | GenericDataModel, 61 | } from "convex/server"; 62 | import type { Validator } from "convex/values"; 63 | import { v } from "convex/values"; 64 | import type { BetterOmit, EmptyObject } from "../index.js"; 65 | 66 | // Branded string type for session IDs. 67 | export type SessionId = string & { __SessionId: true }; 68 | // Validator for session IDs. 69 | export const vSessionId = v.string() as Validator; 70 | export const SessionIdArg = { sessionId: vSessionId }; 71 | 72 | type SessionFunction< 73 | T extends "query" | "mutation" | "action", 74 | Args extends any = any, 75 | > = FunctionReference< 76 | T, 77 | "public" | "internal", 78 | { sessionId: SessionId } & Args, 79 | any 80 | >; 81 | 82 | type SessionArgsArray< 83 | Fn extends SessionFunction<"query" | "mutation" | "action", any>, 84 | > = keyof FunctionArgs extends "sessionId" 85 | ? [args?: EmptyObject] 86 | : [args: BetterOmit, "sessionId">]; 87 | 88 | export interface RunSessionFunctions { 89 | /** 90 | * Run the Convex query with the given name and arguments. 91 | * 92 | * Consider using an {@link internalQuery} to prevent users from calling the 93 | * query directly. 94 | * 95 | * @param query - A {@link FunctionReference} for the query to run. 96 | * @param args - The arguments to the query function. 97 | * @returns A promise of the query's result. 98 | */ 99 | runSessionQuery>( 100 | query: Query, 101 | ...args: SessionArgsArray 102 | ): Promise>; 103 | 104 | /** 105 | * Run the Convex mutation with the given name and arguments. 106 | * 107 | * Consider using an {@link internalMutation} to prevent users from calling 108 | * the mutation directly. 109 | * 110 | * @param mutation - A {@link FunctionReference} for the mutation to run. 111 | * @param args - The arguments to the mutation function. 112 | * @returns A promise of the mutation's result. 113 | */ 114 | runSessionMutation>( 115 | mutation: Mutation, 116 | ...args: SessionArgsArray 117 | ): Promise>; 118 | 119 | /** 120 | * Run the Convex action with the given name and arguments. 121 | * 122 | * Consider using an {@link internalAction} to prevent users from calling the 123 | * action directly. 124 | * 125 | * @param action - A {@link FunctionReference} for the action to run. 126 | * @param args - The arguments to the action function. 127 | * @returns A promise of the action's result. 128 | */ 129 | runSessionAction>( 130 | action: Action, 131 | ...args: SessionArgsArray 132 | ): Promise>; 133 | } 134 | export function runSessionFunctions( 135 | ctx: GenericActionCtx, 136 | sessionId: SessionId, 137 | ): RunSessionFunctions { 138 | return { 139 | runSessionQuery(fn, ...args) { 140 | const argsWithSession = { ...(args[0] ?? {}), sessionId } as FunctionArgs< 141 | typeof fn 142 | >; 143 | return ctx.runQuery(fn, argsWithSession); 144 | }, 145 | runSessionMutation(fn, ...args) { 146 | const argsWithSession = { ...(args[0] ?? {}), sessionId } as FunctionArgs< 147 | typeof fn 148 | >; 149 | return ctx.runMutation(fn, argsWithSession); 150 | }, 151 | runSessionAction(fn, ...args) { 152 | const argsWithSession = { ...(args[0] ?? {}), sessionId } as FunctionArgs< 153 | typeof fn 154 | >; 155 | return ctx.runAction(fn, argsWithSession); 156 | }, 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/setup.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export const modules = import.meta.glob("./**/*.*s"); 3 | 4 | test("setup", () => {}); 5 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/table.test.ts: -------------------------------------------------------------------------------- 1 | import { omit, pick, pruneNull } from "../index.js"; 2 | import { Table } from "../server.js"; 3 | import { partial } from "../validators.js"; 4 | import { convexTest } from "convex-test"; 5 | import type { 6 | ApiFromModules, 7 | DataModelFromSchemaDefinition, 8 | MutationBuilder, 9 | QueryBuilder, 10 | } from "convex/server"; 11 | import { 12 | anyApi, 13 | defineSchema, 14 | internalMutationGeneric, 15 | internalQueryGeneric, 16 | } from "convex/server"; 17 | import { v } from "convex/values"; 18 | import { assertType, expect, test } from "vitest"; 19 | import { modules } from "./setup.test.js"; 20 | 21 | // Define a table with system fields _id and _creationTime. This also returns 22 | // helpers for working with the table in validators. See: 23 | // https://stack.convex.dev/argument-validation-without-repetition#table-helper-for-schema-definition--validation 24 | const Example = Table("table_example", { 25 | foo: v.string(), 26 | bar: v.union(v.number(), v.null()), 27 | baz: v.optional(v.boolean()), 28 | }); 29 | 30 | const schema = defineSchema({ 31 | [Example.name]: Example.table.index("by_foo", ["foo"]), 32 | }); 33 | type DataModel = DataModelFromSchemaDefinition; 34 | const internalQuery = internalQueryGeneric as QueryBuilder< 35 | DataModel, 36 | "internal" 37 | >; 38 | const internalMutation = internalMutationGeneric as MutationBuilder< 39 | DataModel, 40 | "internal" 41 | >; 42 | 43 | export const allAtOnce = internalQuery({ 44 | args: { 45 | id: Example._id, 46 | whole: Example.doc, 47 | insertable: v.object(Example.withoutSystemFields), 48 | patchable: v.object(partial(Example.withoutSystemFields)), 49 | replaceable: v.object({ 50 | ...Example.withoutSystemFields, 51 | ...partial(Example.systemFields), 52 | }), 53 | picked: v.object(pick(Example.withSystemFields, ["foo", "bar"])), 54 | omitted: v.object(omit(Example.withSystemFields, ["foo"])), 55 | }, 56 | handler: async (_ctx, args) => { 57 | return args; 58 | }, 59 | }); 60 | 61 | export const get = internalQuery({ 62 | args: { id: Example._id }, 63 | handler: async (ctx, args) => { 64 | return await ctx.db.get(args.id); 65 | }, 66 | }); 67 | 68 | export const docAsParam = internalQuery({ 69 | args: { docs: v.array(Example.doc) }, 70 | handler: async (ctx, args) => { 71 | return args.docs.map((doc) => { 72 | return `${doc.foo} ${doc.bar} ${doc.baz}`; 73 | }); 74 | }, 75 | }); 76 | 77 | export const insert = internalMutation({ 78 | args: Example.withoutSystemFields, 79 | handler: async (ctx, args) => { 80 | assertType(true); 81 | return ctx.db.insert(Example.name, args); 82 | }, 83 | }); 84 | 85 | export const patch = internalMutation({ 86 | args: { 87 | id: Example._id, 88 | patch: v.object(partial(Example.withoutSystemFields)), 89 | }, 90 | handler: async (ctx, args) => { 91 | await ctx.db.patch(args.id, args.patch); 92 | }, 93 | }); 94 | 95 | export const replace = internalMutation({ 96 | args: { 97 | // You can provide the document with or without system fields. 98 | ...Example.withoutSystemFields, 99 | ...partial(Example.systemFields), 100 | _id: Example._id, 101 | }, 102 | handler: async (ctx, args) => { 103 | await ctx.db.replace(args._id, args); 104 | }, 105 | }); 106 | 107 | const testApi: ApiFromModules<{ 108 | fns: { 109 | allAtOnce: typeof allAtOnce; 110 | get: typeof get; 111 | docAsParam: typeof docAsParam; 112 | insert: typeof insert; 113 | patch: typeof patch; 114 | replace: typeof replace; 115 | }; 116 | }>["fns"] = anyApi["table.test"] as any; 117 | 118 | test("crud for table", async () => { 119 | const t = convexTest(schema, modules); 120 | const id = await t.mutation(testApi.insert, { 121 | foo: "", 122 | bar: null, 123 | }); 124 | const original = await t.query(testApi.get, { id }); 125 | expect(original).toMatchObject({ foo: "", bar: null }); 126 | await t.mutation(testApi.patch, { 127 | id, 128 | patch: { foo: "new", baz: true }, 129 | }); 130 | const patched = await t.query(testApi.get, { id }); 131 | expect(patched).toMatchObject({ foo: "new", bar: null, baz: true }); 132 | if (!patched) throw new Error("patched is undefined"); 133 | const toReplace = { ...patched, bar: 42, _creationTime: undefined }; 134 | await t.mutation(testApi.replace, toReplace); 135 | const replaced = await t.query(testApi.get, { id }); 136 | expect(replaced).toMatchObject({ foo: "new", bar: 42, baz: true }); 137 | expect(replaced?._id).toBe(patched._id); 138 | const docs = await t.query(testApi.docAsParam, { 139 | docs: pruneNull([original, patched, replaced]), 140 | }); 141 | expect(docs).toEqual([" null undefined", "new null true", "new 42 true"]); 142 | }); 143 | 144 | test("all at once", async () => { 145 | const t = convexTest(schema, modules); 146 | const id = await t.mutation(testApi.insert, { 147 | foo: "foo", 148 | bar: 123, 149 | baz: false, 150 | }); 151 | const whole = await t.query(testApi.get, { id }); 152 | if (!whole) throw new Error("whole is undefined"); 153 | const { foo, ...omitted } = whole; 154 | const all = await t.query(testApi.allAtOnce, { 155 | id, 156 | whole, 157 | insertable: { foo: "insert", bar: 42 }, 158 | patchable: { foo: "patch" }, 159 | replaceable: { foo: "replace", bar: 42, baz: true }, 160 | picked: { foo, bar: null }, 161 | omitted, 162 | }); 163 | expect(all).toMatchObject({ 164 | id, 165 | whole, 166 | insertable: { foo: "insert", bar: 42 }, 167 | patchable: { foo: "patch" }, 168 | replaceable: { foo: "replace", bar: 42 }, 169 | picked: { foo, bar: null }, 170 | omitted, 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /packages/convex-helpers/server/triggers.test.ts: -------------------------------------------------------------------------------- 1 | import { customCtx, customMutation } from "./customFunctions.js"; 2 | import { Triggers } from "./triggers.js"; 3 | import { convexTest } from "convex-test"; 4 | import type { 5 | ApiFromModules, 6 | DataModelFromSchemaDefinition, 7 | MutationBuilder, 8 | } from "convex/server"; 9 | import { 10 | anyApi, 11 | defineSchema, 12 | defineTable, 13 | mutationGeneric, 14 | } from "convex/server"; 15 | import { v } from "convex/values"; 16 | import { expect, test } from "vitest"; 17 | import { modules } from "./setup.test.js"; 18 | 19 | const schema = defineSchema({ 20 | users: defineTable({ 21 | firstName: v.string(), 22 | lastName: v.string(), 23 | fullName: v.string(), 24 | }), 25 | }); 26 | type DataModel = DataModelFromSchemaDefinition; 27 | const rawMutation = mutationGeneric as MutationBuilder; 28 | 29 | const triggers = new Triggers(); 30 | 31 | triggers.register("users", async (ctx, change) => { 32 | if (change.newDoc) { 33 | const fullName = `${change.newDoc.firstName} ${change.newDoc.lastName}`; 34 | if (change.newDoc.fullName !== fullName) { 35 | ctx.db.patch(change.id, { fullName }); 36 | } 37 | } 38 | }); 39 | 40 | const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB)); 41 | 42 | export const createUser = mutation({ 43 | args: { firstName: v.string(), lastName: v.string() }, 44 | handler: async (ctx, args) => { 45 | return ctx.db.insert("users", { 46 | firstName: args.firstName, 47 | lastName: args.lastName, 48 | fullName: "", 49 | }); 50 | }, 51 | }); 52 | 53 | const testApi: ApiFromModules<{ 54 | fns: { 55 | createUser: typeof createUser; 56 | }; 57 | }>["fns"] = anyApi["triggers.test"] as any; 58 | 59 | test("trigger denormalizes field", async () => { 60 | const t = convexTest(schema, modules); 61 | const userId = await t.mutation(testApi.createUser, { 62 | firstName: "John", 63 | lastName: "Doe", 64 | }); 65 | await t.run(async (ctx) => { 66 | const user = await ctx.db.get(userId); 67 | expect(user!.fullName).toStrictEqual("John Doe"); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/convex-helpers/standardSchema.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { toStandardSchema } from "./standardSchema.js"; 3 | import { v } from "convex/values"; 4 | import { expectTypeOf } from "vitest"; 5 | 6 | describe("toStandardSchema", () => { 7 | test("conforms to StandardSchemaV1 for string", () => { 8 | const schema = toStandardSchema(v.string()); 9 | expect(schema).toHaveProperty("~standard"); 10 | expect(schema["~standard"]).toHaveProperty("version", 1); 11 | expect(schema["~standard"]).toHaveProperty("vendor", "convex-helpers"); 12 | expect(typeof schema["~standard"].validate).toBe("function"); 13 | }); 14 | 15 | test("types the same as an equivalent zod validator", () => { 16 | const ours = toStandardSchema(v.string()); 17 | const value = ours["~standard"].validate("hello"); 18 | const zods = z.string(); 19 | const zodValue = zods["~standard"].validate("hello"); 20 | expectTypeOf(ours["~standard"]).toEqualTypeOf(zods["~standard"]); 21 | expectTypeOf(value).toEqualTypeOf(zodValue); 22 | }); 23 | 24 | test("validates string type", () => { 25 | const schema = toStandardSchema(v.string()); 26 | expect(schema["~standard"].validate("hello")).toEqual({ value: "hello" }); 27 | const fail = schema["~standard"].validate(123); 28 | expect("issues" in fail && fail.issues).toBeTruthy(); 29 | if ("issues" in fail && fail.issues) { 30 | expect(fail.issues[0]?.message).toMatch(/string/); 31 | } 32 | }); 33 | 34 | test("validates number type", () => { 35 | const schema = toStandardSchema(v.number()); 36 | expect(schema["~standard"].validate(42)).toEqual({ value: 42 }); 37 | const fail = schema["~standard"].validate("not a number"); 38 | expect("issues" in fail && fail.issues).toBeTruthy(); 39 | if ("issues" in fail && fail.issues) { 40 | expect(fail.issues[0]?.message).toMatch(/number/); 41 | } 42 | }); 43 | 44 | test("validates boolean type", () => { 45 | const schema = toStandardSchema(v.boolean()); 46 | expect(schema["~standard"].validate(true)).toEqual({ value: true }); 47 | expect(schema["~standard"].validate(false)).toEqual({ value: false }); 48 | const fail = schema["~standard"].validate("not a boolean"); 49 | expect("issues" in fail && fail.issues).toBeTruthy(); 50 | if ("issues" in fail && fail.issues) { 51 | expect(fail.issues[0]?.message).toMatch(/boolean/); 52 | } 53 | }); 54 | 55 | test("validates object type", () => { 56 | const schema = toStandardSchema(v.object({ foo: v.string() })); 57 | expect(schema["~standard"].validate({ foo: "bar" })).toEqual({ 58 | value: { foo: "bar" }, 59 | }); 60 | const fail = schema["~standard"].validate({ foo: 123 }); 61 | expect("issues" in fail && fail.issues).toBeTruthy(); 62 | if ("issues" in fail && fail.issues) { 63 | expect(fail.issues[0]?.message).toMatch(/string/); 64 | expect(fail.issues[0]?.path).toEqual(["foo"]); 65 | } 66 | }); 67 | 68 | test("validates nested object type", () => { 69 | const schema = toStandardSchema( 70 | v.object({ foo: v.object({ bar: v.string() }) }), 71 | ); 72 | expect(schema["~standard"].validate({ foo: { bar: "baz" } })).toEqual({ 73 | value: { foo: { bar: "baz" } }, 74 | }); 75 | const fail = schema["~standard"].validate({ foo: { bar: 123 } }); 76 | expect("issues" in fail && fail.issues).toBeTruthy(); 77 | if ("issues" in fail && fail.issues) { 78 | expect(fail.issues[0]?.message).toMatch(/string/); 79 | expect(fail.issues[0]?.path).toEqual(["foo", "bar"]); 80 | } 81 | }); 82 | 83 | test("validates array type", () => { 84 | const schema = toStandardSchema(v.array(v.number())); 85 | expect(schema["~standard"].validate([1, 2, 3])).toEqual({ 86 | value: [1, 2, 3], 87 | }); 88 | const fail = schema["~standard"].validate([1, "two", 3]); 89 | expect("issues" in fail && fail.issues).toBeTruthy(); 90 | if ("issues" in fail && fail.issues) { 91 | expect(fail.issues[0]?.message).toMatch(/number/); 92 | } 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /packages/convex-helpers/standardSchema.ts: -------------------------------------------------------------------------------- 1 | import type { GenericDatabaseReader } from "convex/server"; 2 | 3 | import type { Infer, Validator } from "convex/values"; 4 | 5 | import type { StandardSchemaV1 } from "@standard-schema/spec"; 6 | import { validate, ValidationError } from "./validators.js"; 7 | import type { GenericDataModel } from "convex/server"; 8 | 9 | /** 10 | * Convert a Convex validator to a Standard Schema. 11 | * @param validator - The Convex validator to convert. 12 | * @param opts - Options for the validation. 13 | * @returns The Standard Schema validator with the type of the Convex validator. 14 | */ 15 | export function toStandardSchema>( 16 | validator: V, 17 | opts?: { 18 | /* If provided, v.id validation will check that the id is for the table. */ 19 | db?: GenericDatabaseReader; 20 | /* If true, allow fields that are not in an object validator. */ 21 | allowUnknownFields?: boolean; 22 | /* A prefix for the path of the value being validated, for error reporting. 23 | This is mostly used for recursive calls, do not set it manually unless you 24 | are validating a value at a sub-path within some parent object. */ 25 | _pathPrefix?: string; 26 | }, 27 | ): StandardSchemaV1> { 28 | return { 29 | "~standard": { 30 | version: 1, 31 | vendor: "convex-helpers", 32 | validate: (value) => { 33 | try { 34 | validate(validator, value, { ...opts, throw: true }); 35 | return { value } as StandardSchemaV1.SuccessResult>; 36 | } catch (e) { 37 | if (e instanceof ValidationError) { 38 | return { 39 | issues: [ 40 | { 41 | message: e.message, 42 | path: e.path ? e.path.split(".") : undefined, 43 | }, 44 | ], 45 | } as StandardSchemaV1.FailureResult; 46 | } 47 | throw e; 48 | } 49 | }, 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/convex-helpers/testing.ts: -------------------------------------------------------------------------------- 1 | import { ConvexClient } from "convex/browser"; 2 | import type { 3 | FunctionArgs, 4 | FunctionReference, 5 | FunctionReturnType, 6 | UserIdentity, 7 | } from "convex/server"; 8 | 9 | /** 10 | * This is a helper for testing Convex functions against a locally running backend. 11 | * 12 | * An example of calling a function: 13 | * ``` 14 | * const t = new ConvexTestingHelper(); 15 | * const result = await t.query(api.foo.bar, { arg1: "baz" }) 16 | * ``` 17 | * 18 | * An example of calling a function with auth: 19 | * ``` 20 | * const t = new ConvexTestingHelper(); 21 | * const identityA = t.newIdentity({ name: "Person A"}) 22 | * const result = await t.withIdentity(identityA).query(api.users.getProfile); 23 | * ``` 24 | */ 25 | export class ConvexTestingHelper { 26 | private _nextSubjectId: number = 0; 27 | public client: ConvexClient; 28 | private _adminKey: string; 29 | 30 | constructor(options: { adminKey?: string; backendUrl?: string } = {}) { 31 | this.client = new ConvexClient( 32 | options.backendUrl ?? "http://127.0.0.1:3210", 33 | ); 34 | this._adminKey = 35 | options.adminKey ?? 36 | // default admin key for local backends - from https://github.com/get-convex/convex-backend/blob/main/Justfile 37 | "0135d8598650f8f5cb0f30c34ec2e2bb62793bc28717c8eb6fb577996d50be5f4281b59181095065c5d0f86a2c31ddbe9b597ec62b47ded69782cd"; 38 | } 39 | 40 | newIdentity( 41 | args: Partial>, 42 | ): Omit { 43 | const subject = `test subject ${this._nextSubjectId}`; 44 | this._nextSubjectId += 1; 45 | const issuer = "test issuer"; 46 | return { 47 | ...args, 48 | subject, 49 | issuer, 50 | }; 51 | } 52 | 53 | withIdentity( 54 | identity: Omit, 55 | ): Pick { 56 | return { 57 | mutation: (functionReference, args) => { 58 | (this.client as any).setAdminAuth(this._adminKey, identity); 59 | return this.client.mutation(functionReference, args).finally(() => { 60 | this.client.client.clearAuth(); 61 | }); 62 | }, 63 | action: (functionReference, args) => { 64 | (this.client as any).setAdminAuth(this._adminKey, identity); 65 | return this.client.action(functionReference, args).finally(() => { 66 | this.client.client.clearAuth(); 67 | }); 68 | }, 69 | query: (functionReference, args) => { 70 | (this.client as any).setAdminAuth(this._adminKey, identity); 71 | return this.client.query(functionReference, args).finally(() => { 72 | this.client.client.clearAuth(); 73 | }); 74 | }, 75 | }; 76 | } 77 | 78 | async mutation>( 79 | mutation: Mutation, 80 | args: FunctionArgs, 81 | ): Promise>> { 82 | return this.client.mutation(mutation, args); 83 | } 84 | 85 | async query>( 86 | query: Query, 87 | args: FunctionArgs, 88 | ): Promise>> { 89 | return this.client.query(query, args); 90 | } 91 | 92 | async action>( 93 | action: Action, 94 | args: FunctionArgs, 95 | ): Promise>> { 96 | return this.client.action(action, args); 97 | } 98 | 99 | async close(): Promise { 100 | return this.client.close(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/convex-helpers/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "edge-runtime", 6 | exclude: ["**/node_modules/**", "dist/**"], 7 | globals: true, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -e 4 | 5 | rm -rf packages/convex-helpers/node_modules 6 | npm i 7 | npm run clean 8 | npm run build 9 | npm i 10 | npm run lint 11 | npm run test 12 | git diff --exit-code || { 13 | echo "Uncommitted changes found. Commit or stash them before publishing." 14 | exit 1 15 | } 16 | function cleanup() { 17 | git checkout -- :/packages/convex-helpers/package.json 18 | } 19 | trap cleanup EXIT 20 | 21 | pushd packages/convex-helpers >/dev/null 22 | if [ "$1" == "alpha" ]; then 23 | npm version prerelease --preid alpha 24 | else 25 | npm version patch 26 | fi 27 | current=$(npm pkg get version | tr -d '"') 28 | popd >/dev/null 29 | 30 | cat </dev/null 42 | if [ -n "$version" ]; then 43 | npm pkg set version="$version" 44 | else 45 | version=$current 46 | fi 47 | 48 | cp package.json dist/ 49 | 50 | cd dist 51 | npm publish --dry-run 52 | popd >/dev/null 53 | echo "^^^ DRY RUN ^^^" 54 | read -r -p "Publish $version to npm? (y/n): " publish 55 | if [ "$publish" = "y" ]; then 56 | npm i 57 | pushd packages/convex-helpers/dist >/dev/null 58 | if (echo "$version" | grep alpha >/dev/null); then 59 | npm publish --tag alpha 60 | else 61 | npm publish 62 | fi 63 | popd >/dev/null 64 | git add package.json package-lock.json packages/convex-helpers/package.json 65 | # If there's nothing to commit, continue 66 | git commit -m "npm $version" || true 67 | git tag "npm/$version" 68 | git push origin "npm/$version" 69 | git push 70 | else 71 | echo "Aborted." 72 | fi 73 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 6 | "automerge": true 7 | }, 8 | { 9 | "matchDepTypes": ["devDependencies"], 10 | "automerge": true 11 | } 12 | ], 13 | "extends": ["config:best-practices"] 14 | } 15 | -------------------------------------------------------------------------------- /scripts/check_cla.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | import sys 5 | 6 | CLA_TEXT = "By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice." 7 | 8 | try: 9 | PR_DESCRIPTION = os.environ["PR_DESCRIPTION"] 10 | except KeyError: 11 | print("There was no pull request description given") 12 | sys.exit(1) 13 | 14 | if not re.search(re.escape(CLA_TEXT), PR_DESCRIPTION, re.MULTILINE): 15 | print( 16 | "Pull request description does not include the required CLA text. Please add the following text to your PR description:\n\n" + CLA_TEXT 17 | ) 18 | sys.exit(1) 19 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Counter from "./components/Counter"; 2 | import RelationshipExample from "./components/RelationshipExample"; 3 | import SessionsExample from "./components/SessionsExample"; 4 | import { HonoExample } from "./components/HonoExample"; 5 | import { SessionProvider } from "convex-helpers/react/sessions"; 6 | import { CacheExample } from "./components/CacheExample"; 7 | import { ConvexQueryCacheProvider } from "convex-helpers/react/cache"; 8 | // Used for the session example if you want to store sessionId in local storage 9 | // import { useLocalStorage } from "usehooks-ts"; 10 | 11 | export default function App() { 12 | return ( 13 |
14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/CacheExample.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useRef, useState } from "react"; 2 | import { useQuery } from "convex-helpers/react/cache"; 3 | import { api } from "../../convex/_generated/api"; 4 | 5 | // Composing this with the tanstack-style useQuery: 6 | // import { makeUseQueryWithStatus } from "convex-helpers/react"; 7 | // import { useQueries } from "convex-helpers/react/cache"; 8 | // const useQuery= makeUseQueryWithStatus(useQueries); 9 | 10 | export const CacheExample: FC = () => { 11 | const [count, setCount] = useState(4); 12 | const ref = useRef(null); 13 | const [skip, setSkip] = useState(true); 14 | const children = []; 15 | const updateCount = () => { 16 | const r = parseInt(ref.current!.value); 17 | if (!isNaN(r)) { 18 | setCount(Math.floor(r)); 19 | } 20 | return false; 21 | }; 22 | for (let i = 0; i < count; i++) { 23 | children.push(); 24 | if (count % 2 == 1) { 25 | children.push(); 26 | } 27 | } 28 | return ( 29 | <> 30 |

Query Cache Example

31 | Enter a new number of children:{" "} 32 | 38 |
    {children}
39 |
This is an element that skips:
40 |
41 | 44 | 45 |
46 | 47 | ); 48 | }; 49 | 50 | const Added: FC<{ top: number; skip?: boolean }> = ({ top, skip }) => { 51 | const sum = useQuery(api.addIt.addItUp, skip ? "skip" : { top }); 52 | if (sum === undefined) { 53 | // If you want to try the tanstack-style useQuery: 54 | // const { data: sum, isPending, error } = useQuery(api.addIt.addItUp, args); 55 | // if (error) throw error; 56 | // if (isPending) { 57 | if (skip) return
  • Skipping...
  • ; 58 | return
  • Loading {top}...
  • ; 59 | } else { 60 | return ( 61 |
  • 62 | {top} → {sum} 63 |
  • 64 | ); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/Counter.injection.test.tsx: -------------------------------------------------------------------------------- 1 | import Counter from "./Counter"; 2 | import { render } from "@testing-library/react"; 3 | import { ConvexReactClientFake } from "../fakeConvexClient/fakeConvexClient"; 4 | import { ConvexProvider } from "convex/react"; 5 | import { describe, it, expect, afterEach, vi } from "vitest"; 6 | import { api } from "../../convex/_generated/api"; 7 | 8 | // Keep track of counter values 9 | let counters: Record = {}; 10 | 11 | // A function very similar to the implementation of `getCounter` 12 | const getCounter = ({ counterName }: { counterName: string }) => 13 | counters[counterName]!; 14 | 15 | // A function very similar to the implementation of `incrementCounter` in `convex/counter.ts` 16 | const incrementCounter = ({ 17 | counterName, 18 | increment, 19 | }: { 20 | counterName: string; 21 | increment: number; 22 | }) => { 23 | if (counters[counterName]) { 24 | counters[counterName] = counters[counterName]! + increment; 25 | } else { 26 | counters[counterName] = increment; 27 | } 28 | return null; 29 | }; 30 | 31 | // Wrap incrementCounter in a vitest function so we can keep track of function calls in tests 32 | const incrementCounterMock = vi.fn().mockImplementation(incrementCounter); 33 | 34 | // Initialize the Convex mock client 35 | const mockClient = new ConvexReactClientFake(); 36 | mockClient.registerQueryFake(api.counter.getCounter, getCounter); 37 | mockClient.registerMutationFake( 38 | api.counter.incrementCounter, 39 | incrementCounterMock, 40 | ); 41 | 42 | const setup = () => 43 | render( 44 | 45 | 46 | , 47 | ); 48 | 49 | afterEach(() => { 50 | // Resets the counter state after every test 51 | counters = {}; 52 | 53 | // Resets mocks after every test 54 | vi.restoreAllMocks(); 55 | 56 | // Reset the dom after every test 57 | document.getElementsByTagName("html")[0]!.innerHTML = ""; 58 | }); 59 | 60 | describe("Counter", () => { 61 | it("renders the counter", async () => { 62 | const { getByText } = setup(); 63 | expect(getByText("Here's the counter: 0")).not.toBeNull(); 64 | }); 65 | 66 | it("increments the counter", async () => { 67 | const { getByRole, queryByText } = setup(); 68 | 69 | getByRole("button").click(); 70 | 71 | // The mocked incrementCounter function will be called. 72 | expect(incrementCounterMock).toHaveBeenCalledOnce(); 73 | expect(incrementCounterMock).toHaveBeenCalledWith({ 74 | counterName: "clicks", 75 | increment: 1, 76 | }); 77 | 78 | // The ConvexReactClientFake doesn't support reactivity, 79 | // so we can't use it to test that components re-render with updated data. 80 | expect(queryByText("Here's the counter: 1")).toBeNull(); 81 | }); 82 | 83 | it("renders the counter with seeded data", async () => { 84 | // Update the test state before rendering the component to seed the getCounter query. 85 | incrementCounter({ counterName: "clicks", increment: 100 }); 86 | 87 | const { getByText } = setup(); 88 | 89 | expect(getByText("Here's the counter: 100")).not.toBeNull(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/components/Counter.mock.test.tsx: -------------------------------------------------------------------------------- 1 | import Counter from "./Counter"; 2 | import { render } from "@testing-library/react"; 3 | import { describe, it, expect, afterEach, vi } from "vitest"; 4 | import * as convexReact from "convex/react"; 5 | import { FunctionReference, getFunctionName } from "convex/server"; 6 | 7 | // Keep track of counter values 8 | let counters: Record = {}; 9 | 10 | // A function very similar to the implementation of `getCounter` 11 | const getCounter = ({ counterName }: { counterName: string }) => 12 | counters[counterName]; 13 | 14 | // A function very similar to the implementation of `incrementCounter` in `convex/counter.ts` 15 | const incrementCounter = ({ 16 | counterName, 17 | increment, 18 | }: { 19 | counterName: string; 20 | increment: number; 21 | }) => { 22 | if (counters[counterName]) { 23 | counters[counterName] = counters[counterName]! + increment; 24 | } else { 25 | counters[counterName] = increment; 26 | } 27 | return null; 28 | }; 29 | 30 | // Wrap incrementCounter in a vitest function so we can keep track of function calls in tests 31 | const incrementCounterMock = vi.fn().mockImplementation(incrementCounter); 32 | 33 | vi.mock("convex/react", async () => { 34 | const actual = await vi.importActual("convex/react"); 35 | 36 | return { 37 | ...actual, 38 | useQuery: ( 39 | queryName: FunctionReference<"query", "public">, 40 | args: Record, 41 | ) => { 42 | if (getFunctionName(queryName) !== "counter:getCounter") { 43 | throw new Error("Unexpected query call!"); 44 | } 45 | return getCounter(args as any); 46 | }, 47 | useMutation: (mutationName: FunctionReference<"mutation", "public">) => { 48 | if (getFunctionName(mutationName) !== "counter:incrementCounter") { 49 | throw new Error("Unexpected mutation call!"); 50 | } 51 | return incrementCounterMock; 52 | }, 53 | }; 54 | }); 55 | 56 | const setup = () => render(); 57 | 58 | afterEach(() => { 59 | // Resets the counter state after every test 60 | counters = {}; 61 | 62 | // Resets mocks after every test 63 | vi.restoreAllMocks(); 64 | 65 | // Reset the dom after every test 66 | document.getElementsByTagName("html")[0]!.innerHTML = ""; 67 | }); 68 | 69 | describe("Counter", () => { 70 | it("renders the counter", async () => { 71 | const { getByText } = setup(); 72 | expect(getByText("Here's the counter: 0")).not.toBeNull(); 73 | }); 74 | 75 | it("increments the counter", async () => { 76 | const { getByRole, queryByText } = setup(); 77 | 78 | getByRole("button").click(); 79 | 80 | // The mocked incrementCounter function will be called. 81 | expect(incrementCounterMock).toHaveBeenCalledOnce(); 82 | expect(incrementCounterMock).toHaveBeenCalledWith({ 83 | counterName: "clicks", 84 | increment: 1, 85 | }); 86 | 87 | // The mocked query doesn't support reactivity, 88 | // so we can't use it to test that components re-render with updated data. 89 | expect(queryByText("Here's the counter: 1")).toBeNull(); 90 | }); 91 | 92 | it("renders the counter with seeded data", async () => { 93 | // Update the test state before rendering the component to seed the getCounter query. 94 | incrementCounter({ counterName: "clicks", increment: 100 }); 95 | 96 | const { getByText } = setup(); 97 | 98 | expect(getByText("Here's the counter: 100")).not.toBeNull(); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../../convex/_generated/api"; 2 | import { useQuery, useMutation } from "convex/react"; 3 | import { useCallback } from "react"; 4 | 5 | const Counter = () => { 6 | const counter = 7 | useQuery(api.counter.getCounter, { counterName: "clicks" }) ?? 0; 8 | const increment = useMutation(api.counter.incrementCounter); 9 | const incrementByOne = useCallback( 10 | () => increment({ counterName: "clicks", increment: 1 }), 11 | [increment], 12 | ); 13 | 14 | return ( 15 |
    16 |

    17 | {"Here's the counter:"} {counter} 18 |

    19 | 20 |
    21 | ); 22 | }; 23 | 24 | export default Counter; 25 | -------------------------------------------------------------------------------- /src/components/Facepile.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useEffect, useState } from "react"; 3 | import { isOnline, PresenceData } from "../hooks/usePresence"; 4 | 5 | const UPDATE_MS = 1000; 6 | 7 | type FacePileProps = { 8 | othersPresence?: PresenceData<{ emoji: string }>[]; 9 | }; 10 | export default ({ othersPresence }: FacePileProps) => { 11 | const [, setNow] = useState(Date.now()); 12 | useEffect(() => { 13 | const intervalId = setInterval(() => setNow(Date.now()), UPDATE_MS); 14 | return () => clearInterval(intervalId); 15 | }, [setNow]); 16 | return ( 17 |
    18 | {othersPresence 19 | ?.slice(0, 5) 20 | .map((presence) => ({ 21 | ...presence, 22 | online: isOnline(presence), 23 | })) 24 | .sort((presence1, presence2) => 25 | presence1.online === presence2.online 26 | ? presence1.created - presence2.created 27 | : Number(presence1.online) - Number(presence2.online), 28 | ) 29 | .map((presence) => ( 30 | 42 | {presence.data.emoji} 43 | 44 | ))} 45 |
    46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/HonoExample.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "convex/react"; 2 | import { api } from "../../convex/_generated/api"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export function HonoExample() { 6 | const siteUrl = useQuery(api.http.siteUrl); 7 | const [value, setValue] = useState(""); 8 | useEffect(() => { 9 | if (!siteUrl) return; 10 | fetch(`${siteUrl}/`) 11 | .then((r) => r.text()) 12 | .then(setValue); 13 | }, [siteUrl]); 14 | 15 | return ( 16 |
    17 |

    Hono Example

    18 |

    19 | Value fetching from {siteUrl}/: {value} 20 |

    21 |
    22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/RelationshipExample.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from "convex/react"; 2 | import { api } from "../../convex/_generated/api"; 3 | import { useState } from "react"; 4 | 5 | function RelationshipExample() { 6 | const [worked, setWorked] = useState(false); 7 | const test = useMutation(api.relationshipsExample.relationshipTest); 8 | return ( 9 |
    10 |

    Relationship Test

    11 | {worked ? ( 12 |
    Worked!
    13 | ) : ( 14 | 15 | )} 16 |
    17 | ); 18 | } 19 | export default RelationshipExample; 20 | -------------------------------------------------------------------------------- /src/components/SessionsExample.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useSessionId, 3 | useSessionIdArg, 4 | useSessionMutation, 5 | useSessionPaginatedQuery, 6 | useSessionQuery, 7 | } from "convex-helpers/react/sessions"; 8 | import { api } from "../../convex/_generated/api"; 9 | import { useState } from "react"; 10 | import { useStableQuery } from "../hooks/useStableQuery"; 11 | // import { useLocalStorage } from "usehooks-ts"; 12 | 13 | export default () => { 14 | const [sessionId, refreshSessionId] = useSessionId(); 15 | const login = useSessionMutation(api.sessionsExample.logIn); 16 | const logout = useSessionMutation(api.sessionsExample.logOut); 17 | const myPresence = useSessionQuery(api.sessionsExample.myPresence); 18 | const paginatedPresence = useSessionPaginatedQuery( 19 | api.sessionsExample.paginatedQueryWithSession, 20 | {}, 21 | { initialNumItems: 2 }, 22 | )!; 23 | const joinRoom = useSessionMutation( 24 | api.sessionsExample.joinRoom, 25 | ).withOptimisticUpdate((store, args) => { 26 | if (!sessionId) return; 27 | const roomPresence = store.getQuery(api.sessionsExample.myPresence, { 28 | sessionId, 29 | }); 30 | store.setQuery( 31 | api.sessionsExample.myPresence, 32 | { sessionId }, 33 | roomPresence ? [...roomPresence, args.room] : undefined, 34 | ); 35 | }); 36 | const [room, setRoom] = useState(""); 37 | const roomData = useStableQuery( 38 | api.sessionsExample.roomPresence, 39 | useSessionIdArg(room ? { room } : "skip"), 40 | ); 41 | return ( 42 |
    43 |

    Sessions Example

    44 | {sessionId} 45 | 52 |
    { 54 | e.preventDefault(); 55 | if (room) joinRoom({ room }); 56 | setRoom(""); 57 | }} 58 | > 59 | 60 | setRoom(e.target.value)} 65 | /> 66 |
    67 |

    {JSON.stringify(roomData)}

    68 |
      {myPresence && myPresence.map((room) =>
    • {room}
    • )}
    69 |
      70 | {paginatedPresence.results.map((room) => ( 71 |
    • {room.room}
    • 72 | ))} 73 |
    74 | 80 | 81 | 84 |
    85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/fakeConvexClient/fakeConvexClient.d.ts: -------------------------------------------------------------------------------- 1 | import { ConvexReactClient, ReactMutation } from "convex/react"; 2 | import { FunctionReference } from "convex/server"; 3 | 4 | export class ConvexReactClientFake extends ConvexReactClient { 5 | constructor(); 6 | 7 | registerQueryFake>( 8 | funcRef: FuncRef, 9 | impl: (args: FuncRef["_args"]) => FuncRef["_returnType"], 10 | ): void; 11 | registerMutationFake>( 12 | funcRef: FuncRef, 13 | impl: (args: FuncRef["_args"]) => FuncRef["_returnType"], 14 | ): void; 15 | } 16 | -------------------------------------------------------------------------------- /src/fakeConvexClient/fakeConvexClient.js: -------------------------------------------------------------------------------- 1 | import { getFunctionName } from "convex/server"; 2 | 3 | // A Mock convex client 4 | export class ConvexReactClientFake { 5 | constructor() { 6 | this.queries = {}; 7 | this.mutations = {}; 8 | this.actions = {}; 9 | } 10 | 11 | registerQueryFake(funcRef, impl) { 12 | this.queries[getFunctionName(funcRef)] = impl; 13 | } 14 | registerMutationFake(funcRef, impl) { 15 | this.mutations[getFunctionName(funcRef)] = impl; 16 | } 17 | 18 | async setAuth() { 19 | throw new Error("Auth is not implemented"); 20 | } 21 | 22 | clearAuth() { 23 | throw new Error("Auth is not implemented"); 24 | } 25 | 26 | watchQuery(functionReference, args) { 27 | const name = getFunctionName(functionReference); 28 | return { 29 | localQueryResult: () => { 30 | const query = this.queries && this.queries[name]; 31 | if (query) { 32 | return query(args); 33 | } 34 | throw new Error( 35 | `Unexpected query: ${name}. Try providing a function for this query in the mock client constructor.`, 36 | ); 37 | }, 38 | onUpdate: () => () => ({ 39 | unsubscribe: () => null, 40 | }), 41 | journal: () => { 42 | throw new Error("Pagination is not implemented"); 43 | }, 44 | }; 45 | } 46 | 47 | mutation(functionReference, args) { 48 | const name = getFunctionName(functionReference); 49 | const mutation = this.mutations && this.mutations[name]; 50 | if (mutation) { 51 | return mutation(args); 52 | } 53 | throw new Error( 54 | `Unexpected mutation: ${name}. Try providing a function for this mutation in the mock client constructor.`, 55 | ); 56 | } 57 | 58 | action(functionReference, args) { 59 | const name = getFunctionName(functionReference); 60 | const action = this.actions && this.actions[name]; 61 | if (action) { 62 | return action(args); 63 | } 64 | throw new Error( 65 | `Unexpected action: ${name}. Try providing a function for this actionin the mock client constructor.`, 66 | ); 67 | } 68 | 69 | connectionState() { 70 | return { 71 | hasInflightRequests: false, 72 | isWebSocketConnected: true, 73 | }; 74 | } 75 | 76 | close() { 77 | return Promise.resolve(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/hooks/useLatestValue.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef } from "react"; 2 | 3 | /** 4 | * Promise-based access to the latest value updated. 5 | * Every call to nextValue will return a promise to the next value. 6 | * "Next value" is defined as the latest value passed to "updateValue" that 7 | * hasn not been returned yet. 8 | * @returns a function to await for a new value, and one to update the value. 9 | */ 10 | export default function useLatestValue() { 11 | const initial = useMemo(() => { 12 | const [promise, resolve] = makeSignal(); 13 | // We won't access data until it has been updated. 14 | return { data: undefined as T, promise, resolve }; 15 | }, []); 16 | const ref = useRef(initial); 17 | const nextValue = useCallback(async () => { 18 | await ref.current.promise; 19 | const [promise, resolve] = makeSignal(); 20 | ref.current.promise = promise; 21 | ref.current.resolve = resolve; 22 | return ref.current.data; 23 | }, [ref]); 24 | 25 | const updateValue = useCallback( 26 | (data: T) => { 27 | ref.current.data = data; 28 | ref.current.resolve(); 29 | }, 30 | [ref], 31 | ); 32 | 33 | return [nextValue, updateValue] as const; 34 | } 35 | 36 | const makeSignal = () => { 37 | let resolve: () => void; 38 | const promise = new Promise((r) => (resolve = r)); 39 | return [promise, resolve!] as const; 40 | }; 41 | -------------------------------------------------------------------------------- /src/hooks/usePresence.ts: -------------------------------------------------------------------------------- 1 | import { api } from "../../convex/_generated/api"; 2 | import { useQuery, useMutation } from "convex/react"; 3 | import { Value } from "convex/values"; 4 | import { useCallback, useEffect, useState } from "react"; 5 | import useSingleFlight from "./useSingleFlight"; 6 | 7 | export type PresenceData = { 8 | created: number; 9 | updated: number; 10 | user: string; 11 | data: D; 12 | }; 13 | 14 | const HEARTBEAT_PERIOD = 5000; 15 | const OLD_MS = 10000; 16 | 17 | /** 18 | * usePresence is a React hook for reading & writing presence data. 19 | * 20 | * The data is written by various users, and comes back as a list of data for 21 | * other users in the same room. It is not meant for mission-critical data, but 22 | * rather for optimistic metadata, like whether a user is online, typing, or 23 | * at a certain location on a page. The data is single-flighted, and when many 24 | * updates are requested while an update is in flight, only the latest data will 25 | * be sent in the next request. See for more details on single-flighting: 26 | * https://stack.convex.dev/throttling-requests-by-single-flighting 27 | * 28 | * Data updates are merged with previous data. This data will reflect all 29 | * updates, not just the data that gets synchronized to the server. So if you 30 | * update with {mug: userMug} and {typing: true}, the data will have both 31 | * `mug` and `typing` fields set, and will be immediately reflected in the data 32 | * returned as the first parameter. 33 | * 34 | * @param room - The location associated with the presence data. Examples: 35 | * page, chat channel, game instance. 36 | * @param user - The user associated with the presence data. 37 | * @param initialData - The initial data to associate with the user. 38 | * @param heartbeatPeriod? - If specified, the interval between heartbeats, in 39 | * milliseconds. A heartbeat updates the user's presence "updated" timestamp. 40 | * The faster the updates, the more quickly you can detect a user "left" at 41 | * the cost of more server function calls. 42 | * @returns A list with 1. this user's data; 2. A list of other users' data; 43 | * 3. function to update this user's data. It will do a shallow merge. 44 | */ 45 | export const usePresence = ( 46 | room: string, 47 | user: string, 48 | initialData: T, 49 | heartbeatPeriod = HEARTBEAT_PERIOD, 50 | ) => { 51 | const [data, setData] = useState(initialData); 52 | let presence: PresenceData[] | undefined = useQuery(api.presence.list, { 53 | room, 54 | }); 55 | if (presence) { 56 | presence = presence.filter((p) => p.user !== user); 57 | } 58 | const updatePresence = useSingleFlight(useMutation(api.presence.update)); 59 | const heartbeat = useSingleFlight(useMutation(api.presence.heartbeat)); 60 | 61 | useEffect(() => { 62 | void updatePresence({ room, user, data }); 63 | const intervalId = setInterval(() => { 64 | void heartbeat({ room, user }); 65 | }, heartbeatPeriod); 66 | // Whenever we have any data change, it will get cleared. 67 | return () => clearInterval(intervalId); 68 | }, [updatePresence, heartbeat, room, user, data, heartbeatPeriod]); 69 | 70 | // Updates the data, merged with previous data state. 71 | const updateData = useCallback((patch: Partial) => { 72 | setData((prevState) => { 73 | return { ...prevState, ...patch }; 74 | }); 75 | }, []); 76 | 77 | return [data, presence, updateData] as const; 78 | }; 79 | 80 | /** 81 | * isOnline determines a user's online status by how recently they've updated. 82 | * 83 | * @param presence - The presence data for one user returned from usePresence. 84 | * @param now - If specified, the time it should consider to be "now". 85 | * @returns True if the user has updated their presence recently. 86 | */ 87 | export const isOnline = (presence: PresenceData) => { 88 | return Date.now() - presence.updated < OLD_MS; 89 | }; 90 | 91 | export default usePresence; 92 | -------------------------------------------------------------------------------- /src/hooks/useSingleFlight.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | /** 4 | * Wraps a function to single-flight invocations, using the latest args. 5 | * 6 | * Generates a function that behaves like the passed in function, 7 | * but only one execution runs at a time. If multiple calls are requested 8 | * before the current call has finished, it will use the latest arguments 9 | * for the next invocation. 10 | * 11 | * Note: some requests may never be made. If while a request is in-flight, N 12 | * requests are made, N-1 of them will never resolve or reject the promise they 13 | * returned. For most applications this is the desired behavior, but if you need 14 | * all calls to eventually resolve, you can modify this code. Some behavior you 15 | * could add, left as an exercise to the reader: 16 | * 1. Resolve with the previous result when a request is about to be dropped. 17 | * 2. Resolve all N requests with the result of the next request. 18 | * 3. Do not return anything, and use this as a fire-and-forget library only. 19 | * 20 | * @param fn - Function to be called, with only one request in flight at a time. 21 | * This must be a stable identifier, e.g. returned from useCallback. 22 | * @returns Function that can be called whenever, returning a promise that will 23 | * only resolve or throw if the underlying function gets called. 24 | */ 25 | export default function useSingleFlight< 26 | F extends (...args: any[]) => Promise, 27 | >(fn: F) { 28 | const flightStatus = useRef({ 29 | inFlight: false, 30 | upNext: null as null | { 31 | fn: F; 32 | resolve: any; 33 | reject: any; 34 | args: Parameters; 35 | }, 36 | }); 37 | 38 | return useCallback( 39 | (...args: Parameters): ReturnType => { 40 | if (flightStatus.current.inFlight) { 41 | return new Promise((resolve, reject) => { 42 | flightStatus.current.upNext = { fn, resolve, reject, args }; 43 | }) as ReturnType; 44 | } 45 | flightStatus.current.inFlight = true; 46 | const firstReq = fn(...args) as ReturnType; 47 | void (async () => { 48 | try { 49 | await firstReq; 50 | } finally { 51 | // If it failed, we naively just move on to the next request. 52 | } 53 | while (flightStatus.current.upNext) { 54 | let cur = flightStatus.current.upNext; 55 | flightStatus.current.upNext = null; 56 | await cur 57 | .fn(...cur.args) 58 | .then(cur.resolve) 59 | .catch(cur.reject); 60 | } 61 | flightStatus.current.inFlight = false; 62 | })(); 63 | return firstReq; 64 | }, 65 | [fn], 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/hooks/useStableQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, usePaginatedQuery } from "convex/react"; 2 | import { useRef } from "react"; 3 | 4 | /** 5 | * Drop-in replacement for useQuery intended to be used with a parametrized query. 6 | * Unlike useQuery, useStableQuery does not return undefined while loading new 7 | * data when the query arguments change, but instead will continue to return 8 | * the previously loaded data until the new data has finished loading. 9 | * 10 | * See https://stack.convex.dev/help-my-app-is-overreacting for details. 11 | * 12 | * @param name - string naming the query function 13 | * @param ...args - arguments to be passed to the query function 14 | * @returns UseQueryResult 15 | */ 16 | export const useStableQuery = ((name, ...args) => { 17 | const result = useQuery(name, ...args); 18 | const stored = useRef(result); // ref objects are stable between rerenders 19 | 20 | // result is only undefined while data is loading 21 | // if a freshly loaded result is available, use the ref to store it 22 | if (result !== undefined) { 23 | stored.current = result; 24 | } 25 | 26 | // undefined on first load, stale data while loading, fresh data after loading 27 | return stored.current; 28 | }) as typeof useQuery; 29 | 30 | /** 31 | * Drop-in replacement for usePaginatedQuery for use with a parametrized query. 32 | * Unlike usePaginatedQuery, when query arguments change useStablePaginatedQuery 33 | * does not return empty results and 'LoadingMore' status. Instead, it continues 34 | * to return the previously loaded results until the new results have finished 35 | * loading. 36 | * 37 | * See https://stack.convex.dev/help-my-app-is-overreacting for details. 38 | * 39 | * @param name - string naming the query function 40 | * @param ...args - arguments to be passed to the query function 41 | * @returns UsePaginatedQueryResult 42 | */ 43 | export const useStablePaginatedQuery = ((name, ...args) => { 44 | const result = usePaginatedQuery(name, ...args); 45 | const stored = useRef(result); // ref objects are stable between rerenders 46 | 47 | // If data is still loading, wait and do nothing 48 | // If data has finished loading, store the result 49 | if (result.status !== "LoadingMore" && result.status !== "LoadingFirstPage") { 50 | stored.current = result; 51 | } 52 | 53 | return stored.current; 54 | }) as typeof usePaginatedQuery; 55 | -------------------------------------------------------------------------------- /src/hooks/useTypingIndicator.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export default ( 4 | text: string, 5 | updateMyPresence: (p: { typing?: boolean }) => void, 6 | ) => { 7 | useEffect(() => { 8 | if (text.length === 0) { 9 | updateMyPresence({ typing: false }); 10 | return; 11 | } 12 | updateMyPresence({ typing: true }); 13 | const timer = setTimeout(() => updateMyPresence({ typing: false }), 1000); 14 | return () => clearTimeout(timer); 15 | }, [updateMyPresence, text]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* reset */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | border: 0; 6 | line-height: 1.5; 7 | } 8 | 9 | body { 10 | font-family: 11 | system-ui, "Segoe UI", Roboto, "Helvetica Neue", helvetica, sans-serif; 12 | } 13 | 14 | main { 15 | padding-top: 1em; 16 | padding-bottom: 1em; 17 | width: min(800px, 95vw); 18 | margin: 0 auto; 19 | } 20 | 21 | h1 { 22 | text-align: center; 23 | margin-bottom: 8px; 24 | font-size: 1.8em; 25 | font-weight: 500; 26 | } 27 | 28 | .badge { 29 | text-align: center; 30 | margin-bottom: 16px; 31 | } 32 | .badge span { 33 | background-color: #212529; 34 | color: #ffffff; 35 | border-radius: 6px; 36 | font-weight: bold; 37 | padding: 4px 8px 4px 8px; 38 | font-size: 0.75em; 39 | } 40 | 41 | ul { 42 | margin: 8px; 43 | border-radius: 8px; 44 | border: solid 1px lightgray; 45 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 46 | } 47 | 48 | ul:empty { 49 | display: none; 50 | } 51 | 52 | li { 53 | display: flex; 54 | justify-content: flex-start; 55 | padding: 8px 16px 8px 16px; 56 | border-bottom: solid 1px lightgray; 57 | font-size: 16px; 58 | } 59 | 60 | li:last-child { 61 | border: 0; 62 | } 63 | 64 | li span:nth-child(1) { 65 | font-weight: bold; 66 | margin-right: 4px; 67 | white-space: nowrap; 68 | } 69 | li span:nth-child(2) { 70 | margin-right: 4px; 71 | word-break: break-word; 72 | } 73 | li span:nth-child(3) { 74 | color: #6c757d; 75 | margin-left: auto; 76 | white-space: nowrap; 77 | } 78 | 79 | form { 80 | display: flex; 81 | justify-content: center; 82 | } 83 | 84 | input:not([type]) { 85 | padding: 6px 12px 6px 12px; 86 | color: rgb(33, 37, 41); 87 | border: solid 1px rgb(206, 212, 218); 88 | border-radius: 8px; 89 | font-size: 16px; 90 | } 91 | 92 | input[type="submit"], 93 | button { 94 | margin-left: 4px; 95 | background: lightblue; 96 | color: white; 97 | padding: 6px 12px 6px 12px; 98 | border-radius: 8px; 99 | font-size: 16px; 100 | background-color: rgb(49, 108, 244); 101 | } 102 | 103 | input[type="submit"]:hover, 104 | button:hover { 105 | background-color: rgb(41, 93, 207); 106 | } 107 | 108 | input[type="submit"]:disabled, 109 | button:disabled { 110 | background-color: rgb(122, 160, 248); 111 | } 112 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import { ConvexProvider, ConvexReactClient } from "convex/react"; 6 | 7 | const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); 8 | 9 | createRoot(document.getElementById("root")!).render( 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx" 17 | }, 18 | "include": ["./src"] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./packages/convex-helpers/tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "noImplicitAny": false, 6 | "noUnusedParameters": false, 7 | "composite": false, 8 | "declaration": false, 9 | "declarationMap": false, 10 | "module": "ESNext", 11 | "moduleResolution": "Bundler" 12 | }, 13 | "exclude": ["node_modules", "packages/convex-helpers/dist"] 14 | } 15 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | environment: "jsdom", 9 | exclude: ["node_modules/**", "convex/**", "packages/**"], 10 | 11 | globals: true, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | export default [".", "packages/convex-helpers"]; 2 | --------------------------------------------------------------------------------