diff --git a/package-lock.json b/package-lock.json index 45f20635..678070e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "react-dom": "^19.0.0", "usehooks-ts": "^3.1.0", "vite": "^6.0.3 <7.0.0", - "zod": "^4.0.15" + "zod": "^4.1", + "zod3": "npm:zod@~3.25.0" }, "devDependencies": { "@arethetypeswrong/cli": "0.18.2", @@ -45,7 +46,8 @@ "typescript": "5.9.3", "typescript-eslint": "8.46.4", "vitest": "3.2.4", - "yaml": "2.8.1" + "yaml": "2.8.1", + "zod-compare": "^2.0.0" } }, "node_modules/@actions/core": { @@ -11498,6 +11500,19 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-compare": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/zod-compare/-/zod-compare-2.0.0.tgz", + "integrity": "sha512-LGqcPk9ZiU4q355YI2LEPoFVD2UJSX5zmW4OfTdO/MBmRCIY68JxAKqyUgeLJ+37gS4jWODqJjoo9UCuWuW1rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zod-package-json": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/zod-package-json/-/zod-package-json-1.2.0.tgz", @@ -11534,6 +11549,16 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zod3": { + "name": "zod", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/convex-helpers/dist": { "name": "convex-helpers", "version": "0.1.105-alpha.0", diff --git a/package.json b/package.json index e3dc5ffd..0bcb080d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "react-dom": "^19.0.0", "usehooks-ts": "^3.1.0", "vite": "^6.0.3 <7.0.0", - "zod": "^4.0.15" + "zod": "^4.1", + "zod3": "npm:zod@~3.25.0" }, "devDependencies": { "@arethetypeswrong/cli": "0.18.2", @@ -65,6 +66,7 @@ "typescript": "5.9.3", "typescript-eslint": "8.46.4", "vitest": "3.2.4", - "yaml": "2.8.1" + "yaml": "2.8.1", + "zod-compare": "^2.0.0" } } diff --git a/packages/convex-helpers/package-lock.json b/packages/convex-helpers/package-lock.json new file mode 100644 index 00000000..ffd93b81 --- /dev/null +++ b/packages/convex-helpers/package-lock.json @@ -0,0 +1,557 @@ +{ + "name": "convex-helpers", + "version": "0.1.104", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "convex-helpers", + "version": "0.1.104", + "license": "Apache-2.0", + "bin": { + "convex-helpers": "bin.cjs" + }, + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "convex": "^1.24.0", + "hono": "^4.0.5", + "react": "^17.0.2 || ^18.0.0 || ^19.0.0", + "typescript": "^5.5", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@standard-schema/spec": { + "optional": true + }, + "hono": { + "optional": true + }, + "react": { + "optional": true + }, + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/convex": { + "version": "1.28.2", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.28.2.tgz", + "integrity": "sha512-KzNsLbcVXb1OhpVQ+vHMgu+hjrsQ1ks5BZwJ2lR8O+nfbeJXE6tHbvsg1H17+ooUDvIDBSMT3vXS+AlodDhTnQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "esbuild": "0.25.4", + "prettier": "^3.0.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/packages/convex-helpers/package.json b/packages/convex-helpers/package.json index 0ba081e4..efcf6e87 100644 --- a/packages/convex-helpers/package.json +++ b/packages/convex-helpers/package.json @@ -115,6 +115,14 @@ "types": "./server/zod.d.ts", "default": "./server/zod.js" }, + "./server/zod3": { + "types": "./server/zod3.d.ts", + "default": "./server/zod3.js" + }, + "./server/zod4": { + "types": "./server/zod4.d.ts", + "default": "./server/zod4.js" + }, "./react/*": { "types": "./react/*.d.ts", "default": "./react/*.js" @@ -160,7 +168,7 @@ "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", - "zod": "^3.22.4 || ^4.0.15" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { "@standard-schema/spec": { diff --git a/packages/convex-helpers/server/zod.test.ts b/packages/convex-helpers/server/zod.test.ts index f03f9d3e..039ab0ef 100644 --- a/packages/convex-helpers/server/zod.test.ts +++ b/packages/convex-helpers/server/zod.test.ts @@ -1,1153 +1,291 @@ -import type { - DataModelFromSchemaDefinition, - QueryBuilder, - ApiFromModules, - RegisteredQuery, - DefaultFunctionArgs, - FunctionReference, -} from "convex/server"; -import { defineTable, defineSchema, queryGeneric, anyApi } from "convex/server"; -import type { Equals } from "../index.js"; -import { omit } from "../index.js"; -import { convexTest } from "convex-test"; -import { assertType, describe, expect, expectTypeOf, test } from "vitest"; -import { modules } from "./setup.test.js"; -import type { ZCustomCtx } from "./zod.js"; +// Test for functions exposed in zod.ts, which work with both Zod 3 and Zod 4. +import { z as z3 } from "zod/v3"; +import * as z4 from "zod/v4"; +import * as z4Mini from "zod/v4/mini"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { v, type Infer } from "convex/values"; +import { Equals } from ".."; import { - zBrand, - zCustomQuery, - zid, + zodToConvex, zodOutputToConvex, zodToConvexFields, - zodToConvex, - convexToZod, - convexToZodFields, + zodOutputToConvexFields, + withSystemFields, } from "./zod.js"; -import { customCtx } from "./customFunctions.js"; -import type { VString, VFloat64, VObject, VId, Infer } from "convex/values"; -import { v } from "convex/values"; -import { z } from "zod/v3"; - -// This is an example of how to make a version of `zid` that -// enforces that the type matches one of your defined tables. -// Note that it can't be used in anything imported by schema.ts -// since the types would be circular. -// For argument validation it might be useful to you, however. -// const zId = zid; - -export const kitchenSinkValidator = { - email: z.string().email(), - userId: zid("users"), - // Otherwise this is equivalent, but wouldn't catch zid("CounterTable") - // counterId: zid("counter_table"), - num: z.number().min(0), - nan: z.nan(), - bigint: z.bigint(), - bool: z.boolean(), - null: z.null(), - any: z.unknown(), - array: z.array(z.string()), - object: z.object({ a: z.string(), b: z.number() }), - objectWithOptional: z.object({ a: z.string(), b: z.number().optional() }), - record: z.record( - z.union([z.string(), zid("users")]), - z.union([z.number(), z.string()]), - ), - union: z.union([z.string(), z.number()]), - discriminatedUnion: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("a"), a: z.string() }), - z.object({ kind: z.literal("b"), b: z.number() }), - ]), - literal: z.literal("hi"), - tuple: z.tuple([z.string(), z.number()]), - lazy: z.lazy(() => z.string()), - enum: z.enum(["a", "b"]), - effect: z.effect(z.string(), { - refinement: () => true, - type: "refinement", - }), - optional: z.object({ a: z.string(), b: z.number() }).optional(), - nullableOptional: z.nullable(z.string().optional()), - optionalNullable: z.nullable(z.string()).optional(), - nullable: z.nullable(z.string()), - // z.string().brand("branded") also works, but zBrand also brands the input - branded: zBrand(z.string(), "branded"), - default: z.string().default("default"), - readonly: z.object({ a: z.string(), b: z.number() }).readonly(), - pipeline: z.number().pipe(z.coerce.string()), -}; - -const schema = defineSchema({ - sink: defineTable(zodToConvexFields(kitchenSinkValidator)).index("email", [ - "email", - ]), - users: defineTable({}), -}); -type DataModel = DataModelFromSchemaDefinition; -const query = queryGeneric as QueryBuilder; -// type DatabaseReader = GenericDatabaseReader; -// type DatabaseWriter = GenericDatabaseWriter; - -const zQuery = zCustomQuery(query, { - // You could require arguments for all queries here. - args: {}, - input: async () => { - // Here you could use the args you declared and return patches for the - // function's ctx and args. e.g. looking up a user and passing it in ctx. - // Or just asserting that the user is logged in. - return { ctx: {}, args: {} }; - }, -}); -export const kitchenSink = zQuery({ - args: kitchenSinkValidator, - handler: async (_ctx, args) => { - return { - args, - json: (v.object(zodToConvexFields(kitchenSinkValidator)) as any).json, - }; - }, - returns: z.object({ - args: z.object({ - ...kitchenSinkValidator, - // round trip the pipeline - pipeline: z.string().pipe(z.coerce.number()), - }), - json: z.any(), - }), - // You can add .strict() to fail if any more fields are passed - // .strict(), -}); - -export const dateRoundTrip = zQuery({ - args: { date: z.string().transform((s) => new Date(Date.parse(s))) }, - handler: async (ctx, args) => { - return args.date; - }, - returns: z.date().transform((d) => d.toISOString()), -}); - -export const failsReturnsValidator = zQuery({ - args: {}, - returns: z.number(), - handler: async () => { - return "foo" as unknown as number; - }, -}); - -export const returnsWithoutArgs = zQuery({ - returns: z.number(), - handler: async () => { - return 1; - }, -}); - -export const zodOutputCompliance = zQuery({ - // Note no args validator - handler: (ctx, args: { optionalString?: string | undefined }) => { - return { - undefinedBecomesFooString: undefined, - stringBecomesNull: "bar", - threeBecomesString: 3, - extraArg: "extraArg", - optionalString: args.optionalString, - arrayWithDefaultFoo: [undefined], - objectWithDefaultFoo: { foo: undefined }, - unionOfDefaultFoo: undefined, - }; - }, - // Note inline record of zod validators works. - returns: { - undefinedBecomesFooString: z.string().default("foo"), - stringBecomesNull: z.string().transform((_) => null), - threeBecomesString: z.number().pipe(z.coerce.string()), - optionalString: z.string().optional(), - arrayWithDefaultFoo: z.array(z.string().default("foo")), - objectWithDefaultFoo: z.object({ foo: z.string().default("foo") }), - unionOfDefaultFoo: z.union([z.string().default("foo"), z.number()]), - }, -}); +function assert<_T extends true>() {} -export const zodArgsObject = zQuery({ - args: z.object({ a: z.string() }), - handler: async (ctx, args) => { - return args; - }, - returns: z.object({ a: z.string() }), -}); +describe("zodToConvex", () => { + test("works with Zod 3", () => { + const zodValidator = z3.string(); + const result = zodToConvex(zodValidator); -// example of helper function -type ZodQueryCtx = ZCustomCtx; -const myArgs = z.object({ a: z.string() }); -const myHandler = async (_ctx: ZodQueryCtx, _args: z.infer) => { - return "foo"; -}; -export const viaHelper = zQuery({ - args: myArgs, - handler: myHandler, - returns: z.string(), -}); + // Runtime check - verify the validator structure + expect(result).toEqual(v.string()); -/** - * Testing custom zod function modifications. - */ + // Type check + assert>>(); + expectTypeOf>().toEqualTypeOf(); + }); -/** - * Adding ctx - */ -const addCtxArg = zCustomQuery( - query, - customCtx(() => { - return { a: "hi" }; - }), -); -export const addC = addCtxArg({ - args: {}, - handler: async (ctx) => { - return { ctxA: ctx.a }; // !!! - }, -}); -queryMatches(addC, {}, { ctxA: "" }); -// Unvalidated -export const addCU = addCtxArg({ - handler: async (ctx) => { - return { ctxA: ctx.a }; // !!! - }, -}); -// Unvalidated variant 2 -queryMatches(addCU, {}, { ctxA: "" }); -export const addCU2 = addCtxArg(async (ctx) => { - return { ctxA: ctx.a }; // !!! -}); -queryMatches(addCU2, {}, { ctxA: "" }); + test("works with Zod 4", () => { + const zodValidator = z4.string(); + const result = zodToConvex(zodValidator); -export const addCtxWithExistingArg = addCtxArg({ - args: { b: z.string() }, - handler: async (ctx, args) => { - return { ctxA: ctx.a, argB: args.b }; // !!! - }, -}); -queryMatches(addCtxWithExistingArg, { b: "" }, { ctxA: "", argB: "" }); -/** - * Adding arg - */ -const addArg = zCustomQuery(query, { - args: {}, - input: async () => { - return { ctx: {}, args: { a: "hi" } }; - }, -}); -export const add = addArg({ - args: {}, - handler: async (_ctx, args) => { - return { argsA: args.a }; // !!! - }, -}); -queryMatches(add, {}, { argsA: "" }); -export const addUnverified = addArg({ - handler: async (_ctx, args) => { - return { argsA: args.a }; // !!! - }, -}); -queryMatches(addUnverified, {}, { argsA: "" }); -export const addUnverified2 = addArg((_ctx, args) => { - return { argsA: args.a }; // !!! -}); -queryMatches(addUnverified2, {}, { argsA: "" }); + // Runtime check - verify the validator structure + expect(result).toEqual(v.string()); -/** - * Consuming arg, add to ctx - */ -const consumeArg = zCustomQuery(query, { - args: { a: v.string() }, - input: async (_ctx, { a }) => { - return { ctx: { a }, args: {} }; - }, -}); -export const consume = consumeArg({ - args: {}, - handler: async (ctx, emptyArgs) => { - assertType>(emptyArgs); // !!! - return { ctxA: ctx.a }; - }, -}); -queryMatches(consume, { a: "" }, { ctxA: "" }); + // Type check + assert>>(); + expectTypeOf>().toEqualTypeOf(); + }); -export const necromanceArg = consumeArg({ - args: { a: z.string() }, - handler: async (ctx, args) => { - assertType<{ a: string }>(args); - return { ctxA: ctx.a, argsA: args.a }; - }, -}); -queryMatches(necromanceArg, { a: "" }, { ctxA: "", argsA: "" }); + test("works with Zod 4 Mini", () => { + const zodValidator = z4Mini.string(); + const result = zodToConvex(zodValidator); -/** - * Passing Through arg, also add to ctx for fun - */ -const passThrougArg = zCustomQuery(query, { - args: { a: v.string() }, - input: async (_ctx, args) => { - return { ctx: { a: args.a }, args }; - }, -}); -export const passThrough = passThrougArg({ - args: {}, - handler: async (ctx, args) => { - return { ctxA: ctx.a, argsA: args.a }; // !!! - }, -}); -queryMatches(passThrough, { a: "" }, { ctxA: "", argsA: "" }); + // Runtime check - verify the validator structure + expect(result).toEqual(v.string()); -/** - * Modify arg type, don't need to re-defined "a" arg - */ -const modifyArg = zCustomQuery(query, { - args: { a: v.string() }, - input: async (_ctx, { a }) => { - return { ctx: { a }, args: { a: 123 } }; // !!! - }, -}); -export const modify = modifyArg({ - args: {}, - handler: async (ctx, args) => { - args.a.toFixed(); // !!! - return { ctxA: ctx.a, argsA: args.a }; - }, -}); -queryMatches(modify, { a: "" }, { ctxA: "", argsA: 0 }); // !!! - -/** - * Redefine arg type with the same type: OK! - */ -const redefineArg = zCustomQuery(query, { - args: { a: v.string() }, - input: async (_ctx, args) => ({ ctx: {}, args }), -}); -export const redefine = redefineArg({ - args: { a: z.string() }, - handler: async (_ctx, args) => { - return { argsA: args.a }; - }, -}); -queryMatches(redefine, { a: "" }, { argsA: "" }); - -/** - * Refine arg type with a more specific type: OK! - */ -const refineArg = zCustomQuery(query, { - args: { a: v.optional(v.string()) }, - input: async (_ctx, args) => ({ ctx: {}, args }), -}); -export const refined = refineArg({ - args: { a: z.string() }, - handler: async (_ctx, args) => { - return { argsA: args.a }; - }, + // Type check + assert>>(); + expectTypeOf>().toEqualTypeOf(); + }); }); -queryMatches(refined, { a: "" }, { argsA: "" }); -/** - * Redefine arg type with different type: error! - */ -const badRedefineArg = zCustomQuery(query, { - args: { a: v.string(), b: v.number() }, - input: async (_ctx, args) => ({ ctx: {}, args }), -}); -expect(() => - badRedefineArg({ - args: { a: z.number() }, - handler: async (_ctx, args) => { - return { argsA: args.a }; - }, - }), -).toThrow(); -/** - * Test helpers - */ -function queryMatches< - A extends DefaultFunctionArgs, - R, - T extends RegisteredQuery<"public", A, R>, ->(_f: T, _a: A, _v: R) {} +describe("zodOutputToConvex", () => { + test("works with Zod 3", () => { + const zodValidator = z3.string().transform((s) => s.length); + const result = zodOutputToConvex(zodValidator); -const testApi: ApiFromModules<{ - fns: { - kitchenSink: typeof kitchenSink; - dateRoundTrip: typeof dateRoundTrip; - failsReturnsValidator: typeof failsReturnsValidator; - returnsWithoutArgs: typeof returnsWithoutArgs; - zodOutputCompliance: typeof zodOutputCompliance; - zodArgsObject: typeof zodArgsObject; - addC: typeof addC; - addCU: typeof addCU; - addCU2: typeof addCU2; - addCtxWithExistingArg: typeof addCtxWithExistingArg; - add: typeof add; - addUnverified: typeof addUnverified; - addUnverified2: typeof addUnverified2; - consume: typeof consume; - necromanceArg: typeof necromanceArg; - passThrough: typeof passThrough; - modify: typeof modify; - redefine: typeof redefine; - refined: typeof refined; - }; -}>["fns"] = anyApi["zod.test"] as any; + // Runtime check - transforms return v.any() because transforms can't be represented in Convex + expect(result).toEqual(v.any()); -test("zod kitchen sink", async () => { - const t = convexTest(schema, modules); - const userId = await t.run((ctx) => ctx.db.insert("users", {})); - const kitchenSink = { - email: "email@example.com", - userId, - num: 1, - nan: NaN, - bigint: BigInt(1), - bool: true, - null: null, - any: [1, "2"], - array: ["1", "2"], - object: { a: "1", b: 2 }, - objectWithOptional: { a: "1" }, - record: { a: 1 }, - union: 1, - discriminatedUnion: { kind: "a" as const, a: "1" }, - literal: "hi" as const, - tuple: ["2", 1] as [string, number], - lazy: "lazy", - enum: "b" as const, - effect: "effect", - optional: undefined, - nullable: null, - branded: "branded" as string & z.BRAND<"branded">, - default: undefined, - readonly: { a: "1", b: 2 }, - pipeline: 0, - }; - const response = await t.query(testApi.kitchenSink, kitchenSink); - expect(response.args).toMatchObject({ - ...omit(kitchenSink, ["optional"]), - default: "default", - }); - expect(response.json).toMatchObject({ - type: "object", - value: { - any: { fieldType: { type: "any" }, optional: false }, - array: { - fieldType: { type: "array", value: { type: "string" } }, - optional: false, - }, - bigint: { fieldType: { type: "bigint" }, optional: false }, - bool: { fieldType: { type: "boolean" }, optional: false }, - branded: { fieldType: { type: "string" }, optional: false }, - default: { fieldType: { type: "string" }, optional: true }, - discriminatedUnion: { - fieldType: { - type: "union", - value: [ - { - type: "object", - value: { - a: { fieldType: { type: "string" }, optional: false }, - kind: { - fieldType: { type: "literal", value: "a" }, - optional: false, - }, - }, - }, - { - type: "object", - value: { - b: { fieldType: { type: "number" }, optional: false }, - kind: { - fieldType: { type: "literal", value: "b" }, - optional: false, - }, - }, - }, - ], - }, - optional: false, - }, - effect: { fieldType: { type: "string" }, optional: false }, - email: { fieldType: { type: "string" }, optional: false }, - enum: { - fieldType: { - type: "union", - value: [ - { type: "literal", value: "a" }, - { type: "literal", value: "b" }, - ], - }, - optional: false, - }, - lazy: { fieldType: { type: "string" }, optional: false }, - literal: { fieldType: { type: "literal", value: "hi" }, optional: false }, - nan: { fieldType: { type: "number" }, optional: false }, - null: { fieldType: { type: "null" }, optional: false }, - nullable: { - fieldType: { - type: "union", - value: [{ type: "string" }, { type: "null" }], - }, - optional: false, - }, - num: { fieldType: { type: "number" }, optional: false }, - object: { - fieldType: { - type: "object", - value: { - a: { fieldType: { type: "string" }, optional: false }, - b: { fieldType: { type: "number" }, optional: false }, - }, - }, - optional: false, - }, - objectWithOptional: { - fieldType: { - type: "object", - value: { - a: { fieldType: { type: "string" }, optional: false }, - b: { fieldType: { type: "number" }, optional: true }, - }, - }, - optional: false, - }, - optional: { - fieldType: { - type: "object", - value: { - a: { fieldType: { type: "string" }, optional: false }, - b: { fieldType: { type: "number" }, optional: false }, - }, - }, - optional: true, - }, - pipeline: { fieldType: { type: "number" }, optional: false }, - readonly: { - fieldType: { - type: "object", - value: { - a: { fieldType: { type: "string" }, optional: false }, - b: { fieldType: { type: "number" }, optional: false }, - }, - }, - optional: false, - }, - record: { - fieldType: { - keys: { - type: "union", - value: [{ type: "string" }, { tableName: "users", type: "id" }], - }, - type: "record", - values: { - fieldType: { - type: "union", - value: [{ type: "number" }, { type: "string" }], - }, - }, - }, - }, - tuple: { - fieldType: { - type: "array", - value: { - type: "union", - value: [{ type: "string" }, { type: "number" }], - }, - }, - optional: false, - }, - union: { - fieldType: { - type: "union", - value: [{ type: "string" }, { type: "number" }], - }, - optional: false, - }, - userId: { - fieldType: { tableName: "users", type: "id" }, - optional: false, - }, - }, + // Type check + assert>>(); + expectTypeOf>().toEqualTypeOf(); }); - const stored = await t.run(async (ctx) => { - const id = await ctx.db.insert("sink", kitchenSink); - return ctx.db.get(id); - }); - expect(stored).toMatchObject(omit(kitchenSink, ["optional", "default"])); -}); -test("zod date round trip", async () => { - const t = convexTest(schema, modules); - const date = new Date().toISOString(); - const response = await t.query(testApi.dateRoundTrip, { date }); - expect(response).toBe(date); -}); + test("works with Zod 4", () => { + const zodValidator = z4.string().transform((s) => s.length); + const result = zodOutputToConvex(zodValidator); -test("zod fails returns validator", async () => { - const t = convexTest(schema, modules); - await expect(() => - t.query(testApi.failsReturnsValidator, {}), - ).rejects.toThrow(); -}); + // Runtime check - transforms return v.any() because transforms can't be represented in Convex + expect(result).toEqual(v.any()); -test("zod returns without args works", async () => { - const t = convexTest(schema, modules); - const response = await t.query(testApi.returnsWithoutArgs, {}); - expect(response).toBe(1); -}); + // Type check + assert>>(); + expectTypeOf>().toEqualTypeOf(); + }); -test("output validators work for arrays objects and unions", async () => { - const array = zodOutputToConvex(z.array(z.string().default("foo"))); - expect(array.kind).toBe("array"); - expect(array.element.kind).toBe("string"); - expect(array.element.isOptional).toBe("required"); - const object = zodOutputToConvex( - z.object({ foo: z.string().default("foo") }), - ); - expect(object.kind).toBe("object"); - expect(object.fields.foo.kind).toBe("string"); - expect(object.fields.foo.isOptional).toBe("required"); - const union = zodOutputToConvex(z.union([z.string(), z.number().default(0)])); - expect(union.kind).toBe("union"); - expect(union.members[0].kind).toBe("string"); - expect(union.members[1].kind).toBe("float64"); - expect(union.members[1].isOptional).toBe("required"); -}); + test("works with Zod 4 Mini", () => { + // Zod 4 Mini doesn't support transform, so we test with a simple validator + // and verify that the output type matches the input type + const zodValidator = z4Mini.string(); + const result = zodOutputToConvex(zodValidator); -test("zod output compliance", async () => { - const t = convexTest(schema, modules); - const response = await t.query(testApi.zodOutputCompliance, {}); - expect(response).toMatchObject({ - undefinedBecomesFooString: "foo", - stringBecomesNull: null, - threeBecomesString: "3", - arrayWithDefaultFoo: ["foo"], - objectWithDefaultFoo: { foo: "foo" }, - unionOfDefaultFoo: "foo", - }); - const responseWithMaybe = await t.query(testApi.zodOutputCompliance, { - optionalString: "optionalString", - }); - expect(responseWithMaybe).toMatchObject({ - optionalString: "optionalString", - }); - // number should fail - await expect(() => - t.query(testApi.zodOutputCompliance, { - optionalString: 1 as any, - }), - ).rejects.toThrow(); -}); + // Runtime check - verify the validator structure + expect(result).toEqual(v.string()); -test("zod args object", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.zodArgsObject, { a: "foo" })).toMatchObject({ - a: "foo", + // Type check + assert>>(); + expectTypeOf>().toEqualTypeOf(); }); - await expect(() => - t.query(testApi.zodArgsObject, { a: 1 } as any), - ).rejects.toThrow(); }); -describe("zod functions", () => { - test("add ctx", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.addC, {})).toMatchObject({ - ctxA: "hi", - }); - expect(await t.query(testApi.addCU, {})).toMatchObject({ - ctxA: "hi", - }); - expect(await t.query(testApi.addCU2, {})).toMatchObject({ - ctxA: "hi", - }); - }); - - test("add ctx with existing arg", async () => { - const t = convexTest(schema, modules); - expect( - await t.query(testApi.addCtxWithExistingArg, { b: "foo" }), - ).toMatchObject({ - ctxA: "hi", - argB: "foo", - }); - expectTypeOf(testApi.addCtxWithExistingArg).toExtend< - FunctionReference< - "query", - "public", - { b: string }, - { ctxA: string; argB: string } +describe("zodToConvexFields", () => { + test("works with Zod 3", () => { + const zodFields = { + name: z3.string(), + age: z3.number().optional(), + }; + const result = zodToConvexFields(zodFields); + + // Runtime check + expect(result.name).toEqual(v.string()); + expect(result.age).toEqual(v.optional(v.number())); + + // Type check + assert>>(); + assert< + Equals< + typeof result.age, + ReturnType>> > >(); - expectTypeOf< - FunctionReference< - "query", - "public", - { b: string }, - { ctxA: string; argB: string } - > - >().toExtend(); - }); - - test("add args", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.add, {})).toMatchObject({ - argsA: "hi", - }); - expect(await t.query(testApi.addUnverified, {})).toMatchObject({ - argsA: "hi", - }); - expect(await t.query(testApi.addUnverified2, {})).toMatchObject({ - argsA: "hi", - }); - }); - - test("consume arg, add to ctx", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.consume, { a: "foo" })).toMatchObject({ - ctxA: "foo", - }); - }); - - test("necromance arg", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.necromanceArg, { a: "foo" })).toMatchObject({ - ctxA: "foo", - argsA: "foo", - }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | undefined + >(); }); - test("pass through arg + ctx", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.passThrough, { a: "foo" })).toMatchObject({ - ctxA: "foo", - argsA: "foo", - }); + test("works with Zod 4", () => { + const zodFields = { + name: z4.string(), + age: z4.number().optional(), + }; + const result = zodToConvexFields(zodFields); + + // Runtime check + expect(result.name).toEqual(v.string()); + expect(result.age).toEqual(v.optional(v.number())); + + // Type check + assert>>(); + assert< + Equals< + typeof result.age, + ReturnType>> + > + >(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | undefined + >(); }); - test("modify arg type", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.modify, { a: "foo" })).toMatchObject({ - ctxA: "foo", - argsA: 123, - }); + test("works with Zod 4 Mini", () => { + const zodFields = { + name: z4Mini.string(), + age: z4Mini.optional(z4Mini.number()), + }; + const result = zodToConvexFields(zodFields); + + // Runtime check + expect(result.name).toEqual(v.string()); + expect(result.age).toEqual(v.optional(v.number())); + + // Type check + assert>>(); + assert< + Equals< + typeof result.age, + ReturnType>> + > + >(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | undefined + >(); }); +}); - test("redefine arg", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.redefine, { a: "foo" })).toMatchObject({ - argsA: "foo", - }); +describe("zodOutputToConvexFields", () => { + test("works with Zod 3", () => { + const zodFields = { + name: z3.string().default("Unknown"), + count: z3.string().transform((s) => parseInt(s, 10)), + }; + const result = zodOutputToConvexFields(zodFields); + + // Runtime check + // For default, output type should be string (not optional) + expect(result.name).toEqual(v.string()); + // For transform, output type is v.any() because transforms can't be represented in Convex + expect(result.count).toEqual(v.any()); + + // Type check + assert>>(); + assert>>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); - test("refined arg", async () => { - const t = convexTest(schema, modules); - expect(await t.query(testApi.refined, { a: "foo" })).toMatchObject({ - argsA: "foo", - }); - await expect(() => - t.query(testApi.refined, { a: undefined as any }), - ).rejects.toThrow("Validator error: Missing required field `a`"); + test("works with Zod 4", () => { + const zodFields = { + name: z4.string().default("Unknown"), + count: z4.string().transform((s) => parseInt(s, 10)), + }; + const result = zodOutputToConvexFields(zodFields); + + // Runtime check + // For default, output type should be string (not optional) + expect(result.name).toEqual(v.string()); + // For transform, output type is v.any() because transforms can't be represented in Convex + expect(result.count).toEqual(v.any()); + + // Type check + assert>>(); + assert>>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); -}); - -/** - * Test type translation - */ - -expectTypeOf( - zodToConvexFields({ - s: z.string().email().max(5), - n: z.number(), - nan: z.nan(), - optional: z.number().optional(), - optional2: z.optional(z.number()), - record: z.record(z.string(), z.number()), - default: z.number().default(0), - nullable: z.number().nullable(), - null: z.null(), - bi: z.bigint(), - bool: z.boolean(), - literal: z.literal("hi"), - branded: z.string().brand("branded"), - }), -).toEqualTypeOf({ - s: v.string(), - n: v.number(), - nan: v.number(), - optional: v.optional(v.number()), - optional2: v.optional(v.number()), - record: v.record(v.string(), v.number()), - default: v.optional(v.number()), - nullable: v.union(v.number(), v.null()), - null: v.null(), - bi: v.int64(), - bool: v.boolean(), - literal: v.literal("hi"), - branded: v.string() as VString>, -}); - -expectTypeOf( - zodToConvexFields({ - simpleArray: z.array(z.boolean()), - tuple: z.tuple([z.boolean(), z.boolean()]), - enum: z.enum(["a", "b"]), - obj: z.object({ a: z.string(), b: z.object({ c: z.array(z.number()) }) }), - union: z.union([z.string(), z.object({ c: z.array(z.number()) })]), - discUnion: z.discriminatedUnion("type", [ - z.object({ type: z.literal("a"), a: z.string() }), - z.object({ type: z.literal("b"), b: z.number() }), - ]), - }), -).toEqualTypeOf({ - simpleArray: v.array(v.boolean()), - tuple: v.array(v.boolean()), - enum: v.union(v.literal("a"), v.literal("b")), - obj: v.object({ a: v.string(), b: v.object({ c: v.array(v.number()) }) }), - union: v.union(v.string(), v.object({ c: v.array(v.number()) })), - discUnion: v.union( - v.object({ - type: v.literal("a"), - a: v.string(), - }), - v.object({ - type: v.literal("b"), - b: v.number(), - }), - ), -}); - -expectTypeOf( - zodToConvexFields({ - transformed: z.transformer(z.string(), { - type: "refinement", - refinement: () => true, - }), - lazy: z.lazy(() => z.string()), - pipe: z.number().pipe(z.string().email()), - ro: z.string().readonly(), - unknown: z.unknown(), - any: z.any(), - }), -).toEqualTypeOf({ - transformed: v.string(), - lazy: v.string(), - pipe: v.number(), - ro: v.string(), - unknown: v.any(), - any: v.any(), -}); -// Validate that our double-branded type is correct. -expectTypeOf( - zodToConvexFields({ - branded2: zBrand(z.string(), "branded2"), - }), -).toEqualTypeOf({ - branded2: v.string() as VString>, -}); -const _s = zBrand(z.string(), "brand"); -const _n = zBrand(z.number(), "brand"); -const _i = zBrand(z.bigint(), "brand"); -expectTypeOf>().toEqualTypeOf>(); -expectTypeOf>().toEqualTypeOf>(); -expectTypeOf>().toEqualTypeOf>(); -expectTypeOf>().toEqualTypeOf>(); -expectTypeOf>().toEqualTypeOf>(); -expectTypeOf>().toEqualTypeOf>(); - -function sameType(_t: T, _u: U): Equals { - return true as any; -} - -test("convexToZod basic types", () => { - expect(convexToZod(v.string()).constructor.name).toBe("ZodString"); - expect(convexToZod(v.number()).constructor.name).toBe("ZodNumber"); - expect(convexToZod(v.int64()).constructor.name).toBe("ZodBigInt"); - expect(convexToZod(v.boolean()).constructor.name).toBe("ZodBoolean"); - expect(convexToZod(v.null()).constructor.name).toBe("ZodNull"); - expect(convexToZod(v.any()).constructor.name).toBe("ZodAny"); - expect(convexToZod(v.id("users")).constructor.name).toBe("Zid"); -}); - -test("convexToZod complex types", () => { - const arrayValidator = convexToZod(v.array(v.string())); - expect(arrayValidator.constructor.name).toBe("ZodArray"); - - const objectValidator = convexToZod( - v.object({ a: v.string(), b: v.number() }), - ); - expect(objectValidator.constructor.name).toBe("ZodObject"); - - const unionValidator = convexToZod(v.union(v.string(), v.number())); - expect(unionValidator.constructor.name).toBe("ZodUnion"); - expect(unionValidator.options[0].constructor.name).toBe("ZodString"); - expect(unionValidator.options[1].constructor.name).toBe("ZodNumber"); - expectTypeOf(unionValidator.options[0]).toEqualTypeOf(); - expectTypeOf(unionValidator.options[1]).toEqualTypeOf(); - const literalValidator = convexToZod(v.literal("hi")); - expect(literalValidator.constructor.name).toBe("ZodLiteral"); - - const recordValidator = convexToZod(v.record(v.string(), v.number())); - expect(recordValidator.constructor.name).toBe("ZodRecord"); - - const optionalValidator = convexToZod(v.optional(v.string())); - expect(optionalValidator.constructor.name).toBe("ZodOptional"); -}); - -test("convexToZodFields", () => { - const fields = { - name: v.string(), - age: v.number(), - isActive: v.boolean(), - tags: v.array(v.string()), - metadata: v.object({ createdBy: v.string() }), - }; - - const zodFields = convexToZodFields(fields); - - expect(zodFields.name.constructor.name).toBe("ZodString"); - expect(zodFields.age.constructor.name).toBe("ZodNumber"); - expect(zodFields.isActive.constructor.name).toBe("ZodBoolean"); - expect(zodFields.tags.constructor.name).toBe("ZodArray"); - expect(zodFields.metadata.constructor.name).toBe("ZodObject"); -}); - -test("convexToZod round trip", () => { - const stringValidator = v.string(); - const zodString = convexToZod(stringValidator); - const roundTripString = zodToConvex(zodString) as VString; - expect(roundTripString.kind).toBe(stringValidator.kind); - - type StringType = z.infer; - type ConvexStringType = Infer; - sameType( - "" as StringType, - "" as ConvexStringType, - ); - - const numberValidator = v.number(); - const zodNumber = convexToZod(numberValidator); - const roundTripNumber = zodToConvex(zodNumber) as VFloat64; - expect(roundTripNumber.kind).toBe(numberValidator.kind); + test("works with Zod 4 Mini", () => { + // Zod 4 Mini doesn't support default or transform, so we test with simple validators + const zodFields = { + name: z4Mini.string(), + count: z4Mini.number(), + }; + const result = zodOutputToConvexFields(zodFields); - type NumberType = z.infer; - type ConvexNumberType = Infer; - sameType( - 0 as NumberType, - 0 as ConvexNumberType, - ); + // Runtime check + expect(result.name).toEqual(v.string()); + expect(result.count).toEqual(v.number()); - const objectValidator = v.object({ - a: v.string(), - b: v.number(), - c: v.boolean(), - d: v.array(v.string()), + // Type check + assert>>(); + assert>>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); - - const zodObject = convexToZod(objectValidator); - const roundTripObject = zodToConvex(zodObject) as VObject; - expect(roundTripObject.kind).toBe(objectValidator.kind); - - type ObjectType = z.infer; - type ConvexObjectType = Infer; - sameType( - {} as ObjectType, - {} as ConvexObjectType, - ); - - const idValidator = v.id("users"); - const zodId = convexToZod(idValidator); - const roundTripId = zodToConvex(zodId) as VId<"users">; - expect(roundTripId.kind).toBe(idValidator.kind); - - type IdType = z.infer; - type ConvexIdType = Infer; - sameType("" as IdType, "" as ConvexIdType); }); -test("convexToZod validation", () => { - const stringValidator = v.string(); - const zodString = convexToZod(stringValidator); - - expect(zodString.parse("hello")).toBe("hello"); - - expect(() => zodString.parse(123)).toThrow(); - - const numberValidator = v.number(); - const zodNumber = convexToZod(numberValidator); - - expect(zodNumber.parse(123)).toBe(123); - - expect(() => zodNumber.parse("hello")).toThrow(); - - const boolValidator = v.boolean(); - const zodBool = convexToZod(boolValidator); - - expect(zodBool.parse(true)).toBe(true); - - expect(() => zodBool.parse("true")).toThrow(); - - const arrayValidator = v.array(v.string()); - const zodArray = convexToZod(arrayValidator); - - expect(zodArray.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]); +describe("withSystemFields", () => { + test("works with Zod 3", () => { + const zodObject = { + name: z3.string(), + age: z3.number(), + }; + const result = withSystemFields("users", zodObject); - expect(() => zodArray.parse(["a", 123, "c"])).toThrow(); + // Runtime check - verify structure + expect(result.name).toBeDefined(); + expect(result.age).toBeDefined(); + expect(result._id).toBeDefined(); + expect(result._creationTime).toBeDefined(); - const objectValidator = v.object({ - name: v.string(), - age: v.number(), - active: v.boolean(), + // Type check - verify that the result has the expected structure + assert< + Equals + >(); }); - const zodObject = convexToZod(objectValidator); - - const validObject = { - name: "John", - age: 30, - active: true, - }; - expect(zodObject.parse(validObject)).toEqual(validObject); - - const invalidObject = { - name: "John", - age: "thirty", - active: true, - }; - expect(() => zodObject.parse(invalidObject)).toThrow(); - - const unionValidator = v.union(v.string(), v.number()); - const zodUnion = convexToZod(unionValidator); - - expect(zodUnion.parse("hello")).toBe("hello"); - - expect(zodUnion.parse(123)).toBe(123); - - expect(() => zodUnion.parse(true)).toThrow(); -}); - -test("convexToZod optional values", () => { - const optionalStringValidator = v.optional(v.string()); - const zodOptionalString = convexToZod(optionalStringValidator); - - expect(zodOptionalString.constructor.name).toBe("ZodOptional"); - - expect(zodOptionalString.parse("hello")).toBe("hello"); - expect(zodOptionalString.parse(undefined)).toBe(undefined); - expect(() => zodOptionalString.parse(123)).toThrow(); - type OptionalStringType = z.infer; - type ConvexOptionalStringType = Infer; - sameType( - "" as OptionalStringType, - "" as ConvexOptionalStringType, - ); - sameType( - undefined as OptionalStringType, - undefined as string | undefined, - ); - - const optionalNumberValidator = v.optional(v.number()); - const zodOptionalNumber = convexToZod(optionalNumberValidator); - - expect(zodOptionalNumber.constructor.name).toBe("ZodOptional"); - - expect(zodOptionalNumber.parse(123)).toBe(123); - expect(zodOptionalNumber.parse(undefined)).toBe(undefined); - expect(() => zodOptionalNumber.parse("hello")).toThrow(); - - type OptionalNumberType = z.infer; - type ConvexOptionalNumberType = Infer; - sameType( - 0 as OptionalNumberType, - 0 as ConvexOptionalNumberType, - ); - - const optionalObjectValidator = v.optional( - v.object({ - name: v.string(), - age: v.number(), - }), - ); - const zodOptionalObject = convexToZod(optionalObjectValidator); - - expect(zodOptionalObject.constructor.name).toBe("ZodOptional"); - - const validObj = { name: "John", age: 30 }; - expect(zodOptionalObject.parse(validObj)).toEqual(validObj); - expect(zodOptionalObject.parse(undefined)).toBe(undefined); - expect(() => zodOptionalObject.parse({ name: "John", age: "30" })).toThrow(); - - type OptionalObjectType = z.infer; - type ConvexOptionalObjectType = Infer; - sameType( - { name: "", age: 0 } as OptionalObjectType, - { name: "", age: 0 } as ConvexOptionalObjectType, - ); + test("works with Zod 4", () => { + const zodObject = { + name: z4.string(), + age: z4.number(), + }; + const result = withSystemFields("users", zodObject); - const objectWithOptionalFieldsValidator = v.object({ - name: v.string(), - age: v.optional(v.number()), - address: v.optional(v.string()), - }); - const zodObjectWithOptionalFields = convexToZod( - objectWithOptionalFieldsValidator, - ); + // Runtime check - verify structure + expect(result.name).toBeDefined(); + expect(result.age).toBeDefined(); + expect(result._id).toBeDefined(); + expect(result._creationTime).toBeDefined(); - expect(zodObjectWithOptionalFields.parse({ name: "John" })).toEqual({ - name: "John", - }); - expect(zodObjectWithOptionalFields.parse({ name: "John", age: 30 })).toEqual({ - name: "John", - age: 30, + // Type check - verify that the result has the expected structure + assert< + Equals + >(); }); - expect( - zodObjectWithOptionalFields.parse({ - name: "John", - age: 30, - address: "123 Main St", - }), - ).toEqual({ name: "John", age: 30, address: "123 Main St" }); - expect(() => zodObjectWithOptionalFields.parse({ age: 30 })).toThrow(); - - type ObjectWithOptionalFieldsType = z.infer< - typeof zodObjectWithOptionalFields - >; - type ConvexObjectWithOptionalFieldsType = Infer< - typeof objectWithOptionalFieldsValidator - >; - sameType( - { name: "" } as ObjectWithOptionalFieldsType, - { name: "" } as ConvexObjectWithOptionalFieldsType, - ); - - const optionalArrayValidator = v.optional(v.array(v.string())); - const zodOptionalArray = convexToZod(optionalArrayValidator); - const roundTripOptionalArray = zodToConvex(zodOptionalArray) as unknown as { - isOptional: string; - }; - expect(roundTripOptionalArray.isOptional).toBe("optional"); -}); + test("works with Zod 4 Mini", () => { + const zodObject = { + name: z4Mini.string(), + age: z4Mini.number(), + }; + const result = withSystemFields("users", zodObject); -test("convexToZod union of one literal", () => { - const unionValidator = v.union(v.literal("hello")); - const zodUnion = convexToZod(unionValidator); - expect(zodUnion.constructor.name).toBe("ZodUnion"); - expect(zodUnion.parse("hello")).toBe("hello"); - expect(() => zodUnion.parse("world")).toThrow(); -}); + // Runtime check - verify structure + expect(result.name).toBeDefined(); + expect(result.age).toBeDefined(); + expect(result._id).toBeDefined(); + expect(result._creationTime).toBeDefined(); -test("convexToZod object with union of one literal", () => { - const unionValidator = v.object({ - member: v.union(v.literal("hello")), + // Type check - verify that the result has the expected structure + assert< + Equals + >(); }); - const zodUnion = convexToZod(unionValidator); - expect(zodUnion.constructor.name).toBe("ZodObject"); - expect(zodUnion.parse({ member: "hello" })).toEqual({ member: "hello" }); - expect(() => zodUnion.parse({ member: "world" })).toThrow(); }); diff --git a/packages/convex-helpers/server/zod.ts b/packages/convex-helpers/server/zod.ts index 9d70bb9c..2fe29997 100644 --- a/packages/convex-helpers/server/zod.ts +++ b/packages/convex-helpers/server/zod.ts @@ -1,500 +1,64 @@ -import type { ZodTypeDef } from "zod/v3"; -import { ZodFirstPartyTypeKind, z } from "zod/v3"; -import type { - GenericId, - Infer, - ObjectType, - PropertyValidators, - Value, - VArray, - VAny, - VString, - VId, - VUnion, - VFloat64, - VInt64, - VBoolean, - VNull, - VLiteral, - GenericValidator, - VOptional, - VObject, - Validator, - VRecord, -} from "convex/values"; -import { ConvexError, v } from "convex/values"; -import type { - FunctionVisibility, - GenericDataModel, - GenericActionCtx, - GenericQueryCtx, - MutationBuilder, - QueryBuilder, - GenericMutationCtx, - ActionBuilder, - TableNamesInDataModel, - DefaultFunctionArgs, - ArgsArrayToObject, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only - defineTable, -} from "convex/server"; -import type { Customization, Registration } from "./customFunctions.js"; +import { z as z3 } from "zod/v3"; +import * as z4 from "zod/v4"; +import * as z4Core from "zod/v4/core"; import { - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only - customQuery, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only - customMutation, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only - customAction, - NoOp, -} from "./customFunctions.js"; -import { pick } from "../index.js"; -import { addFieldsToValidator } from "../validators.js"; - -export type ZodValidator = Record; + zid as zid3, + type ZCustomCtx as ZCustomCtx3, + zCustomQuery as zCustomQuery3, + zCustomMutation as zCustomMutation3, + zCustomAction as zCustomAction3, + type CustomBuilder as CustomBuilder3, + zodToConvex as zodToConvex3, + type ConvexValidatorFromZodOutput as ConvexValidatorFromZodOutput3, + zodOutputToConvex as zodOutputToConvex3, + Zid as Zid3, + withSystemFields as withSystemFields3, + ZodBrandedInputAndOutput as ZodBrandedInputAndOutput3, + zBrand as zBrand3, + type ConvexToZod as ConvexToZod3, + type ZodValidatorFromConvex as ZodValidatorFromConvex3, + convexToZod as convexToZod3, + convexToZodFields as convexToZodFields3, + type ConvexValidatorFromZod as ConvexValidatorFromZod3, +} from "./zod3.js"; +import type { GenericValidator, PropertyValidators } from "convex/values"; +import type { FunctionVisibility } from "convex/server"; +import { + type ConvexValidatorFromZod as ConvexValidatorFromZod4, + zodToConvex as zodToConvex4, + zodOutputToConvex as zodOutputToConvex4, + type ConvexValidatorFromZodOutput as ConvexValidatorFromZodOutput4, + withSystemFields as withSystemFields4, + type Zid as Zid4, +} from "./zod4.js"; /** - * Creates a validator for a Convex `Id`. - * - * - When **used within Zod**, it will only check that the ID is a string. - * - When **converted to a Convex validator** (e.g. through {@link zodToConvex}), - * it will check that it's for the right table. - * - * @param tableName - The table that the `Id` references. i.e. `Id` - * @returns A Zod object representing a Convex `Id` + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export const zid = < - DataModel extends GenericDataModel, - TableName extends - TableNamesInDataModel = TableNamesInDataModel, ->( - tableName: TableName, -) => new Zid({ typeName: "ConvexId", tableName }); +export const zid = zid3; /** - * Useful to get the input context type for a custom function using Zod. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export type ZCustomCtx = - Builder extends CustomBuilder< - any, - any, - infer CustomCtx, - any, - infer InputCtx, - any, - any - > - ? Overwrite - : never; +export type ZCustomCtx = ZCustomCtx3; /** - * `zCustomQuery` is like {@link customQuery}, but allows validation via Zod. - * You can define custom behavior on top of `query` or `internalQuery` - * by passing a function that modifies the ctx and args. Or {@link NoOp} to do nothing. - * - * Example usage: - * ```js - * const myQueryBuilder = zCustomQuery(query, { - * args: { sessionId: v.id("sessions") }, - * input: async (ctx, args) => { - * const user = await getUserOrNull(ctx); - * const session = await db.get(sessionId); - * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); - * return { ctx: { db, user, session }, args: {} }; - * }, - * }); - * - * // Using the custom builder - * export const getSomeData = myQueryBuilder({ - * args: { someArg: z.string() }, - * handler: async (ctx, args) => { - * const { db, user, session, scheduler } = ctx; - * const { someArg } = args; - * // ... - * } - * }); - * ``` - * - * Simple usage only modifying ctx: - * ```js - * const myInternalQuery = zCustomQuery( - * internalQuery, - * customCtx(async (ctx) => { - * return { - * // Throws an exception if the user isn't logged in - * user: await getUserByTokenIdentifier(ctx), - * }; - * }) - * ); - * - * // Using it - * export const getUser = myInternalQuery({ - * args: { email: z.string().email() }, - * handler: async (ctx, args) => { - * console.log(args.email); - * return ctx.user; - * }, - * }); - * - * @param query The query to be modified. Usually `query` or `internalQuery` - * from `_generated/server`. - * @param customization The customization to be applied to the query, changing ctx and args. - * @returns A new query builder using Zod validation to define queries. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export function zCustomQuery< - CustomArgsValidator extends PropertyValidators, - CustomCtx extends Record, - CustomMadeArgs extends Record, - Visibility extends FunctionVisibility, - DataModel extends GenericDataModel, - ExtraArgs extends Record = object, ->( - query: QueryBuilder, - customization: Customization< - GenericQueryCtx, - CustomArgsValidator, - CustomCtx, - CustomMadeArgs, - ExtraArgs - >, -) { - return customFnBuilder(query, customization) as CustomBuilder< - "query", - CustomArgsValidator, - CustomCtx, - CustomMadeArgs, - GenericQueryCtx, - Visibility, - ExtraArgs - >; -} +export const zCustomQuery = zCustomQuery3; /** - * `zCustomMutation` is like {@link customMutation}, but allows validation via Zod. - * You can define custom behavior on top of `mutation` or `internalMutation` - * by passing a function that modifies the ctx and args. Or {@link NoOp} to do nothing. - * - * Example usage: - * ```js - * const myMutationBuilder = zCustomMutation(mutation, { - * args: { sessionId: v.id("sessions") }, - * input: async (ctx, args) => { - * const user = await getUserOrNull(ctx); - * const session = await db.get(sessionId); - * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); - * return { ctx: { db, user, session }, args: {} }; - * }, - * }); - * - * // Using the custom builder - * export const getSomeData = myMutationBuilder({ - * args: { someArg: z.string() }, - * handler: async (ctx, args) => { - * const { db, user, session, scheduler } = ctx; - * const { someArg } = args; - * // ... - * } - * }); - * ``` - * - * Simple usage only modifying ctx: - * ```js - * const myInternalMutation = zCustomMutation( - * internalMutation, - * customCtx(async (ctx) => { - * return { - * // Throws an exception if the user isn't logged in - * user: await getUserByTokenIdentifier(ctx), - * }; - * }) - * ); - * - * // Using it - * export const getUser = myInternalMutation({ - * args: { email: z.string().email() }, - * handler: async (ctx, args) => { - * console.log(args.email); - * return ctx.user; - * }, - * }); - * - * @param mutation The mutation to be modified. Usually `mutation` or `internalMutation` - * from `_generated/server`. - * @param customization The customization to be applied to the mutation, changing ctx and args. - * @returns A new mutation builder using Zod validation to define queries. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export function zCustomMutation< - CustomArgsValidator extends PropertyValidators, - CustomCtx extends Record, - CustomMadeArgs extends Record, - Visibility extends FunctionVisibility, - DataModel extends GenericDataModel, - ExtraArgs extends Record = object, ->( - mutation: MutationBuilder, - customization: Customization< - GenericMutationCtx, - CustomArgsValidator, - CustomCtx, - CustomMadeArgs, - ExtraArgs - >, -) { - return customFnBuilder(mutation, customization) as CustomBuilder< - "mutation", - CustomArgsValidator, - CustomCtx, - CustomMadeArgs, - GenericMutationCtx, - Visibility, - ExtraArgs - >; -} +export const zCustomMutation = zCustomMutation3; /** - * `zCustomAction` is like {@link customAction}, but allows validation via Zod. - * You can define custom behavior on top of `action` or `internalAction` - * by passing a function that modifies the ctx and args. Or {@link NoOp} to do nothing. - * - * Example usage: - * ```js - * const myActionBuilder = zCustomAction(action, { - * args: { sessionId: v.id("sessions") }, - * input: async (ctx, args) => { - * const user = await getUserOrNull(ctx); - * const session = await db.get(sessionId); - * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); - * return { ctx: { db, user, session }, args: {} }; - * }, - * }); - * - * // Using the custom builder - * export const getSomeData = myActionBuilder({ - * args: { someArg: z.string() }, - * handler: async (ctx, args) => { - * const { db, user, session, scheduler } = ctx; - * const { someArg } = args; - * // ... - * } - * }); - * ``` - * - * Simple usage only modifying ctx: - * ```js - * const myInternalAction = zCustomAction( - * internalAction, - * customCtx(async (ctx) => { - * return { - * // Throws an exception if the user isn't logged in - * user: await getUserByTokenIdentifier(ctx), - * }; - * }) - * ); - * - * // Using it - * export const getUser = myInternalAction({ - * args: { email: z.string().email() }, - * handler: async (ctx, args) => { - * console.log(args.email); - * return ctx.user; - * }, - * }); - * - * @param action The action to be modified. Usually `action` or `internalAction` - * from `_generated/server`. - * @param customization The customization to be applied to the action, changing ctx and args. - * @returns A new action builder using Zod validation to define queries. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export function zCustomAction< - CustomArgsValidator extends PropertyValidators, - CustomCtx extends Record, - CustomMadeArgs extends Record, - Visibility extends FunctionVisibility, - DataModel extends GenericDataModel, - ExtraArgs extends Record = object, ->( - action: ActionBuilder, - customization: Customization< - GenericActionCtx, - CustomArgsValidator, - CustomCtx, - CustomMadeArgs, - ExtraArgs - >, -) { - return customFnBuilder(action, customization) as CustomBuilder< - "action", - CustomArgsValidator, - CustomCtx, - CustomMadeArgs, - GenericActionCtx, - Visibility, - ExtraArgs - >; -} - -function customFnBuilder( - builder: (args: any) => any, - customization: Customization, -) { - // Looking forward to when input / args / ... are optional - const customInput: Customization["input"] = - customization.input ?? NoOp.input; - const inputArgs = customization.args ?? NoOp.args; - return function customBuilder(fn: any): any { - const { args, handler = fn, returns: maybeObject, ...extra } = fn; - - const returns = - maybeObject && !(maybeObject instanceof z.ZodType) - ? z.object(maybeObject) - : maybeObject; - - const returnValidator = - returns && !fn.skipConvexValidation - ? { returns: zodOutputToConvex(returns) } - : null; - - if (args && !fn.skipConvexValidation) { - let argsValidator = args; - if (argsValidator instanceof z.ZodType) { - if (argsValidator instanceof z.ZodObject) { - argsValidator = argsValidator._def.shape(); - } else { - throw new Error( - "Unsupported zod type as args validator: " + - argsValidator.constructor.name, - ); - } - } - const convexValidator = zodToConvexFields(argsValidator); - return builder({ - args: addFieldsToValidator(convexValidator, inputArgs), - ...returnValidator, - handler: async (ctx: any, allArgs: any) => { - const added = await customInput( - ctx, - pick(allArgs, Object.keys(inputArgs)) as any, - extra, - ); - const rawArgs = pick(allArgs, Object.keys(argsValidator)); - const parsed = z.object(argsValidator).safeParse(rawArgs); - if (!parsed.success) { - throw new ConvexError({ - ZodError: JSON.parse( - JSON.stringify(parsed.error.errors, null, 2), - ) as Value[], - }); - } - const args = parsed.data; - const finalCtx = { ...ctx, ...added.ctx }; - const finalArgs = { ...args, ...added.args }; - const ret = await handler(finalCtx, finalArgs); - // We don't catch the error here. It's a developer error and we - // don't want to risk exposing the unexpected value to the client. - const result = returns ? returns.parse(ret) : ret; - if (added.onSuccess) { - await added.onSuccess({ ctx, args, result }); - } - return result; - }, - }); - } - if (Object.keys(inputArgs).length > 0 && !fn.skipConvexValidation) { - throw new Error( - "If you're using a custom function with arguments for the input " + - "customization, you must declare the arguments for the function too.", - ); - } - return builder({ - ...returnValidator, - handler: async (ctx: any, args: any) => { - const added = await customInput(ctx, args, extra); - const finalCtx = { ...ctx, ...added.ctx }; - const finalArgs = { ...args, ...added.args }; - const ret = await handler(finalCtx, finalArgs); - // We don't catch the error here. It's a developer error and we - // don't want to risk exposing the unexpected value to the client. - const result = returns ? returns.parse(ret) : ret; - if (added.onSuccess) { - await added.onSuccess({ ctx, args, result }); - } - return result; - }, - }); - }; -} - -type OneArgArray = - [ArgsObject]; - -// Copied from convex/src/server/api.ts since they aren't exported -type NullToUndefinedOrNull = T extends null ? T | undefined | void : T; -type Returns = Promise> | NullToUndefinedOrNull; - -// The return value before it's been validated: returned by the handler -type ReturnValueInput< - ReturnsValidator extends z.ZodTypeAny | ZodValidator | void, -> = [ReturnsValidator] extends [z.ZodTypeAny] - ? Returns> - : [ReturnsValidator] extends [ZodValidator] - ? Returns>> - : any; - -// The return value after it's been validated: returned to the client -type ReturnValueOutput< - ReturnsValidator extends z.ZodTypeAny | ZodValidator | void, -> = [ReturnsValidator] extends [z.ZodTypeAny] - ? Returns> - : [ReturnsValidator] extends [ZodValidator] - ? Returns>> - : any; - -// The args before they've been validated: passed from the client -type ArgsInput | void> = [ - ArgsValidator, -] extends [z.ZodObject] - ? [z.input] - : [ArgsValidator] extends [ZodValidator] - ? [z.input>] - : OneArgArray; - -// The args after they've been validated: passed to the handler -type ArgsOutput | void> = - [ArgsValidator] extends [z.ZodObject] - ? [z.output] - : [ArgsValidator] extends [ZodValidator] - ? [z.output>] - : OneArgArray; - -type Overwrite = Omit & U; - -/* - * Hack! This type causes TypeScript to simplify how it renders object types. - * - * It is functionally the identity for object types, but in practice it can - * simplify expressions like `A & B`. - */ -type Expand> = - ObjectType extends Record - ? { - [Key in keyof ObjectType]: ObjectType[Key]; - } - : never; - -type ArgsForHandlerType< - OneOrZeroArgs extends [] | [Record], - CustomMadeArgs extends Record, -> = - CustomMadeArgs extends Record - ? OneOrZeroArgs - : OneOrZeroArgs extends [infer A] - ? [Expand] - : [CustomMadeArgs]; +export const zCustomAction = zCustomAction3; /** - * A builder that customizes a Convex function, whether or not it validates - * arguments. If the customization requires arguments, however, the resulting - * builder will require argument validation too. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ export type CustomBuilder< FuncType extends "query" | "mutation" | "action", @@ -504,319 +68,30 @@ export type CustomBuilder< InputCtx, Visibility extends FunctionVisibility, ExtraArgs extends Record, -> = { - < - ArgsValidator extends ZodValidator | z.ZodObject | void, - ReturnsZodValidator extends z.ZodTypeAny | ZodValidator | void = void, - ReturnValue extends ReturnValueInput = any, - // Note: this differs from customFunctions.ts b/c we don't need to track - // the exact args to match the standard builder types. For Zod we don't - // try to ever pass a custom function as a builder to another custom - // function, so we can be looser here. - >( - func: - | ({ - /** - * Specify the arguments to the function as a Zod validator. - */ - args?: ArgsValidator; - handler: ( - ctx: Overwrite, - ...args: ArgsForHandlerType< - ArgsOutput, - CustomMadeArgs - > - ) => ReturnValue; - /** - * Validates the value returned by the function. - * Note: you can't pass an object directly without wrapping it - * in `z.object()`. - */ - returns?: ReturnsZodValidator; - /** - * If true, the function will not be validated by Convex, - * in case you're seeing performance issues with validating twice. - */ - skipConvexValidation?: boolean; - } & { - [key in keyof ExtraArgs as key extends - | "args" - | "handler" - | "skipConvexValidation" - | "returns" - ? never - : key]: ExtraArgs[key]; - }) - | { - ( - ctx: Overwrite, - ...args: ArgsForHandlerType< - ArgsOutput, - CustomMadeArgs - > - ): ReturnValue; - }, - ): Registration< - FuncType, - Visibility, - ArgsArrayToObject< - CustomArgsValidator extends Record - ? ArgsInput - : ArgsInput extends [infer A] - ? [Expand>] - : [ObjectType] - >, - ReturnsZodValidator extends void - ? ReturnValue - : ReturnValueOutput - >; -}; - -type ConvexUnionValidatorFromZod = T extends z.ZodTypeAny[] - ? VUnion< - ConvexValidatorFromZod["type"], - { - [Index in keyof T]: T[Index] extends z.ZodTypeAny - ? ConvexValidatorFromZod - : never; - }, - "required", - ConvexValidatorFromZod["fieldPaths"] - > - : never; - -type ConvexObjectValidatorFromZod = VObject< - ObjectType<{ - [key in keyof T]: T[key] extends z.ZodTypeAny - ? ConvexValidatorFromZod - : never; - }>, - { - [key in keyof T]: ConvexValidatorFromZod; - } +> = CustomBuilder3< + FuncType, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + InputCtx, + Visibility, + ExtraArgs >; /** - * Converts a Zod validator type - * to the corresponding Convex validator type from `convex/values`. - * - * ```ts - * ConvexValidatorFromZod // → VString - * ``` - */ -type ConvexValidatorFromZod = - // Keep this in sync with zodToConvex implementation - // and the ConvexValidatorFromZodOutput type - Z extends Zid - ? VId> - : Z extends z.ZodString - ? VString - : Z extends z.ZodNumber - ? VFloat64 - : Z extends z.ZodNaN - ? VFloat64 - : Z extends z.ZodBigInt - ? VInt64 - : Z extends z.ZodBoolean - ? VBoolean - : Z extends z.ZodNull - ? VNull - : Z extends z.ZodUnknown - ? VAny - : Z extends z.ZodAny - ? VAny - : Z extends z.ZodArray - ? VArray< - ConvexValidatorFromZod["type"][], - ConvexValidatorFromZod - > - : Z extends z.ZodObject - ? ConvexObjectValidatorFromZod - : Z extends z.ZodUnion - ? ConvexUnionValidatorFromZod - : Z extends z.ZodDiscriminatedUnion - ? VUnion< - ConvexValidatorFromZod["type"], - { - -readonly [Index in keyof T]: ConvexValidatorFromZod< - T[Index] - >; - }, - "required", - ConvexValidatorFromZod["fieldPaths"] - > - : Z extends z.ZodTuple - ? VArray< - ConvexValidatorFromZod< - Inner[number] - >["type"][], - ConvexValidatorFromZod - > - : Z extends z.ZodLazy - ? ConvexValidatorFromZod - : Z extends z.ZodLiteral - ? VLiteral - : Z extends z.ZodEnum - ? T extends Array - ? VUnion< - T[number], - { - [Index in keyof T]: VLiteral< - T[Index] - >; - }, - "required", - ConvexValidatorFromZod< - T[number] - >["fieldPaths"] - > - : never - : Z extends z.ZodEffects - ? ConvexValidatorFromZod - : Z extends z.ZodOptional - ? ConvexValidatorFromZod extends GenericValidator - ? VOptional< - ConvexValidatorFromZod - > - : never - : Z extends z.ZodNullable - ? ConvexValidatorFromZod extends Validator< - any, - "required", - any - > - ? VUnion< - | null - | ConvexValidatorFromZod["type"], - [ - ConvexValidatorFromZod, - VNull, - ], - "required", - ConvexValidatorFromZod["fieldPaths"] - > - : // Swap nullable(optional(foo)) for optional(nullable(foo)) - ConvexValidatorFromZod extends Validator< - infer T, - "optional", - infer F - > - ? VUnion< - null | Exclude< - ConvexValidatorFromZod["type"], - undefined - >, - [ - Validator, - VNull, - ], - "optional", - ConvexValidatorFromZod["fieldPaths"] - > - : never - : Z extends - | z.ZodBranded< - infer Inner, - infer Brand - > - | ZodBrandedInputAndOutput< - infer Inner, - infer Brand - > - ? Inner extends z.ZodString - ? VString> - : Inner extends z.ZodNumber - ? VFloat64< - number & z.BRAND - > - : Inner extends z.ZodBigInt - ? VInt64< - bigint & z.BRAND - > - : ConvexValidatorFromZod - : Z extends z.ZodDefault< - infer Inner - > // Treat like optional - ? ConvexValidatorFromZod extends GenericValidator - ? VOptional< - ConvexValidatorFromZod - > - : never - : Z extends z.ZodRecord< - infer K, - infer V - > - ? K extends - | z.ZodString - | Zid - | z.ZodUnion< - [ - ( - | z.ZodString - | Zid - ), - ( - | z.ZodString - | Zid - ), - ...( - | z.ZodString - | Zid - )[], - ] - > - ? VRecord< - z.RecordType< - ConvexValidatorFromZod["type"], - ConvexValidatorFromZod["type"] - >, - ConvexValidatorFromZod, - ConvexValidatorFromZod - > - : never - : Z extends z.ZodReadonly< - infer Inner - > - ? ConvexValidatorFromZod - : Z extends z.ZodPipeline< - infer Inner, - any - > // Validate input type - ? ConvexValidatorFromZod - : // Some that are a bit unknown - // : Z extends z.ZodDate ? Validator - // : Z extends z.ZodSymbol ? Validator - // : Z extends z.ZodNever ? Validator - // : Z extends z.ZodIntersection - // ? Validator< - // ConvexValidatorFromZod["type"] & - // ConvexValidatorFromZod["type"], - // "required", - // ConvexValidatorFromZod["fieldPaths"] | - // ConvexValidatorFromZod["fieldPaths"] - // > - // Is arraybuffer a thing? - // Z extends z.??? ? Validator : - // Note: we don't handle z.undefined() in union, nullable, etc. - // : Validator - // We avoid doing this catch-all to avoid over-promising on types - // : Z extends z.ZodTypeAny - never; - -/** - * Turns a Zod validator into a Convex Validator. + * Turns a Zod 3, Zod 4, or Zod 4 Mini validator into a Convex validator. * * The Convex validator will be as close to possible to the Zod validator, * but might be broader than the Zod validator: * - * ```js + * ```ts * zodToConvex(z.string().email()) // → v.string() * ``` * * This function is useful when running the Zod validator _after_ running the Convex validator * (i.e. the Convex validator validates the input of the Zod validator). Hence, the Convex types * will match the _input type_ of Zod transformations: - * ```js + * ```ts * zodToConvex(z.object({ * name: z.string().default("Nicolas"), * })) // → v.object({ name: v.optional(v.string()) }) @@ -878,325 +153,32 @@ type ConvexValidatorFromZod = * @returns Convex Validator (e.g. `v.string()` from "convex/values") * @throws If there is no equivalent Convex validator for the value (e.g. `z.date()`) */ -export function zodToConvex( - zod: Z, -): ConvexValidatorFromZod { - const typeName: ZodFirstPartyTypeKind | "ConvexId" = zod._def.typeName; - switch (typeName) { - case "ConvexId": - return v.id(zod._def.tableName) as ConvexValidatorFromZod; - case "ZodString": - return v.string() as ConvexValidatorFromZod; - case "ZodNumber": - case "ZodNaN": - return v.number() as ConvexValidatorFromZod; - case "ZodBigInt": - return v.int64() as ConvexValidatorFromZod; - case "ZodBoolean": - return v.boolean() as ConvexValidatorFromZod; - case "ZodNull": - return v.null() as ConvexValidatorFromZod; - case "ZodAny": - case "ZodUnknown": - return v.any() as ConvexValidatorFromZod; - case "ZodArray": { - const inner = zodToConvex(zod._def.type); - if (inner.isOptional === "optional") { - throw new Error("Arrays of optional values are not supported"); - } - return v.array(inner) as ConvexValidatorFromZod; - } - case "ZodObject": - return v.object( - zodToConvexFields(zod._def.shape()), - ) as ConvexValidatorFromZod; - case "ZodUnion": - case "ZodDiscriminatedUnion": - return v.union( - ...zod._def.options.map((v: z.ZodTypeAny) => zodToConvex(v)), - ) as ConvexValidatorFromZod; - case "ZodTuple": { - const allTypes = zod._def.items.map((v: z.ZodTypeAny) => zodToConvex(v)); - if (zod._def.rest) { - allTypes.push(zodToConvex(zod._def.rest)); - } - return v.array( - v.union(...allTypes), - ) as unknown as ConvexValidatorFromZod; - } - case "ZodLazy": - return zodToConvex(zod._def.getter()) as ConvexValidatorFromZod; - case "ZodLiteral": - return v.literal(zod._def.value) as ConvexValidatorFromZod; - case "ZodEnum": - return v.union( - ...zod._def.values.map((l: string | number | boolean | bigint) => - v.literal(l), - ), - ) as ConvexValidatorFromZod; - case "ZodEffects": - return zodToConvex(zod._def.schema) as ConvexValidatorFromZod; - case "ZodOptional": - return v.optional( - zodToConvex((zod as any).unwrap()) as any, - ) as ConvexValidatorFromZod; - case "ZodNullable": { - const nullable = (zod as any).unwrap(); - if (nullable._def.typeName === "ZodOptional") { - // Swap nullable(optional(Z)) for optional(nullable(Z)) - // Casting to any to ignore the mismatch of optional - return v.optional( - v.union(zodToConvex(nullable.unwrap()) as any, v.null()), - ) as unknown as ConvexValidatorFromZod; - } - return v.union( - zodToConvex(nullable) as any, - v.null(), - ) as unknown as ConvexValidatorFromZod; - } - case "ZodBranded": - return zodToConvex((zod as any).unwrap()) as ConvexValidatorFromZod; - case "ZodDefault": { - const withDefault = zodToConvex(zod._def.innerType); - if (withDefault.isOptional === "optional") { - return withDefault as ConvexValidatorFromZod; - } - return v.optional(withDefault) as ConvexValidatorFromZod; - } - case "ZodRecord": { - const keyType = zodToConvex( - zod._def.keyType, - ) as ConvexValidatorFromZod; - function ensureStringOrId(v: GenericValidator) { - if (v.kind === "union") { - v.members.map(ensureStringOrId); - } else if (v.kind !== "string" && v.kind !== "id") { - throw new Error("Record keys must be strings or ids: " + v.kind); - } - } - ensureStringOrId(keyType); - return v.record( - keyType, - zodToConvex(zod._def.valueType) as ConvexValidatorFromZod, - ) as unknown as ConvexValidatorFromZod; - } - case "ZodReadonly": - return zodToConvex(zod._def.innerType) as ConvexValidatorFromZod; - case "ZodPipeline": - return zodToConvex(zod._def.in) as ConvexValidatorFromZod; - default: - throw new Error(`Unknown Zod type: ${typeName}`); - // N/A or not supported - // case "ZodDate": - // case "ZodSymbol": - // case "ZodUndefined": - // case "ZodNever": - // case "ZodVoid": - // case "ZodIntersection": - // case "ZodMap": - // case "ZodSet": - // case "ZodFunction": - // case "ZodNativeEnum": - // case "ZodCatch": - // case "ZodPromise": - } +export function zodToConvex( + validator: Z, +): ConvexValidatorFromZod4; +export function zodToConvex( + validator: Z, +): ConvexValidatorFromZod3; +export function zodToConvex(validator: z4Core.$ZodType | z3.ZodTypeAny) { + return "_zod" in validator + ? zodToConvex4(validator) + : zodToConvex3(validator); } /** - * This is the type of a Convex validator that checks the value *after* it has - * been validated (and possibly transformed) by a Zod validator. - * - * The difference between {@link ConvexValidatorFromZod} - * and `ConvexValidatorFromZodOutput` are explained in the documentation of - * {@link zodToConvex}/{@link zodOutputToConvex}. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export type ConvexValidatorFromZodOutput = - // Keep this in sync with the zodOutputToConvex implementation - // IMPORTANT: The differences are at the bottom - Z extends Zid - ? VId> - : Z extends z.ZodString - ? VString - : Z extends z.ZodNumber - ? VFloat64 - : Z extends z.ZodNaN - ? VFloat64 - : Z extends z.ZodBigInt - ? VInt64 - : Z extends z.ZodBoolean - ? VBoolean - : Z extends z.ZodNull - ? VNull - : Z extends z.ZodUnknown - ? VAny - : Z extends z.ZodAny - ? VAny - : Z extends z.ZodArray - ? VArray< - ConvexValidatorFromZodOutput["type"][], - ConvexValidatorFromZodOutput - > - : Z extends z.ZodObject - ? ConvexObjectValidatorFromZod - : Z extends z.ZodUnion - ? ConvexUnionValidatorFromZod - : Z extends z.ZodDiscriminatedUnion - ? VUnion< - ConvexValidatorFromZodOutput["type"], - { - -readonly [Index in keyof T]: ConvexValidatorFromZodOutput< - T[Index] - >; - }, - "required", - ConvexValidatorFromZodOutput< - T[number] - >["fieldPaths"] - > - : Z extends z.ZodTuple - ? VArray< - ConvexValidatorFromZodOutput< - Inner[number] - >["type"][], - ConvexValidatorFromZodOutput - > - : Z extends z.ZodLazy - ? ConvexValidatorFromZodOutput - : Z extends z.ZodLiteral - ? VLiteral - : Z extends z.ZodEnum - ? T extends Array - ? VUnion< - T[number], - { - [Index in keyof T]: VLiteral< - T[Index] - >; - }, - "required", - ConvexValidatorFromZodOutput< - T[number] - >["fieldPaths"] - > - : never - : Z extends z.ZodOptional - ? ConvexValidatorFromZodOutput extends GenericValidator - ? VOptional< - ConvexValidatorFromZodOutput - > - : never - : Z extends z.ZodNullable - ? ConvexValidatorFromZodOutput extends Validator< - any, - "required", - any - > - ? VUnion< - | null - | ConvexValidatorFromZodOutput["type"], - [ - ConvexValidatorFromZodOutput, - VNull, - ], - "required", - ConvexValidatorFromZodOutput["fieldPaths"] - > - : // Swap nullable(optional(foo)) for optional(nullable(foo)) - ConvexValidatorFromZodOutput extends Validator< - infer T, - "optional", - infer F - > - ? VUnion< - null | Exclude< - ConvexValidatorFromZodOutput["type"], - undefined - >, - [ - Validator, - VNull, - ], - "optional", - ConvexValidatorFromZodOutput["fieldPaths"] - > - : never - : Z extends - | z.ZodBranded< - infer Inner, - infer Brand - > - | ZodBrandedInputAndOutput< - infer Inner, - infer Brand - > - ? Inner extends z.ZodString - ? VString> - : Inner extends z.ZodNumber - ? VFloat64< - number & z.BRAND - > - : Inner extends z.ZodBigInt - ? VInt64< - bigint & z.BRAND - > - : ConvexValidatorFromZodOutput - : Z extends z.ZodRecord< - infer K, - infer V - > - ? K extends - | z.ZodString - | Zid - | z.ZodUnion< - [ - z.ZodString | Zid, - z.ZodString | Zid, - ...( - | z.ZodString - | Zid - )[], - ] - > - ? VRecord< - z.RecordType< - ConvexValidatorFromZodOutput["type"], - ConvexValidatorFromZodOutput["type"] - >, - ConvexValidatorFromZodOutput, - ConvexValidatorFromZodOutput - > - : never - : Z extends z.ZodReadonly< - infer Inner - > - ? ConvexValidatorFromZodOutput - : /* - * IMPORTANT: these are the different ones - */ - Z extends z.ZodDefault< - infer Inner - > - ? // Default values are always set after validation - ConvexValidatorFromZodOutput - : Z extends z.ZodEffects - ? // We don't know what the output type is, it's a function return - VAny - : // Validate output type instead of input - Z extends z.ZodPipeline< - z.ZodTypeAny, - infer Out - > - ? ConvexValidatorFromZodOutput - : never; +export type ConvexValidatorFromZodOutput = + ConvexValidatorFromZodOutput3; /** - * Converts a Zod validator to a Convex validator that checks the value _after_ - * it has been validated (and possibly transformed) by the Zod validator. + * Converts a Zod 3, Zod 4, or Zod 4 Mini validator to a Convex validator that checks the value + * _after_ it has been validated (and possibly transformed) by the Zod validator. * * This is similar to {@link zodToConvex}, but is meant for cases where the Convex * validator runs _after_ the Zod validator. Thus, the Convex type refers to the * _output_ type of the Zod transformations: - * ```js + * ```ts * zodOutputToConvex(z.object({ * name: z.string().default("Nicolas"), * })) // → v.object({ name: v.string() }) @@ -1247,173 +229,68 @@ export type ConvexValidatorFromZodOutput = * @returns Convex Validator (e.g. `v.string()` from "convex/values") * @throws If there is no equivalent Convex validator for the value (e.g. `z.date()`) */ -export function zodOutputToConvex( - zod: Z, -): ConvexValidatorFromZodOutput { - const typeName: ZodFirstPartyTypeKind | "ConvexId" = zod._def.typeName; - switch (typeName) { - // These are the special cases that differ from the input validator - case "ZodDefault": - // Here we return the non-optional inner type - return zodOutputToConvex( - zod._def.innerType, - ) as unknown as ConvexValidatorFromZodOutput; - case "ZodEffects": - console.warn( - "Note: ZodEffects (like z.transform) do not do output validation", - ); - return v.any() as ConvexValidatorFromZodOutput; - case "ZodPipeline": - // IMPORTANT: The output type of the pipeline can differ from the input. - return zodOutputToConvex(zod._def.out) as ConvexValidatorFromZodOutput; - // These are the same as input - case "ConvexId": - return v.id(zod._def.tableName) as ConvexValidatorFromZodOutput; - case "ZodString": - return v.string() as ConvexValidatorFromZodOutput; - case "ZodNumber": - case "ZodNaN": - return v.number() as ConvexValidatorFromZodOutput; - case "ZodBigInt": - return v.int64() as ConvexValidatorFromZodOutput; - case "ZodBoolean": - return v.boolean() as ConvexValidatorFromZodOutput; - case "ZodNull": - return v.null() as ConvexValidatorFromZodOutput; - case "ZodAny": - case "ZodUnknown": - return v.any() as ConvexValidatorFromZodOutput; - case "ZodArray": { - const inner = zodOutputToConvex(zod._def.type); - if (inner.isOptional === "optional") { - throw new Error("Arrays of optional values are not supported"); - } - return v.array(inner) as ConvexValidatorFromZodOutput; - } - case "ZodObject": - return v.object( - zodOutputToConvexFields(zod._def.shape()), - ) as ConvexValidatorFromZodOutput; - case "ZodUnion": - case "ZodDiscriminatedUnion": - return v.union( - ...zod._def.options.map((v: z.ZodTypeAny) => zodOutputToConvex(v)), - ) as ConvexValidatorFromZodOutput; - case "ZodTuple": { - const allTypes = zod._def.items.map((v: z.ZodTypeAny) => - zodOutputToConvex(v), - ); - if (zod._def.rest) { - allTypes.push(zodOutputToConvex(zod._def.rest)); - } - return v.array( - v.union(...allTypes), - ) as unknown as ConvexValidatorFromZodOutput; - } - case "ZodLazy": - return zodOutputToConvex( - zod._def.getter(), - ) as ConvexValidatorFromZodOutput; - case "ZodLiteral": - return v.literal(zod._def.value) as ConvexValidatorFromZodOutput; - case "ZodEnum": - return v.union( - ...zod._def.values.map((l: string | number | boolean | bigint) => - v.literal(l), - ), - ) as ConvexValidatorFromZodOutput; - case "ZodOptional": - return v.optional( - zodOutputToConvex((zod as any).unwrap()) as any, - ) as ConvexValidatorFromZodOutput; - case "ZodNullable": { - const nullable = (zod as any).unwrap(); - if (nullable._def.typeName === "ZodOptional") { - // Swap nullable(optional(Z)) for optional(nullable(Z)) - // Casting to any to ignore the mismatch of optional - return v.optional( - v.union(zodOutputToConvex(nullable.unwrap()) as any, v.null()), - ) as unknown as ConvexValidatorFromZodOutput; - } - return v.union( - zodOutputToConvex(nullable) as any, - v.null(), - ) as unknown as ConvexValidatorFromZodOutput; - } - case "ZodBranded": - return zodOutputToConvex( - (zod as any).unwrap(), - ) as ConvexValidatorFromZodOutput; - case "ZodRecord": { - const keyType = zodOutputToConvex( - zod._def.keyType, - ) as ConvexValidatorFromZodOutput; - function ensureStringOrId(v: GenericValidator) { - if (v.kind === "union") { - v.members.map(ensureStringOrId); - } else if (v.kind !== "string" && v.kind !== "id") { - throw new Error("Record keys must be strings or ids: " + v.kind); - } - } - ensureStringOrId(keyType); - return v.record( - keyType, - zodOutputToConvex( - zod._def.valueType, - ) as ConvexValidatorFromZodOutput, - ) as unknown as ConvexValidatorFromZodOutput; - } - case "ZodReadonly": - return zodOutputToConvex( - zod._def.innerType, - ) as ConvexValidatorFromZodOutput; - default: - throw new Error(`Unknown zod type: ${typeName}`); - // N/A or not supported - // case "ZodDate": - // case "ZodSymbol": - // case "ZodUndefined": - // case "ZodNever": - // case "ZodVoid": - // case "ZodIntersection": - // case "ZodMap": - // case "ZodSet": - // case "ZodFunction": - // case "ZodNativeEnum": - // case "ZodCatch": - // case "ZodPromise": - } +export function zodOutputToConvex( + validator: Z, +): ConvexValidatorFromZodOutput4; +export function zodOutputToConvex( + validator: Z, +): ConvexValidatorFromZodOutput3; +export function zodOutputToConvex(validator: z4Core.$ZodType | z3.ZodTypeAny) { + return "_zod" in validator + ? zodOutputToConvex4(validator) + : zodOutputToConvex3(validator); } /** * Like {@link zodToConvex}, but it takes in a bare object, as expected by Convex * function arguments, or the argument to {@link defineTable}. * - * ```js - * zodToConvex({ + * This function works with both Zod 3 and Zod 4 validators. + * + * ```ts + * zodToConvexFields({ * name: z.string().default("Nicolas"), * }) // → { name: v.optional(v.string()) } * ``` * - * @param zod Object with string keys and Zod validators as values + * This function works with both Zod 3 and Zod 4 validators. + * + * @param fields Object with string keys and Zod validators as values * @returns Object with the same keys, but with Convex validators as values */ -export function zodToConvexFields(zod: Z) { +export function zodToConvexFields< + Fields extends Record, +>( + fields: Fields, +): { + [k in keyof Fields]: ConvexValidatorFromZod4; +}; +export function zodToConvexFields>( + fields: Fields, +): { [k in keyof Fields]: ConvexValidatorFromZod3 }; +export function zodToConvexFields( + fields: Record, +) { return Object.fromEntries( - Object.entries(zod).map(([k, v]) => [k, zodToConvex(v)]), - ) as { [k in keyof Z]: ConvexValidatorFromZod }; + Object.entries(fields).map(([k, v]) => [ + k, + "_zod" in v ? zodToConvex4(v) : zodToConvex3(v), + ]), + ); } /** * Like {@link zodOutputToConvex}, but it takes in a bare object, as expected by * Convex function arguments, or the argument to {@link defineTable}. * - * ```js + * ```ts * zodOutputToConvexFields({ * name: z.string().default("Nicolas"), * }) // → { name: v.string() } * ``` * + * This function works with both Zod 3 and Zod 4 validators. + * * This is different from {@link zodToConvexFields} because it generates the * Convex validator for the output of the Zod validator, not the input; * see the documentation of {@link zodToConvex} and {@link zodOutputToConvex} @@ -1422,32 +299,39 @@ export function zodToConvexFields(zod: Z) { * @param zod Object with string keys and Zod validators as values * @returns Object with the same keys, but with Convex validators as values */ -export function zodOutputToConvexFields(zod: Z) { +export function zodOutputToConvexFields< + Fields extends Record, +>( + fields: Fields, +): { + [k in keyof Fields]: ConvexValidatorFromZodOutput4; +}; +export function zodOutputToConvexFields< + Fields extends Record, +>( + fields: Fields, +): { [k in keyof Fields]: ConvexValidatorFromZodOutput3 }; +export function zodOutputToConvexFields( + fields: Record, +) { return Object.fromEntries( - Object.entries(zod).map(([k, v]) => [k, zodOutputToConvex(v)]), - ) as { [k in keyof Z]: ConvexValidatorFromZodOutput }; -} - -interface ZidDef extends ZodTypeDef { - typeName: "ConvexId"; - tableName: TableName; + Object.entries(fields).map(([k, v]) => [ + k, + "_zod" in v ? zodOutputToConvex4(v) : zodOutputToConvex3(v), + ]), + ); } /** - * A Zod validator for a Convex ID. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export class Zid extends z.ZodType< - GenericId, - ZidDef -> { - _parse(input: z.ParseInput) { - return z.string()._parse(input) as z.ParseReturnType>; - } -} +export const Zid = Zid3; /** * Zod helper for adding Convex system fields to a record to return. * + * This function works with both Zod 3 and Zod 4 validators. + * * ```js * withSystemFields("users", { * name: z.string(), @@ -1463,253 +347,64 @@ export class Zid extends z.ZodType< * @param zObject - Validators for the user-defined fields on the document. * @returns Zod shape for use with `z.object(shape)` that includes system fields. */ -export const withSystemFields = < +export function withSystemFields< Table extends string, - T extends { [key: string]: z.ZodTypeAny }, + T extends { [key: string]: z4Core.$ZodType }, >( tableName: Table, zObject: T, -) => { - return { ...zObject, _id: zid(tableName), _creationTime: z.number() }; +): T & { + _id: Zid4; + _creationTime: z4.ZodNumber; }; +export function withSystemFields< + Table extends string, + T extends { [key: string]: z3.ZodTypeAny }, +>( + tableName: Table, + zObject: T, +): T & { + _id: Zid3
; + _creationTime: z3.ZodNumber; +}; +export function withSystemFields( + tableName: string, + zObject: Record, +) { + const firstValidator = Object.values(zObject)[0]; + const isZod4 = firstValidator !== undefined ? "_zod" in firstValidator : true; + return isZod4 + ? withSystemFields4(tableName, zObject as any) + : withSystemFields3(tableName, zObject as any); +} /** - * This is a copy of Zod’s `ZodBranded` which also brands the input - * (see {@link zBrand}) + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export class ZodBrandedInputAndOutput< - T extends z.ZodTypeAny, - B extends string | number | symbol, -> extends z.ZodType< - T["_output"] & z.BRAND, - z.ZodBrandedDef, - T["_input"] & z.BRAND -> { - _parse(input: z.ParseInput) { - const { ctx } = this._processInputParams(input); - const data = ctx.data; - return this._def.type._parse({ - data, - path: ctx.path, - parent: ctx, - }); - } - unwrap() { - return this._def.type; - } -} +export const ZodBrandedInputAndOutput = ZodBrandedInputAndOutput3; /** - * Adds a brand to a Zod validator. Used like `zBrand(z.string(), "MyBrand")`. - * Compared to zod's `.brand`, this also brands the input type, so if you use - * the branded validator as an argument to a function, the input type will also - * be branded. The normal `.brand` only brands the output type, so only the type - * returned by validation would be branded. - * - * @param validator A zod validator - generally a string, number, or bigint - * @param brand A string, number, or symbol to brand the validator with - * @returns A zod validator that brands both the input and output types. + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export function zBrand< - T extends z.ZodTypeAny, - B extends string | number | symbol, ->(validator: T, brand?: B): ZodBrandedInputAndOutput { - return validator.brand(brand); -} +export const zBrand = zBrand3; /** - * Simple type conversion from a Convex validator to a Zod validator. - * - * ```ts - * ConvexToZod // → z.ZodType - * ``` + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export type ConvexToZod = z.ZodType>; - -type ZodFromValidatorBase = - V extends VId> - ? Zid - : V extends VString - ? T extends string & { _: infer Brand extends string } - ? z.ZodBranded - : z.ZodString - : V extends VFloat64 - ? z.ZodNumber - : V extends VInt64 - ? z.ZodBigInt - : V extends VBoolean - ? z.ZodBoolean - : V extends VNull - ? z.ZodNull - : V extends VLiteral - ? z.ZodLiteral - : V extends VObject - ? z.ZodObject< - { - [K in keyof Fields]: ZodValidatorFromConvex; - }, - "strip" - > - : V extends VRecord - ? Key extends VId> - ? z.ZodRecord< - Zid, - ZodValidatorFromConvex - > - : z.ZodRecord> - : V extends VArray - ? z.ZodArray> - : V extends VUnion< - any, - [ - infer A extends GenericValidator, - infer B extends GenericValidator, - ...infer Rest extends GenericValidator[], - ], - any, - any - > - ? z.ZodUnion< - [ - ZodValidatorFromConvex, - ZodValidatorFromConvex, - ...{ - [K in keyof Rest]: ZodValidatorFromConvex< - Rest[K] - >; - }, - ] - > - : z.ZodTypeAny; // fallback for unknown validators +export type ConvexToZod = ConvexToZod3; /** - * Better type conversion from a Convex validator to a Zod validator - * where the output is not a generic ZodType but it's more specific. - * - * This allows you to use methods specific to the Zod type (e.g. `.email()` for `z.ZodString). - * - * ```ts - * ZodFromValidatorBase // → z.ZodString - * ``` + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ export type ZodValidatorFromConvex = - V extends Validator - ? z.ZodOptional> - : ZodFromValidatorBase; + ZodValidatorFromConvex3; /** - * Turns a Convex validator into a Zod validator. - * - * This is useful when you want to use types you defined using Convex validators - * with external libraries that expect to receive a Zod validator. - * - * ```js - * convexToZod(v.string()) // → z.string() - * ``` - * - * @param convexValidator Convex validator can be any validator from "convex/values" e.g. `v.string()` - * @returns Zod validator (e.g. `z.string()`) with inferred type matching the Convex validator + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export function convexToZod( - convexValidator: V, -): ZodValidatorFromConvex { - const isOptional = (convexValidator as any).isOptional === "optional"; - - let zodValidator: z.ZodTypeAny; - - const { kind } = convexValidator; - switch (kind) { - case "id": - zodValidator = zid((convexValidator as VId).tableName); - break; - case "string": - zodValidator = z.string(); - break; - case "float64": - zodValidator = z.number(); - break; - case "int64": - zodValidator = z.bigint(); - break; - case "boolean": - zodValidator = z.boolean(); - break; - case "null": - zodValidator = z.null(); - break; - case "any": - zodValidator = z.any(); - break; - case "array": { - const arrayValidator = convexValidator as VArray; - zodValidator = z.array(convexToZod(arrayValidator.element)); - break; - } - case "object": { - const objectValidator = convexValidator as VObject; - zodValidator = z.object(convexToZodFields(objectValidator.fields)); - break; - } - case "union": { - const unionValidator = convexValidator as VUnion; - const memberValidators = unionValidator.members.map( - (member: GenericValidator) => convexToZod(member), - ); - zodValidator = z.union([ - memberValidators[0], - memberValidators[1], - ...memberValidators.slice(2), - ]); - break; - } - case "literal": { - const literalValidator = convexValidator as VLiteral; - zodValidator = z.literal(literalValidator.value); - break; - } - case "record": { - const recordValidator = convexValidator as VRecord< - any, - any, - any, - any, - any - >; - zodValidator = z.record( - convexToZod(recordValidator.key), - convexToZod(recordValidator.value), - ); - break; - } - case "bytes": - throw new Error("v.bytes() is not supported"); - default: - kind satisfies never; - throw new Error(`Unknown convex validator type: ${kind}`); - } - - return isOptional - ? (z.optional(zodValidator) as ZodValidatorFromConvex) - : (zodValidator as ZodValidatorFromConvex); -} +export const convexToZod = convexToZod3; /** - * Like {@link convexToZod}, but it takes in a bare object, as expected by Convex - * function arguments, or the argument to {@link defineTable}. - * - * ```js - * convexToZodFields({ - * name: v.string(), - * }) // → { name: z.string() } - * ``` - * - * @param convexValidators Object with string keys and Convex validators as values - * @returns Object with the same keys, but with Zod validators as values + * @deprecated Please import from `convex-helpers/server/zod3` instead. */ -export function convexToZodFields( - convexValidators: C, -) { - return Object.fromEntries( - Object.entries(convexValidators).map(([k, v]) => [k, convexToZod(v)]), - ) as { [k in keyof C]: ZodValidatorFromConvex }; -} +export const convexToZodFields = convexToZodFields3; diff --git a/packages/convex-helpers/server/zod3.test.ts b/packages/convex-helpers/server/zod3.test.ts new file mode 100644 index 00000000..17c622d8 --- /dev/null +++ b/packages/convex-helpers/server/zod3.test.ts @@ -0,0 +1,1153 @@ +import type { + DataModelFromSchemaDefinition, + QueryBuilder, + ApiFromModules, + RegisteredQuery, + DefaultFunctionArgs, + FunctionReference, +} from "convex/server"; +import { defineTable, defineSchema, queryGeneric, anyApi } from "convex/server"; +import type { Equals } from "../index.js"; +import { omit } from "../index.js"; +import { convexTest } from "convex-test"; +import { assertType, describe, expect, expectTypeOf, test } from "vitest"; +import { modules } from "./setup.test.js"; +import type { ZCustomCtx } from "./zod3.js"; +import { + zBrand, + zCustomQuery, + zid, + zodOutputToConvex, + zodToConvexFields, + zodToConvex, + convexToZod, + convexToZodFields, +} from "./zod3.js"; +import { customCtx } from "./customFunctions.js"; +import type { VString, VFloat64, VObject, VId, Infer } from "convex/values"; +import { v } from "convex/values"; +import { z } from "zod/v3"; + +// This is an example of how to make a version of `zid` that +// enforces that the type matches one of your defined tables. +// Note that it can't be used in anything imported by schema.ts +// since the types would be circular. +// For argument validation it might be useful to you, however. +// const zId = zid; + +export const kitchenSinkValidator = { + email: z.string().email(), + userId: zid("users"), + // Otherwise this is equivalent, but wouldn't catch zid("CounterTable") + // counterId: zid("counter_table"), + num: z.number().min(0), + nan: z.nan(), + bigint: z.bigint(), + bool: z.boolean(), + null: z.null(), + any: z.unknown(), + array: z.array(z.string()), + object: z.object({ a: z.string(), b: z.number() }), + objectWithOptional: z.object({ a: z.string(), b: z.number().optional() }), + record: z.record( + z.union([z.string(), zid("users")]), + z.union([z.number(), z.string()]), + ), + union: z.union([z.string(), z.number()]), + discriminatedUnion: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("a"), a: z.string() }), + z.object({ kind: z.literal("b"), b: z.number() }), + ]), + literal: z.literal("hi"), + tuple: z.tuple([z.string(), z.number()]), + lazy: z.lazy(() => z.string()), + enum: z.enum(["a", "b"]), + effect: z.effect(z.string(), { + refinement: () => true, + type: "refinement", + }), + optional: z.object({ a: z.string(), b: z.number() }).optional(), + nullableOptional: z.nullable(z.string().optional()), + optionalNullable: z.nullable(z.string()).optional(), + nullable: z.nullable(z.string()), + // z.string().brand("branded") also works, but zBrand also brands the input + branded: zBrand(z.string(), "branded"), + default: z.string().default("default"), + readonly: z.object({ a: z.string(), b: z.number() }).readonly(), + pipeline: z.number().pipe(z.coerce.string()), +}; + +const schema = defineSchema({ + sink: defineTable(zodToConvexFields(kitchenSinkValidator)).index("email", [ + "email", + ]), + users: defineTable({}), +}); +type DataModel = DataModelFromSchemaDefinition; +const query = queryGeneric as QueryBuilder; +// type DatabaseReader = GenericDatabaseReader; +// type DatabaseWriter = GenericDatabaseWriter; + +const zQuery = zCustomQuery(query, { + // You could require arguments for all queries here. + args: {}, + input: async () => { + // Here you could use the args you declared and return patches for the + // function's ctx and args. e.g. looking up a user and passing it in ctx. + // Or just asserting that the user is logged in. + return { ctx: {}, args: {} }; + }, +}); + +export const kitchenSink = zQuery({ + args: kitchenSinkValidator, + handler: async (_ctx, args) => { + return { + args, + json: (v.object(zodToConvexFields(kitchenSinkValidator)) as any).json, + }; + }, + returns: z.object({ + args: z.object({ + ...kitchenSinkValidator, + // round trip the pipeline + pipeline: z.string().pipe(z.coerce.number()), + }), + json: z.any(), + }), + // You can add .strict() to fail if any more fields are passed + // .strict(), +}); + +export const dateRoundTrip = zQuery({ + args: { date: z.string().transform((s) => new Date(Date.parse(s))) }, + handler: async (ctx, args) => { + return args.date; + }, + returns: z.date().transform((d) => d.toISOString()), +}); + +export const failsReturnsValidator = zQuery({ + args: {}, + returns: z.number(), + handler: async () => { + return "foo" as unknown as number; + }, +}); + +export const returnsWithoutArgs = zQuery({ + returns: z.number(), + handler: async () => { + return 1; + }, +}); + +export const zodOutputCompliance = zQuery({ + // Note no args validator + handler: (ctx, args: { optionalString?: string | undefined }) => { + return { + undefinedBecomesFooString: undefined, + stringBecomesNull: "bar", + threeBecomesString: 3, + extraArg: "extraArg", + optionalString: args.optionalString, + arrayWithDefaultFoo: [undefined], + objectWithDefaultFoo: { foo: undefined }, + unionOfDefaultFoo: undefined, + }; + }, + // Note inline record of zod validators works. + returns: { + undefinedBecomesFooString: z.string().default("foo"), + stringBecomesNull: z.string().transform((_) => null), + threeBecomesString: z.number().pipe(z.coerce.string()), + optionalString: z.string().optional(), + arrayWithDefaultFoo: z.array(z.string().default("foo")), + objectWithDefaultFoo: z.object({ foo: z.string().default("foo") }), + unionOfDefaultFoo: z.union([z.string().default("foo"), z.number()]), + }, +}); + +export const zodArgsObject = zQuery({ + args: z.object({ a: z.string() }), + handler: async (ctx, args) => { + return args; + }, + returns: z.object({ a: z.string() }), +}); + +// example of helper function +type ZodQueryCtx = ZCustomCtx; +const myArgs = z.object({ a: z.string() }); +const myHandler = async (_ctx: ZodQueryCtx, _args: z.infer) => { + return "foo"; +}; +export const viaHelper = zQuery({ + args: myArgs, + handler: myHandler, + returns: z.string(), +}); + +/** + * Testing custom zod function modifications. + */ + +/** + * Adding ctx + */ +const addCtxArg = zCustomQuery( + query, + customCtx(() => { + return { a: "hi" }; + }), +); +export const addC = addCtxArg({ + args: {}, + handler: async (ctx) => { + return { ctxA: ctx.a }; // !!! + }, +}); +queryMatches(addC, {}, { ctxA: "" }); +// Unvalidated +export const addCU = addCtxArg({ + handler: async (ctx) => { + return { ctxA: ctx.a }; // !!! + }, +}); +// Unvalidated variant 2 +queryMatches(addCU, {}, { ctxA: "" }); +export const addCU2 = addCtxArg(async (ctx) => { + return { ctxA: ctx.a }; // !!! +}); +queryMatches(addCU2, {}, { ctxA: "" }); + +export const addCtxWithExistingArg = addCtxArg({ + args: { b: z.string() }, + handler: async (ctx, args) => { + return { ctxA: ctx.a, argB: args.b }; // !!! + }, +}); +queryMatches(addCtxWithExistingArg, { b: "" }, { ctxA: "", argB: "" }); +/** + * Adding arg + */ +const addArg = zCustomQuery(query, { + args: {}, + input: async () => { + return { ctx: {}, args: { a: "hi" } }; + }, +}); +export const add = addArg({ + args: {}, + handler: async (_ctx, args) => { + return { argsA: args.a }; // !!! + }, +}); +queryMatches(add, {}, { argsA: "" }); +export const addUnverified = addArg({ + handler: async (_ctx, args) => { + return { argsA: args.a }; // !!! + }, +}); +queryMatches(addUnverified, {}, { argsA: "" }); +export const addUnverified2 = addArg((_ctx, args) => { + return { argsA: args.a }; // !!! +}); +queryMatches(addUnverified2, {}, { argsA: "" }); + +/** + * Consuming arg, add to ctx + */ +const consumeArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, { a }) => { + return { ctx: { a }, args: {} }; + }, +}); +export const consume = consumeArg({ + args: {}, + handler: async (ctx, emptyArgs) => { + assertType>(emptyArgs); // !!! + return { ctxA: ctx.a }; + }, +}); +queryMatches(consume, { a: "" }, { ctxA: "" }); + +export const necromanceArg = consumeArg({ + args: { a: z.string() }, + handler: async (ctx, args) => { + assertType<{ a: string }>(args); + return { ctxA: ctx.a, argsA: args.a }; + }, +}); +queryMatches(necromanceArg, { a: "" }, { ctxA: "", argsA: "" }); + +/** + * Passing Through arg, also add to ctx for fun + */ +const passThrougArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, args) => { + return { ctx: { a: args.a }, args }; + }, +}); +export const passThrough = passThrougArg({ + args: {}, + handler: async (ctx, args) => { + return { ctxA: ctx.a, argsA: args.a }; // !!! + }, +}); +queryMatches(passThrough, { a: "" }, { ctxA: "", argsA: "" }); + +/** + * Modify arg type, don't need to re-defined "a" arg + */ +const modifyArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, { a }) => { + return { ctx: { a }, args: { a: 123 } }; // !!! + }, +}); +export const modify = modifyArg({ + args: {}, + handler: async (ctx, args) => { + args.a.toFixed(); // !!! + return { ctxA: ctx.a, argsA: args.a }; + }, +}); +queryMatches(modify, { a: "" }, { ctxA: "", argsA: 0 }); // !!! + +/** + * Redefine arg type with the same type: OK! + */ +const redefineArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, args) => ({ ctx: {}, args }), +}); +export const redefine = redefineArg({ + args: { a: z.string() }, + handler: async (_ctx, args) => { + return { argsA: args.a }; + }, +}); +queryMatches(redefine, { a: "" }, { argsA: "" }); + +/** + * Refine arg type with a more specific type: OK! + */ +const refineArg = zCustomQuery(query, { + args: { a: v.optional(v.string()) }, + input: async (_ctx, args) => ({ ctx: {}, args }), +}); +export const refined = refineArg({ + args: { a: z.string() }, + handler: async (_ctx, args) => { + return { argsA: args.a }; + }, +}); +queryMatches(refined, { a: "" }, { argsA: "" }); + +/** + * Redefine arg type with different type: error! + */ +const badRedefineArg = zCustomQuery(query, { + args: { a: v.string(), b: v.number() }, + input: async (_ctx, args) => ({ ctx: {}, args }), +}); +expect(() => + badRedefineArg({ + args: { a: z.number() }, + handler: async (_ctx, args) => { + return { argsA: args.a }; + }, + }), +).toThrow(); +/** + * Test helpers + */ +function queryMatches< + A extends DefaultFunctionArgs, + R, + T extends RegisteredQuery<"public", A, R>, +>(_f: T, _a: A, _v: R) {} + +const testApi: ApiFromModules<{ + fns: { + kitchenSink: typeof kitchenSink; + dateRoundTrip: typeof dateRoundTrip; + failsReturnsValidator: typeof failsReturnsValidator; + returnsWithoutArgs: typeof returnsWithoutArgs; + zodOutputCompliance: typeof zodOutputCompliance; + zodArgsObject: typeof zodArgsObject; + addC: typeof addC; + addCU: typeof addCU; + addCU2: typeof addCU2; + addCtxWithExistingArg: typeof addCtxWithExistingArg; + add: typeof add; + addUnverified: typeof addUnverified; + addUnverified2: typeof addUnverified2; + consume: typeof consume; + necromanceArg: typeof necromanceArg; + passThrough: typeof passThrough; + modify: typeof modify; + redefine: typeof redefine; + refined: typeof refined; + }; +}>["fns"] = anyApi["zod3.test"] as any; + +test("zod kitchen sink", async () => { + const t = convexTest(schema, modules); + const userId = await t.run((ctx) => ctx.db.insert("users", {})); + const kitchenSink = { + email: "email@example.com", + userId, + num: 1, + nan: NaN, + bigint: BigInt(1), + bool: true, + null: null, + any: [1, "2"], + array: ["1", "2"], + object: { a: "1", b: 2 }, + objectWithOptional: { a: "1" }, + record: { a: 1 }, + union: 1, + discriminatedUnion: { kind: "a" as const, a: "1" }, + literal: "hi" as const, + tuple: ["2", 1] as [string, number], + lazy: "lazy", + enum: "b" as const, + effect: "effect", + optional: undefined, + nullable: null, + branded: "branded" as string & z.BRAND<"branded">, + default: undefined, + readonly: { a: "1", b: 2 }, + pipeline: 0, + }; + const response = await t.query(testApi.kitchenSink, kitchenSink); + expect(response.args).toMatchObject({ + ...omit(kitchenSink, ["optional"]), + default: "default", + }); + expect(response.json).toMatchObject({ + type: "object", + value: { + any: { fieldType: { type: "any" }, optional: false }, + array: { + fieldType: { type: "array", value: { type: "string" } }, + optional: false, + }, + bigint: { fieldType: { type: "bigint" }, optional: false }, + bool: { fieldType: { type: "boolean" }, optional: false }, + branded: { fieldType: { type: "string" }, optional: false }, + default: { fieldType: { type: "string" }, optional: true }, + discriminatedUnion: { + fieldType: { + type: "union", + value: [ + { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + kind: { + fieldType: { type: "literal", value: "a" }, + optional: false, + }, + }, + }, + { + type: "object", + value: { + b: { fieldType: { type: "number" }, optional: false }, + kind: { + fieldType: { type: "literal", value: "b" }, + optional: false, + }, + }, + }, + ], + }, + optional: false, + }, + effect: { fieldType: { type: "string" }, optional: false }, + email: { fieldType: { type: "string" }, optional: false }, + enum: { + fieldType: { + type: "union", + value: [ + { type: "literal", value: "a" }, + { type: "literal", value: "b" }, + ], + }, + optional: false, + }, + lazy: { fieldType: { type: "string" }, optional: false }, + literal: { fieldType: { type: "literal", value: "hi" }, optional: false }, + nan: { fieldType: { type: "number" }, optional: false }, + null: { fieldType: { type: "null" }, optional: false }, + nullable: { + fieldType: { + type: "union", + value: [{ type: "string" }, { type: "null" }], + }, + optional: false, + }, + num: { fieldType: { type: "number" }, optional: false }, + object: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: false }, + }, + }, + optional: false, + }, + objectWithOptional: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: true }, + }, + }, + optional: false, + }, + optional: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: false }, + }, + }, + optional: true, + }, + pipeline: { fieldType: { type: "number" }, optional: false }, + readonly: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: false }, + }, + }, + optional: false, + }, + record: { + fieldType: { + keys: { + type: "union", + value: [{ type: "string" }, { tableName: "users", type: "id" }], + }, + type: "record", + values: { + fieldType: { + type: "union", + value: [{ type: "number" }, { type: "string" }], + }, + }, + }, + }, + tuple: { + fieldType: { + type: "array", + value: { + type: "union", + value: [{ type: "string" }, { type: "number" }], + }, + }, + optional: false, + }, + union: { + fieldType: { + type: "union", + value: [{ type: "string" }, { type: "number" }], + }, + optional: false, + }, + userId: { + fieldType: { tableName: "users", type: "id" }, + optional: false, + }, + }, + }); + const stored = await t.run(async (ctx) => { + const id = await ctx.db.insert("sink", kitchenSink); + return ctx.db.get(id); + }); + expect(stored).toMatchObject(omit(kitchenSink, ["optional", "default"])); +}); + +test("zod date round trip", async () => { + const t = convexTest(schema, modules); + const date = new Date().toISOString(); + const response = await t.query(testApi.dateRoundTrip, { date }); + expect(response).toBe(date); +}); + +test("zod fails returns validator", async () => { + const t = convexTest(schema, modules); + await expect(() => + t.query(testApi.failsReturnsValidator, {}), + ).rejects.toThrow(); +}); + +test("zod returns without args works", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.returnsWithoutArgs, {}); + expect(response).toBe(1); +}); + +test("output validators work for arrays objects and unions", async () => { + const array = zodOutputToConvex(z.array(z.string().default("foo"))); + expect(array.kind).toBe("array"); + expect(array.element.kind).toBe("string"); + expect(array.element.isOptional).toBe("required"); + const object = zodOutputToConvex( + z.object({ foo: z.string().default("foo") }), + ); + expect(object.kind).toBe("object"); + expect(object.fields.foo.kind).toBe("string"); + expect(object.fields.foo.isOptional).toBe("required"); + const union = zodOutputToConvex(z.union([z.string(), z.number().default(0)])); + expect(union.kind).toBe("union"); + expect(union.members[0].kind).toBe("string"); + expect(union.members[1].kind).toBe("float64"); + expect(union.members[1].isOptional).toBe("required"); +}); + +test("zod output compliance", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.zodOutputCompliance, {}); + expect(response).toMatchObject({ + undefinedBecomesFooString: "foo", + stringBecomesNull: null, + threeBecomesString: "3", + arrayWithDefaultFoo: ["foo"], + objectWithDefaultFoo: { foo: "foo" }, + unionOfDefaultFoo: "foo", + }); + const responseWithMaybe = await t.query(testApi.zodOutputCompliance, { + optionalString: "optionalString", + }); + expect(responseWithMaybe).toMatchObject({ + optionalString: "optionalString", + }); + // number should fail + await expect(() => + t.query(testApi.zodOutputCompliance, { + optionalString: 1 as any, + }), + ).rejects.toThrow(); +}); + +test("zod args object", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.zodArgsObject, { a: "foo" })).toMatchObject({ + a: "foo", + }); + await expect(() => + t.query(testApi.zodArgsObject, { a: 1 } as any), + ).rejects.toThrow(); +}); + +describe("zod functions", () => { + test("add ctx", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.addC, {})).toMatchObject({ + ctxA: "hi", + }); + expect(await t.query(testApi.addCU, {})).toMatchObject({ + ctxA: "hi", + }); + expect(await t.query(testApi.addCU2, {})).toMatchObject({ + ctxA: "hi", + }); + }); + + test("add ctx with existing arg", async () => { + const t = convexTest(schema, modules); + expect( + await t.query(testApi.addCtxWithExistingArg, { b: "foo" }), + ).toMatchObject({ + ctxA: "hi", + argB: "foo", + }); + expectTypeOf(testApi.addCtxWithExistingArg).toExtend< + FunctionReference< + "query", + "public", + { b: string }, + { ctxA: string; argB: string } + > + >(); + expectTypeOf< + FunctionReference< + "query", + "public", + { b: string }, + { ctxA: string; argB: string } + > + >().toExtend(); + }); + + test("add args", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.add, {})).toMatchObject({ + argsA: "hi", + }); + expect(await t.query(testApi.addUnverified, {})).toMatchObject({ + argsA: "hi", + }); + expect(await t.query(testApi.addUnverified2, {})).toMatchObject({ + argsA: "hi", + }); + }); + + test("consume arg, add to ctx", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.consume, { a: "foo" })).toMatchObject({ + ctxA: "foo", + }); + }); + + test("necromance arg", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.necromanceArg, { a: "foo" })).toMatchObject({ + ctxA: "foo", + argsA: "foo", + }); + }); + + test("pass through arg + ctx", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.passThrough, { a: "foo" })).toMatchObject({ + ctxA: "foo", + argsA: "foo", + }); + }); + + test("modify arg type", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.modify, { a: "foo" })).toMatchObject({ + ctxA: "foo", + argsA: 123, + }); + }); + + test("redefine arg", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.redefine, { a: "foo" })).toMatchObject({ + argsA: "foo", + }); + }); + + test("refined arg", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.refined, { a: "foo" })).toMatchObject({ + argsA: "foo", + }); + await expect(() => + t.query(testApi.refined, { a: undefined as any }), + ).rejects.toThrow("Validator error: Missing required field `a`"); + }); +}); + +/** + * Test type translation + */ + +expectTypeOf( + zodToConvexFields({ + s: z.string().email().max(5), + n: z.number(), + nan: z.nan(), + optional: z.number().optional(), + optional2: z.optional(z.number()), + record: z.record(z.string(), z.number()), + default: z.number().default(0), + nullable: z.number().nullable(), + null: z.null(), + bi: z.bigint(), + bool: z.boolean(), + literal: z.literal("hi"), + branded: z.string().brand("branded"), + }), +).toEqualTypeOf({ + s: v.string(), + n: v.number(), + nan: v.number(), + optional: v.optional(v.number()), + optional2: v.optional(v.number()), + record: v.record(v.string(), v.number()), + default: v.optional(v.number()), + nullable: v.union(v.number(), v.null()), + null: v.null(), + bi: v.int64(), + bool: v.boolean(), + literal: v.literal("hi"), + branded: v.string() as VString>, +}); + +expectTypeOf( + zodToConvexFields({ + simpleArray: z.array(z.boolean()), + tuple: z.tuple([z.boolean(), z.boolean()]), + enum: z.enum(["a", "b"]), + obj: z.object({ a: z.string(), b: z.object({ c: z.array(z.number()) }) }), + union: z.union([z.string(), z.object({ c: z.array(z.number()) })]), + discUnion: z.discriminatedUnion("type", [ + z.object({ type: z.literal("a"), a: z.string() }), + z.object({ type: z.literal("b"), b: z.number() }), + ]), + }), +).toEqualTypeOf({ + simpleArray: v.array(v.boolean()), + tuple: v.array(v.boolean()), + enum: v.union(v.literal("a"), v.literal("b")), + obj: v.object({ a: v.string(), b: v.object({ c: v.array(v.number()) }) }), + union: v.union(v.string(), v.object({ c: v.array(v.number()) })), + discUnion: v.union( + v.object({ + type: v.literal("a"), + a: v.string(), + }), + v.object({ + type: v.literal("b"), + b: v.number(), + }), + ), +}); + +expectTypeOf( + zodToConvexFields({ + transformed: z.transformer(z.string(), { + type: "refinement", + refinement: () => true, + }), + lazy: z.lazy(() => z.string()), + pipe: z.number().pipe(z.string().email()), + ro: z.string().readonly(), + unknown: z.unknown(), + any: z.any(), + }), +).toEqualTypeOf({ + transformed: v.string(), + lazy: v.string(), + pipe: v.number(), + ro: v.string(), + unknown: v.any(), + any: v.any(), +}); +// Validate that our double-branded type is correct. +expectTypeOf( + zodToConvexFields({ + branded2: zBrand(z.string(), "branded2"), + }), +).toEqualTypeOf({ + branded2: v.string() as VString>, +}); +const _s = zBrand(z.string(), "brand"); +const _n = zBrand(z.number(), "brand"); +const _i = zBrand(z.bigint(), "brand"); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf>(); + +function sameType(_t: T, _u: U): Equals { + return true as any; +} + +test("convexToZod basic types", () => { + expect(convexToZod(v.string()).constructor.name).toBe("ZodString"); + expect(convexToZod(v.number()).constructor.name).toBe("ZodNumber"); + expect(convexToZod(v.int64()).constructor.name).toBe("ZodBigInt"); + expect(convexToZod(v.boolean()).constructor.name).toBe("ZodBoolean"); + expect(convexToZod(v.null()).constructor.name).toBe("ZodNull"); + expect(convexToZod(v.any()).constructor.name).toBe("ZodAny"); + expect(convexToZod(v.id("users")).constructor.name).toBe("Zid"); +}); + +test("convexToZod complex types", () => { + const arrayValidator = convexToZod(v.array(v.string())); + expect(arrayValidator.constructor.name).toBe("ZodArray"); + + const objectValidator = convexToZod( + v.object({ a: v.string(), b: v.number() }), + ); + expect(objectValidator.constructor.name).toBe("ZodObject"); + + const unionValidator = convexToZod(v.union(v.string(), v.number())); + expect(unionValidator.constructor.name).toBe("ZodUnion"); + expect(unionValidator.options[0].constructor.name).toBe("ZodString"); + expect(unionValidator.options[1].constructor.name).toBe("ZodNumber"); + expectTypeOf(unionValidator.options[0]).toEqualTypeOf(); + expectTypeOf(unionValidator.options[1]).toEqualTypeOf(); + + const literalValidator = convexToZod(v.literal("hi")); + expect(literalValidator.constructor.name).toBe("ZodLiteral"); + + const recordValidator = convexToZod(v.record(v.string(), v.number())); + expect(recordValidator.constructor.name).toBe("ZodRecord"); + + const optionalValidator = convexToZod(v.optional(v.string())); + expect(optionalValidator.constructor.name).toBe("ZodOptional"); +}); + +test("convexToZodFields", () => { + const fields = { + name: v.string(), + age: v.number(), + isActive: v.boolean(), + tags: v.array(v.string()), + metadata: v.object({ createdBy: v.string() }), + }; + + const zodFields = convexToZodFields(fields); + + expect(zodFields.name.constructor.name).toBe("ZodString"); + expect(zodFields.age.constructor.name).toBe("ZodNumber"); + expect(zodFields.isActive.constructor.name).toBe("ZodBoolean"); + expect(zodFields.tags.constructor.name).toBe("ZodArray"); + expect(zodFields.metadata.constructor.name).toBe("ZodObject"); +}); + +test("convexToZod round trip", () => { + const stringValidator = v.string(); + const zodString = convexToZod(stringValidator); + const roundTripString = zodToConvex(zodString) as VString; + expect(roundTripString.kind).toBe(stringValidator.kind); + + type StringType = z.infer; + type ConvexStringType = Infer; + sameType( + "" as StringType, + "" as ConvexStringType, + ); + + const numberValidator = v.number(); + const zodNumber = convexToZod(numberValidator); + const roundTripNumber = zodToConvex(zodNumber) as VFloat64; + expect(roundTripNumber.kind).toBe(numberValidator.kind); + + type NumberType = z.infer; + type ConvexNumberType = Infer; + sameType( + 0 as NumberType, + 0 as ConvexNumberType, + ); + + const objectValidator = v.object({ + a: v.string(), + b: v.number(), + c: v.boolean(), + d: v.array(v.string()), + }); + + const zodObject = convexToZod(objectValidator); + const roundTripObject = zodToConvex(zodObject) as VObject; + expect(roundTripObject.kind).toBe(objectValidator.kind); + + type ObjectType = z.infer; + type ConvexObjectType = Infer; + sameType( + {} as ObjectType, + {} as ConvexObjectType, + ); + + const idValidator = v.id("users"); + const zodId = convexToZod(idValidator); + const roundTripId = zodToConvex(zodId) as VId<"users">; + expect(roundTripId.kind).toBe(idValidator.kind); + + type IdType = z.infer; + type ConvexIdType = Infer; + sameType("" as IdType, "" as ConvexIdType); +}); + +test("convexToZod validation", () => { + const stringValidator = v.string(); + const zodString = convexToZod(stringValidator); + + expect(zodString.parse("hello")).toBe("hello"); + + expect(() => zodString.parse(123)).toThrow(); + + const numberValidator = v.number(); + const zodNumber = convexToZod(numberValidator); + + expect(zodNumber.parse(123)).toBe(123); + + expect(() => zodNumber.parse("hello")).toThrow(); + + const boolValidator = v.boolean(); + const zodBool = convexToZod(boolValidator); + + expect(zodBool.parse(true)).toBe(true); + + expect(() => zodBool.parse("true")).toThrow(); + + const arrayValidator = v.array(v.string()); + const zodArray = convexToZod(arrayValidator); + + expect(zodArray.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]); + + expect(() => zodArray.parse(["a", 123, "c"])).toThrow(); + + const objectValidator = v.object({ + name: v.string(), + age: v.number(), + active: v.boolean(), + }); + const zodObject = convexToZod(objectValidator); + + const validObject = { + name: "John", + age: 30, + active: true, + }; + expect(zodObject.parse(validObject)).toEqual(validObject); + + const invalidObject = { + name: "John", + age: "thirty", + active: true, + }; + expect(() => zodObject.parse(invalidObject)).toThrow(); + + const unionValidator = v.union(v.string(), v.number()); + const zodUnion = convexToZod(unionValidator); + + expect(zodUnion.parse("hello")).toBe("hello"); + + expect(zodUnion.parse(123)).toBe(123); + + expect(() => zodUnion.parse(true)).toThrow(); +}); + +test("convexToZod optional values", () => { + const optionalStringValidator = v.optional(v.string()); + const zodOptionalString = convexToZod(optionalStringValidator); + + expect(zodOptionalString.constructor.name).toBe("ZodOptional"); + + expect(zodOptionalString.parse("hello")).toBe("hello"); + expect(zodOptionalString.parse(undefined)).toBe(undefined); + expect(() => zodOptionalString.parse(123)).toThrow(); + + type OptionalStringType = z.infer; + type ConvexOptionalStringType = Infer; + sameType( + "" as OptionalStringType, + "" as ConvexOptionalStringType, + ); + sameType( + undefined as OptionalStringType, + undefined as string | undefined, + ); + + const optionalNumberValidator = v.optional(v.number()); + const zodOptionalNumber = convexToZod(optionalNumberValidator); + + expect(zodOptionalNumber.constructor.name).toBe("ZodOptional"); + + expect(zodOptionalNumber.parse(123)).toBe(123); + expect(zodOptionalNumber.parse(undefined)).toBe(undefined); + expect(() => zodOptionalNumber.parse("hello")).toThrow(); + + type OptionalNumberType = z.infer; + type ConvexOptionalNumberType = Infer; + sameType( + 0 as OptionalNumberType, + 0 as ConvexOptionalNumberType, + ); + + const optionalObjectValidator = v.optional( + v.object({ + name: v.string(), + age: v.number(), + }), + ); + const zodOptionalObject = convexToZod(optionalObjectValidator); + + expect(zodOptionalObject.constructor.name).toBe("ZodOptional"); + + const validObj = { name: "John", age: 30 }; + expect(zodOptionalObject.parse(validObj)).toEqual(validObj); + expect(zodOptionalObject.parse(undefined)).toBe(undefined); + expect(() => zodOptionalObject.parse({ name: "John", age: "30" })).toThrow(); + + type OptionalObjectType = z.infer; + type ConvexOptionalObjectType = Infer; + sameType( + { name: "", age: 0 } as OptionalObjectType, + { name: "", age: 0 } as ConvexOptionalObjectType, + ); + + const objectWithOptionalFieldsValidator = v.object({ + name: v.string(), + age: v.optional(v.number()), + address: v.optional(v.string()), + }); + const zodObjectWithOptionalFields = convexToZod( + objectWithOptionalFieldsValidator, + ); + + expect(zodObjectWithOptionalFields.parse({ name: "John" })).toEqual({ + name: "John", + }); + expect(zodObjectWithOptionalFields.parse({ name: "John", age: 30 })).toEqual({ + name: "John", + age: 30, + }); + expect( + zodObjectWithOptionalFields.parse({ + name: "John", + age: 30, + address: "123 Main St", + }), + ).toEqual({ name: "John", age: 30, address: "123 Main St" }); + expect(() => zodObjectWithOptionalFields.parse({ age: 30 })).toThrow(); + + type ObjectWithOptionalFieldsType = z.infer< + typeof zodObjectWithOptionalFields + >; + type ConvexObjectWithOptionalFieldsType = Infer< + typeof objectWithOptionalFieldsValidator + >; + sameType( + { name: "" } as ObjectWithOptionalFieldsType, + { name: "" } as ConvexObjectWithOptionalFieldsType, + ); + + const optionalArrayValidator = v.optional(v.array(v.string())); + const zodOptionalArray = convexToZod(optionalArrayValidator); + const roundTripOptionalArray = zodToConvex(zodOptionalArray) as unknown as { + isOptional: string; + }; + + expect(roundTripOptionalArray.isOptional).toBe("optional"); +}); + +test("convexToZod union of one literal", () => { + const unionValidator = v.union(v.literal("hello")); + const zodUnion = convexToZod(unionValidator); + expect(zodUnion.constructor.name).toBe("ZodUnion"); + expect(zodUnion.parse("hello")).toBe("hello"); + expect(() => zodUnion.parse("world")).toThrow(); +}); + +test("convexToZod object with union of one literal", () => { + const unionValidator = v.object({ + member: v.union(v.literal("hello")), + }); + const zodUnion = convexToZod(unionValidator); + expect(zodUnion.constructor.name).toBe("ZodObject"); + expect(zodUnion.parse({ member: "hello" })).toEqual({ member: "hello" }); + expect(() => zodUnion.parse({ member: "world" })).toThrow(); +}); diff --git a/packages/convex-helpers/server/zod3.ts b/packages/convex-helpers/server/zod3.ts new file mode 100644 index 00000000..f9a53d82 --- /dev/null +++ b/packages/convex-helpers/server/zod3.ts @@ -0,0 +1,1718 @@ +import type { ZodTypeDef } from "zod/v3"; +import { ZodFirstPartyTypeKind, z } from "zod/v3"; +import type { + GenericId, + Infer, + ObjectType, + PropertyValidators, + Value, + VArray, + VAny, + VString, + VId, + VUnion, + VFloat64, + VInt64, + VBoolean, + VNull, + VLiteral, + GenericValidator, + VOptional, + VObject, + Validator, + VRecord, +} from "convex/values"; +import { ConvexError, v } from "convex/values"; +import type { + FunctionVisibility, + GenericDataModel, + GenericActionCtx, + GenericQueryCtx, + MutationBuilder, + QueryBuilder, + GenericMutationCtx, + ActionBuilder, + TableNamesInDataModel, + DefaultFunctionArgs, + ArgsArrayToObject, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only + defineTable, +} from "convex/server"; +import type { Customization, Registration } from "./customFunctions.js"; +import { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only + customQuery, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only + customMutation, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used in docs only + customAction, + NoOp, +} from "./customFunctions.js"; +import { pick } from "../index.js"; +import { addFieldsToValidator } from "../validators.js"; + +export type ZodValidator = Record; + +/** + * Creates a validator for a Convex `Id`. + * + * - When **used within Zod**, it will only check that the ID is a string. + * - When **converted to a Convex validator** (e.g. through {@link zodToConvex}), + * it will check that it's for the right table. + * + * @param tableName - The table that the `Id` references. i.e. `Id` + * @returns A Zod object representing a Convex `Id` + */ +export const zid = < + DataModel extends GenericDataModel, + TableName extends + TableNamesInDataModel = TableNamesInDataModel, +>( + tableName: TableName, +) => new Zid({ typeName: "ConvexId", tableName }); + +/** + * Useful to get the input context type for a custom function using Zod. + */ +export type ZCustomCtx = + Builder extends CustomBuilder< + any, + any, + infer CustomCtx, + any, + infer InputCtx, + any, + any + > + ? Overwrite + : never; + +/** + * `zCustomQuery` is like {@link customQuery}, but allows validation via Zod. + * You can define custom behavior on top of `query` or `internalQuery` + * by passing a function that modifies the ctx and args. Or {@link NoOp} to do nothing. + * + * Example usage: + * ```js + * const myQueryBuilder = zCustomQuery(query, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myQueryBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```js + * const myInternalQuery = zCustomQuery( + * internalQuery, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalQuery({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param query The query to be modified. Usually `query` or `internalQuery` + * from `_generated/server`. + * @param customization The customization to be applied to the query, changing ctx and args. + * @returns A new query builder using Zod validation to define queries. + */ +export function zCustomQuery< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + query: QueryBuilder, + customization: Customization< + GenericQueryCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(query, customization) as CustomBuilder< + "query", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericQueryCtx, + Visibility, + ExtraArgs + >; +} + +/** + * `zCustomMutation` is like {@link customMutation}, but allows validation via Zod. + * You can define custom behavior on top of `mutation` or `internalMutation` + * by passing a function that modifies the ctx and args. Or {@link NoOp} to do nothing. + * + * Example usage: + * ```js + * const myMutationBuilder = zCustomMutation(mutation, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myMutationBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```js + * const myInternalMutation = zCustomMutation( + * internalMutation, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalMutation({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param mutation The mutation to be modified. Usually `mutation` or `internalMutation` + * from `_generated/server`. + * @param customization The customization to be applied to the mutation, changing ctx and args. + * @returns A new mutation builder using Zod validation to define queries. + */ +export function zCustomMutation< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + mutation: MutationBuilder, + customization: Customization< + GenericMutationCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(mutation, customization) as CustomBuilder< + "mutation", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericMutationCtx, + Visibility, + ExtraArgs + >; +} + +/** + * `zCustomAction` is like {@link customAction}, but allows validation via Zod. + * You can define custom behavior on top of `action` or `internalAction` + * by passing a function that modifies the ctx and args. Or {@link NoOp} to do nothing. + * + * Example usage: + * ```js + * const myActionBuilder = zCustomAction(action, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myActionBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```js + * const myInternalAction = zCustomAction( + * internalAction, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalAction({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param action The action to be modified. Usually `action` or `internalAction` + * from `_generated/server`. + * @param customization The customization to be applied to the action, changing ctx and args. + * @returns A new action builder using Zod validation to define queries. + */ +export function zCustomAction< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + action: ActionBuilder, + customization: Customization< + GenericActionCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(action, customization) as CustomBuilder< + "action", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericActionCtx, + Visibility, + ExtraArgs + >; +} + +function customFnBuilder( + builder: (args: any) => any, + customization: Customization, +) { + // Most of the code in here is identical to customFnBuilder in zod4.ts. + // If making changes, please keep zod3.ts in sync. + + // Looking forward to when input / args / ... are optional + const customInput: Customization["input"] = + customization.input ?? NoOp.input; + const inputArgs = customization.args ?? NoOp.args; + return function customBuilder(fn: any): any { + const { args, handler = fn, returns: maybeObject, ...extra } = fn; + + const returns = + maybeObject && !(maybeObject instanceof z.ZodType) + ? z.object(maybeObject) + : maybeObject; + + const returnValidator = + returns && !fn.skipConvexValidation + ? { returns: zodOutputToConvex(returns) } + : null; + + if (args && !fn.skipConvexValidation) { + let argsValidator = args; + if (argsValidator instanceof z.ZodType) { + if (argsValidator instanceof z.ZodObject) { + argsValidator = argsValidator._def.shape(); + } else { + throw new Error( + "Unsupported zod type as args validator: " + + argsValidator.constructor.name, + ); + } + } + const convexValidator = zodToConvexFields(argsValidator); + return builder({ + args: addFieldsToValidator(convexValidator, inputArgs), + ...returnValidator, + handler: async (ctx: any, allArgs: any) => { + const added = await customInput( + ctx, + pick(allArgs, Object.keys(inputArgs)) as any, + extra, + ); + const rawArgs = pick(allArgs, Object.keys(argsValidator)); + const parsed = z.object(argsValidator).safeParse(rawArgs); + if (!parsed.success) { + throw new ConvexError({ + ZodError: JSON.parse( + JSON.stringify(parsed.error.errors, null, 2), + ) as Value[], + }); + } + const args = parsed.data; + const finalCtx = { ...ctx, ...added.ctx }; + const finalArgs = { ...args, ...added.args }; + const ret = await handler(finalCtx, finalArgs); + // We don't catch the error here. It's a developer error and we + // don't want to risk exposing the unexpected value to the client. + const result = returns ? returns.parse(ret) : ret; + if (added.onSuccess) { + await added.onSuccess({ ctx, args, result }); + } + return result; + }, + }); + } + if (Object.keys(inputArgs).length > 0 && !fn.skipConvexValidation) { + throw new Error( + "If you're using a custom function with arguments for the input " + + "customization, you must declare the arguments for the function too.", + ); + } + return builder({ + ...returnValidator, + handler: async (ctx: any, args: any) => { + const added = await customInput(ctx, args, extra); + const finalCtx = { ...ctx, ...added.ctx }; + const finalArgs = { ...args, ...added.args }; + const ret = await handler(finalCtx, finalArgs); + // We don't catch the error here. It's a developer error and we + // don't want to risk exposing the unexpected value to the client. + const result = returns ? returns.parse(ret) : ret; + if (added.onSuccess) { + await added.onSuccess({ ctx, args, result }); + } + return result; + }, + }); + }; +} + +type OneArgArray = + [ArgsObject]; + +// Copied from convex/src/server/api.ts since they aren't exported +type NullToUndefinedOrNull = T extends null ? T | undefined | void : T; +type Returns = Promise> | NullToUndefinedOrNull; + +// The return value before it's been validated: returned by the handler +type ReturnValueInput< + ReturnsValidator extends z.ZodTypeAny | ZodValidator | void, +> = [ReturnsValidator] extends [z.ZodTypeAny] + ? Returns> + : [ReturnsValidator] extends [ZodValidator] + ? Returns>> + : any; + +// The return value after it's been validated: returned to the client +type ReturnValueOutput< + ReturnsValidator extends z.ZodTypeAny | ZodValidator | void, +> = [ReturnsValidator] extends [z.ZodTypeAny] + ? Returns> + : [ReturnsValidator] extends [ZodValidator] + ? Returns>> + : any; + +// The args before they've been validated: passed from the client +type ArgsInput | void> = [ + ArgsValidator, +] extends [z.ZodObject] + ? [z.input] + : [ArgsValidator] extends [ZodValidator] + ? [z.input>] + : OneArgArray; + +// The args after they've been validated: passed to the handler +type ArgsOutput | void> = + [ArgsValidator] extends [z.ZodObject] + ? [z.output] + : [ArgsValidator] extends [ZodValidator] + ? [z.output>] + : OneArgArray; + +type Overwrite = Omit & U; + +/* + * Hack! This type causes TypeScript to simplify how it renders object types. + * + * It is functionally the identity for object types, but in practice it can + * simplify expressions like `A & B`. + */ +type Expand> = + ObjectType extends Record + ? { + [Key in keyof ObjectType]: ObjectType[Key]; + } + : never; + +type ArgsForHandlerType< + OneOrZeroArgs extends [] | [Record], + CustomMadeArgs extends Record, +> = + CustomMadeArgs extends Record + ? OneOrZeroArgs + : OneOrZeroArgs extends [infer A] + ? [Expand] + : [CustomMadeArgs]; + +/** + * A builder that customizes a Convex function, whether or not it validates + * arguments. If the customization requires arguments, however, the resulting + * builder will require argument validation too. + */ +export type CustomBuilder< + FuncType extends "query" | "mutation" | "action", + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + InputCtx, + Visibility extends FunctionVisibility, + ExtraArgs extends Record, +> = { + < + ArgsValidator extends ZodValidator | z.ZodObject | void, + ReturnsZodValidator extends z.ZodTypeAny | ZodValidator | void = void, + ReturnValue extends ReturnValueInput = any, + // Note: this differs from customFunctions.ts b/c we don't need to track + // the exact args to match the standard builder types. For Zod we don't + // try to ever pass a custom function as a builder to another custom + // function, so we can be looser here. + >( + func: + | ({ + /** + * Specify the arguments to the function as a Zod validator. + */ + args?: ArgsValidator; + handler: ( + ctx: Overwrite, + ...args: ArgsForHandlerType< + ArgsOutput, + CustomMadeArgs + > + ) => ReturnValue; + /** + * Validates the value returned by the function. + * Note: you can't pass an object directly without wrapping it + * in `z.object()`. + */ + returns?: ReturnsZodValidator; + /** + * If true, the function will not be validated by Convex, + * in case you're seeing performance issues with validating twice. + */ + skipConvexValidation?: boolean; + } & { + [key in keyof ExtraArgs as key extends + | "args" + | "handler" + | "skipConvexValidation" + | "returns" + ? never + : key]: ExtraArgs[key]; + }) + | { + ( + ctx: Overwrite, + ...args: ArgsForHandlerType< + ArgsOutput, + CustomMadeArgs + > + ): ReturnValue; + }, + ): Registration< + FuncType, + Visibility, + ArgsArrayToObject< + CustomArgsValidator extends Record + ? ArgsInput + : ArgsInput extends [infer A] + ? [Expand>] + : [ObjectType] + >, + ReturnsZodValidator extends void + ? ReturnValue + : ReturnValueOutput + >; +}; + +type ConvexUnionValidatorFromZod = T extends z.ZodTypeAny[] + ? VUnion< + ConvexValidatorFromZod["type"], + { + [Index in keyof T]: T[Index] extends z.ZodTypeAny + ? ConvexValidatorFromZod + : never; + }, + "required", + ConvexValidatorFromZod["fieldPaths"] + > + : never; + +type ConvexObjectValidatorFromZod = VObject< + ObjectType<{ + [key in keyof T]: T[key] extends z.ZodTypeAny + ? ConvexValidatorFromZod + : never; + }>, + { + [key in keyof T]: ConvexValidatorFromZod; + } +>; + +/** + * Converts a Zod validator type + * to the corresponding Convex validator type from `convex/values`. + * + * ```ts + * ConvexValidatorFromZod // → VString + * ``` + */ +export type ConvexValidatorFromZod = + // Keep this in sync with zodToConvex implementation + // and the ConvexValidatorFromZodOutput type + Z extends Zid + ? VId> + : Z extends z.ZodString + ? VString + : Z extends z.ZodNumber + ? VFloat64 + : Z extends z.ZodNaN + ? VFloat64 + : Z extends z.ZodBigInt + ? VInt64 + : Z extends z.ZodBoolean + ? VBoolean + : Z extends z.ZodNull + ? VNull + : Z extends z.ZodUnknown + ? VAny + : Z extends z.ZodAny + ? VAny + : Z extends z.ZodArray + ? VArray< + ConvexValidatorFromZod["type"][], + ConvexValidatorFromZod + > + : Z extends z.ZodObject + ? ConvexObjectValidatorFromZod + : Z extends z.ZodUnion + ? ConvexUnionValidatorFromZod + : Z extends z.ZodDiscriminatedUnion + ? VUnion< + ConvexValidatorFromZod["type"], + { + -readonly [Index in keyof T]: ConvexValidatorFromZod< + T[Index] + >; + }, + "required", + ConvexValidatorFromZod["fieldPaths"] + > + : Z extends z.ZodTuple + ? VArray< + ConvexValidatorFromZod< + Inner[number] + >["type"][], + ConvexValidatorFromZod + > + : Z extends z.ZodLazy + ? ConvexValidatorFromZod + : Z extends z.ZodLiteral + ? VLiteral + : Z extends z.ZodEnum + ? T extends Array + ? VUnion< + T[number], + { + [Index in keyof T]: VLiteral< + T[Index] + >; + }, + "required", + ConvexValidatorFromZod< + T[number] + >["fieldPaths"] + > + : never + : Z extends z.ZodEffects + ? ConvexValidatorFromZod + : Z extends z.ZodOptional + ? ConvexValidatorFromZod extends GenericValidator + ? VOptional< + ConvexValidatorFromZod + > + : never + : Z extends z.ZodNullable + ? ConvexValidatorFromZod extends Validator< + any, + "required", + any + > + ? VUnion< + | null + | ConvexValidatorFromZod["type"], + [ + ConvexValidatorFromZod, + VNull, + ], + "required", + ConvexValidatorFromZod["fieldPaths"] + > + : // Swap nullable(optional(foo)) for optional(nullable(foo)) + ConvexValidatorFromZod extends Validator< + infer T, + "optional", + infer F + > + ? VUnion< + null | Exclude< + ConvexValidatorFromZod["type"], + undefined + >, + [ + Validator, + VNull, + ], + "optional", + ConvexValidatorFromZod["fieldPaths"] + > + : never + : Z extends + | z.ZodBranded< + infer Inner, + infer Brand + > + | ZodBrandedInputAndOutput< + infer Inner, + infer Brand + > + ? Inner extends z.ZodString + ? VString> + : Inner extends z.ZodNumber + ? VFloat64< + number & z.BRAND + > + : Inner extends z.ZodBigInt + ? VInt64< + bigint & z.BRAND + > + : ConvexValidatorFromZod + : Z extends z.ZodDefault< + infer Inner + > // Treat like optional + ? ConvexValidatorFromZod extends GenericValidator + ? VOptional< + ConvexValidatorFromZod + > + : never + : Z extends z.ZodRecord< + infer K, + infer V + > + ? K extends + | z.ZodString + | Zid + | z.ZodUnion< + [ + ( + | z.ZodString + | Zid + ), + ( + | z.ZodString + | Zid + ), + ...( + | z.ZodString + | Zid + )[], + ] + > + ? VRecord< + z.RecordType< + ConvexValidatorFromZod["type"], + ConvexValidatorFromZod["type"] + >, + ConvexValidatorFromZod, + ConvexValidatorFromZod + > + : never + : Z extends z.ZodReadonly< + infer Inner + > + ? ConvexValidatorFromZod + : Z extends z.ZodPipeline< + infer Inner, + any + > // Validate input type + ? ConvexValidatorFromZod + : // Some that are a bit unknown + // : Z extends z.ZodDate ? Validator + // : Z extends z.ZodSymbol ? Validator + // : Z extends z.ZodNever ? Validator + // : Z extends z.ZodIntersection + // ? Validator< + // ConvexValidatorFromZod["type"] & + // ConvexValidatorFromZod["type"], + // "required", + // ConvexValidatorFromZod["fieldPaths"] | + // ConvexValidatorFromZod["fieldPaths"] + // > + // Is arraybuffer a thing? + // Z extends z.??? ? Validator : + // Note: we don't handle z.undefined() in union, nullable, etc. + // : Validator + // We avoid doing this catch-all to avoid over-promising on types + // : Z extends z.ZodTypeAny + never; + +/** + * Turns a Zod validator into a Convex Validator. + * + * The Convex validator will be as close to possible to the Zod validator, + * but might be broader than the Zod validator: + * + * ```js + * zodToConvex(z.string().email()) // → v.string() + * ``` + * + * This function is useful when running the Zod validator _after_ running the Convex validator + * (i.e. the Convex validator validates the input of the Zod validator). Hence, the Convex types + * will match the _input type_ of Zod transformations: + * ```js + * zodToConvex(z.object({ + * name: z.string().default("Nicolas"), + * })) // → v.object({ name: v.optional(v.string()) }) + * + * zodToConvex(z.object({ + * name: z.string().transform(s => s.length) + * })) // → v.object({ name: v.string() }) + * ```` + * + * This function is useful for: + * * **Validating function arguments with Zod**: through {@link zCustomQuery}, + * {@link zCustomMutation} and {@link zCustomAction}, you can define the argument validation logic + * using Zod validators instead of Convex validators. `zodToConvex` will generate a Convex validator + * from your Zod validator. This will allow you to: + * - validate at run time that Convex IDs are from the right table (using {@link zid}) + * - allow some features of Convex to understand the expected shape of the arguments + * (e.g. argument validation/prefilling in the function runner on the Convex dashboard) + * - still run the full Zod validation when the function runs + * (which is useful for more advanced Zod validators like `z.string().email()`) + * * **Validating data after reading it from the database**: if you want to write your DB schema + * with Zod, you can run Zod whenever you read from the database to check that the data + * still matches the schema. Note that this approach won’t ensure that the data stored in the DB + * matches the Zod schema; see + * https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too + * for more details. + * + * Note that some values might be valid in Zod but not in Convex, + * in the same way that valid JavaScript values might not be valid + * Convex values for the corresponding Convex type. + * (see the limits of Convex data types on https://docs.convex.dev/database/types). + * + * ``` + * ┌─────────────────────────────────────┬─────────────────────────────────────┐ + * │ **zodToConvex** │ zodOutputToConvex │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ For when the Zod validator runs │ For when the Zod validator runs │ + * │ _after_ the Convex validator │ _before_ the Convex validator │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ Convex types use the _input types_ │ Convex types use the _return types_ │ + * │ of Zod transformations │ of Zod transformations │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ The Convex validator can be less │ The Convex validator can be less │ + * │ strict (i.e. some inputs might be │ strict (i.e. the type in Convex can │ + * │ accepted by Convex then rejected │ be less precise than the type in │ + * │ by Zod) │ the Zod output) │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When using Zod schemas │ When using Zod schemas │ + * │ for function definitions: │ for function definitions: │ + * │ used for _arguments_ │ used for _return values_ │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When validating contents of the │ When validating contents of the │ + * │ database with a Zod schema: │ database with a Zod schema: │ + * │ used to validate data │ used to validate data │ + * │ _after reading_ │ _before writing_ │ + * └─────────────────────────────────────┴─────────────────────────────────────┘ + * ``` + * + * @param zod Zod validator can be a Zod object, or a Zod type like `z.string()` + * @returns Convex Validator (e.g. `v.string()` from "convex/values") + * @throws If there is no equivalent Convex validator for the value (e.g. `z.date()`) + */ +export function zodToConvex( + zod: Z, +): ConvexValidatorFromZod { + const typeName: ZodFirstPartyTypeKind | "ConvexId" = zod._def.typeName; + switch (typeName) { + case "ConvexId": + return v.id(zod._def.tableName) as ConvexValidatorFromZod; + case "ZodString": + return v.string() as ConvexValidatorFromZod; + case "ZodNumber": + case "ZodNaN": + return v.number() as ConvexValidatorFromZod; + case "ZodBigInt": + return v.int64() as ConvexValidatorFromZod; + case "ZodBoolean": + return v.boolean() as ConvexValidatorFromZod; + case "ZodNull": + return v.null() as ConvexValidatorFromZod; + case "ZodAny": + case "ZodUnknown": + return v.any() as ConvexValidatorFromZod; + case "ZodArray": { + const inner = zodToConvex(zod._def.type); + if (inner.isOptional === "optional") { + throw new Error("Arrays of optional values are not supported"); + } + return v.array(inner) as ConvexValidatorFromZod; + } + case "ZodObject": + return v.object( + zodToConvexFields(zod._def.shape()), + ) as ConvexValidatorFromZod; + case "ZodUnion": + case "ZodDiscriminatedUnion": + return v.union( + ...zod._def.options.map((v: z.ZodTypeAny) => zodToConvex(v)), + ) as ConvexValidatorFromZod; + case "ZodTuple": { + const allTypes = zod._def.items.map((v: z.ZodTypeAny) => zodToConvex(v)); + if (zod._def.rest) { + allTypes.push(zodToConvex(zod._def.rest)); + } + return v.array( + v.union(...allTypes), + ) as unknown as ConvexValidatorFromZod; + } + case "ZodLazy": + return zodToConvex(zod._def.getter()) as ConvexValidatorFromZod; + case "ZodLiteral": + return v.literal(zod._def.value) as ConvexValidatorFromZod; + case "ZodEnum": + return v.union( + ...zod._def.values.map((l: string | number | boolean | bigint) => + v.literal(l), + ), + ) as ConvexValidatorFromZod; + case "ZodEffects": + return zodToConvex(zod._def.schema) as ConvexValidatorFromZod; + case "ZodOptional": + return v.optional( + zodToConvex((zod as any).unwrap()) as any, + ) as ConvexValidatorFromZod; + case "ZodNullable": { + const nullable = (zod as any).unwrap(); + if (nullable._def.typeName === "ZodOptional") { + // Swap nullable(optional(Z)) for optional(nullable(Z)) + // Casting to any to ignore the mismatch of optional + return v.optional( + v.union(zodToConvex(nullable.unwrap()) as any, v.null()), + ) as unknown as ConvexValidatorFromZod; + } + return v.union( + zodToConvex(nullable) as any, + v.null(), + ) as unknown as ConvexValidatorFromZod; + } + case "ZodBranded": + return zodToConvex((zod as any).unwrap()) as ConvexValidatorFromZod; + case "ZodDefault": { + const withDefault = zodToConvex(zod._def.innerType); + if (withDefault.isOptional === "optional") { + return withDefault as ConvexValidatorFromZod; + } + return v.optional(withDefault) as ConvexValidatorFromZod; + } + case "ZodRecord": { + const keyType = zodToConvex( + zod._def.keyType, + ) as ConvexValidatorFromZod; + function ensureStringOrId(v: GenericValidator) { + if (v.kind === "union") { + v.members.map(ensureStringOrId); + } else if (v.kind !== "string" && v.kind !== "id") { + throw new Error("Record keys must be strings or ids: " + v.kind); + } + } + ensureStringOrId(keyType); + return v.record( + keyType, + zodToConvex(zod._def.valueType) as ConvexValidatorFromZod, + ) as unknown as ConvexValidatorFromZod; + } + case "ZodReadonly": + return zodToConvex(zod._def.innerType) as ConvexValidatorFromZod; + case "ZodPipeline": + return zodToConvex(zod._def.in) as ConvexValidatorFromZod; + default: + throw new Error(`Unknown Zod type: ${typeName}`); + // N/A or not supported + // case "ZodDate": + // case "ZodSymbol": + // case "ZodUndefined": + // case "ZodNever": + // case "ZodVoid": + // case "ZodIntersection": + // case "ZodMap": + // case "ZodSet": + // case "ZodFunction": + // case "ZodNativeEnum": + // case "ZodCatch": + // case "ZodPromise": + } +} + +/** + * This is the type of a Convex validator that checks the value *after* it has + * been validated (and possibly transformed) by a Zod validator. + * + * The difference between {@link ConvexValidatorFromZod} + * and `ConvexValidatorFromZodOutput` are explained in the documentation of + * {@link zodToConvex}/{@link zodOutputToConvex}. + */ +export type ConvexValidatorFromZodOutput = + // Keep this in sync with the zodOutputToConvex implementation + // IMPORTANT: The differences are at the bottom + Z extends Zid + ? VId> + : Z extends z.ZodString + ? VString + : Z extends z.ZodNumber + ? VFloat64 + : Z extends z.ZodNaN + ? VFloat64 + : Z extends z.ZodBigInt + ? VInt64 + : Z extends z.ZodBoolean + ? VBoolean + : Z extends z.ZodNull + ? VNull + : Z extends z.ZodUnknown + ? VAny + : Z extends z.ZodAny + ? VAny + : Z extends z.ZodArray + ? VArray< + ConvexValidatorFromZodOutput["type"][], + ConvexValidatorFromZodOutput + > + : Z extends z.ZodObject + ? ConvexObjectValidatorFromZod + : Z extends z.ZodUnion + ? ConvexUnionValidatorFromZod + : Z extends z.ZodDiscriminatedUnion + ? VUnion< + ConvexValidatorFromZodOutput["type"], + { + -readonly [Index in keyof T]: ConvexValidatorFromZodOutput< + T[Index] + >; + }, + "required", + ConvexValidatorFromZodOutput< + T[number] + >["fieldPaths"] + > + : Z extends z.ZodTuple + ? VArray< + ConvexValidatorFromZodOutput< + Inner[number] + >["type"][], + ConvexValidatorFromZodOutput + > + : Z extends z.ZodLazy + ? ConvexValidatorFromZodOutput + : Z extends z.ZodLiteral + ? VLiteral + : Z extends z.ZodEnum + ? T extends Array + ? VUnion< + T[number], + { + [Index in keyof T]: VLiteral< + T[Index] + >; + }, + "required", + ConvexValidatorFromZodOutput< + T[number] + >["fieldPaths"] + > + : never + : Z extends z.ZodOptional + ? ConvexValidatorFromZodOutput extends GenericValidator + ? VOptional< + ConvexValidatorFromZodOutput + > + : never + : Z extends z.ZodNullable + ? ConvexValidatorFromZodOutput extends Validator< + any, + "required", + any + > + ? VUnion< + | null + | ConvexValidatorFromZodOutput["type"], + [ + ConvexValidatorFromZodOutput, + VNull, + ], + "required", + ConvexValidatorFromZodOutput["fieldPaths"] + > + : // Swap nullable(optional(foo)) for optional(nullable(foo)) + ConvexValidatorFromZodOutput extends Validator< + infer T, + "optional", + infer F + > + ? VUnion< + null | Exclude< + ConvexValidatorFromZodOutput["type"], + undefined + >, + [ + Validator, + VNull, + ], + "optional", + ConvexValidatorFromZodOutput["fieldPaths"] + > + : never + : Z extends + | z.ZodBranded< + infer Inner, + infer Brand + > + | ZodBrandedInputAndOutput< + infer Inner, + infer Brand + > + ? Inner extends z.ZodString + ? VString> + : Inner extends z.ZodNumber + ? VFloat64< + number & z.BRAND + > + : Inner extends z.ZodBigInt + ? VInt64< + bigint & z.BRAND + > + : ConvexValidatorFromZodOutput + : Z extends z.ZodRecord< + infer K, + infer V + > + ? K extends + | z.ZodString + | Zid + | z.ZodUnion< + [ + z.ZodString | Zid, + z.ZodString | Zid, + ...( + | z.ZodString + | Zid + )[], + ] + > + ? VRecord< + z.RecordType< + ConvexValidatorFromZodOutput["type"], + ConvexValidatorFromZodOutput["type"] + >, + ConvexValidatorFromZodOutput, + ConvexValidatorFromZodOutput + > + : never + : Z extends z.ZodReadonly< + infer Inner + > + ? ConvexValidatorFromZodOutput + : /* + * IMPORTANT: these are the different ones + */ + Z extends z.ZodDefault< + infer Inner + > + ? // Default values are always set after validation + ConvexValidatorFromZodOutput + : Z extends z.ZodEffects + ? // We don't know what the output type is, it's a function return + VAny + : // Validate output type instead of input + Z extends z.ZodPipeline< + z.ZodTypeAny, + infer Out + > + ? ConvexValidatorFromZodOutput + : never; + +/** + * Converts a Zod validator to a Convex validator that checks the value _after_ + * it has been validated (and possibly transformed) by the Zod validator. + * + * This is similar to {@link zodToConvex}, but is meant for cases where the Convex + * validator runs _after_ the Zod validator. Thus, the Convex type refers to the + * _output_ type of the Zod transformations: + * ```js + * zodOutputToConvex(z.object({ + * name: z.string().default("Nicolas"), + * })) // → v.object({ name: v.string() }) + * + * zodOutputToConvex(z.object({ + * name: z.string().transform(s => s.length) + * })) // → v.object({ name: v.number() }) + * ```` + * + * This function can be useful for: + * - **Validating function return values with Zod**: through {@link zCustomQuery}, + * {@link zCustomMutation} and {@link zCustomAction}, you can define the `returns` property + * of a function using Zod validators instead of Convex validators. + * - **Validating data after reading it from the database**: if you want to write your DB schema + * Zod validators, you can run Zod whenever you write to the database to ensure your data matches + * the expected format. Note that this approach won’t ensure that the data stored in the DB + * isn’t modified manually in a way that doesn’t match your Zod schema; see + * https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too + * for more details. + * + * ``` + * ┌─────────────────────────────────────┬─────────────────────────────────────┐ + * │ zodToConvex │ **zodOutputToConvex** │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ For when the Zod validator runs │ For when the Zod validator runs │ + * │ _after_ the Convex validator │ _before_ the Convex validator │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ Convex types use the _input types_ │ Convex types use the _return types_ │ + * │ of Zod transformations │ of Zod transformations │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ The Convex validator can be less │ The Convex validator can be less │ + * │ strict (i.e. some inputs might be │ strict (i.e. the type in Convex can │ + * │ accepted by Convex then rejected │ be less precise than the type in │ + * │ by Zod) │ the Zod output) │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When using Zod schemas │ When using Zod schemas │ + * │ for function definitions: │ for function definitions: │ + * │ used for _arguments_ │ used for _return values_ │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When validating contents of the │ When validating contents of the │ + * │ database with a Zod schema: │ database with a Zod schema: │ + * │ used to validate data │ used to validate data │ + * │ _after reading_ │ _before writing_ │ + * └─────────────────────────────────────┴─────────────────────────────────────┘ + * ``` + * + * @param z The zod validator + * @returns Convex Validator (e.g. `v.string()` from "convex/values") + * @throws If there is no equivalent Convex validator for the value (e.g. `z.date()`) + */ +export function zodOutputToConvex( + zod: Z, +): ConvexValidatorFromZodOutput { + const typeName: ZodFirstPartyTypeKind | "ConvexId" = zod._def.typeName; + switch (typeName) { + // These are the special cases that differ from the input validator + case "ZodDefault": + // Here we return the non-optional inner type + return zodOutputToConvex( + zod._def.innerType, + ) as unknown as ConvexValidatorFromZodOutput; + case "ZodEffects": + console.warn( + "Note: ZodEffects (like z.transform) do not do output validation", + ); + return v.any() as ConvexValidatorFromZodOutput; + case "ZodPipeline": + // IMPORTANT: The output type of the pipeline can differ from the input. + return zodOutputToConvex(zod._def.out) as ConvexValidatorFromZodOutput; + // These are the same as input + case "ConvexId": + return v.id(zod._def.tableName) as ConvexValidatorFromZodOutput; + case "ZodString": + return v.string() as ConvexValidatorFromZodOutput; + case "ZodNumber": + case "ZodNaN": + return v.number() as ConvexValidatorFromZodOutput; + case "ZodBigInt": + return v.int64() as ConvexValidatorFromZodOutput; + case "ZodBoolean": + return v.boolean() as ConvexValidatorFromZodOutput; + case "ZodNull": + return v.null() as ConvexValidatorFromZodOutput; + case "ZodAny": + case "ZodUnknown": + return v.any() as ConvexValidatorFromZodOutput; + case "ZodArray": { + const inner = zodOutputToConvex(zod._def.type); + if (inner.isOptional === "optional") { + throw new Error("Arrays of optional values are not supported"); + } + return v.array(inner) as ConvexValidatorFromZodOutput; + } + case "ZodObject": + return v.object( + zodOutputToConvexFields(zod._def.shape()), + ) as ConvexValidatorFromZodOutput; + case "ZodUnion": + case "ZodDiscriminatedUnion": + return v.union( + ...zod._def.options.map((v: z.ZodTypeAny) => zodOutputToConvex(v)), + ) as ConvexValidatorFromZodOutput; + case "ZodTuple": { + const allTypes = zod._def.items.map((v: z.ZodTypeAny) => + zodOutputToConvex(v), + ); + if (zod._def.rest) { + allTypes.push(zodOutputToConvex(zod._def.rest)); + } + return v.array( + v.union(...allTypes), + ) as unknown as ConvexValidatorFromZodOutput; + } + case "ZodLazy": + return zodOutputToConvex( + zod._def.getter(), + ) as ConvexValidatorFromZodOutput; + case "ZodLiteral": + return v.literal(zod._def.value) as ConvexValidatorFromZodOutput; + case "ZodEnum": + return v.union( + ...zod._def.values.map((l: string | number | boolean | bigint) => + v.literal(l), + ), + ) as ConvexValidatorFromZodOutput; + case "ZodOptional": + return v.optional( + zodOutputToConvex((zod as any).unwrap()) as any, + ) as ConvexValidatorFromZodOutput; + case "ZodNullable": { + const nullable = (zod as any).unwrap(); + if (nullable._def.typeName === "ZodOptional") { + // Swap nullable(optional(Z)) for optional(nullable(Z)) + // Casting to any to ignore the mismatch of optional + return v.optional( + v.union(zodOutputToConvex(nullable.unwrap()) as any, v.null()), + ) as unknown as ConvexValidatorFromZodOutput; + } + return v.union( + zodOutputToConvex(nullable) as any, + v.null(), + ) as unknown as ConvexValidatorFromZodOutput; + } + case "ZodBranded": + return zodOutputToConvex( + (zod as any).unwrap(), + ) as ConvexValidatorFromZodOutput; + case "ZodRecord": { + const keyType = zodOutputToConvex( + zod._def.keyType, + ) as ConvexValidatorFromZodOutput; + function ensureStringOrId(v: GenericValidator) { + if (v.kind === "union") { + v.members.map(ensureStringOrId); + } else if (v.kind !== "string" && v.kind !== "id") { + throw new Error("Record keys must be strings or ids: " + v.kind); + } + } + ensureStringOrId(keyType); + return v.record( + keyType, + zodOutputToConvex( + zod._def.valueType, + ) as ConvexValidatorFromZodOutput, + ) as unknown as ConvexValidatorFromZodOutput; + } + case "ZodReadonly": + return zodOutputToConvex( + zod._def.innerType, + ) as ConvexValidatorFromZodOutput; + default: + throw new Error(`Unknown zod type: ${typeName}`); + // N/A or not supported + // case "ZodDate": + // case "ZodSymbol": + // case "ZodUndefined": + // case "ZodNever": + // case "ZodVoid": + // case "ZodIntersection": + // case "ZodMap": + // case "ZodSet": + // case "ZodFunction": + // case "ZodNativeEnum": + // case "ZodCatch": + // case "ZodPromise": + } +} + +/** + * Like {@link zodToConvex}, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to {@link defineTable}. + * + * ```js + * zodToConvexFields({ + * name: z.string().default("Nicolas"), + * }) // → { name: v.optional(v.string()) } + * ``` + * + * @param zod Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodToConvexFields(zod: Z) { + return Object.fromEntries( + Object.entries(zod).map(([k, v]) => [k, zodToConvex(v)]), + ) as { [k in keyof Z]: ConvexValidatorFromZod }; +} + +/** + * Like {@link zodOutputToConvex}, but it takes in a bare object, as expected by + * Convex function arguments, or the argument to {@link defineTable}. + * + * ```js + * zodOutputToConvexFields({ + * name: z.string().default("Nicolas"), + * }) // → { name: v.string() } + * ``` + * + * This is different from {@link zodToConvexFields} because it generates the + * Convex validator for the output of the Zod validator, not the input; + * see the documentation of {@link zodToConvex} and {@link zodOutputToConvex} + * for more details. + * + * @param zod Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodOutputToConvexFields(zod: Z) { + return Object.fromEntries( + Object.entries(zod).map(([k, v]) => [k, zodOutputToConvex(v)]), + ) as { [k in keyof Z]: ConvexValidatorFromZodOutput }; +} + +interface ZidDef extends ZodTypeDef { + typeName: "ConvexId"; + tableName: TableName; +} + +/** + * A Zod validator for a Convex ID. + */ +export class Zid extends z.ZodType< + GenericId, + ZidDef +> { + _parse(input: z.ParseInput) { + return z.string()._parse(input) as z.ParseReturnType>; + } +} + +/** + * Zod helper for adding Convex system fields to a record to return. + * + * ```js + * withSystemFields("users", { + * name: z.string(), + * }) + * // → { + * // name: z.string(), + * // _id: zid("users"), + * // _creationTime: z.number(), + * // } + * ``` + * + * @param tableName - The table where records are from, i.e. Doc + * @param zObject - Validators for the user-defined fields on the document. + * @returns Zod shape for use with `z.object(shape)` that includes system fields. + */ +export const withSystemFields = < + Table extends string, + T extends { [key: string]: z.ZodTypeAny }, +>( + tableName: Table, + zObject: T, +) => { + return { ...zObject, _id: zid(tableName), _creationTime: z.number() }; +}; + +/** + * This is a copy of Zod’s `ZodBranded` which also brands the input + * (see {@link zBrand}) + */ +export class ZodBrandedInputAndOutput< + T extends z.ZodTypeAny, + B extends string | number | symbol, +> extends z.ZodType< + T["_output"] & z.BRAND, + z.ZodBrandedDef, + T["_input"] & z.BRAND +> { + _parse(input: z.ParseInput) { + const { ctx } = this._processInputParams(input); + const data = ctx.data; + return this._def.type._parse({ + data, + path: ctx.path, + parent: ctx, + }); + } + unwrap() { + return this._def.type; + } +} + +/** + * Adds a brand to a Zod validator. Used like `zBrand(z.string(), "MyBrand")`. + * Compared to zod's `.brand`, this also brands the input type, so if you use + * the branded validator as an argument to a function, the input type will also + * be branded. The normal `.brand` only brands the output type, so only the type + * returned by validation would be branded. + * + * @param validator A zod validator - generally a string, number, or bigint + * @param brand A string, number, or symbol to brand the validator with + * @returns A zod validator that brands both the input and output types. + */ +export function zBrand< + T extends z.ZodTypeAny, + B extends string | number | symbol, +>(validator: T, brand?: B): ZodBrandedInputAndOutput { + return validator.brand(brand); +} + +/** + * Simple type conversion from a Convex validator to a Zod validator. + * + * ```ts + * ConvexToZod // → z.ZodType + * ``` + */ +export type ConvexToZod = z.ZodType>; + +type ZodFromValidatorBase = + V extends VId> + ? Zid + : V extends VString + ? T extends string & { _: infer Brand extends string } + ? z.ZodBranded + : z.ZodString + : V extends VFloat64 + ? z.ZodNumber + : V extends VInt64 + ? z.ZodBigInt + : V extends VBoolean + ? z.ZodBoolean + : V extends VNull + ? z.ZodNull + : V extends VLiteral + ? z.ZodLiteral + : V extends VObject + ? z.ZodObject< + { + [K in keyof Fields]: ZodValidatorFromConvex; + }, + "strip" + > + : V extends VRecord + ? Key extends VId> + ? z.ZodRecord< + Zid, + ZodValidatorFromConvex + > + : z.ZodRecord> + : V extends VArray + ? z.ZodArray> + : V extends VUnion< + any, + [ + infer A extends GenericValidator, + infer B extends GenericValidator, + ...infer Rest extends GenericValidator[], + ], + any, + any + > + ? z.ZodUnion< + [ + ZodValidatorFromConvex, + ZodValidatorFromConvex, + ...{ + [K in keyof Rest]: ZodValidatorFromConvex< + Rest[K] + >; + }, + ] + > + : z.ZodTypeAny; // fallback for unknown validators + +/** + * Better type conversion from a Convex validator to a Zod validator + * where the output is not a generic ZodType but it's more specific. + * + * This allows you to use methods specific to the Zod type (e.g. `.email()` for `z.ZodString`). + * + * ```ts + * ZodValidatorFromConvex // → z.ZodString + * ``` + */ +export type ZodValidatorFromConvex = + V extends Validator + ? z.ZodOptional> + : ZodFromValidatorBase; + +/** + * Turns a Convex validator into a Zod validator. + * + * This is useful when you want to use types you defined using Convex validators + * with external libraries that expect to receive a Zod validator. + * + * ```js + * convexToZod(v.string()) // → z.string() + * ``` + * + * @param convexValidator Convex validator can be any validator from "convex/values" e.g. `v.string()` + * @returns Zod validator (e.g. `z.string()`) with inferred type matching the Convex validator + */ +export function convexToZod( + convexValidator: V, +): ZodValidatorFromConvex { + const isOptional = (convexValidator as any).isOptional === "optional"; + + let zodValidator: z.ZodTypeAny; + + const { kind } = convexValidator; + switch (kind) { + case "id": + zodValidator = zid((convexValidator as VId).tableName); + break; + case "string": + zodValidator = z.string(); + break; + case "float64": + zodValidator = z.number(); + break; + case "int64": + zodValidator = z.bigint(); + break; + case "boolean": + zodValidator = z.boolean(); + break; + case "null": + zodValidator = z.null(); + break; + case "any": + zodValidator = z.any(); + break; + case "array": { + const arrayValidator = convexValidator as VArray; + zodValidator = z.array(convexToZod(arrayValidator.element)); + break; + } + case "object": { + const objectValidator = convexValidator as VObject; + zodValidator = z.object(convexToZodFields(objectValidator.fields)); + break; + } + case "union": { + const unionValidator = convexValidator as VUnion; + const memberValidators = unionValidator.members.map( + (member: GenericValidator) => convexToZod(member), + ); + zodValidator = z.union([ + memberValidators[0], + memberValidators[1], + ...memberValidators.slice(2), + ]); + break; + } + case "literal": { + const literalValidator = convexValidator as VLiteral; + zodValidator = z.literal(literalValidator.value); + break; + } + case "record": { + const recordValidator = convexValidator as VRecord< + any, + any, + any, + any, + any + >; + zodValidator = z.record( + convexToZod(recordValidator.key), + convexToZod(recordValidator.value), + ); + break; + } + case "bytes": + throw new Error("v.bytes() is not supported"); + default: + kind satisfies never; + throw new Error(`Unknown convex validator type: ${kind}`); + } + + return isOptional + ? (z.optional(zodValidator) as ZodValidatorFromConvex) + : (zodValidator as ZodValidatorFromConvex); +} + +/** + * Like {@link convexToZod}, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to {@link defineTable}. + * + * ```js + * convexToZodFields({ + * name: v.string(), + * }) // → { name: z.string() } + * ``` + * + * @param convexValidators Object with string keys and Convex validators as values + * @returns Object with the same keys, but with Zod validators as values + */ +export function convexToZodFields( + convexValidators: C, +) { + return Object.fromEntries( + Object.entries(convexValidators).map(([k, v]) => [k, convexToZod(v)]), + ) as { [k in keyof C]: ZodValidatorFromConvex }; +} diff --git a/packages/convex-helpers/server/zod4.convextozod.test.ts b/packages/convex-helpers/server/zod4.convextozod.test.ts new file mode 100644 index 00000000..5032628e --- /dev/null +++ b/packages/convex-helpers/server/zod4.convextozod.test.ts @@ -0,0 +1,229 @@ +import * as zCore from "zod/v4/core"; +import * as z from "zod/v4"; +import { assertType, describe, expect, expectTypeOf, test } from "vitest"; +import { + GenericId, + GenericValidator, + Infer, + v, + VFloat64, + VString, +} from "convex/values"; +import { convexToZod, Zid, zid, ZodValidatorFromConvex } from "./zod4"; +import { isSameType } from "zod-compare/zod4"; + +test("Zid is a record key", () => { + const myZid = zid("users"); + expectTypeOf(myZid).toExtend(); +}); + +describe("convexToZod", () => { + test("id", () => { + expectTypeOf(convexToZod(v.id("users"))).toEqualTypeOf>(); + }); + test("string", () => testConvexToZod(v.string(), z.string())); + test("number", () => testConvexToZod(v.number(), z.number())); + test("int64", () => testConvexToZod(v.int64(), z.bigint())); + test("boolean", () => testConvexToZod(v.boolean(), z.boolean())); + test("null", () => testConvexToZod(v.null(), z.null())); + test("any", () => testConvexToZod(v.any(), z.any())); + test("bytes", () => { + expect(() => convexToZod(v.bytes())).toThrow(); + }); + + test("optional", () => { + testConvexToZod(v.optional(v.string()), z.string().optional()); + }); + + test("array", () => { + testConvexToZod(v.array(v.string()), z.array(z.string())); + }); + + describe("union", () => { + test("never", () => { + testConvexToZod(v.union(), z.never()); + }); + test("one element (number)", () => { + testConvexToZod(v.union(v.number()), z.number()); + }); + test("one element (string)", () => { + testConvexToZod(v.union(v.string()), z.string()); + }); + test("multiple elements", () => { + testConvexToZod( + v.union(v.string(), v.number()), + z.union([z.string(), z.number()]), + ); + }); + }); + + test("branded string", () => { + const brandedString = z.string().brand("myBrand"); + type BrandedStringType = z.output; + + testConvexToZod( + v.string() as VString, + brandedString, + ); + }); + test("branded number", () => { + const brandedNumber = z.number().brand("myBrand"); + type BrandedNumberType = z.output; + + testConvexToZod( + v.number() as VFloat64, + brandedNumber, + ); + }); + + test("object", () => { + testConvexToZod( + v.object({ + name: v.string(), + age: v.number(), + picture: v.optional(v.string()), + }), + z.strictObject({ + name: z.string(), + age: z.number(), + picture: z.string().optional(), + }), + ); + }); + + describe("record", () => { + test("key = string", () => { + testConvexToZod( + v.record(v.string(), v.number()), + z.record(z.string(), z.number()), + ); + }); + + test("key = literal", () => { + testConvexToZod( + v.record(v.literal("user"), v.number()), + z.record(z.literal("user"), z.number()), + ); + }); + + test("key = union of literals", () => { + const convexValidator = v.record( + v.union(v.literal("user"), v.literal("admin")), + v.number(), + ); + const zodSchema = z.record( + z.union([z.literal("user"), z.literal("admin")]), + z.number(), + ); + testConvexToZod(convexValidator, zodSchema); + + // On both Zod and Convex, the record must be exhaustive when the key is a union of literals. + const partial = { user: 42 } as const; + // @ts-expect-error -- This should not typecheck + const _asConvex: Infer = partial; + // @ts-expect-error -- This should not typecheck + const _asZod: z.output = partial; + }); + + test("key = v.id()", () => { + const convexValidator = v.record(v.id("users"), v.number()); + const _zodSchema = z.record(zid("users"), z.number()); + expectTypeOf(convexToZod(convexValidator)).toEqualTypeOf< + typeof _zodSchema + >(); + + const sampleId = "abc" as GenericId<"users">; + const sampleValue: Record, number> = { + [sampleId]: 42, + }; + assertType>(sampleValue); + assertType>(sampleValue); + }); + + describe("optional", () => { + test("id", () => { + // Testing manually the result since it’s a custom type + const actual = convexToZod(v.optional(v.id("documents"))); + expect(actual.safeParse(undefined).success).toBe(true); + expect(actual.safeParse("abc").success).toBe(true); + expect(actual.safeParse(42).success).toBe(false); + }); + test("string", () => { + testConvexToZod(v.optional(v.string()), z.string().optional()); + }); + test("float64", () => { + testConvexToZod(v.optional(v.float64()), z.number().optional()); + }); + test("int64", () => { + testConvexToZod(v.optional(v.int64()), z.bigint().optional()); + }); + test("boolean", () => { + testConvexToZod(v.optional(v.boolean()), z.boolean().optional()); + }); + test("null", () => { + testConvexToZod(v.optional(v.null()), z.null().optional()); + }); + test("any", () => { + testConvexToZod(v.optional(v.any()), z.any().optional()); + }); + test("literal", () => { + testConvexToZod(v.optional(v.literal(42n)), z.literal(42n).optional()); + }); + test("object", () => { + testConvexToZod( + v.optional( + v.object({ + required: v.string(), + optional: v.optional(v.number()), + }), + ), + z + .object({ + required: z.string(), + optional: z.number().optional(), + }) + .optional(), + ); + }); + test("array", () => { + testConvexToZod( + v.optional(v.array(v.int64())), + z.array(z.bigint()).optional(), + ); + }); + test("record", () => { + testConvexToZod( + v.optional(v.record(v.string(), v.number())), + z.record(z.string(), z.number()).optional(), + ); + }); + test("union", () => { + testConvexToZod( + v.optional(v.union(v.number(), v.string())), + z.union([z.number(), z.string()]).optional(), + ); + }); + }); + }); +}); + +// Type equality helper: checks if two types are exactly equal (bidirectionally assignable) +type Equals = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; + +function testConvexToZod< + C extends GenericValidator, + Expected extends zCore.$ZodType, +>( + validator: C, + expected: Expected & + (Equals> extends true + ? // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {} + : "Expected type must exactly match ZodValidatorFromConvex"), +) { + const actual = convexToZod(validator); + expect(isSameType(actual, expected)).toBe(true); +} diff --git a/packages/convex-helpers/server/zod4.functions.test.ts b/packages/convex-helpers/server/zod4.functions.test.ts new file mode 100644 index 00000000..f981d79d --- /dev/null +++ b/packages/convex-helpers/server/zod4.functions.test.ts @@ -0,0 +1,460 @@ +import type { + DataModelFromSchemaDefinition, + QueryBuilder, + MutationBuilder, + ActionBuilder, + ApiFromModules, + FunctionReference, +} from "convex/server"; +import { + defineTable, + defineSchema, + queryGeneric, + mutationGeneric, + actionGeneric, + anyApi, +} from "convex/server"; +import { convexTest } from "convex-test"; +import { assertType, describe, expect, expectTypeOf, test } from "vitest"; +import { modules } from "./setup.test.js"; +import { zCustomQuery, zCustomMutation, zCustomAction } from "./zod4.js"; +import { z } from "zod/v4"; +import { v } from "convex/values"; + +const schema = defineSchema({ + users: defineTable({ + name: v.string(), + }), +}); +type DataModel = DataModelFromSchemaDefinition; +const query = queryGeneric as QueryBuilder; +const mutation = mutationGeneric as MutationBuilder; +const action = actionGeneric as ActionBuilder; + +const zQuery = zCustomQuery(query, { + args: {}, + input: async () => ({ ctx: {}, args: {} }), +}); + +const zMutation = zCustomMutation(mutation, { + args: {}, + input: async () => ({ ctx: {}, args: {} }), +}); + +const zAction = zCustomAction(action, { + args: {}, + input: async () => ({ ctx: {}, args: {} }), +}); + +/** + * Test zCustomQuery with Zod schemas for args and return value + */ +export const testQuery = zQuery({ + args: { + name: z.string(), + age: z.number(), + }, + handler: async (_ctx, args) => { + assertType<{ name: string; age: number }>(args); + return { + message: `Hello ${args.name}, you are ${args.age} years old`, + doubledAge: args.age * 2, + }; + }, + returns: z.object({ + message: z.string(), + doubledAge: z.number(), + }), +}); + +/** + * Test zCustomMutation with Zod schemas for args and return value + */ +export const testMutation = zMutation({ + args: { + userId: z.string(), + score: z.number().min(0).max(100), + }, + handler: async (ctx, args) => { + assertType<{ userId: string; score: number }>(args); + const id = await ctx.db.insert("users", { + name: `User ${args.userId}`, + }); + return { + id, + userId: args.userId, + score: args.score, + passed: args.score >= 50, + }; + }, + returns: z.object({ + id: z.string(), + userId: z.string(), + score: z.number(), + passed: z.boolean(), + }), +}); + +/** + * Test zCustomAction with Zod schemas for args and return value + */ +export const testAction = zAction({ + args: { + input: z.string(), + multiplier: z.number().int().positive(), + }, + handler: async (_ctx, args) => { + assertType<{ input: string; multiplier: number }>(args); + return { + result: args.input.repeat(args.multiplier), + length: args.input.length * args.multiplier, + }; + }, + returns: z.object({ + result: z.string(), + length: z.number(), + }), +}); + +/** + * Test transform in query args and return value + */ +export const transform = zQuery({ + args: { + // Transform number to string in args + count: z.number().transform((n) => n.toString()), + items: z.array(z.string().transform((s) => s.toUpperCase())), + }, + handler: async (_ctx, args) => { + // Type should be the output of the transform + assertType<{ count: string; items: string[] }>(args); + // Verify the transform worked + expect(typeof args.count).toBe("string"); + expect(args.items.every((item) => item === item.toUpperCase())).toBe(true); + + const total = parseInt(args.count, 10) * args.items.length; + return { + total, + // Transform number to string in return value + totalAsString: total.toString(), + items: args.items, + }; + }, + returns: z.object({ + total: z.number(), + totalAsString: z.string().transform((s) => parseInt(s, 10)), + items: z.array(z.string()), + }), +}); + +/** + * Test codec in query args and return value + */ +export const codec = zQuery({ + args: { + // Codec: string input -> number output + encodedNumber: z.codec(z.string(), z.number(), { + decode: (s: string) => parseInt(s, 10), + encode: (n: number) => n.toString(), + }), + // Codec: number input -> string output + encodedString: z.codec(z.number(), z.string(), { + decode: (n: number) => n.toString(), + encode: (s: string) => parseInt(s, 10), + }), + }, + handler: async (_ctx, args) => { + // Type should be the output type of the codec + assertType<{ encodedNumber: number; encodedString: string }>(args); + expect(typeof args.encodedNumber).toBe("number"); + expect(typeof args.encodedString).toBe("string"); + + const sum = args.encodedNumber + parseInt(args.encodedString, 10); + return { + sum, + // Codec in return: handler returns number, client receives string + sumAsString: sum, + }; + }, + returns: z.object({ + sum: z.number(), + // Codec: handler returns number, client receives string + sumAsString: z.codec(z.number(), z.string(), { + decode: (n: number) => n.toString(), + encode: (s: string) => parseInt(s, 10), + }), + }), +}); + +const testApi: ApiFromModules<{ + fns: { + testQuery: typeof testQuery; + testMutation: typeof testMutation; + testAction: typeof testAction; + transform: typeof transform; + codec: typeof codec; + }; +}>["fns"] = anyApi["zod4.functions.test"] as any; + +describe("zCustomQuery, zCustomMutation, zCustomAction", () => { + describe("simple function calls", () => { + test("zCustomQuery", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.testQuery, { + name: "Alice", + age: 30, + }); + expect(response).toMatchObject({ + message: "Hello Alice, you are 30 years old", + doubledAge: 60, + }); + expectTypeOf(testApi.testQuery).toExtend< + FunctionReference< + "query", + "public", + { name: string; age: number }, + { message: string; doubledAge: number } + > + >(); + }); + + test("zCustomMutation", async () => { + const t = convexTest(schema, modules); + const response = await t.mutation(testApi.testMutation, { + userId: "user123", + score: 75, + }); + expect(response).toMatchObject({ + userId: "user123", + score: 75, + passed: true, + }); + expect(response.id).toBeDefined(); + expectTypeOf(testApi.testMutation).toExtend< + FunctionReference< + "mutation", + "public", + { userId: string; score: number }, + { id: string; userId: string; score: number; passed: boolean } + > + >(); + }); + + test("zCustomAction", async () => { + const t = convexTest(schema, modules); + const response = await t.action(testApi.testAction, { + input: "test", + multiplier: 3, + }); + expect(response).toMatchObject({ + result: "testtesttest", + length: 12, + }); + expectTypeOf(testApi.testAction).toExtend< + FunctionReference< + "action", + "public", + { input: string; multiplier: number }, + { result: string; length: number } + > + >(); + }); + }); + + describe("transform", () => { + test("calling a function with transforms in arguments and return values", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.transform, { + count: 5, + items: ["hello", "world"], + }); + + // Verify the transform in args worked + expect(response.total).toBe(10); // 5 * 2 items + expect(response.items).toEqual(["HELLO", "WORLD"]); + + // Verify the transform in return value worked + // The return type says totalAsString is a number (after transform) + expect(response.totalAsString).toBe(10); + + expectTypeOf(testApi.transform).toExtend< + FunctionReference< + "query", + "public", + { count: number; items: string[] }, + { total: number; totalAsString: number; items: string[] } + > + >(); + }); + }); + + describe("codec", () => { + test("calling a function with codecs in arguments and return values", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.codec, { + encodedNumber: "10", // string input, decoded to number + encodedString: 5, // number input, decoded to string + }); + + // Verify the codec in args worked + expect(response.sum).toBe(15); // 10 + 5 + + // Verify the codec in return value worked + // sumAsString is encoded as string (client receives string) + expect(response.sumAsString).toBe("15"); + + expectTypeOf(testApi.codec).toExtend< + FunctionReference< + "query", + "public", + { encodedNumber: string; encodedString: number }, + { sum: number; sumAsString: string } + > + >(); + }); + + test("calling a function with wrong argument types throws ConvexError", async () => { + const t = convexTest(schema, modules); + + // Test with values that pass Convex validation but fail Zod validation + await expect( + t.query(testApi.codec, { + encodedNumber: "not-a-number", // passes Convex (string) but fails Zod decode + encodedString: 5, // passes Convex (number) but will be decoded to string "5" which is fine + }), + ).rejects.toThrowError( + expect.objectContaining({ + data: expect.stringMatching( + /(?=.*"ZodError")(?=.*"encodedNumber")(?=.*"invalid_type")(?=.*"expected")(?=.*"number")/s, + ), + }), + ); + }); + + test("it rejects incorrect argument types at compile and runtime", async () => { + const t = convexTest(schema, modules); + + await expect( + t.query(testApi.codec, { + // @ts-expect-error - encodedNumber expects string but got number + encodedNumber: 10, + encodedString: 5, + }), + ).rejects.toThrowError(); + + await expect( + t.query(testApi.codec, { + encodedNumber: "10", + // @ts-expect-error - encodedString expects number but got string + encodedString: "5", + }), + ).rejects.toThrowError(); + + await expect( + t.query( + testApi.codec, + // @ts-expect-error - missing required argument encodedNumber + { + encodedString: 5, + }, + ), + ).rejects.toThrowError(); + + await expect( + t.query( + testApi.codec, + // @ts-expect-error - missing required argument encodedString + { + encodedNumber: "10", + }, + ), + ).rejects.toThrowError(); + }); + }); +}); + +const zQueryCustom = zCustomQuery(query, { + args: { + requiredArg: v.string(), + optionalArg: v.optional(v.number()), + consumedArg: v.string(), + overridenArg: v.null(), + }, + input: async (ctx, args) => ({ + ctx: { + ...ctx, + storage: undefined, + auth: undefined, + db: "custom db!" as const, + runQuery: undefined, + ...args, + }, + args: { + extraArg: "extraArg" as const, + requiredArg: args.requiredArg, + optionalArg: args.optionalArg, + overridenArg: true, + }, + }), +}); + +export const testQueryCustom = zQueryCustom({ + args: { + specificArg: z.string(), + // Promote optionalArg to required + optionalArg: z.number(), + // Try to demote requiredArg to optional (internally it will stay required) + requiredArg: z.optional(z.string()), + }, + handler: async (ctx, args) => { + assertType<{ + specificArg: string; + optionalArg: number; + requiredArg: string; + extraArg: "extraArg"; + overridenArg: boolean; + }>(args); + assertType<{ + requiredArg: string; + optionalArg?: number; + consumedArg: string; + overridenArg: null; + db: "custom db!"; + }>(ctx); + return { ctx, args }; + }, +}); +const testApiCustom: ApiFromModules<{ + fns: { + testQueryCustom: typeof testQueryCustom; + }; +}>["fns"] = anyApi["zod4.functions.test"] as any; + +describe("zCustomQuery customizations", () => { + test("it can override args and ctx", async () => { + const t = convexTest(schema, modules); + const args = { + overridenArg: null, + requiredArg: "requiredArg", + optionalArg: 1, + consumedArg: "consumedArg", + }; + const response = await t.query(testApiCustom.testQueryCustom, { + ...args, + specificArg: "specificArg", + }); + expect(response).toMatchObject({ + ctx: { + db: "custom db!", + ...args, + }, + args: { + specificArg: "specificArg", + optionalArg: 1, + requiredArg: "requiredArg", + extraArg: "extraArg", + overridenArg: true, + }, + }); + }); +}); diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts new file mode 100644 index 00000000..0bf128a3 --- /dev/null +++ b/packages/convex-helpers/server/zod4.ts @@ -0,0 +1,1982 @@ +import { ConvexError, v } from "convex/values"; +import type { + GenericId, + GenericValidator, + ObjectType, + OptionalProperty, + PropertyValidators, + Validator, + Value, + VAny, + VArray, + VBoolean, + VBytes, + VFloat64, + VId, + VInt64, + VLiteral, + VNull, + VObject, + VOptional, + VRecord, + VString, + VUnion, +} from "convex/values"; +import * as zCore from "zod/v4/core"; +import * as z from "zod/v4"; +import type { + ActionBuilder, + ArgsArrayToObject, + DefaultFunctionArgs, + FunctionVisibility, + GenericActionCtx, + GenericDataModel, + GenericMutationCtx, + GenericQueryCtx, + MutationBuilder, + QueryBuilder, + TableNamesInDataModel, +} from "convex/server"; +import { pick, type Expand } from "../index.js"; +import type { Customization, Registration } from "./customFunctions.js"; +import { NoOp } from "./customFunctions.js"; +import { addFieldsToValidator } from "../validators.js"; + +// #region Convex function definition with Zod + +/** + * zCustomQuery is like customQuery, but allows validation via zod. + * You can define custom behavior on top of `query` or `internalQuery` + * by passing a function that modifies the ctx and args. Or NoOp to do nothing. + * + * Example usage: + * ```ts + * const myQueryBuilder = zCustomQuery(query, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myQueryBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```ts + * const myInternalQuery = zCustomQuery( + * internalQuery, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalQuery({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param query The query to be modified. Usually `query` or `internalQuery` + * from `_generated/server`. + * @param customization The customization to be applied to the query, changing ctx and args. + * @returns A new query builder using zod validation to define queries. + */ +export function zCustomQuery< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + query: QueryBuilder, + customization: Customization< + GenericQueryCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(query, customization) as CustomBuilder< + "query", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericQueryCtx, + Visibility, + ExtraArgs + >; +} + +/** + * zCustomMutation is like customMutation, but allows validation via zod. + * You can define custom behavior on top of `mutation` or `internalMutation` + * by passing a function that modifies the ctx and args. Or NoOp to do nothing. + * + * Example usage: + * ```ts + * const myMutationBuilder = zCustomMutation(mutation, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myMutationBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```ts + * const myInternalMutation = zCustomMutation( + * internalMutation, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalMutation({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param mutation The mutation to be modified. Usually `mutation` or `internalMutation` + * from `_generated/server`. + * @param customization The customization to be applied to the mutation, changing ctx and args. + * @returns A new mutation builder using zod validation to define queries. + */ +export function zCustomMutation< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + mutation: MutationBuilder, + customization: Customization< + GenericMutationCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(mutation, customization) as CustomBuilder< + "mutation", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericMutationCtx, + Visibility, + ExtraArgs + >; +} + +/** + * zCustomAction is like customAction, but allows validation via zod. + * You can define custom behavior on top of `action` or `internalAction` + * by passing a function that modifies the ctx and args. Or NoOp to do nothing. + * + * Example usage: + * ```ts + * const myActionBuilder = zCustomAction(action, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myActionBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```ts + * const myInternalAction = zCustomAction( + * internalAction, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalAction({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param action The action to be modified. Usually `action` or `internalAction` + * from `_generated/server`. + * @param customization The customization to be applied to the action, changing ctx and args. + * @returns A new action builder using zod validation to define queries. + */ +export function zCustomAction< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + action: ActionBuilder, + customization: Customization< + GenericActionCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(action, customization) as CustomBuilder< + "action", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericActionCtx, + Visibility, + ExtraArgs + >; +} + +// #endregion + +// #region Convex IDs + +/** + * Creates a validator for a Convex `Id`. + * + * - When **used within Zod**, it will only check that the ID is a string. + * - When **converted to a Convex validator** (e.g. through {@link zodToConvex}), + * it will check that it's for the right table. + * + * @param tableName - The table that the `Id` references. i.e. `Id` + * @returns A Zod schema representing a Convex `Id` + */ +export const zid = < + DataModel extends GenericDataModel, + TableName extends + TableNamesInDataModel = TableNamesInDataModel, +>( + tableName: TableName, +): Zid => { + const result = z.custom>( + (val) => typeof val === "string", + ); + _zidRegistry.add(result, { tableName }); + return result; +}; + +/** The type of Convex validators in Zod */ +export type Zid = z.ZodCustom> & + zCore.$ZodRecordKey; + +/** + * Useful to get the input context type for a custom function using Zod. + */ +export type ZCustomCtx = + Builder extends CustomBuilder< + any, + any, + infer CustomCtx, + any, + infer InputCtx, + any, + any + > + ? Overwrite + : never; + +// #endregion + +// #region Zod → Convex + +/** + * Turns a Zod or Zod Mini validator into a Convex validator. + * + * The Convex validator will be as close to possible to the Zod validator, + * but might be broader than the Zod validator: + * + * ```ts + * zodToConvex(z.string().email()) // → v.string() + * ``` + * + * This function is useful when running the Zod validator _after_ running the Convex validator + * (i.e. the Convex validator validates the input of the Zod validator). Hence, the Convex types + * will match the _input type_ of Zod transformations: + * ```ts + * zodToConvex(z.object({ + * name: z.string().default("Nicolas"), + * })) // → v.object({ name: v.optional(v.string()) }) + * + * zodToConvex(z.object({ + * name: z.string().transform(s => s.length) + * })) // → v.object({ name: v.string() }) + * ```` + * + * This function is useful for: + * * **Validating function arguments with Zod**: through {@link zCustomQuery}, + * {@link zCustomMutation} and {@link zCustomAction}, you can define the argument validation logic + * using Zod validators instead of Convex validators. `zodToConvex` will generate a Convex validator + * from your Zod validator. This will allow you to: + * - validate at run time that Convex IDs are from the right table (using {@link zid}) + * - allow some features of Convex to understand the expected shape of the arguments + * (e.g. argument validation/prefilling in the function runner on the Convex dashboard) + * - still run the full Zod validation when the function runs + * (which is useful for more advanced Zod validators like `z.string().email()`) + * * **Validating data after reading it from the database**: if you want to write your DB schema + * with Zod, you can run Zod whenever you read from the database to check that the data + * still matches the schema. Note that this approach won’t ensure that the data stored in the DB + * matches the Zod schema; see + * https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too + * for more details. + * + * Note that some values might be valid in Zod but not in Convex, + * in the same way that valid JavaScript values might not be valid + * Convex values for the corresponding Convex type. + * (see the limits of Convex data types on https://docs.convex.dev/database/types). + * + * ``` + * ┌─────────────────────────────────────┬─────────────────────────────────────┐ + * │ **zodToConvex** │ zodOutputToConvex │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ For when the Zod validator runs │ For when the Zod validator runs │ + * │ _after_ the Convex validator │ _before_ the Convex validator │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ Convex types use the _input types_ │ Convex types use the _return types_ │ + * │ of Zod transformations │ of Zod transformations │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ The Convex validator can be less │ The Convex validator can be less │ + * │ strict (i.e. some inputs might be │ strict (i.e. the type in Convex can │ + * │ accepted by Convex then rejected │ be less precise than the type in │ + * │ by Zod) │ the Zod output) │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When using Zod schemas │ When using Zod schemas │ + * │ for function definitions: │ for function definitions: │ + * │ used for _arguments_ │ used for _return values_ │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When validating contents of the │ When validating contents of the │ + * │ database with a Zod schema: │ database with a Zod schema: │ + * │ used to validate data │ used to validate data │ + * │ _after reading_ │ _before writing_ │ + * └─────────────────────────────────────┴─────────────────────────────────────┘ + * ``` + * + * @param zod Zod validator can be a Zod object, or a Zod type like `z.string()` + * @returns Convex Validator (e.g. `v.string()` from "convex/values") + * @throws If there is no equivalent Convex validator for the value (e.g. `z.date()`) + */ +export function zodToConvex( + validator: Z, +): ConvexValidatorFromZod { + const visited = new WeakSet(); + + function zodToConvexInner(validator: zCore.$ZodType): GenericValidator { + // Circular validator definitions are not supported by Convex validators, + // so we use v.any() when there is a cycle. + if (visited.has(validator)) { + return v.any(); + } + visited.add(validator); + + if (validator instanceof zCore.$ZodDefault) { + return v.optional(zodToConvexInner(validator._zod.def.innerType)); + } + + if (validator instanceof zCore.$ZodPipe) { + return zodToConvexInner(validator._zod.def.in); + } + + return zodToConvexCommon(validator, zodToConvexInner); + } + + // `as any` because ConvexValidatorFromZod is defined from the behavior of zodToConvex. + // We assume the type is correct to simplify the life of the compiler. + return zodToConvexInner(validator) as any; +} + +/** + * Converts a Zod or Zod Mini validator to a Convex validator that checks the value _after_ + * it has been validated (and possibly transformed) by the Zod validator. + * + * This is similar to {@link zodToConvex}, but is meant for cases where the Convex + * validator runs _after_ the Zod validator. Thus, the Convex type refers to the + * _output_ type of the Zod transformations: + * ```ts + * zodOutputToConvex(z.object({ + * name: z.string().default("Nicolas"), + * })) // → v.object({ name: v.string() }) + * + * zodOutputToConvex(z.object({ + * name: z.string().transform(s => s.length) + * })) // → v.object({ name: v.number() }) + * ```` + * + * This function can be useful for: + * - **Validating function return values with Zod**: through {@link zCustomQuery}, + * {@link zCustomMutation} and {@link zCustomAction}, you can define the `returns` property + * of a function using Zod validators instead of Convex validators. + * - **Validating data after reading it from the database**: if you want to write your DB schema + * Zod validators, you can run Zod whenever you write to the database to ensure your data matches + * the expected format. Note that this approach won’t ensure that the data stored in the DB + * isn’t modified manually in a way that doesn’t match your Zod schema; see + * https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too + * for more details. + * + * ``` + * ┌─────────────────────────────────────┬─────────────────────────────────────┐ + * │ zodToConvex │ **zodOutputToConvex** │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ For when the Zod validator runs │ For when the Zod validator runs │ + * │ _after_ the Convex validator │ _before_ the Convex validator │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ Convex types use the _input types_ │ Convex types use the _return types_ │ + * │ of Zod transformations │ of Zod transformations │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ The Convex validator can be less │ The Convex validator can be less │ + * │ strict (i.e. some inputs might be │ strict (i.e. the type in Convex can │ + * │ accepted by Convex then rejected │ be less precise than the type in │ + * │ by Zod) │ the Zod output) │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When using Zod schemas │ When using Zod schemas │ + * │ for function definitions: │ for function definitions: │ + * │ used for _arguments_ │ used for _return values_ │ + * ├─────────────────────────────────────┼─────────────────────────────────────┤ + * │ When validating contents of the │ When validating contents of the │ + * │ database with a Zod schema: │ database with a Zod schema: │ + * │ used to validate data │ used to validate data │ + * │ _after reading_ │ _before writing_ │ + * └─────────────────────────────────────┴─────────────────────────────────────┘ + * ``` + * + * @param z The zod validator + * @returns Convex Validator (e.g. `v.string()` from "convex/values") + * @throws If there is no equivalent Convex validator for the value (e.g. `z.date()`) + */ +export function zodOutputToConvex( + validator: Z, +): ConvexValidatorFromZodOutput { + const visited = new WeakSet(); + + function zodOutputToConvexInner(validator: zCore.$ZodType): GenericValidator { + // Circular validator definitions are not supported by Convex validators, + // so we use v.any() when there is a cycle. + if (visited.has(validator)) { + return v.any(); + } + visited.add(validator); + + if (validator instanceof zCore.$ZodDefault) { + // Output: always there + return zodOutputToConvexInner(validator._zod.def.innerType); + } + + if (validator instanceof zCore.$ZodPipe) { + return zodOutputToConvexInner(validator._zod.def.out); + } + + if (validator instanceof zCore.$ZodTransform) { + return v.any(); + } + + return zodToConvexCommon(validator, zodOutputToConvexInner); + } + + // `as any` because ConvexValidatorFromZodOutput is defined from the behavior of zodOutputToConvex. + // We assume the type is correct to simplify the life of the compiler. + return zodOutputToConvexInner(validator) as any; +} + +type ZodFields = Record; + +/** + * Like {@link zodToConvex}, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to {@link defineTable}. + * + * ```ts + * zodToConvexFields({ + * name: z.string().default("Nicolas"), + * }) // → { name: v.optional(v.string()) } + * ``` + * + * @param fields Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodToConvexFields(fields: Fields) { + return Object.fromEntries( + Object.entries(fields).map(([k, v]) => [k, zodToConvex(v)]), + ) as { + [k in keyof Fields]: Fields[k] extends zCore.$ZodType + ? ConvexValidatorFromZod + : never; + }; +} + +/** + * Like {@link zodOutputToConvex}, but it takes in a bare object, as expected by + * Convex function arguments, or the argument to {@link defineTable}. + * + * ```ts + * zodOutputToConvexFields({ + * name: z.string().default("Nicolas"), + * }) // → { name: v.string() } + * ``` + * + * This is different from {@link zodToConvexFields} because it generates the + * Convex validator for the output of the Zod validator, not the input; + * see the documentation of {@link zodToConvex} and {@link zodOutputToConvex} + * for more details. + * + * @param zod Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodOutputToConvexFields( + fields: Fields, +) { + return Object.fromEntries( + Object.entries(fields).map(([k, v]) => [k, zodOutputToConvex(v)]), + ) as { + [k in keyof Fields]: ConvexValidatorFromZodOutput; + }; +} + +// #endregion + +// #region Convex → Zod + +/** + * Turns a Convex validator into a Zod validator. + * + * This is useful when you want to use types you defined using Convex validators + * with external libraries that expect to receive a Zod validator. + * + * ```ts + * convexToZod(v.string()) // → z.string() + * ``` + * + * This function returns Zod validators, not Zod Mini validators. + * + * @param convexValidator Convex validator can be any validator from "convex/values" e.g. `v.string()` + * @returns Zod validator (e.g. `z.string()`) with inferred type matching the Convex validator + */ +export function convexToZod( + convexValidator: V, +): ZodValidatorFromConvex { + const isOptional = (convexValidator as any).isOptional === "optional"; + + let zodValidator: zCore.$ZodType; + + const { kind } = convexValidator; + switch (kind) { + case "id": + convexValidator satisfies VId; + zodValidator = zid(convexValidator.tableName); + break; + case "string": + zodValidator = z.string(); + break; + case "float64": + zodValidator = z.number(); + break; + case "int64": + zodValidator = z.bigint(); + break; + case "boolean": + zodValidator = z.boolean(); + break; + case "null": + zodValidator = z.null(); + break; + case "any": + zodValidator = z.any(); + break; + case "array": { + convexValidator satisfies VArray; + zodValidator = z.array(convexToZod(convexValidator.element)); + break; + } + case "object": { + convexValidator satisfies VObject; + zodValidator = z.object(convexToZodFields(convexValidator.fields)); + break; + } + case "union": { + convexValidator satisfies VUnion; + + if (convexValidator.members.length === 0) { + zodValidator = z.never(); + break; + } + + if (convexValidator.members.length === 1) { + zodValidator = convexToZod(convexValidator.members[0]!); + break; + } + + const memberValidators = convexValidator.members.map( + (member: GenericValidator) => convexToZod(member), + ); + zodValidator = z.union([...memberValidators]); + break; + } + case "literal": { + const literalValidator = convexValidator as VLiteral; + zodValidator = z.literal(literalValidator.value); + break; + } + case "record": { + convexValidator satisfies VRecord; + zodValidator = z.record( + convexToZod(convexValidator.key) as zCore.$ZodRecordKey, + convexToZod(convexValidator.value), + ); + break; + } + case "bytes": + throw new Error("v.bytes() is not supported"); + default: + kind satisfies never; + throw new Error(`Unknown convex validator type: ${kind}`); + } + + return isOptional + ? (z.optional(zodValidator) as ZodValidatorFromConvex) + : (zodValidator as ZodValidatorFromConvex); +} + +/** + * Like {@link convexToZod}, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to {@link defineTable}. + * + * ```ts + * convexToZodFields({ + * name: v.string(), + * }) // → { name: z.string() } + * ``` + * + * @param convexValidators Object with string keys and Convex validators as values + * @returns Object with the same keys, but with Zod validators as values + */ +export function convexToZodFields( + convexValidators: C, +) { + return Object.fromEntries( + Object.entries(convexValidators).map(([k, v]) => [k, convexToZod(v)]), + ) as { [k in keyof C]: ZodValidatorFromConvex }; +} + +// #endregion + +// #region Utils + +/** + * Zod helper for adding Convex system fields to a record to return. + * + * ```ts + * withSystemFields("users", { + * name: z.string(), + * }) + * // → { + * // name: z.string(), + * // _id: zid("users"), + * // _creationTime: z.number(), + * // } + * ``` + * + * @param tableName - The table where records are from, i.e. Doc + * @param zObject - Validators for the user-defined fields on the document. + * @returns Zod shape for use with `z.object(shape)` that includes system fields. + */ +export function withSystemFields< + Table extends string, + T extends { [key: string]: zCore.$ZodType }, +>(tableName: Table, zObject: T) { + return { ...zObject, _id: zid(tableName), _creationTime: z.number() }; +} + +// #endregion + +// #region Implementation: Convex function definition with Zod + +/** + * A builder that customizes a Convex function, whether or not it validates + * arguments. If the customization requires arguments, however, the resulting + * builder will require argument validation too. + */ +export type CustomBuilder< + FuncType extends "query" | "mutation" | "action", + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + InputCtx, + Visibility extends FunctionVisibility, + ExtraArgs extends Record, +> = { + < + ArgsValidator extends ZodFields | zCore.$ZodObject | void, + ReturnsZodValidator extends zCore.$ZodType | ZodFields | void = void, + ReturnValue extends ReturnValueInput = any, + // Note: this differs from customFunctions.ts b/c we don't need to track + // the exact args to match the standard builder types. For Zod we don't + // try to ever pass a custom function as a builder to another custom + // function, so we can be looser here. + >( + func: + | ({ + /** + * Specify the arguments to the function as a Zod validator. + */ + args?: ArgsValidator; + handler: ( + ctx: Overwrite, + ...args: ArgsForHandlerType< + ArgsOutput, + CustomMadeArgs + > + ) => ReturnValue; + /** + * Validates the value returned by the function. + * Note: you can't pass an object directly without wrapping it + * in `z.object()`. + */ + returns?: ReturnsZodValidator; + /** + * If true, the function will not be validated by Convex, + * in case you're seeing performance issues with validating twice. + */ + skipConvexValidation?: boolean; + } & { + [key in keyof ExtraArgs as key extends + | "args" + | "handler" + | "skipConvexValidation" + | "returns" + ? never + : key]: ExtraArgs[key]; + }) + | { + ( + ctx: Overwrite, + ...args: ArgsForHandlerType< + ArgsOutput, + CustomMadeArgs + > + ): ReturnValue; + }, + ): Registration< + FuncType, + Visibility, + ArgsArrayToObject< + CustomArgsValidator extends Record + ? ArgsInput + : ArgsInput extends [infer A] + ? [Expand>] + : [ObjectType] + >, + ReturnsZodValidator extends void + ? ReturnValue + : ReturnValueOutput + >; +}; + +function customFnBuilder( + builder: (args: any) => any, + customization: Customization, +) { + // Most of the code in here is identical to customFnBuilder in zod3.ts. + // If making changes, please keep zod3.ts in sync. + + // Looking forward to when input / args / ... are optional + const customInput: Customization["input"] = + customization.input ?? NoOp.input; + const inputArgs = customization.args ?? NoOp.args; + return function customBuilder(fn: any): any { + const { args, handler = fn, returns: maybeObject, ...extra } = fn; + + const returns = + maybeObject && !(maybeObject instanceof zCore.$ZodType) + ? z.object(maybeObject) + : maybeObject; + + const returnValidator = + returns && !fn.skipConvexValidation + ? { returns: zodOutputToConvex(returns) } + : null; + + if (args && !fn.skipConvexValidation) { + let argsValidator = args; + if (argsValidator instanceof zCore.$ZodType) { + if (argsValidator instanceof zCore.$ZodObject) { + argsValidator = argsValidator._zod.def.shape; + } else { + throw new Error( + "Unsupported zod type as args validator: " + + argsValidator.constructor.name, + ); + } + } + const convexValidator = zodToConvexFields(argsValidator); + return builder({ + args: addFieldsToValidator(convexValidator, inputArgs), + ...returnValidator, + handler: async (ctx: any, allArgs: any) => { + const added = await customInput( + ctx, + pick(allArgs, Object.keys(inputArgs)) as any, + extra, + ); + const rawArgs = pick(allArgs, Object.keys(argsValidator)); + const parsed = z.object(argsValidator).safeParse(rawArgs); + if (!parsed.success) { + throw new ConvexError({ + ZodError: JSON.parse( + JSON.stringify(parsed.error.issues, null, 2), + ) as Value[], + }); + } + const args = parsed.data; + const finalCtx = { ...ctx, ...added.ctx }; + const finalArgs = { ...args, ...added.args }; + const ret = await handler(finalCtx, finalArgs); + // We don't catch the error here. It's a developer error and we + // don't want to risk exposing the unexpected value to the client. + const result = returns ? returns.parse(ret) : ret; + if (added.onSuccess) { + await added.onSuccess({ ctx, args, result }); + } + return result; + }, + }); + } + if (Object.keys(inputArgs).length > 0 && !fn.skipConvexValidation) { + throw new Error( + "If you're using a custom function with arguments for the input " + + "customization, you must declare the arguments for the function too.", + ); + } + return builder({ + ...returnValidator, + handler: async (ctx: any, args: any) => { + const added = await customInput(ctx, args, extra); + const finalCtx = { ...ctx, ...added.ctx }; + const finalArgs = { ...args, ...added.args }; + const ret = await handler(finalCtx, finalArgs); + // We don't catch the error here. It's a developer error and we + // don't want to risk exposing the unexpected value to the client. + const result = returns ? returns.parse(ret) : ret; + if (added.onSuccess) { + await added.onSuccess({ ctx, args, result }); + } + return result; + }, + }); + }; +} + +type ArgsForHandlerType< + OneOrZeroArgs extends [] | [Record], + CustomMadeArgs extends Record, +> = + CustomMadeArgs extends Record + ? OneOrZeroArgs + : OneOrZeroArgs extends [infer A] + ? [Expand] + : [CustomMadeArgs]; + +// Copied from convex/src/server/api.ts since they aren't exported +type NullToUndefinedOrNull = T extends null ? T | undefined | void : T; +type Returns = Promise> | NullToUndefinedOrNull; + +// The return value before it's been validated: returned by the handler +type ReturnValueInput< + ReturnsValidator extends zCore.$ZodType | ZodFields | void, +> = [ReturnsValidator] extends [zCore.$ZodType] + ? Returns> + : [ReturnsValidator] extends [ZodFields] + ? Returns>> + : any; + +// The return value after it's been validated: returned to the client +type ReturnValueOutput< + ReturnsValidator extends zCore.$ZodType | ZodFields | void, +> = [ReturnsValidator] extends [zCore.$ZodType] + ? Returns> + : [ReturnsValidator] extends [ZodFields] + ? Returns>> + : any; + +// The args before they've been validated: passed from the client +type ArgsInput | void> = + [ArgsValidator] extends [zCore.$ZodObject] + ? [zCore.input] + : [ArgsValidator] extends [ZodFields] + ? [zCore.input>] + : OneArgArray; + +// The args after they've been validated: passed to the handler +type ArgsOutput< + ArgsValidator extends ZodFields | zCore.$ZodObject | void, +> = [ArgsValidator] extends [zCore.$ZodObject] + ? [zCore.output] + : [ArgsValidator] extends [ZodFields] + ? [zCore.output>] + : OneArgArray; + +type Overwrite = Omit & U; +type OneArgArray = + [ArgsObject]; + +// #endregion + +// #region Implementation: Zod → Convex + +/** + * Return type of {@link zodToConvex}. + */ +export type ConvexValidatorFromZod< + Z extends zCore.$ZodType, + IsOptional extends "required" | "optional", +> = + // `unknown` / `any`: we can’t infer a precise return type at compile time + IsUnknownOrAny extends true + ? GenericValidator + : // z.default() + Z extends zCore.$ZodDefault // input: Treat like optional + ? VOptional> + : // z.pipe() + Z extends zCore.$ZodPipe< + infer Input extends zCore.$ZodType, + infer _Output extends zCore.$ZodType + > + ? ConvexValidatorFromZod + : // All other schemas have the same input/output types + ConvexValidatorFromZodCommon; + +/** + * Return type of {@link zodOutputToConvex}. + */ +export type ConvexValidatorFromZodOutput< + Z extends zCore.$ZodType, + IsOptional extends "required" | "optional", +> = + // `unknown` / `any`: we can’t infer a precise return type at compile time + IsUnknownOrAny extends true + ? GenericValidator + : // z.default() + Z extends zCore.$ZodDefault // output: always there + ? VRequired> + : // z.pipe() + Z extends zCore.$ZodPipe< + infer _Input extends zCore.$ZodType, + infer Output extends zCore.$ZodType + > + ? ConvexValidatorFromZod + : // All other schemas have the same input/output types + ConvexValidatorFromZodCommon; + +// Conversions used for both zodToConvex and zodOutputToConvex +type ConvexValidatorFromZodCommon< + Z extends zCore.$ZodType, + IsOptional extends "required" | "optional", +> = // Basic types + Z extends Zid + ? VId> + : Z extends zCore.$ZodString + ? VString, IsOptional> + : Z extends zCore.$ZodNumber + ? VFloat64, IsOptional> + : Z extends zCore.$ZodNaN + ? VFloat64, IsOptional> + : Z extends zCore.$ZodBigInt + ? VInt64, IsOptional> + : Z extends zCore.$ZodBoolean + ? VBoolean, IsOptional> + : Z extends zCore.$ZodNull + ? VNull, IsOptional> + : Z extends zCore.$ZodUnknown + ? VAny + : Z extends zCore.$ZodAny + ? VAny, "required"> + : // z.array() + Z extends zCore.$ZodArray< + infer Inner extends zCore.$ZodType + > + ? ConvexValidatorFromZod< + Inner, + "required" + > extends GenericValidator + ? VArray< + ConvexValidatorFromZod["type"][], + ConvexValidatorFromZod, + IsOptional + > + : never + : // z.object() + Z extends zCore.$ZodObject< + infer Fields extends Readonly + > + ? VObject< + zCore.infer, + ConvexObjectFromZodShape, + IsOptional + > + : // z.never() (→ z.union() with no elements) + Z extends zCore.$ZodNever + ? VUnion + : // z.union() + Z extends zCore.$ZodUnion< + infer T extends readonly zCore.$ZodType[] + > + ? ConvexUnionValidatorFromZod + : // z.tuple() + Z extends zCore.$ZodTuple< + infer Inner extends readonly zCore.$ZodType[], + infer Rest extends null | zCore.$ZodType + > + ? VArray< + null extends Rest + ? Array< + ConvexValidatorFromZod< + Inner[number], + "required" + >["type"] + > + : Array< + | ConvexValidatorFromZod< + Inner[number], + "required" + >["type"] + | zCore.infer + >, + null extends Rest + ? ConvexUnionValidatorFromZod + : ConvexUnionValidatorFromZod< + [ + ...Inner, + Rest extends zCore.$ZodType // won’t be null here + ? Rest + : never, + ] + >, + IsOptional + > + : // z.literal() + Z extends zCore.$ZodLiteral< + infer Literal extends zCore.util.Literal + > + ? ConvexLiteralFromZod + : // z.enum() + Z extends zCore.$ZodEnum< + infer EnumContents extends + zCore.util.EnumLike + > + ? VUnion< + zCore.infer, + keyof EnumContents extends string + ? { + [K in keyof EnumContents]: VLiteral< + EnumContents[K], + "required" + >; + }[keyof EnumContents][] + : never, + IsOptional + > + : // z.optional() + Z extends zCore.$ZodOptional< + infer Inner extends zCore.$ZodType + > + ? VOptional< + ConvexValidatorFromZod< + Inner, + "optional" + > + > + : // z.nonoptional() + Z extends zCore.$ZodNonOptional< + infer Inner extends zCore.$ZodType + > + ? VRequired< + ConvexValidatorFromZod< + Inner, + "required" + > + > + : // z.nullable() + Z extends zCore.$ZodNullable< + infer Inner extends zCore.$ZodType + > + ? ConvexValidatorFromZod< + Inner, + IsOptional + > extends Validator< + any, + "optional", + any + > + ? VUnion< + | ConvexValidatorFromZod< + Inner, + IsOptional + >["type"] + | null + | undefined, + [ + VRequired< + ConvexValidatorFromZod< + Inner, + IsOptional + > + >, + VNull, + ], + "optional", + ConvexValidatorFromZod< + Inner, + IsOptional + >["fieldPaths"] + > + : VUnion< + | ConvexValidatorFromZod< + Inner, + IsOptional + >["type"] + | null, + [ + VRequired< + ConvexValidatorFromZod< + Inner, + IsOptional + > + >, + VNull, + ], + IsOptional, + ConvexValidatorFromZod< + Inner, + IsOptional + >["fieldPaths"] + > + : // z.brand() + Z extends zCore.$ZodBranded< + infer Inner extends + zCore.$ZodType, + infer Brand + > + ? Inner extends zCore.$ZodString + ? VString< + string & zCore.$brand, + IsOptional + > + : Inner extends zCore.$ZodNumber + ? VFloat64< + number & zCore.$brand, + IsOptional + > + : Inner extends zCore.$ZodBigInt + ? VInt64< + bigint & + zCore.$brand, + IsOptional + > + : ConvexValidatorFromZod< + Inner, + IsOptional + > + : // z.record() + Z extends zCore.$ZodRecord< + infer Key extends + zCore.$ZodRecordKey, + infer Value extends + zCore.$ZodType + > + ? ConvexValidatorFromZodRecord< + Key, + Value, + IsOptional + > + : // z.readonly() + Z extends zCore.$ZodReadonly< + infer Inner extends + zCore.$ZodType + > + ? ConvexValidatorFromZod< + Inner, + IsOptional + > + : // z.lazy() + Z extends zCore.$ZodLazy< + infer Inner extends + zCore.$ZodType + > + ? ConvexValidatorFromZod< + Inner, + IsOptional + > + : // z.templateLiteral() + Z extends zCore.$ZodTemplateLiteral< + infer Template extends + string + > + ? VString< + Template, + IsOptional + > + : // z.catch() + Z extends zCore.$ZodCatch< + infer T extends + zCore.$ZodType + > + ? ConvexValidatorFromZod< + T, + IsOptional + > + : // z.transform() + Z extends zCore.$ZodTransform< + any, + any + > + ? VAny // No runtime info about types so we use v.any() + : // z.custom() + Z extends zCore.$ZodCustom + ? VAny + : // z.intersection() + // We could do some more advanced logic here where we compute + // the Convex validator that results from the intersection. + // For now, we simply use v.any() + Z extends zCore.$ZodIntersection + ? VAny< + any, + "required" + > + : // unencodable types + IsConvexUnencodableType extends true + ? never + : // Other validators: we don’t return VAny + // because it might be a type that is + // recognized at runtime but is not + // recognized at typecheck time + // (e.g. zCore.$ZodType) + GenericValidator; + +type ConvexUnionValidatorFromZod = VUnion< + ConvexValidatorFromZod["type"], + T extends readonly [ + infer Head extends zCore.$ZodType, + ...infer Tail extends zCore.$ZodType[], + ] + ? [ + VRequired>, + ...ConvexUnionValidatorFromZodMembers, + ] + : T extends readonly [] + ? [] + : Validator[], + "required", + ConvexValidatorFromZod["fieldPaths"] +>; + +type ConvexUnionValidatorFromZodMembers = + T extends readonly [ + infer Head extends zCore.$ZodType, + ...infer Tail extends zCore.$ZodType[], + ] + ? [ + VRequired>, + ...ConvexUnionValidatorFromZodMembers, + ] + : T extends readonly [] + ? [] + : Validator[]; + +type ConvexObjectFromZodShape> = + Fields extends infer F // dark magic to get the TypeScript compiler happy about circular types + ? { + [K in keyof F]: F[K] extends zCore.$ZodType + ? ConvexValidatorFromZod + : Validator; + } + : never; + +type ConvexObjectValidatorFromRecord< + Key extends string, + Value extends zCore.$ZodType, + IsOptional extends "required" | "optional", + IsPartial extends "partial" | "full", +> = VObject< + IsPartial extends "partial" + ? { + [K in Key]?: zCore.infer; + } + : MakeUndefinedPropertiesOptional<{ + [K in Key]: zCore.infer; + }>, + IsPartial extends "partial" + ? { + [K in Key]: VOptional>; + } + : { + [K in Key]: ConvexValidatorFromZod; + }, + IsOptional +>; + +// MakeUndefinedPropertiesOptional<{ a: string | undefined; b: string }> = { a?: string | undefined; b: string } +// ^ +type MakeUndefinedPropertiesOptional = Expand< + { + [K in keyof Obj as undefined extends Obj[K] ? never : K]: Obj[K]; + } & { + [K in keyof Obj as undefined extends Obj[K] ? K : never]?: Obj[K]; + } +>; + +type ConvexValidatorFromZodRecord< + Key extends zCore.$ZodRecordKey, + Value extends zCore.$ZodType, + IsOptional extends "required" | "optional", +> = + // key = v.string() / v.id() / v.union(v.id()) + Key extends + | zCore.$ZodString + | Zid + | zCore.$ZodUnion[]> + ? VRecord< + Record, NotUndefined>>, + VRequired>, + VRequired>, + IsOptional + > + : // key = v.literal() + Key extends zCore.$ZodLiteral + ? ConvexObjectValidatorFromRecord< + Literal, + Value, + IsOptional, + Key extends zCore.$partial ? "partial" : "full" + > + : // key = v.union(v.literal()) + Key extends zCore.$ZodUnion< + infer Literals extends readonly zCore.$ZodLiteral[] + > + ? ConvexObjectValidatorFromRecord< + zCore.infer extends string + ? zCore.infer + : never, + Value, + IsOptional, + Key extends zCore.$partial ? "partial" : "full" + > + : // key = v.any() / otehr + VRecord< + Record>>, + VString, + VRequired>, + IsOptional + >; + +type IsConvexUnencodableType = Z extends + | zCore.$ZodDate + | zCore.$ZodSymbol + | zCore.$ZodMap + | zCore.$ZodSet + | zCore.$ZodPromise + | zCore.$ZodFile + | zCore.$ZodFunction + // undefined is not a valid Convex value. Consider using v.optional() or v.null() instead + | zCore.$ZodUndefined + | zCore.$ZodVoid + ? true + : false; + +type IsUnion = T extends unknown + ? [U] extends [T] + ? false + : true + : false; +type ConvexLiteralFromZod< + Literal extends zCore.util.Literal, + IsOptional extends "required" | "optional", +> = undefined extends Literal // undefined is not a valid Convex valvue + ? never + : // z.literal(null) → v.null() + [Literal] extends [null] + ? VNull + : // z.literal([…]) (multiple values) + IsUnion extends true + ? VUnion< + Literal, + Array< + // `extends unknown` forces TypeScript to map over each member of the union + Literal extends unknown + ? ConvexLiteralFromZod + : never + >, + IsOptional, + never + > + : VLiteral; + +type IsUnknownOrAny = + // any? + 0 extends 1 & T + ? true + : // unknown? + unknown extends T + ? true + : false; + +function zodToConvexCommon( + validator: Z, + toConvex: (x: zCore.$ZodType) => GenericValidator, +): GenericValidator { + // Check for zid (Convex ID) validators + const idTableName = _zidRegistry.get(validator); + if (idTableName !== undefined) { + return v.id(idTableName.tableName); + } + + if (validator instanceof zCore.$ZodString) { + return v.string(); + } + + if ( + validator instanceof zCore.$ZodNumber || + validator instanceof zCore.$ZodNaN + ) { + return v.number(); + } + + if (validator instanceof zCore.$ZodBigInt) { + return v.int64(); + } + + if (validator instanceof zCore.$ZodBoolean) { + return v.boolean(); + } + + if (validator instanceof zCore.$ZodNull) { + return v.null(); + } + + if ( + validator instanceof zCore.$ZodAny || + validator instanceof zCore.$ZodUnknown + ) { + return v.any(); + } + + if (validator instanceof zCore.$ZodArray) { + const inner = toConvex(validator._zod.def.element); + if (inner.isOptional === "optional") { + throw new Error("Arrays of optional values are not supported"); + } + return v.array(inner); + } + + if (validator instanceof zCore.$ZodObject) { + return v.object( + Object.fromEntries( + Object.entries(validator._zod.def.shape).map(([k, v]) => [ + k, + toConvex(v), + ]), + ), + ); + } + + if (validator instanceof zCore.$ZodUnion) { + return v.union(...validator._zod.def.options.map(toConvex)); + } + + if (validator instanceof zCore.$ZodNever) { + return v.union(); + } + + if (validator instanceof zCore.$ZodTuple) { + const { items, rest } = validator._zod.def; + return v.array( + v.union( + ...[ + ...items, + // + rest if set + ...(rest !== null ? [rest] : []), + ].map(toConvex), + ), + ); + } + + if (validator instanceof zCore.$ZodLiteral) { + const { values } = validator._zod.def; + if (values.length === 1) { + return convexToZodLiteral(values[0]); + } + + return v.union(...values.map(convexToZodLiteral)); + } + + if (validator instanceof zCore.$ZodEnum) { + return v.union( + ...Object.entries(validator._zod.def.entries) + .filter(([key, value]) => key === value || isNaN(Number(key))) + .map(([_key, value]) => v.literal(value)), + ); + } + + if (validator instanceof zCore.$ZodOptional) { + return v.optional(toConvex(validator._zod.def.innerType)); + } + + if (validator instanceof zCore.$ZodNonOptional) { + return vRequired(toConvex(validator._zod.def.innerType)); + } + + if (validator instanceof zCore.$ZodNullable) { + const inner = toConvex(validator._zod.def.innerType); + + // Invert z.optional().nullable() → v.optional(v.nullable()) + if (inner.isOptional === "optional") { + return v.optional(v.union(vRequired(inner), v.null())); + } + + return v.union(inner, v.null()); + } + + if (validator instanceof zCore.$ZodRecord) { + const { keyType, valueType } = validator._zod.def; + + const isPartial = keyType._zod.values === undefined; + + // Convert value type, stripping optional + const valueValidator = toConvex(valueType); + + // Convert key type + const keyValidator = toConvex(keyType); + + // key = string literals? + // If so, not supported by v.record() → use v.object() instead + const stringLiterals = extractStringLiterals(keyValidator); + if (stringLiterals !== null) { + const fieldValue = + isPartial || valueValidator.isOptional === "optional" + ? v.optional(valueValidator) + : vRequired(valueValidator); + const fields: Record = {}; + for (const literal of stringLiterals) { + fields[literal] = fieldValue; + } + return v.object(fields); + } + + return v.record( + isValidRecordKey(keyValidator) ? keyValidator : v.string(), + vRequired(valueValidator), + ); + } + + if (validator instanceof zCore.$ZodReadonly) { + return toConvex(validator._zod.def.innerType); + } + + if (validator instanceof zCore.$ZodLazy) { + return toConvex(validator._zod.def.getter()); + } + + if (validator instanceof zCore.$ZodTemplateLiteral) { + return v.string(); + } + + if ( + validator instanceof zCore.$ZodCustom || + validator instanceof zCore.$ZodIntersection + ) { + return v.any(); + } + + if (validator instanceof zCore.$ZodCatch) { + return toConvex(validator._zod.def.innerType); + } + + if ( + validator instanceof zCore.$ZodDate || + validator instanceof zCore.$ZodSymbol || + validator instanceof zCore.$ZodMap || + validator instanceof zCore.$ZodSet || + validator instanceof zCore.$ZodPromise || + validator instanceof zCore.$ZodFile || + validator instanceof zCore.$ZodFunction || + validator instanceof zCore.$ZodVoid || + validator instanceof zCore.$ZodUndefined + ) { + throw new Error( + `Validator ${validator.constructor.name} is not supported in Convex`, + ); + } + + // Unsupported type + return v.any(); +} + +function convexToZodLiteral(literal: zCore.util.Literal): GenericValidator { + if (literal === undefined) { + throw new Error("undefined is not a valid Convex value"); + } + + if (literal === null) { + return v.null(); + } + + return v.literal(literal); +} + +function extractStringLiterals(validator: GenericValidator): string[] | null { + if (validator.kind === "literal") { + const literalValidator = validator as VLiteral; + if (typeof literalValidator.value === "string") { + return [literalValidator.value]; + } + return null; + } + if (validator.kind === "union") { + const unionValidator = validator as VUnion; + const literals: string[] = []; + for (const member of unionValidator.members) { + const memberLiterals = extractStringLiterals(member); + if (memberLiterals === null) { + return null; // Not all members are string literals + } + literals.push(...memberLiterals); + } + return literals; + } + return null; // Not a literal or union of literals +} + +function isValidRecordKey(validator: GenericValidator): boolean { + if (validator.kind === "string" || validator.kind === "id") { + return true; + } + if (validator.kind === "union") { + const unionValidator = validator as VUnion; + return unionValidator.members.every(isValidRecordKey); + } + return false; +} + +// #endregion + +// #region Implementation: Convex → Zod + +/** + * Better type conversion from a Convex validator to a Zod validator + * where the output is not a generic ZodType but it's more specific. + * + * This allows you to use methods specific to the Zod type (e.g. `.email()` for `z.ZodString`). + * + * ```ts + * ZodValidatorFromConvex // → z.ZodString + * ``` + */ +export type ZodValidatorFromConvex = + V extends Validator + ? z.ZodOptional>> + : ZodFromValidatorBase; + +export type ZodFromValidatorBase = + V extends VId + ? Zid>> + : V extends VString + ? BrandIfBranded + : V extends VFloat64 + ? BrandIfBranded + : V extends VInt64 + ? z.ZodBigInt + : V extends VBoolean + ? z.ZodBoolean + : V extends VNull + ? z.ZodNull + : V extends VArray + ? Element extends VArray // This check is used to avoid TypeScript complaining about infinite type instantiation + ? z.ZodArray + : z.ZodArray> + : V extends VObject< + any, + infer Fields extends Record + > + ? z.ZodObject, zCore.$strict> + : V extends VBytes + ? never + : V extends VLiteral< + infer T extends zCore.util.Literal, + OptionalProperty + > + ? z.ZodLiteral> + : V extends VRecord< + any, + infer Key, + infer Value, + OptionalProperty, + any + > + ? z.ZodRecord< + ZodFromStringValidator, + ZodFromValidatorBase + > + : // Union: must handle separately cases for 0/1/2+ elements + // instead of simply writing it as + // V extends VUnion + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // ? z.ZodUnion<{ [k in keyof Elements]: ZodValidatorFromConvex }> + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // because the TypeScript compiler would complain about infinite type instantiation otherwise :( + V extends VUnion + ? z.ZodNever + : V extends VUnion< + any, + [infer I extends GenericValidator], + OptionalProperty, + any + > + ? ZodValidatorFromConvex + : V extends VUnion< + any, + [ + infer A extends GenericValidator, + ...infer Rest extends GenericValidator[], + ], + OptionalProperty, + any + > + ? z.ZodUnion< + readonly [ + ZodValidatorFromConvex, + ...{ + [K in keyof Rest]: ZodValidatorFromConvex< + Rest[K] + >; + }, + ] + > + : V extends VAny + ? z.ZodAny + : never; + +type BrandIfBranded = + InnerType extends zCore.$brand + ? zCore.$ZodBranded + : Validator; + +type StringValidator = Validator; +type ZodFromStringValidator = + V extends VId> + ? Zid + : V extends VString + ? BrandIfBranded + : // Literals + V extends VLiteral + ? z.ZodLiteral + : // Union (see below) + V extends VUnion + ? z.ZodNever + : V extends VUnion + ? ZodFromStringValidator + : V extends VUnion< + any, + [ + infer A extends GenericValidator, + ...infer Rest extends GenericValidator[], + ], + any, + any + > + ? z.ZodUnion< + readonly [ + ZodFromStringValidator, + ...{ + [K in keyof Rest]: ZodFromStringValidator; + }, + ] + > + : never; + +type ZodShapeFromConvexObject> = + Fields extends infer F // dark magic to get the TypeScript compiler happy about circular types + ? { + [K in keyof F]: F[K] extends GenericValidator + ? ZodValidatorFromConvex + : never; + } + : never; + +// #endregion + +// #region Implementation: zid + +/** Stores the table names for each `Zid` instance that is created. */ +const _zidRegistry = zCore.registry<{ tableName: string }>(); + +// #endregion + +// #region Implementation: Utilities + +type NotUndefined = Exclude; + +type VRequired> = + T extends VId + ? VId, "required"> + : T extends VString + ? VString, "required"> + : T extends VFloat64 + ? VFloat64, "required"> + : T extends VInt64 + ? VInt64, "required"> + : T extends VBoolean + ? VBoolean, "required"> + : T extends VNull + ? VNull, "required"> + : T extends VAny + ? VAny, "required"> + : T extends VLiteral + ? VLiteral, "required"> + : T extends VBytes + ? VBytes, "required"> + : T extends VObject< + infer Type, + infer Fields, + OptionalProperty, + infer FieldPaths + > + ? VObject< + NotUndefined, + Fields, + "required", + FieldPaths + > + : T extends VArray< + infer Type, + infer Element, + OptionalProperty + > + ? VArray, Element, "required"> + : T extends VRecord< + infer Type, + infer Key, + infer Value, + OptionalProperty, + infer FieldPaths + > + ? VRecord< + NotUndefined, + Key, + Value, + "required", + FieldPaths + > + : T extends VUnion< + infer Type, + infer Members, + OptionalProperty, + infer FieldPaths + > + ? VUnion< + NotUndefined, + Members, + "required", + FieldPaths + > + : never; + +function vRequired(validator: GenericValidator) { + const { kind, isOptional } = validator; + if (isOptional === "required") { + return validator; + } + + switch (kind) { + case "id": + return v.id(validator.tableName); + case "string": + return v.string(); + case "float64": + return v.float64(); + case "int64": + return v.int64(); + case "boolean": + return v.boolean(); + case "null": + return v.null(); + case "any": + return v.any(); + case "literal": + return v.literal(validator.value); + case "bytes": + return v.bytes(); + case "object": + return v.object(validator.fields); + case "array": + return v.array(validator.element); + case "record": + return v.record(validator.key, validator.value); + case "union": + return v.union(...validator.members); + default: + kind satisfies never; + throw new Error("Unknown Convex validator type: " + kind); + } +} + +type TableNameFromType = + T extends GenericId ? TableName : string; + +// #endregion diff --git a/packages/convex-helpers/server/zod4.zod3.test.ts b/packages/convex-helpers/server/zod4.zod3.test.ts new file mode 100644 index 00000000..7dd0bcc3 --- /dev/null +++ b/packages/convex-helpers/server/zod4.zod3.test.ts @@ -0,0 +1,1127 @@ +// This is a copy of the tests in zod3.test.ts, but ported to use Zod 4 instead + +import type { + DataModelFromSchemaDefinition, + QueryBuilder, + ApiFromModules, + RegisteredQuery, + DefaultFunctionArgs, + FunctionReference, +} from "convex/server"; +import { defineTable, defineSchema, queryGeneric, anyApi } from "convex/server"; +import type { Equals } from "../index.js"; +import { omit } from "../index.js"; +import { convexTest } from "convex-test"; +import { assertType, describe, expect, expectTypeOf, test } from "vitest"; +import { modules } from "./setup.test.js"; +import type { ZCustomCtx } from "./zod4.js"; +import { + zCustomQuery, + zid, + zodOutputToConvex, + zodToConvexFields, + zodToConvex, + convexToZod, + convexToZodFields, +} from "./zod4.js"; +import { customCtx } from "./customFunctions.js"; +import type { VString, VFloat64, VObject, VId, Infer } from "convex/values"; +import { v } from "convex/values"; +import { z } from "zod/v4"; +import { ignoreUnionOrder } from "./zod4.zodtoconvex.test.js"; + +// This is an example of how to make a version of `zid` that +// enforces that the type matches one of your defined tables. +// Note that it can't be used in anything imported by schema.ts +// since the types would be circular. +// For argument validation it might be useful to you, however. +// const zId = zid; + +export const kitchenSinkValidator = { + email: z.email(), + userId: zid("users"), + // Otherwise this is equivalent, but wouldn't catch zid("CounterTable") + // counterId: zid("counter_table"), + num: z.number().min(0), + nan: z.nan(), + bigint: z.bigint(), + bool: z.boolean(), + null: z.null(), + any: z.unknown(), + array: z.array(z.string()), + object: z.object({ a: z.string(), b: z.number() }), + objectWithOptional: z.object({ a: z.string(), b: z.number().optional() }), + record: z.record( + z.union([z.string(), zid("users")]), + z.union([z.number(), z.string()]), + ), + union: z.union([z.string(), z.number()]), + discriminatedUnion: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("a"), a: z.string() }), + z.object({ kind: z.literal("b"), b: z.number() }), + ]), + literal: z.literal("hi"), + tuple: z.tuple([z.string(), z.number()]), + lazy: z.lazy(() => z.string()), + enum: z.enum(["a", "b"]), + optional: z.object({ a: z.string(), b: z.number() }).optional(), + nullableOptional: z.nullable(z.string().optional()), + optionalNullable: z.nullable(z.string()).optional(), + nullable: z.nullable(z.string()), + branded: z.string().brand("branded"), + default: z.string().default("default"), + readonly: z.object({ a: z.string(), b: z.number() }).readonly(), + pipeline: z.number().pipe(z.coerce.string()), +}; + +const schema = defineSchema({ + sink: defineTable(zodToConvexFields(kitchenSinkValidator)).index("email", [ + "email", + ]), + users: defineTable({}), +}); +type DataModel = DataModelFromSchemaDefinition; +const query = queryGeneric as QueryBuilder; +// type DatabaseReader = GenericDatabaseReader; +// type DatabaseWriter = GenericDatabaseWriter; + +const zQuery = zCustomQuery(query, { + // You could require arguments for all queries here. + args: {}, + input: async () => { + // Here you could use the args you declared and return patches for the + // function's ctx and args. e.g. looking up a user and passing it in ctx. + // Or just asserting that the user is logged in. + return { ctx: {}, args: {} }; + }, +}); + +export const kitchenSink = zQuery({ + args: kitchenSinkValidator, + handler: async (_ctx, args) => { + return { + args, + json: (v.object(zodToConvexFields(kitchenSinkValidator)) as any).json, + }; + }, + returns: z.object({ + args: z.object({ + ...kitchenSinkValidator, + // round trip the pipeline + pipeline: z.string().pipe(z.coerce.number()), + }), + json: z.any(), + }), + // You can add .strict() to fail if any more fields are passed + // .strict(), +}); + +export const dateRoundTrip = zQuery({ + args: { date: z.string().transform((s) => new Date(Date.parse(s))) }, + handler: async (ctx, args) => { + return args.date; + }, + returns: z.date().transform((d) => d.toISOString()), +}); + +export const failsReturnsValidator = zQuery({ + args: {}, + returns: z.number(), + handler: async () => { + return "foo" as unknown as number; + }, +}); + +export const returnsWithoutArgs = zQuery({ + returns: z.number(), + handler: async () => { + return 1; + }, +}); + +export const zodOutputCompliance = zQuery({ + // Note no args validator + handler: (ctx, args: { optionalString?: string | undefined }) => { + return { + undefinedBecomesFooString: undefined, + stringBecomesNull: "bar", + threeBecomesString: 3, + extraArg: "extraArg", + optionalString: args.optionalString, + arrayWithDefaultFoo: [undefined], + objectWithDefaultFoo: { foo: undefined }, + unionOfDefaultFoo: undefined, + }; + }, + // Note inline record of zod validators works. + returns: { + undefinedBecomesFooString: z.string().default("foo"), + stringBecomesNull: z.string().transform((_) => null), + threeBecomesString: z.number().pipe(z.coerce.string()), + optionalString: z.string().optional(), + arrayWithDefaultFoo: z.array(z.string().default("foo")), + objectWithDefaultFoo: z.object({ foo: z.string().default("foo") }), + unionOfDefaultFoo: z.union([z.string().default("foo"), z.number()]), + }, +}); + +export const zodArgsObject = zQuery({ + args: z.object({ a: z.string() }), + handler: async (ctx, args) => { + return args; + }, + returns: z.object({ a: z.string() }), +}); + +// example of helper function +type ZodQueryCtx = ZCustomCtx; +const myArgs = z.object({ a: z.string() }); +const myHandler = async (_ctx: ZodQueryCtx, _args: z.infer) => { + return "foo"; +}; +export const viaHelper = zQuery({ + args: myArgs, + handler: myHandler, + returns: z.string(), +}); + +/** + * Testing custom zod function modifications. + */ + +/** + * Adding ctx + */ +const addCtxArg = zCustomQuery( + query, + customCtx(() => { + return { a: "hi" }; + }), +); +export const addC = addCtxArg({ + args: {}, + handler: async (ctx) => { + return { ctxA: ctx.a }; // !!! + }, +}); +queryMatches(addC, {}, { ctxA: "" }); +// Unvalidated +export const addCU = addCtxArg({ + handler: async (ctx) => { + return { ctxA: ctx.a }; // !!! + }, +}); +// Unvalidated variant 2 +queryMatches(addCU, {}, { ctxA: "" }); +export const addCU2 = addCtxArg(async (ctx) => { + return { ctxA: ctx.a }; // !!! +}); +queryMatches(addCU2, {}, { ctxA: "" }); + +export const addCtxWithExistingArg = addCtxArg({ + args: { b: z.string() }, + handler: async (ctx, args) => { + return { ctxA: ctx.a, argB: args.b }; // !!! + }, +}); +queryMatches(addCtxWithExistingArg, { b: "" }, { ctxA: "", argB: "" }); +/** + * Adding arg + */ +const addArg = zCustomQuery(query, { + args: {}, + input: async () => { + return { ctx: {}, args: { a: "hi" } }; + }, +}); +export const add = addArg({ + args: {}, + handler: async (_ctx, args) => { + return { argsA: args.a }; // !!! + }, +}); +queryMatches(add, {}, { argsA: "" }); +export const addUnverified = addArg({ + handler: async (_ctx, args) => { + return { argsA: args.a }; // !!! + }, +}); +queryMatches(addUnverified, {}, { argsA: "" }); +export const addUnverified2 = addArg((_ctx, args) => { + return { argsA: args.a }; // !!! +}); +queryMatches(addUnverified2, {}, { argsA: "" }); + +/** + * Consuming arg, add to ctx + */ +const consumeArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, { a }) => { + return { ctx: { a }, args: {} }; + }, +}); +export const consume = consumeArg({ + args: {}, + handler: async (ctx, emptyArgs) => { + assertType>(emptyArgs); // !!! + return { ctxA: ctx.a }; + }, +}); +queryMatches(consume, { a: "" }, { ctxA: "" }); + +export const necromanceArg = consumeArg({ + args: { a: z.string() }, + handler: async (ctx, args) => { + assertType<{ a: string }>(args); + return { ctxA: ctx.a, argsA: args.a }; + }, +}); +queryMatches(necromanceArg, { a: "" }, { ctxA: "", argsA: "" }); + +/** + * Passing Through arg, also add to ctx for fun + */ +const passThrougArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, args) => { + return { ctx: { a: args.a }, args }; + }, +}); +export const passThrough = passThrougArg({ + args: {}, + handler: async (ctx, args) => { + return { ctxA: ctx.a, argsA: args.a }; // !!! + }, +}); +queryMatches(passThrough, { a: "" }, { ctxA: "", argsA: "" }); + +/** + * Modify arg type, don't need to re-defined "a" arg + */ +const modifyArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, { a }) => { + return { ctx: { a }, args: { a: 123 } }; // !!! + }, +}); +export const modify = modifyArg({ + args: {}, + handler: async (ctx, args) => { + args.a.toFixed(); // !!! + return { ctxA: ctx.a, argsA: args.a }; + }, +}); +queryMatches(modify, { a: "" }, { ctxA: "", argsA: 0 }); // !!! + +/** + * Redefine arg type with the same type: OK! + */ +const redefineArg = zCustomQuery(query, { + args: { a: v.string() }, + input: async (_ctx, args) => ({ ctx: {}, args }), +}); +export const redefine = redefineArg({ + args: { a: z.string() }, + handler: async (_ctx, args) => { + return { argsA: args.a }; + }, +}); +queryMatches(redefine, { a: "" }, { argsA: "" }); + +/** + * Refine arg type with a more specific type: OK! + */ +const refineArg = zCustomQuery(query, { + args: { a: v.optional(v.string()) }, + input: async (_ctx, args) => ({ ctx: {}, args }), +}); +export const refined = refineArg({ + args: { a: z.string() }, + handler: async (_ctx, args) => { + return { argsA: args.a }; + }, +}); +queryMatches(refined, { a: "" }, { argsA: "" }); + +/** + * Redefine arg type with different type: error! + */ +const badRedefineArg = zCustomQuery(query, { + args: { a: v.string(), b: v.number() }, + input: async (_ctx, args) => ({ ctx: {}, args }), +}); +expect(() => + badRedefineArg({ + args: { a: z.number() }, + handler: async (_ctx, args) => { + return { argsA: args.a }; + }, + }), +).toThrow(); +/** + * Test helpers + */ +function queryMatches< + A extends DefaultFunctionArgs, + R, + T extends RegisteredQuery<"public", A, R>, +>(_f: T, _a: A, _v: R) {} + +const testApi: ApiFromModules<{ + fns: { + kitchenSink: typeof kitchenSink; + dateRoundTrip: typeof dateRoundTrip; + failsReturnsValidator: typeof failsReturnsValidator; + returnsWithoutArgs: typeof returnsWithoutArgs; + zodOutputCompliance: typeof zodOutputCompliance; + zodArgsObject: typeof zodArgsObject; + addC: typeof addC; + addCU: typeof addCU; + addCU2: typeof addCU2; + addCtxWithExistingArg: typeof addCtxWithExistingArg; + add: typeof add; + addUnverified: typeof addUnverified; + addUnverified2: typeof addUnverified2; + consume: typeof consume; + necromanceArg: typeof necromanceArg; + passThrough: typeof passThrough; + modify: typeof modify; + redefine: typeof redefine; + refined: typeof refined; + }; +}>["fns"] = anyApi["zod4.zod3.test"] as any; + +test("zod kitchen sink", async () => { + const t = convexTest(schema, modules); + const userId = await t.run((ctx) => ctx.db.insert("users", {})); + const kitchenSink = { + email: "email@example.com", + userId, + num: 1, + nan: NaN, + bigint: BigInt(1), + bool: true, + null: null, + any: [1, "2"], + array: ["1", "2"], + object: { a: "1", b: 2 }, + objectWithOptional: { a: "1" }, + record: { a: 1 }, + union: 1, + discriminatedUnion: { kind: "a" as const, a: "1" }, + literal: "hi" as const, + tuple: ["2", 1] as [string, number], + lazy: "lazy", + enum: "b" as const, + optional: undefined, + nullable: null, + branded: "branded" as string & z.BRAND<"branded">, + default: undefined, + readonly: { a: "1", b: 2 }, + pipeline: 0, + }; + const response = await t.query(testApi.kitchenSink, kitchenSink); + expect(response.args).toMatchObject({ + ...omit(kitchenSink, ["optional"]), + default: "default", + }); + expect(response.json).toMatchObject({ + type: "object", + value: { + any: { fieldType: { type: "any" }, optional: false }, + array: { + fieldType: { type: "array", value: { type: "string" } }, + optional: false, + }, + bigint: { fieldType: { type: "bigint" }, optional: false }, + bool: { fieldType: { type: "boolean" }, optional: false }, + branded: { fieldType: { type: "string" }, optional: false }, + default: { fieldType: { type: "string" }, optional: true }, + discriminatedUnion: { + fieldType: { + type: "union", + value: [ + { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + kind: { + fieldType: { type: "literal", value: "a" }, + optional: false, + }, + }, + }, + { + type: "object", + value: { + b: { fieldType: { type: "number" }, optional: false }, + kind: { + fieldType: { type: "literal", value: "b" }, + optional: false, + }, + }, + }, + ], + }, + optional: false, + }, + email: { fieldType: { type: "string" }, optional: false }, + enum: { + fieldType: { + type: "union", + value: [ + { type: "literal", value: "a" }, + { type: "literal", value: "b" }, + ], + }, + optional: false, + }, + lazy: { fieldType: { type: "string" }, optional: false }, + literal: { fieldType: { type: "literal", value: "hi" }, optional: false }, + nan: { fieldType: { type: "number" }, optional: false }, + null: { fieldType: { type: "null" }, optional: false }, + nullable: { + fieldType: { + type: "union", + value: [{ type: "string" }, { type: "null" }], + }, + optional: false, + }, + num: { fieldType: { type: "number" }, optional: false }, + object: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: false }, + }, + }, + optional: false, + }, + objectWithOptional: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: true }, + }, + }, + optional: false, + }, + optional: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: false }, + }, + }, + optional: true, + }, + pipeline: { fieldType: { type: "number" }, optional: false }, + readonly: { + fieldType: { + type: "object", + value: { + a: { fieldType: { type: "string" }, optional: false }, + b: { fieldType: { type: "number" }, optional: false }, + }, + }, + optional: false, + }, + record: { + fieldType: { + keys: { + type: "union", + value: [{ type: "string" }, { tableName: "users", type: "id" }], + }, + type: "record", + values: { + fieldType: { + type: "union", + value: [{ type: "number" }, { type: "string" }], + }, + }, + }, + }, + tuple: { + fieldType: { + type: "array", + value: { + type: "union", + value: [{ type: "string" }, { type: "number" }], + }, + }, + optional: false, + }, + union: { + fieldType: { + type: "union", + value: [{ type: "string" }, { type: "number" }], + }, + optional: false, + }, + userId: { + fieldType: { tableName: "users", type: "id" }, + optional: false, + }, + }, + }); + const stored = await t.run(async (ctx) => { + const id = await ctx.db.insert("sink", kitchenSink); + return ctx.db.get(id); + }); + expect(stored).toMatchObject(omit(kitchenSink, ["optional", "default"])); +}); + +test("zod date round trip", async () => { + const t = convexTest(schema, modules); + const date = new Date().toISOString(); + const response = await t.query(testApi.dateRoundTrip, { date }); + expect(response).toBe(date); +}); + +test("zod fails returns validator", async () => { + const t = convexTest(schema, modules); + await expect(() => + t.query(testApi.failsReturnsValidator, {}), + ).rejects.toThrow(); +}); + +test("zod returns without args works", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.returnsWithoutArgs, {}); + expect(response).toBe(1); +}); + +test("output validators work for arrays objects and unions", async () => { + const array = zodOutputToConvex(z.array(z.string().default("foo"))); + expect(array.kind).toBe("array"); + expect(array.element.kind).toBe("string"); + expect(array.element.isOptional).toBe("required"); + const object = zodOutputToConvex( + z.object({ foo: z.string().default("foo") }), + ); + expect(object.kind).toBe("object"); + expect(object.fields.foo.kind).toBe("string"); + expect(object.fields.foo.isOptional).toBe("required"); + const union = zodOutputToConvex(z.union([z.string(), z.number().default(0)])); + expect(union.kind).toBe("union"); + expect(union.members[0].kind).toBe("string"); + expect(union.members[1].kind).toBe("float64"); + expect(union.members[1].isOptional).toBe("required"); +}); + +test("zod output compliance", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.zodOutputCompliance, {}); + expect(response).toMatchObject({ + undefinedBecomesFooString: "foo", + stringBecomesNull: null, + threeBecomesString: "3", + arrayWithDefaultFoo: ["foo"], + objectWithDefaultFoo: { foo: "foo" }, + unionOfDefaultFoo: "foo", + }); + const responseWithMaybe = await t.query(testApi.zodOutputCompliance, { + optionalString: "optionalString", + }); + expect(responseWithMaybe).toMatchObject({ + optionalString: "optionalString", + }); + // number should fail + await expect(() => + t.query(testApi.zodOutputCompliance, { + optionalString: 1 as any, + }), + ).rejects.toThrow(); +}); + +test("zod args object", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.zodArgsObject, { a: "foo" })).toMatchObject({ + a: "foo", + }); + await expect(() => + t.query(testApi.zodArgsObject, { a: 1 } as any), + ).rejects.toThrow(); +}); + +describe("zod functions", () => { + test("add ctx", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.addC, {})).toMatchObject({ + ctxA: "hi", + }); + expect(await t.query(testApi.addCU, {})).toMatchObject({ + ctxA: "hi", + }); + expect(await t.query(testApi.addCU2, {})).toMatchObject({ + ctxA: "hi", + }); + }); + + test("add ctx with existing arg", async () => { + const t = convexTest(schema, modules); + expect( + await t.query(testApi.addCtxWithExistingArg, { b: "foo" }), + ).toMatchObject({ + ctxA: "hi", + argB: "foo", + }); + expectTypeOf(testApi.addCtxWithExistingArg).toExtend< + FunctionReference< + "query", + "public", + { b: string }, + { ctxA: string; argB: string } + > + >(); + expectTypeOf< + FunctionReference< + "query", + "public", + { b: string }, + { ctxA: string; argB: string } + > + >().toExtend(); + }); + + test("add args", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.add, {})).toMatchObject({ + argsA: "hi", + }); + expect(await t.query(testApi.addUnverified, {})).toMatchObject({ + argsA: "hi", + }); + expect(await t.query(testApi.addUnverified2, {})).toMatchObject({ + argsA: "hi", + }); + }); + + test("consume arg, add to ctx", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.consume, { a: "foo" })).toMatchObject({ + ctxA: "foo", + }); + }); + + test("necromance arg", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.necromanceArg, { a: "foo" })).toMatchObject({ + ctxA: "foo", + argsA: "foo", + }); + }); + + test("pass through arg + ctx", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.passThrough, { a: "foo" })).toMatchObject({ + ctxA: "foo", + argsA: "foo", + }); + }); + + test("modify arg type", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.modify, { a: "foo" })).toMatchObject({ + ctxA: "foo", + argsA: 123, + }); + }); + + test("redefine arg", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.redefine, { a: "foo" })).toMatchObject({ + argsA: "foo", + }); + }); + + test("refined arg", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.refined, { a: "foo" })).toMatchObject({ + argsA: "foo", + }); + await expect(() => + t.query(testApi.refined, { a: undefined as any }), + ).rejects.toThrow("Validator error: Missing required field `a`"); + }); +}); + +/** + * Test type translation + */ + +expectTypeOf( + zodToConvexFields({ + s: z.email().max(5), + n: z.number(), + nan: z.nan(), + optional: z.number().optional(), + optional2: z.optional(z.number()), + record: z.record(z.string(), z.number()), + default: z.number().default(0), + nullable: z.number().nullable(), + null: z.null(), + bi: z.bigint(), + bool: z.boolean(), + literal: z.literal("hi"), + branded: z.string().brand("branded"), + }), +).toEqualTypeOf({ + s: v.string(), + n: v.number(), + nan: v.number(), + optional: v.optional(v.number()), + optional2: v.optional(v.number()), + record: v.record(v.string(), v.number()), + default: v.optional(v.number()), + nullable: v.union(v.number(), v.null()), + null: v.null(), + bi: v.int64(), + bool: v.boolean(), + literal: v.literal("hi"), + branded: v.string() as VString>, +}); + +expectTypeOf( + zodToConvexFields({ + simpleArray: z.array(z.boolean()), + tuple: z.tuple([z.boolean(), z.boolean()]), + enum: z.enum(["a", "b"]), + obj: z.object({ a: z.string(), b: z.object({ c: z.array(z.number()) }) }), + union: z.union([z.string(), z.object({ c: z.array(z.number()) })]), + discUnion: z.discriminatedUnion("type", [ + z.object({ type: z.literal("a"), a: z.string() }), + z.object({ type: z.literal("b"), b: z.number() }), + ]), + }), +).toEqualTypeOf({ + simpleArray: v.array(v.boolean()), + tuple: v.array(v.union(v.boolean(), v.boolean())), + enum: ignoreUnionOrder(v.union(v.literal("a"), v.literal("b"))), + obj: v.object({ a: v.string(), b: v.object({ c: v.array(v.number()) }) }), + union: v.union(v.string(), v.object({ c: v.array(v.number()) })), + discUnion: v.union( + v.object({ + type: v.literal("a"), + a: v.string(), + }), + v.object({ + type: v.literal("b"), + b: v.number(), + }), + ), +}); + +expectTypeOf( + zodToConvexFields({ + lazy: z.lazy(() => z.string()), + pipe: z.string().pipe(z.email()), + ro: z.string().readonly(), + unknown: z.unknown(), + any: z.any(), + }), +).toEqualTypeOf({ + lazy: v.string(), + pipe: v.string(), + ro: v.string(), + unknown: v.any(), + any: v.any(), +}); +const _s = z.string().brand("brand"); +const _n = z.number().brand("brand"); +const _i = z.bigint().brand("brand"); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf>(); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf>(); + +function sameType(_t: T, _u: U): Equals { + return true as any; +} + +test("convexToZod basic types", () => { + expect(convexToZod(v.string()).constructor.name).toBe("ZodString"); + expect(convexToZod(v.number()).constructor.name).toBe("ZodNumber"); + expect(convexToZod(v.int64()).constructor.name).toBe("ZodBigInt"); + expect(convexToZod(v.boolean()).constructor.name).toBe("ZodBoolean"); + expect(convexToZod(v.null()).constructor.name).toBe("ZodNull"); + expect(convexToZod(v.any()).constructor.name).toBe("ZodAny"); + expect(convexToZod(v.id("users")).constructor.name).toBe("ZodCustom"); // This differs in v4 +}); + +test("convexToZod complex types", () => { + const arrayValidator = convexToZod(v.array(v.string())); + expect(arrayValidator.constructor.name).toBe("ZodArray"); + + const objectValidator = convexToZod( + v.object({ a: v.string(), b: v.number() }), + ); + expect(objectValidator.constructor.name).toBe("ZodObject"); + + const unionValidator = convexToZod(v.union(v.string(), v.number())); + expect(unionValidator.constructor.name).toBe("ZodUnion"); + expect(unionValidator.options[0].constructor.name).toBe("ZodString"); + expect(unionValidator.options[1].constructor.name).toBe("ZodNumber"); + expectTypeOf(unionValidator.options[0]).toEqualTypeOf(); + expectTypeOf(unionValidator.options[1]).toEqualTypeOf(); + + const literalValidator = convexToZod(v.literal("hi")); + expect(literalValidator.constructor.name).toBe("ZodLiteral"); + + const recordValidator = convexToZod(v.record(v.string(), v.number())); + expect(recordValidator.constructor.name).toBe("ZodRecord"); + + const optionalValidator = convexToZod(v.optional(v.string())); + expect(optionalValidator.constructor.name).toBe("ZodOptional"); +}); + +test("convexToZodFields", () => { + const fields = { + name: v.string(), + age: v.number(), + isActive: v.boolean(), + tags: v.array(v.string()), + metadata: v.object({ createdBy: v.string() }), + }; + + const zodFields = convexToZodFields(fields); + + expect(zodFields.name.constructor.name).toBe("ZodString"); + expect(zodFields.age.constructor.name).toBe("ZodNumber"); + expect(zodFields.isActive.constructor.name).toBe("ZodBoolean"); + expect(zodFields.tags.constructor.name).toBe("ZodArray"); + expect(zodFields.metadata.constructor.name).toBe("ZodObject"); +}); + +test("convexToZod round trip", () => { + const stringValidator = v.string(); + const zodString = convexToZod(stringValidator); + const roundTripString = zodToConvex(zodString) as VString; + expect(roundTripString.kind).toBe(stringValidator.kind); + + type StringType = z.infer; + type ConvexStringType = Infer; + sameType( + "" as StringType, + "" as ConvexStringType, + ); + + const numberValidator = v.number(); + const zodNumber = convexToZod(numberValidator); + const roundTripNumber = zodToConvex(zodNumber) as VFloat64; + expect(roundTripNumber.kind).toBe(numberValidator.kind); + + type NumberType = z.infer; + type ConvexNumberType = Infer; + sameType( + 0 as NumberType, + 0 as ConvexNumberType, + ); + + const objectValidator = v.object({ + a: v.string(), + b: v.number(), + c: v.boolean(), + d: v.array(v.string()), + }); + + const zodObject = convexToZod(objectValidator); + const roundTripObject = zodToConvex(zodObject) as VObject; + expect(roundTripObject.kind).toBe(objectValidator.kind); + + type ObjectType = z.infer; + type ConvexObjectType = Infer; + sameType( + {} as ObjectType, + {} as ConvexObjectType, + ); + + const idValidator = v.id("users"); + const zodId = convexToZod(idValidator); + const roundTripId = zodToConvex(zodId) as VId<"users">; + expect(roundTripId.kind).toBe(idValidator.kind); + + type IdType = z.infer; + type ConvexIdType = Infer; + sameType("" as IdType, "" as ConvexIdType); +}); + +test("convexToZod validation", () => { + const stringValidator = v.string(); + const zodString = convexToZod(stringValidator); + + expect(zodString.parse("hello")).toBe("hello"); + + expect(() => zodString.parse(123)).toThrow(); + + const numberValidator = v.number(); + const zodNumber = convexToZod(numberValidator); + + expect(zodNumber.parse(123)).toBe(123); + + expect(() => zodNumber.parse("hello")).toThrow(); + + const boolValidator = v.boolean(); + const zodBool = convexToZod(boolValidator); + + expect(zodBool.parse(true)).toBe(true); + + expect(() => zodBool.parse("true")).toThrow(); + + const arrayValidator = v.array(v.string()); + const zodArray = convexToZod(arrayValidator); + + expect(zodArray.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]); + + expect(() => zodArray.parse(["a", 123, "c"])).toThrow(); + + const objectValidator = v.object({ + name: v.string(), + age: v.number(), + active: v.boolean(), + }); + const zodObject = convexToZod(objectValidator); + + const validObject = { + name: "John", + age: 30, + active: true, + }; + expect(zodObject.parse(validObject)).toEqual(validObject); + + const invalidObject = { + name: "John", + age: "thirty", + active: true, + }; + expect(() => zodObject.parse(invalidObject)).toThrow(); + + const unionValidator = v.union(v.string(), v.number()); + const zodUnion = convexToZod(unionValidator); + + expect(zodUnion.parse("hello")).toBe("hello"); + + expect(zodUnion.parse(123)).toBe(123); + + expect(() => zodUnion.parse(true)).toThrow(); +}); + +test("convexToZod optional values", () => { + const optionalStringValidator = v.optional(v.string()); + const zodOptionalString = convexToZod(optionalStringValidator); + + expect(zodOptionalString.constructor.name).toBe("ZodOptional"); + + expect(zodOptionalString.parse("hello")).toBe("hello"); + expect(zodOptionalString.parse(undefined)).toBe(undefined); + expect(() => zodOptionalString.parse(123)).toThrow(); + + type OptionalStringType = z.infer; + type ConvexOptionalStringType = Infer; + sameType( + "" as OptionalStringType, + "" as ConvexOptionalStringType, + ); + sameType( + undefined as OptionalStringType, + undefined as string | undefined, + ); + + const optionalNumberValidator = v.optional(v.number()); + const zodOptionalNumber = convexToZod(optionalNumberValidator); + + expect(zodOptionalNumber.constructor.name).toBe("ZodOptional"); + + expect(zodOptionalNumber.parse(123)).toBe(123); + expect(zodOptionalNumber.parse(undefined)).toBe(undefined); + expect(() => zodOptionalNumber.parse("hello")).toThrow(); + + type OptionalNumberType = z.infer; + type ConvexOptionalNumberType = Infer; + sameType( + 0 as OptionalNumberType, + 0 as ConvexOptionalNumberType, + ); + + const optionalObjectValidator = v.optional( + v.object({ + name: v.string(), + age: v.number(), + }), + ); + const zodOptionalObject = convexToZod(optionalObjectValidator); + + expect(zodOptionalObject.constructor.name).toBe("ZodOptional"); + + const validObj = { name: "John", age: 30 }; + expect(zodOptionalObject.parse(validObj)).toEqual(validObj); + expect(zodOptionalObject.parse(undefined)).toBe(undefined); + expect(() => zodOptionalObject.parse({ name: "John", age: "30" })).toThrow(); + + type OptionalObjectType = z.infer; + type ConvexOptionalObjectType = Infer; + sameType( + { name: "", age: 0 } as OptionalObjectType, + { name: "", age: 0 } as ConvexOptionalObjectType, + ); + + const objectWithOptionalFieldsValidator = v.object({ + name: v.string(), + age: v.optional(v.number()), + address: v.optional(v.string()), + }); + const zodObjectWithOptionalFields = convexToZod( + objectWithOptionalFieldsValidator, + ); + + expect(zodObjectWithOptionalFields.parse({ name: "John" })).toEqual({ + name: "John", + }); + expect(zodObjectWithOptionalFields.parse({ name: "John", age: 30 })).toEqual({ + name: "John", + age: 30, + }); + expect( + zodObjectWithOptionalFields.parse({ + name: "John", + age: 30, + address: "123 Main St", + }), + ).toEqual({ name: "John", age: 30, address: "123 Main St" }); + expect(() => zodObjectWithOptionalFields.parse({ age: 30 })).toThrow(); + + type ObjectWithOptionalFieldsType = z.infer< + typeof zodObjectWithOptionalFields + >; + type ConvexObjectWithOptionalFieldsType = Infer< + typeof objectWithOptionalFieldsValidator + >; + sameType( + { name: "" } as ObjectWithOptionalFieldsType, + { name: "" } as ConvexObjectWithOptionalFieldsType, + ); + + const optionalArrayValidator = v.optional(v.array(v.string())); + const zodOptionalArray = convexToZod(optionalArrayValidator); + const roundTripOptionalArray = zodToConvex(zodOptionalArray) as unknown as { + isOptional: string; + }; + + expect(roundTripOptionalArray.isOptional).toBe("optional"); +}); + +test("convexToZod object with union of one literal", () => { + const unionValidator = v.object({ + member: v.union(v.literal("hello")), + }); + const zodUnion = convexToZod(unionValidator); + expect(zodUnion.constructor.name).toBe("ZodObject"); + expect(zodUnion.parse({ member: "hello" })).toEqual({ member: "hello" }); + expect(() => zodUnion.parse({ member: "world" })).toThrow(); +}); diff --git a/packages/convex-helpers/server/zod4.zodtoconvex.mini.test.ts b/packages/convex-helpers/server/zod4.zodtoconvex.mini.test.ts new file mode 100644 index 00000000..7645ab3e --- /dev/null +++ b/packages/convex-helpers/server/zod4.zodtoconvex.mini.test.ts @@ -0,0 +1,1075 @@ +import * as zCore from "zod/v4/core"; +import * as z from "zod/v4/mini"; +import { describe, expect, test } from "vitest"; +import { + GenericValidator, + v, + VAny, + VFloat64, + VLiteral, + VNull, + VOptional, + VString, + VUnion, +} from "convex/values"; +import { + zodToConvex, + zid, + zodToConvexFields, + zodOutputToConvexFields, +} from "./zod4"; +import { Equals } from ".."; +import { + assert, + assertUnrepresentableType, + ignoreUnionOrder, + testZodOutputToConvex, + testZodToConvex, + testZodToConvexInputAndOutput, +} from "./zod4.zodtoconvex.test"; + +describe("zodToConvex + zodOutputToConvex", () => { + test("id", () => { + testZodToConvexInputAndOutput(zid("users"), v.id("users")); + }); + test("string", () => testZodToConvexInputAndOutput(z.string(), v.string())); + test("string formatters", () => + testZodToConvexInputAndOutput(z.email(), v.string())); + test("number", () => testZodToConvexInputAndOutput(z.number(), v.number())); + test("float64", () => testZodToConvexInputAndOutput(z.float64(), v.number())); + test("nan", () => testZodToConvexInputAndOutput(z.nan(), v.number())); + test("int64", () => testZodToConvexInputAndOutput(z.int64(), v.int64())); + test("bigint", () => testZodToConvexInputAndOutput(z.bigint(), v.int64())); + test("boolean", () => + testZodToConvexInputAndOutput(z.boolean(), v.boolean())); + test("null", () => testZodToConvexInputAndOutput(z.null(), v.null())); + test("any", () => testZodToConvexInputAndOutput(z.any(), v.any())); + + describe("literal", () => { + test("string", () => { + testZodToConvexInputAndOutput(z.literal("hey"), v.literal("hey")); + }); + test("number", () => { + testZodToConvexInputAndOutput(z.literal(42), v.literal(42)); + }); + test("int64", () => { + testZodToConvexInputAndOutput(z.literal(42n), v.literal(42n)); + }); + test("boolean", () => { + testZodToConvexInputAndOutput(z.literal(true), v.literal(true)); + }); + test("null", () => { + testZodToConvexInputAndOutput(z.literal(null), v.null()); // ! + }); + + test("multiple values, same type", () => { + testZodToConvexInputAndOutput( + z.literal([1, 2, 3]), + ignoreUnionOrder(v.union(v.literal(1), v.literal(2), v.literal(3))), + ); + }); + test("multiple values, different tyeps", () => { + testZodToConvexInputAndOutput( + z.literal([123, "xyz", null]), + ignoreUnionOrder(v.union(v.literal(123), v.literal("xyz"), v.null())), + ); + }); + test("union of literals", () => { + testZodToConvexInputAndOutput( + z.union([z.literal([1, 2]), z.literal([3, 4])]), + v.union( + ignoreUnionOrder(v.union(v.literal(1), v.literal(2))), + ignoreUnionOrder(v.union(v.literal(3), v.literal(4))), + ), + ); + }); + }); + + describe("optional", () => { + test("z.optional()", () => { + testZodToConvexInputAndOutput( + z.optional(z.string()), + v.optional(v.string()), + ); + }); + test("z.XYZ.optional()", () => { + testZodToConvexInputAndOutput( + z.optional(z.string()), + v.optional(v.string()), + ); + }); + test("optional doesn’t propagate to array elements", () => { + testZodToConvexInputAndOutput( + z.optional(z.array(z.number())), + v.optional(v.array(v.number())), // and not v.optional(v.array(v.optional(v.number()))) + ); + }); + }); + + test("array", () => { + testZodToConvexInputAndOutput(z.array(z.string()), v.array(v.string())); + }); + + describe("union", () => { + test("never", () => { + testZodToConvexInputAndOutput(z.never(), v.union()); + }); + test("one element (number)", () => { + testZodToConvexInputAndOutput(z.union([z.number()]), v.union(v.number())); + }); + test("one element (string)", () => { + testZodToConvexInputAndOutput(z.union([z.string()]), v.union(v.string())); + }); + test("multiple elements", () => [ + testZodToConvexInputAndOutput( + z.union([z.string(), z.number()]), + v.union(v.string(), v.number()), + ), + ]); + }); + + describe("brand", () => { + test("string", () => { + testZodToConvexInputAndOutput( + z.string().brand("myBrand"), + v.string() as VString>, + ); + }); + test("number", () => { + testZodToConvexInputAndOutput( + z.number().brand("myBrand"), + v.number() as VFloat64>, + ); + }); + }); + + test("object", () => { + testZodToConvexInputAndOutput( + z.object({ + name: z.string(), + age: z.number(), + picture: z.optional(z.string()), + }), + + // v.object() is a strict object, not a loose object, + // but we still convert z.object() to it for convenience + v.object({ + name: v.string(), + age: v.number(), + picture: v.optional(v.string()), + }), + ); + }); + + test("strict object", () => { + testZodToConvexInputAndOutput( + z.strictObject({ + name: z.string(), + age: z.number(), + picture: z.optional(z.string()), + }), + v.object({ + name: v.string(), + age: v.number(), + picture: v.optional(v.string()), + }), + ); + }); + + describe("record", () => { + test("key = string", () => { + testZodToConvexInputAndOutput( + z.record(z.string(), z.number()), + v.record(v.string(), v.number()), + ); + }); + + test("key = string, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.string(), z.optional(z.number())), + v.record(v.string(), v.number()), + ); + }); + + test("key = any", () => { + testZodToConvexInputAndOutput( + z.record(z.any(), z.number()), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = any, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.any(), z.optional(z.number())), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = literal", () => { + testZodToConvexInputAndOutput( + z.record(z.literal("user"), z.number()), + // Convex records can’t have string literals as keys + v.object({ + user: v.number(), + }), + ); + }); + + test("key = literal, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.literal("user"), z.optional(z.number())), + // Convex records can’t have string literals as keys + v.object({ + user: v.optional(v.number()), + }), + ); + }); + + test("key = literal with multiple values", () => { + testZodToConvexInputAndOutput( + z.record(z.literal(["user", "admin"]), z.number()), + v.object({ + user: v.number(), + admin: v.number(), + }), + ); + }); + + test("key = literal with multiple values, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.literal(["user", "admin"]), z.optional(z.number())), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals", () => { + testZodToConvexInputAndOutput( + z.record(z.union([z.literal("user"), z.literal("admin")]), z.number()), + v.object({ + user: v.number(), + admin: v.number(), + }), + ); + }); + + test("key = union of literals, optional", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([z.literal("user"), z.literal("admin")]), + z.optional(z.number()), + ), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals with multiple values", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([z.literal(["one", "two"]), z.literal(["three", "four"])]), + z.number(), + ), + v.object({ + one: v.number(), + two: v.number(), + three: v.number(), + four: v.number(), + }), + ); + }); + + test("key = union of literals with multiple values, optional", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([z.literal(["one", "two"]), z.literal(["three", "four"])]), + z.optional(z.number()), + ), + v.object({ + one: v.optional(v.number()), + two: v.optional(v.number()), + three: v.optional(v.number()), + four: v.optional(v.number()), + }), + ); + }); + + test("key = v.id()", () => { + { + testZodToConvexInputAndOutput( + z.record(zid("documents"), z.number()), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = v.id(), optional", () => { + { + testZodToConvexInputAndOutput( + z.record(zid("documents"), z.optional(z.number())), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = union of ids", () => { + testZodToConvexInputAndOutput( + z.record(z.union([zid("users"), zid("documents")]), z.number()), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = union of ids, optional", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([zid("users"), zid("documents")]), + z.optional(z.number()), + ), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = other", () => { + testZodToConvexInputAndOutput( + z.record(z.union([zid("users"), z.literal("none")]), z.number()), + v.record(v.string(), v.number()), + ); + }); + }); + + describe("partial record", () => { + test("key = any", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.any(), z.number()), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = any, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.any(), z.optional(z.number())), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = string", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.string(), z.number()), + v.record(v.string(), v.number()), + ); + }); + + test("key = string, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.string(), z.optional(z.number())), + v.record(v.string(), v.number()), + ); + }); + + test("key = literal", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.literal("user"), z.number()), + // Convex records can’t have string literals as keys + v.object({ + user: v.optional(v.number()), + }), + ); + }); + + test("key = literal, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.literal("user"), z.optional(z.number())), + // Convex records can’t have string literals as keys + v.object({ + user: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals", () => { + testZodToConvexInputAndOutput( + z.partialRecord( + z.union([z.literal("user"), z.literal("admin")]), + z.number(), + ), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord( + z.union([z.literal("user"), z.literal("admin")]), + z.optional(z.number()), + ), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = v.id()", () => { + { + testZodToConvexInputAndOutput( + z.partialRecord(zid("documents"), z.number()), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = v.id(), optional", () => { + { + testZodToConvexInputAndOutput( + z.partialRecord(zid("documents"), z.optional(z.number())), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = union of ids", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.union([zid("users"), zid("documents")]), z.number()), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = union of ids, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord( + z.union([zid("users"), zid("documents")]), + z.optional(z.number()), + ), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = other", () => { + testZodToConvexInputAndOutput( + z.record(z.union([zid("users"), z.literal("none")]), z.number()), + v.record(v.string(), v.number()), + ); + }); + }); + + test("readonly", () => { + testZodToConvexInputAndOutput( + z.readonly(z.array(z.string())), + v.array(v.string()), + ); + }); + + // Discriminated union + test("discriminated union", () => { + testZodToConvexInputAndOutput( + z.discriminatedUnion("status", [ + z.object({ status: z.literal("success"), data: z.string() }), + z.object({ status: z.literal("failed"), error: z.string() }), + ]), + v.union( + v.object({ status: v.literal("success"), data: v.string() }), + v.object({ status: v.literal("failed"), error: v.string() }), + ), + ); + }); + + describe("enum", () => { + test("const array", () => { + testZodToConvexInputAndOutput( + z.enum(["Salmon", "Tuna", "Trout"]), + ignoreUnionOrder( + v.union(v.literal("Salmon"), v.literal("Tuna"), v.literal("Trout")), + ), + ); + }); + + test("const array with a number", () => { + testZodToConvexInputAndOutput( + z.enum(["2", "Salmon", "Tuna"]), + ignoreUnionOrder( + v.union(v.literal("2"), v.literal("Salmon"), v.literal("Tuna")), + ), + ); + }); + + test("enum-like object literal", () => { + const Fish = { + Salmon: 0, + Tuna: 1, + } as const; + testZodToConvexInputAndOutput( + z.enum(Fish), + ignoreUnionOrder(v.union(v.literal(0), v.literal(1))), + ); + }); + + test("TypeScript string enum", () => { + enum Fish { + Salmon = 0, + Tuna = 1, + } + + testZodToConvexInputAndOutput( + z.enum(Fish), + // Interestingly, TypeScript enums make Fish.Salmon be its own type, + // even if its value is 0 at runtime. + ignoreUnionOrder(v.union(v.literal(Fish.Salmon), v.literal(Fish.Tuna))), + ); + }); + }); + + // Tuple + describe("tuple", () => { + test("one-element tuple", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string()]), + v.array(v.union(v.string())), // suboptimal, we could remove the union + ); + }); + test("fixed elements, same type", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string(), z.string()]), + v.array(v.union(v.string(), v.string())), // suboptimal, we could remove duplicates + ); + }); + test("fixed elements", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string(), z.number()]), + v.array(v.union(v.string(), v.number())), + ); + }); + test("variadic element, same type", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string()], z.string()), + v.array(v.union(v.string(), v.string())), // suboptimal, we could remove duplicates + ); + }); + test("variadic element", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string()], z.number()), + v.array(v.union(v.string(), v.number())), + ); + }); + }); + + describe("nullable", () => { + test("nullable(string)", () => { + testZodToConvexInputAndOutput( + z.nullable(z.string()), + v.union(v.string(), v.null()), + ); + }); + test("nullable(number)", () => { + testZodToConvexInputAndOutput( + z.nullable(z.number()), + v.union(v.number(), v.null()), + ); + }); + test("optional(nullable(string))", () => { + testZodToConvexInputAndOutput( + z.optional(z.nullable(z.string())), + v.optional(v.union(v.string(), v.null())), + ); + + zodToConvex(z.optional(z.nullable(z.string()))) satisfies VUnion< + string | null | undefined, + [VString, VNull], + "optional" + >; + }); + test("nullable(optional(string)) → swap nullable and optional", () => { + testZodToConvexInputAndOutput( + z.optional(z.nullable(z.string())), + v.optional(v.union(v.string(), v.null())), + ); + + zodToConvex(z.optional(z.nullable(z.string()))) satisfies VUnion< + string | null | undefined, + [VString, VNull], + "optional" + >; + }); + }); + + test("optional", () => { + testZodToConvexInputAndOutput( + z.optional(z.string()), + v.optional(v.string()), + ); + }); + + describe("non-optional", () => { + test("id", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(zid("documents"))), + v.id("documents"), + ); + }); + test("string", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.string())), + v.string(), + ); + }); + test("float64", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.float64())), + v.float64(), + ); + }); + test("int64", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.int64())), + v.int64(), + ); + }); + test("boolean", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.boolean())), + v.boolean(), + ); + }); + test("null", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.null())), + v.null(), + ); + }); + test("any", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.any())), + v.any(), + ); + }); + test("literal", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.literal(42n))), + v.literal(42n), + ); + }); + test("object", () => { + testZodToConvexInputAndOutput( + z.nonoptional( + z.optional( + z.object({ + required: z.string(), + optional: z.optional(z.number()), + }), + ), + ), + v.object({ required: v.string(), optional: v.optional(v.number()) }), + ); + }); + test("array", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.array(z.int64()))), + v.array(v.int64()), + ); + }); + test("record", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.record(z.string(), z.number()))), + v.record(v.string(), v.number()), + ); + }); + test("union", () => { + testZodToConvexInputAndOutput( + z.nonoptional(z.optional(z.union([z.number(), z.string()]))), + v.union(v.number(), v.string()), + ); + }); + + test("nonoptional on non-optional type", () => { + testZodToConvexInputAndOutput(z.nonoptional(z.string()), v.string()); + }); + }); + + test("lazy", () => { + testZodToConvexInputAndOutput( + z.lazy(() => z.string()), + v.string(), + ); + }); + + test("custom", () => { + testZodToConvexInputAndOutput( + z.custom(() => true), + v.any(), + ); + }); + + test("recursive type", () => { + const category = z.object({ + name: z.string(), + get subcategories() { + return z.array(category); + }, + }); + + testZodToConvexInputAndOutput( + category, + // @ts-expect-error -- TypeScript can’t compute the full type and uses `unknown` + v.object({ + name: v.string(), + subcategories: v.array(v.any()), + }), + ); + }); + + test("catch", () => { + testZodToConvexInputAndOutput(z.catch(z.string(), "hello"), v.string()); + }); + + describe("template literals", () => { + test("constant string", () => { + testZodToConvexInputAndOutput( + z.templateLiteral(["hi there"]), + v.string() as VString<"hi there", "required">, + ); + }); + test("string interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral(["email: ", z.string()]), + v.string() as VString<`email: ${string}`, "required">, + ); + }); + test("literal interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral(["high", z.literal(5)]), + v.string() as VString<"high5", "required">, + ); + }); + test("nullable interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral([z.nullable(z.literal("grassy"))]), + v.string() as VString<"grassy" | "null", "required">, + ); + }); + test("enum interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral([z.number(), z.enum(["px", "em", "rem"])]), + v.string() as VString<`${number}${"px" | "em" | "rem"}`, "required">, + ); + }); + }); + + test("intersection", () => { + // We could do some more advanced logic here where we compute + // the Convex validator that results from the intersection. + // For now, we simply use v.any() + testZodToConvexInputAndOutput( + z.intersection( + z.object({ key1: z.string() }), + z.object({ key2: z.string() }), + ), + v.any(), + ); + }); + + describe("unencodable types", () => { + test("z.date", () => { + assertUnrepresentableType(z.date()); + }); + test("z.symbol", () => { + assertUnrepresentableType(z.symbol()); + }); + test("z.map", () => { + assertUnrepresentableType(z.map(z.string(), z.string())); + }); + test("z.set", () => { + assertUnrepresentableType(z.set(z.string())); + }); + test("z.promise", () => { + assertUnrepresentableType(z.promise(z.string())); + }); + test("z.file", () => { + assertUnrepresentableType(z.file()); + }); + test("z.function", () => { + assertUnrepresentableType(z.function()); + }); + test("z.void", () => { + assertUnrepresentableType(z.void()); + }); + test("z.undefined", () => { + assertUnrepresentableType(z.undefined()); + }); + test("z.literal(undefined)", () => { + assertUnrepresentableType(z.literal(undefined)); + }); + test("z.literal including undefined", () => { + assertUnrepresentableType(z.literal([123, undefined])); + }); + }); +}); + +describe("zodToConvex", () => { + test("transform", () => { + testZodOutputToConvex( + z.transform((s: number) => s.toString()), + v.any(), // input type unknown here + ); + }); + + test("pipe", () => { + testZodToConvex( + z.pipe( + z.number(), + z.transform((s) => s.toString()), + ), + v.number(), // input type + ); + }); + + test("codec", () => { + testZodToConvex( + z.codec(z.string(), z.number(), { + decode: (s: string) => parseInt(s, 10), + encode: (n: number) => n.toString(), + }), + v.string(), // input type + ); + }); + + test("default", () => { + testZodToConvex(z._default(z.string(), "hello"), v.optional(v.string())); + }); + + describe("problematic inputs", () => { + test("unknown", () => { + const someType: unknown = z.string(); + const _asConvex = zodToConvex( + // @ts-expect-error Can’t use unknown + someType, + ); + assert>(); + }); + + test("ZodType", () => { + const someType: zCore.$ZodType = z.string(); + const _asConvex = zodToConvex(someType); + assert>(); + }); + + test("ZodType", () => { + const someType: zCore.$ZodType = z.string(); + const _asConvex = zodToConvex(someType); + assert>(); + }); + + test("any type", () => { + const someType: any = z.string(); + const _asConvex = zodToConvex(someType); + assert>(); + }); + }); + + describe("lazy", () => { + test("throwing", () => { + expect(() => + zodToConvex( + z.lazy((): zCore.$ZodString => { + throw new Error("This shouldn’t throw but it did"); + }), + ), + ).toThrowError("This shouldn’t throw but it did"); + }); + }); +}); + +describe("zodOutputToConvex", () => { + test("transform", () => { + testZodOutputToConvex( + z.transform((s: number) => s.toString()), + v.any(), // this transform doesn’t hold runtime info about the output type + ); + }); + + test("pipe", () => { + testZodOutputToConvex( + z.pipe( + z.number(), + z.transform((s) => s.toString()), + ), + v.any(), // this transform doesn’t hold runtime info about the output type + ); + }); + + test("codec", () => { + testZodOutputToConvex( + z.codec(z.string(), z.number(), { + decode: (s: string) => parseInt(s, 10), + encode: (n: number) => n.toString(), + }), + v.number(), // output type + ); + }); + + test("default", () => { + testZodOutputToConvex(z._default(z.string(), "hello"), v.string()); + }); +}); + +test("zodToConvexFields", () => { + const convexFields = zodToConvexFields({ + name: z.string(), + age: z.optional(z.number()), + transform: z.pipe( + z.number(), + z.transform((z) => z.toString()), + ), + }); + + assert< + Equals< + typeof convexFields, + { + name: VString; + age: VOptional; + transform: VFloat64; + } + > + >(); + + expect(convexFields).toEqual({ + name: v.string(), + age: v.optional(v.number()), + transform: v.number(), + }); +}); + +test("zodOutputToConvexFields", () => { + const convexFields = zodOutputToConvexFields({ + name: z.string(), + age: z.optional(z.number()), + transform: z.pipe( + z.number(), + z.transform((z) => z.toString()), + ), + }); + + assert< + Equals< + typeof convexFields, + { + name: VString; + age: VOptional; + transform: VAny; + } + > + >(); + + expect(convexFields).toEqual({ + name: v.string(), + age: v.optional(v.number()), + transform: v.any(), + }); +}); + +describe("testing infrastructure", () => { + test("test methods don’t typecheck if the IsOptional value of the result isn’t set correctly", () => { + // eslint-disable-next-line no-constant-condition + if (false) { + // typecheck only + testZodToConvex( + z.string(), + // @ts-expect-error -- This error should be caught by TypeScript + v.optional(v.string()), + ); + testZodToConvex( + z.optional(z.string()), + // @ts-expect-error -- This error should be caught by TypeScript + v.string(), + ); + + testZodOutputToConvex( + z.string(), + // @ts-expect-error -- This error should be caught by TypeScript + v.optional(v.string()), + ); + testZodOutputToConvex( + z.optional(z.string()), + // @ts-expect-error -- This error should be caught by TypeScript + v.string(), + ); + + testZodToConvexInputAndOutput( + z.string(), + // @ts-expect-error -- This error should be caught by TypeScript + v.optional(v.string()), + ); + testZodToConvexInputAndOutput( + z.optional(z.string()), + // @ts-expect-error -- This error should be caught by TypeScript + v.string(), + ); + } + }); + + test("test methods typecheck if the IsOptional value of the result is set correctly", () => { + testZodToConvex(z.optional(z.string()), v.optional(v.string())); + testZodToConvex(z.string(), v.string()); + + testZodOutputToConvex(z.optional(z.string()), v.optional(v.string())); + testZodOutputToConvex(z.string(), v.string()); + + testZodToConvexInputAndOutput( + z.optional(z.string()), + v.optional(v.string()), + ); + testZodToConvexInputAndOutput(z.string(), v.string()); + }); + + test("removeUnionOrder", () => { + const unionWithOrder = v.union(v.literal(1), v.literal(2), v.literal(3)); + assert< + Equals< + typeof unionWithOrder, + VUnion< + 1 | 2 | 3, + [ + VLiteral<1, "required">, + VLiteral<2, "required">, + VLiteral<3, "required">, + ], + "required", + never + > + > + >(); + + const _unionWithoutOrder = ignoreUnionOrder(unionWithOrder); + assert< + Equals< + typeof _unionWithoutOrder, + VUnion< + 1 | 2 | 3, + ( + | VLiteral<1, "required"> + | VLiteral<2, "required"> + | VLiteral<3, "required"> + )[], + "required", + never + > + > + >(); + }); + + test("assertUnrepresentableType", () => { + expect(() => { + assertUnrepresentableType(z.string()); + }).toThrowError(); + }); +}); diff --git a/packages/convex-helpers/server/zod4.zodtoconvex.test.ts b/packages/convex-helpers/server/zod4.zodtoconvex.test.ts new file mode 100644 index 00000000..b31bffde --- /dev/null +++ b/packages/convex-helpers/server/zod4.zodtoconvex.test.ts @@ -0,0 +1,1226 @@ +import * as zCore from "zod/v4/core"; +import * as z from "zod/v4"; +import { describe, expect, test } from "vitest"; +import { + GenericValidator, + OptionalProperty, + v, + Validator, + VAny, + VFloat64, + VLiteral, + VNull, + VOptional, + VString, + VUnion, +} from "convex/values"; +import { + zodToConvex, + zid, + ConvexValidatorFromZod, + ConvexValidatorFromZodOutput, + zodOutputToConvex, + zodToConvexFields, + zodOutputToConvexFields, + withSystemFields, + Zid, +} from "./zod4"; +import { Equals } from ".."; +import { isSameType } from "zod-compare/zod4"; + +describe("zodToConvex + zodOutputToConvex", () => { + test("id", () => { + testZodToConvexInputAndOutput(zid("users"), v.id("users")); + }); + test("string", () => testZodToConvexInputAndOutput(z.string(), v.string())); + test("string formatters", () => + testZodToConvexInputAndOutput(z.email(), v.string())); + test("number", () => testZodToConvexInputAndOutput(z.number(), v.number())); + test("float64", () => testZodToConvexInputAndOutput(z.float64(), v.number())); + test("nan", () => testZodToConvexInputAndOutput(z.nan(), v.number())); + test("int64", () => testZodToConvexInputAndOutput(z.int64(), v.int64())); + test("bigint", () => testZodToConvexInputAndOutput(z.bigint(), v.int64())); + test("boolean", () => + testZodToConvexInputAndOutput(z.boolean(), v.boolean())); + test("null", () => testZodToConvexInputAndOutput(z.null(), v.null())); + test("any", () => testZodToConvexInputAndOutput(z.any(), v.any())); + test("unknown", () => testZodToConvexInputAndOutput(z.unknown(), v.any())); + + describe("literal", () => { + test("string", () => { + testZodToConvexInputAndOutput(z.literal("hey"), v.literal("hey")); + }); + test("number", () => { + testZodToConvexInputAndOutput(z.literal(42), v.literal(42)); + }); + test("int64", () => { + testZodToConvexInputAndOutput(z.literal(42n), v.literal(42n)); + }); + test("boolean", () => { + testZodToConvexInputAndOutput(z.literal(true), v.literal(true)); + }); + test("null", () => { + testZodToConvexInputAndOutput(z.literal(null), v.null()); // ! + }); + + test("multiple values, same type", () => { + testZodToConvexInputAndOutput( + z.literal([1, 2, 3]), + ignoreUnionOrder(v.union(v.literal(1), v.literal(2), v.literal(3))), + ); + }); + test("multiple values, different tyeps", () => { + testZodToConvexInputAndOutput( + z.literal([123, "xyz", null]), + ignoreUnionOrder(v.union(v.literal(123), v.literal("xyz"), v.null())), + ); + }); + test("union of literals", () => { + testZodToConvexInputAndOutput( + z.union([z.literal([1, 2]), z.literal([3, 4])]), + v.union( + ignoreUnionOrder(v.union(v.literal(1), v.literal(2))), + ignoreUnionOrder(v.union(v.literal(3), v.literal(4))), + ), + ); + }); + }); + + describe("optional", () => { + test("z.optional()", () => { + testZodToConvexInputAndOutput( + z.optional(z.string()), + v.optional(v.string()), + ); + }); + test("z.XYZ.optional()", () => { + testZodToConvexInputAndOutput( + z.string().optional(), + v.optional(v.string()), + ); + }); + test("optional doesn’t propagate to array elements", () => { + testZodToConvexInputAndOutput( + z.array(z.number()).optional(), + v.optional(v.array(v.number())), // and not v.optional(v.array(v.optional(v.number()))) + ); + }); + }); + + test("array", () => { + testZodToConvexInputAndOutput(z.array(z.string()), v.array(v.string())); + }); + + describe("union", () => { + test("never", () => { + testZodToConvexInputAndOutput(z.never(), v.union()); + }); + test("one element (number)", () => { + testZodToConvexInputAndOutput(z.union([z.number()]), v.union(v.number())); + }); + test("one element (string)", () => { + testZodToConvexInputAndOutput(z.union([z.string()]), v.union(v.string())); + }); + test("multiple elements", () => [ + testZodToConvexInputAndOutput( + z.union([z.string(), z.number()]), + v.union(v.string(), v.number()), + ), + ]); + }); + + describe("brand", () => { + test("string", () => { + testZodToConvexInputAndOutput( + z.string().brand("myBrand"), + v.string() as VString>, + ); + }); + test("number", () => { + testZodToConvexInputAndOutput( + z.number().brand("myBrand"), + v.number() as VFloat64>, + ); + }); + }); + + test("object", () => { + testZodToConvexInputAndOutput( + z.object({ + name: z.string(), + age: z.number(), + picture: z.string().optional(), + }), + + // v.object() is a strict object, not a loose object, + // but we still convert z.object() to it for convenience + v.object({ + name: v.string(), + age: v.number(), + picture: v.optional(v.string()), + }), + ); + }); + + test("strict object", () => { + testZodToConvexInputAndOutput( + z.strictObject({ + name: z.string(), + age: z.number(), + picture: z.string().optional(), + }), + v.object({ + name: v.string(), + age: v.number(), + picture: v.optional(v.string()), + }), + ); + }); + + describe("record", () => { + test("key = string", () => { + testZodToConvexInputAndOutput( + z.record(z.string(), z.number()), + v.record(v.string(), v.number()), + ); + }); + + test("key = string, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.string(), z.number().optional()), + v.record(v.string(), v.number()), + ); + }); + + test("key = any", () => { + testZodToConvexInputAndOutput( + z.record(z.any(), z.number()), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = any, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.any(), z.number().optional()), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = literal", () => { + testZodToConvexInputAndOutput( + z.record(z.literal("user"), z.number()), + // Convex records can’t have string literals as keys + v.object({ + user: v.number(), + }), + ); + }); + + test("key = literal, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.literal("user"), z.number().optional()), + // Convex records can’t have string literals as keys + v.object({ + user: v.optional(v.number()), + }), + ); + }); + + test("key = literal with multiple values", () => { + testZodToConvexInputAndOutput( + z.record(z.literal(["user", "admin"]), z.number()), + v.object({ + user: v.number(), + admin: v.number(), + }), + ); + }); + + test("key = literal with multiple values, optional", () => { + testZodToConvexInputAndOutput( + z.record(z.literal(["user", "admin"]), z.number().optional()), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals", () => { + testZodToConvexInputAndOutput( + z.record(z.union([z.literal("user"), z.literal("admin")]), z.number()), + v.object({ + user: v.number(), + admin: v.number(), + }), + ); + }); + + test("key = union of literals, optional", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([z.literal("user"), z.literal("admin")]), + z.number().optional(), + ), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals with multiple values", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([z.literal(["one", "two"]), z.literal(["three", "four"])]), + z.number(), + ), + v.object({ + one: v.number(), + two: v.number(), + three: v.number(), + four: v.number(), + }), + ); + }); + + test("key = union of literals with multiple values, optional", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([z.literal(["one", "two"]), z.literal(["three", "four"])]), + z.number().optional(), + ), + v.object({ + one: v.optional(v.number()), + two: v.optional(v.number()), + three: v.optional(v.number()), + four: v.optional(v.number()), + }), + ); + }); + + test("key = v.id()", () => { + { + testZodToConvexInputAndOutput( + z.record(zid("documents"), z.number()), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = v.id(), optional", () => { + { + testZodToConvexInputAndOutput( + z.record(zid("documents"), z.number().optional()), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = union of ids", () => { + testZodToConvexInputAndOutput( + z.record(z.union([zid("users"), zid("documents")]), z.number()), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = union of ids, optional", () => { + testZodToConvexInputAndOutput( + z.record( + z.union([zid("users"), zid("documents")]), + z.number().optional(), + ), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = other", () => { + testZodToConvexInputAndOutput( + z.record(z.union([zid("users"), z.literal("none")]), z.number()), + v.record(v.string(), v.number()), + ); + }); + }); + + describe("partial record", () => { + test("key = any", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.any(), z.number()), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = any, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.any(), z.number().optional()), + // v.record(v.any(), …) is not allowed in Convex validators + v.record(v.string(), v.number()), + ); + }); + + test("key = string", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.string(), z.number()), + v.record(v.string(), v.number()), + ); + }); + + test("key = string, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.string(), z.number().optional()), + v.record(v.string(), v.number()), + ); + }); + + test("key = literal", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.literal("user"), z.number()), + // Convex records can’t have string literals as keys + v.object({ + user: v.optional(v.number()), + }), + ); + }); + + test("key = literal, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.literal("user"), z.number().optional()), + // Convex records can’t have string literals as keys + v.object({ + user: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals", () => { + testZodToConvexInputAndOutput( + z.partialRecord( + z.union([z.literal("user"), z.literal("admin")]), + z.number(), + ), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = union of literals, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord( + z.union([z.literal("user"), z.literal("admin")]), + z.number().optional(), + ), + v.object({ + user: v.optional(v.number()), + admin: v.optional(v.number()), + }), + ); + }); + + test("key = v.id()", () => { + { + testZodToConvexInputAndOutput( + z.partialRecord(zid("documents"), z.number()), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = v.id(), optional", () => { + { + testZodToConvexInputAndOutput( + z.partialRecord(zid("documents"), z.number().optional()), + v.record(v.id("documents"), v.number()), + ); + } + }); + + test("key = union of ids", () => { + testZodToConvexInputAndOutput( + z.partialRecord(z.union([zid("users"), zid("documents")]), z.number()), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = union of ids, optional", () => { + testZodToConvexInputAndOutput( + z.partialRecord( + z.union([zid("users"), zid("documents")]), + z.number().optional(), + ), + v.record(v.union(v.id("users"), v.id("documents")), v.number()), + ); + }); + + test("key = other", () => { + testZodToConvexInputAndOutput( + z.record(z.union([zid("users"), z.literal("none")]), z.number()), + v.record(v.string(), v.number()), + ); + }); + }); + + test("readonly", () => { + testZodToConvexInputAndOutput( + z.array(z.string()).readonly(), + v.array(v.string()), + ); + }); + + // Discriminated union + test("discriminated union", () => { + testZodToConvexInputAndOutput( + z.discriminatedUnion("status", [ + z.object({ status: z.literal("success"), data: z.string() }), + z.object({ status: z.literal("failed"), error: z.string() }), + ]), + v.union( + v.object({ status: v.literal("success"), data: v.string() }), + v.object({ status: v.literal("failed"), error: v.string() }), + ), + ); + }); + + describe("enum", () => { + test("const array", () => { + testZodToConvexInputAndOutput( + z.enum(["Salmon", "Tuna", "Trout"]), + ignoreUnionOrder( + v.union(v.literal("Salmon"), v.literal("Tuna"), v.literal("Trout")), + ), + ); + }); + + test("const array with a number", () => { + testZodToConvexInputAndOutput( + z.enum(["2", "Salmon", "Tuna"]), + ignoreUnionOrder( + v.union(v.literal("2"), v.literal("Salmon"), v.literal("Tuna")), + ), + ); + }); + + test("enum-like object literal", () => { + const Fish = { + Salmon: 0, + Tuna: 1, + } as const; + testZodToConvexInputAndOutput( + z.enum(Fish), + ignoreUnionOrder(v.union(v.literal(0), v.literal(1))), + ); + }); + + test("TypeScript string enum", () => { + enum Fish { + Salmon = 0, + Tuna = 1, + } + + testZodToConvexInputAndOutput( + z.enum(Fish), + // Interestingly, TypeScript enums make Fish.Salmon be its own type, + // even if its value is 0 at runtime. + ignoreUnionOrder(v.union(v.literal(Fish.Salmon), v.literal(Fish.Tuna))), + ); + }); + }); + + // Tuple + describe("tuple", () => { + test("one-element tuple", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string()]), + v.array(v.union(v.string())), // suboptimal, we could remove the union + ); + }); + test("fixed elements, same type", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string(), z.string()]), + v.array(v.union(v.string(), v.string())), // suboptimal, we could remove duplicates + ); + }); + test("fixed elements", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string(), z.number()]), + v.array(v.union(v.string(), v.number())), + ); + }); + test("variadic element, same type", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string()], z.string()), + v.array(v.union(v.string(), v.string())), // suboptimal, we could remove duplicates + ); + }); + test("variadic element", () => { + testZodToConvexInputAndOutput( + z.tuple([z.string()], z.number()), + v.array(v.union(v.string(), v.number())), + ); + }); + }); + + describe("nullable", () => { + test("nullable(string)", () => { + testZodToConvexInputAndOutput( + z.string().nullable(), + v.union(v.string(), v.null()), + ); + }); + test("nullable(number)", () => { + testZodToConvexInputAndOutput( + z.number().nullable(), + v.union(v.number(), v.null()), + ); + }); + test("optional(nullable(string))", () => { + testZodToConvexInputAndOutput( + z.string().nullable().optional(), + v.optional(v.union(v.string(), v.null())), + ); + }); + test("nullable(optional(string)) → swap nullable and optional", () => { + testZodToConvexInputAndOutput( + z.string().optional().nullable(), + v.optional(v.union(v.string(), v.null())), + ); + }); + }); + + test("optional", () => { + testZodToConvexInputAndOutput( + z.string().optional(), + v.optional(v.string()), + ); + }); + + describe("non-optional", () => { + test("id", () => { + testZodToConvexInputAndOutput( + zid("documents").optional().nonoptional(), + v.id("documents"), + ); + }); + test("string", () => { + testZodToConvexInputAndOutput( + z.string().optional().nonoptional(), + v.string(), + ); + }); + test("float64", () => { + testZodToConvexInputAndOutput( + z.float64().optional().nonoptional(), + v.float64(), + ); + }); + test("int64", () => { + testZodToConvexInputAndOutput( + z.int64().optional().nonoptional(), + v.int64(), + ); + }); + test("boolean", () => { + testZodToConvexInputAndOutput( + z.boolean().optional().nonoptional(), + v.boolean(), + ); + }); + test("null", () => { + testZodToConvexInputAndOutput( + z.null().optional().nonoptional(), + v.null(), + ); + }); + test("any", () => { + testZodToConvexInputAndOutput(z.any().optional().nonoptional(), v.any()); + }); + test("literal", () => { + testZodToConvexInputAndOutput( + z.literal(42n).optional().nonoptional(), + v.literal(42n), + ); + }); + test("object", () => { + testZodToConvexInputAndOutput( + z + .object({ + required: z.string(), + optional: z.number().optional(), + }) + .optional() + .nonoptional(), + v.object({ required: v.string(), optional: v.optional(v.number()) }), + ); + }); + test("array", () => { + testZodToConvexInputAndOutput( + z.array(z.int64()).optional().nonoptional(), + v.array(v.int64()), + ); + }); + test("record", () => { + testZodToConvexInputAndOutput( + z.record(z.string(), z.number()).optional().nonoptional(), + v.record(v.string(), v.number()), + ); + }); + test("union", () => { + testZodToConvexInputAndOutput( + z.union([z.number(), z.string()]).optional().nonoptional(), + v.union(v.number(), v.string()), + ); + }); + + test("nonoptional on non-optional type", () => { + testZodToConvexInputAndOutput(z.string().nonoptional(), v.string()); + }); + }); + + test("lazy", () => { + testZodToConvexInputAndOutput( + z.lazy(() => z.string()), + v.string(), + ); + }); + + test("custom", () => { + testZodToConvexInputAndOutput( + z.custom(() => true), + v.any(), + ); + }); + + test("recursive type", () => { + const category = z.object({ + name: z.string(), + get subcategories() { + return z.array(category); + }, + }); + + testZodToConvexInputAndOutput( + category, + // @ts-expect-error -- TypeScript can’t compute the full type and uses `unknown` + v.object({ + name: v.string(), + subcategories: v.array(v.any()), + }), + ); + }); + + test("catch", () => { + testZodToConvexInputAndOutput(z.string().catch("hello"), v.string()); + }); + + describe("template literals", () => { + test("constant string", () => { + testZodToConvexInputAndOutput( + z.templateLiteral(["hi there"]), + v.string() as VString<"hi there", "required">, + ); + }); + test("string interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral(["email: ", z.string()]), + v.string() as VString<`email: ${string}`, "required">, + ); + }); + test("literal interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral(["high", z.literal(5)]), + v.string() as VString<"high5", "required">, + ); + }); + test("nullable interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral([z.nullable(z.literal("grassy"))]), + v.string() as VString<"grassy" | "null", "required">, + ); + }); + test("enum interpolation", () => { + testZodToConvexInputAndOutput( + z.templateLiteral([z.number(), z.enum(["px", "em", "rem"])]), + v.string() as VString<`${number}${"px" | "em" | "rem"}`, "required">, + ); + }); + }); + + test("intersection", () => { + // We could do some more advanced logic here where we compute + // the Convex validator that results from the intersection. + // For now, we simply use v.any() + testZodToConvexInputAndOutput( + z.intersection( + z.object({ key1: z.string() }), + z.object({ key2: z.string() }), + ), + v.any(), + ); + }); + + describe("unencodable types", () => { + test("z.date", () => { + assertUnrepresentableType(z.date()); + }); + test("z.symbol", () => { + assertUnrepresentableType(z.symbol()); + }); + test("z.map", () => { + assertUnrepresentableType(z.map(z.string(), z.string())); + }); + test("z.set", () => { + assertUnrepresentableType(z.set(z.string())); + }); + test("z.promise", () => { + assertUnrepresentableType(z.promise(z.string())); + }); + test("z.file", () => { + assertUnrepresentableType(z.file()); + }); + test("z.function", () => { + assertUnrepresentableType(z.function()); + }); + test("z.void", () => { + assertUnrepresentableType(z.void()); + }); + test("z.undefined", () => { + assertUnrepresentableType(z.undefined()); + }); + test("z.literal(undefined)", () => { + assertUnrepresentableType(z.literal(undefined)); + }); + test("z.literal including undefined", () => { + assertUnrepresentableType(z.literal([123, undefined])); + }); + }); +}); + +describe("zodToConvex", () => { + test("transform", () => { + testZodToConvex( + z.number().transform((s) => s.toString()), + v.number(), // input type + ); + }); + + test("pipe", () => { + testZodToConvex( + z.number().pipe(z.transform((s) => s.toString())), + v.number(), // input type + ); + }); + + test("codec", () => { + testZodToConvex( + z.codec(z.string(), z.number(), { + decode: (s: string) => parseInt(s, 10), + encode: (n: number) => n.toString(), + }), + v.string(), // input type + ); + }); + + test("default", () => { + testZodToConvex(z.string().default("hello"), v.optional(v.string())); + }); + + describe("problematic inputs", () => { + test("unknown", () => { + const someType: unknown = z.string(); + const _asConvex = zodToConvex( + // @ts-expect-error Can’t use unknown + someType, + ); + assert>(); + }); + + test("ZodType", () => { + const someType: zCore.$ZodType = z.string(); + const _asConvex = zodToConvex(someType); + assert>(); + }); + + test("ZodType", () => { + const someType: zCore.$ZodType = z.string(); + const _asConvex = zodToConvex(someType); + assert>(); + }); + + test("any type", () => { + const someType: any = z.string(); + const _asConvex = zodToConvex(someType); + assert>(); + }); + }); + + describe("lazy", () => { + test("throwing", () => { + expect(() => + zodToConvex( + z.lazy((): zCore.$ZodString => { + throw new Error("This shouldn’t throw but it did"); + }), + ), + ).toThrowError("This shouldn’t throw but it did"); + }); + }); +}); + +describe("zodOutputToConvex", () => { + test("transform", () => { + testZodOutputToConvex( + z.number().transform((s) => s.toString()), + v.any(), // this transform doesn’t hold runtime info about the output type + ); + }); + + test("pipe", () => { + testZodOutputToConvex( + z.number().pipe(z.transform((s) => s.toString())), + v.any(), // this transform doesn’t hold runtime info about the output type + ); + }); + + test("codec", () => { + testZodOutputToConvex( + z.codec(z.string(), z.number(), { + decode: (s: string) => parseInt(s, 10), + encode: (n: number) => n.toString(), + }), + v.number(), // output type + ); + }); + + test("default", () => { + testZodOutputToConvex(z.string().default("hello"), v.string()); + }); +}); + +test("zodToConvexFields", () => { + const convexFields = zodToConvexFields({ + name: z.string(), + optional: z.number().optional(), + nullable: z.string().nullable(), + transform: z.number().transform((z) => z.toString()), + }); + + assert< + Equals< + typeof convexFields, + { + name: VString; + optional: VOptional; + nullable: VUnion; + transform: VFloat64; + } + > + >(); + + expect(convexFields).toEqual({ + name: v.string(), + optional: v.optional(v.number()), + nullable: v.union(v.string(), v.null()), + transform: v.number(), + }); +}); + +test("zodOutputToConvexFields", () => { + const convexFields = zodOutputToConvexFields({ + name: z.string(), + optional: z.number().optional(), + nullable: z.string().nullable(), + transform: z.number().transform((z) => z.toString()), + }); + + assert< + Equals< + typeof convexFields, + { + name: VString; + optional: VOptional; + nullable: VUnion; + transform: VAny; + } + > + >(); + + expect(convexFields).toEqual({ + name: v.string(), + optional: v.optional(v.number()), + nullable: v.union(v.string(), v.null()), + transform: v.any(), + }); +}); + +test("withSystemFields", () => { + const sysFieldsShape = withSystemFields("users", { + name: z.string(), + age: z.number().optional(), + }); + + // Type assertion - sysFieldsShape should have _id and _creationTime + assert< + Equals< + typeof sysFieldsShape, + { + name: z.ZodString; + age: z.ZodOptional; + } & { _id: Zid<"users">; _creationTime: z.ZodNumber } + > + >(); + + expect(Object.keys(sysFieldsShape)).to.deep.equal([ + "name", + "age", + "_id", + "_creationTime", + ]); + + for (const [key, value] of Object.entries(sysFieldsShape)) { + if (key === "_id") { + expect(zodToConvex(value)).to.deep.equal(v.id("users")); + continue; + } + + expect( + isSameType(value, sysFieldsShape[key as keyof typeof sysFieldsShape]), + ).toBe(true); + } +}); + +describe("testing infrastructure", () => { + test("test methods don’t typecheck if the IsOptional value of the result isn’t set correctly", () => { + // eslint-disable-next-line no-constant-condition + if (false) { + // typecheck only + testZodToConvex( + z.string(), + // @ts-expect-error -- This error should be caught by TypeScript + v.optional(v.string()), + ); + testZodToConvex( + z.string().optional(), + // @ts-expect-error -- This error should be caught by TypeScript + v.string(), + ); + + testZodOutputToConvex( + z.string(), + // @ts-expect-error -- This error should be caught by TypeScript + v.optional(v.string()), + ); + testZodOutputToConvex( + z.string().optional(), + // @ts-expect-error -- This error should be caught by TypeScript + v.string(), + ); + + testZodToConvexInputAndOutput( + z.string(), + // @ts-expect-error -- This error should be caught by TypeScript + v.optional(v.string()), + ); + testZodToConvexInputAndOutput( + z.string().optional(), + // @ts-expect-error -- This error should be caught by TypeScript + v.string(), + ); + } + }); + + test("test methods typecheck if the IsOptional value of the result is set correctly", () => { + testZodToConvex(z.string().optional(), v.optional(v.string())); + testZodToConvex(z.string(), v.string()); + + testZodOutputToConvex(z.string().optional(), v.optional(v.string())); + testZodOutputToConvex(z.string(), v.string()); + + testZodToConvexInputAndOutput( + z.string().optional(), + v.optional(v.string()), + ); + testZodToConvexInputAndOutput(z.string(), v.string()); + }); + + test("removeUnionOrder", () => { + const unionWithOrder = v.union(v.literal(1), v.literal(2), v.literal(3)); + assert< + Equals< + typeof unionWithOrder, + VUnion< + 1 | 2 | 3, + [ + VLiteral<1, "required">, + VLiteral<2, "required">, + VLiteral<3, "required">, + ], + "required", + never + > + > + >(); + + const _unionWithoutOrder = ignoreUnionOrder(unionWithOrder); + assert< + Equals< + typeof _unionWithoutOrder, + VUnion< + 1 | 2 | 3, + ( + | VLiteral<1, "required"> + | VLiteral<2, "required"> + | VLiteral<3, "required"> + )[], + "required", + never + > + > + >(); + }); + + test("assertUnrepresentableType", () => { + expect(() => { + assertUnrepresentableType(z.string()); + }).toThrowError(); + }); +}); + +export function testZodToConvex< + Z extends zCore.$ZodType, + Expected extends GenericValidator, +>( + validator: Z, + expected: Expected & + (ExtractOptional extends infer IsOpt extends OptionalProperty + ? Equals> extends true + ? // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {} + : "Expected type must exactly match ConvexValidatorFromZod" + : "Could not extract IsOptional from Expected"), +) { + const actual = zodToConvex(validator); + expect(actual).to.deep.equal(expected); +} + +export function testZodOutputToConvex< + Z extends zCore.$ZodType, + Expected extends GenericValidator, +>( + validator: Z, + expected: Expected & + (ExtractOptional extends infer IsOpt extends OptionalProperty + ? Equals> extends true + ? // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {} + : "Expected type must exactly match ConvexValidatorFromZodOutput" + : "Could not extract IsOptional from Expected"), +) { + const actual = zodOutputToConvex(validator); + expect(actual).to.deep.equal(expected); +} + +// Extract the optionality (IsOptional) from a validator type +type ExtractOptional = + V extends Validator ? IsOptional : never; + +export function testZodToConvexInputAndOutput< + Z extends zCore.$ZodType, + Expected extends GenericValidator, +>( + validator: Z, + expected: Expected & + (ExtractOptional extends infer IsOpt extends OptionalProperty + ? Equals> extends true + ? Equals> extends true + ? // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {} + : "Expected type must exactly match ConvexValidatorFromZodOutput" + : "Expected type must exactly match ConvexValidatorFromZod" + : "Could not extract IsOptional from Expected"), +) { + testZodToConvex(validator, expected as any); + testZodOutputToConvex(validator, expected as any); +} + +type MustBeUnrepresentable = [ + ConvexValidatorFromZod, +] extends [never] + ? never + : [ConvexValidatorFromZodOutput] extends [never] + ? never + : Z; + +export function assertUnrepresentableType< + Z extends MustBeUnrepresentable, +>(validator: Z) { + expect(() => { + zodToConvex(validator); + }).toThrowError(); + expect(() => { + zodOutputToConvex(validator); + }).toThrowError(/(is not supported in Convex|is not a valid Convex value)/); +} + +/** + * The TypeScript type of Convex union validators has a tuple type argument: + * + * ```ts + * const sampleUnionValidator: VUnion< + * string | number, + * [ + * VLiteral<1, "required">, + * VLiteral<2, "required">, + * VLiteral<3, "required">, + * ], + * "required", + * never + * > = v.union(v.literal(1), v.literal(2), v.literal(3)); + * ``` + * + * Some Zod schemas (e.g. `v.enum(…)` and `v.literal([…])`) store their inner + * types as a union and not as a tuple type. + * Since TypeScript has no guarantees about the order of union members, + * the type returned by `zodToConvex` must be imprecise, for instance: + * + * ```ts + * // The inner type 1 | 2 | 3, so any type transformation that we do could + * // result in a different order of the union members + * const zodLiteralValidator: z.ZodLiteral<1 | 2 | 3> = z.literal([1, 2, 3]); + * + * const sampleUnionValidator: VUnion< + * string | number, + * ( + * | VLiteral<1, "required"> + * | VLiteral<2, "required"> + * | VLiteral<3, "required"> + * )[], + * "required", + * never + * > = zodToConvex(zodLiteralValidator); + * ``` + * + * This function takes a union validator and returns it with a more imprecise + * type where the order of the union members is not guaranteed. + */ +export function ignoreUnionOrder< + Type, + Members extends Validator[], + IsOptional extends OptionalProperty, + FieldPaths extends string, +>( + union: VUnion, +): VUnion< + Type, + // ↓ tuple to array of union + Members[number][], + IsOptional, + FieldPaths +> { + return union; +} + +export function assert<_T extends true>() {}