From 415b26adc1e2a3b7187441d0ee4be4a84a438f6e Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Sun, 12 Jul 2026 23:44:45 +0800 Subject: [PATCH 1/6] feat(temporal-zod): add oRPC support via temporal-zod/orpc interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oRPC's ZodToJsonSchemaConverter re-implements Zod → JSON Schema instead of calling z.toJSONSchema(), so it ignores the .meta() metadata temporal-zod attaches and emits a messy anyOf for each z.union-based validator. Add a temporal-zod/orpc entry point exporting temporalJsonSchemaInterceptor, which short-circuits the converter and returns the registered JSON Schema (type/format/pattern) directly. @orpc/zod is an optional peer dependency and only its types are imported, so there is no new runtime dependency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/orpc-support.md | 10 +++ bun.lock | 56 ++++++++++++++ packages/temporal-zod/README.md | 30 ++++++++ packages/temporal-zod/package.json | 11 +++ packages/temporal-zod/src/orpc.test.ts | 102 +++++++++++++++++++++++++ packages/temporal-zod/src/orpc.ts | 80 +++++++++++++++++++ 6 files changed, 289 insertions(+) create mode 100644 .changeset/orpc-support.md create mode 100644 packages/temporal-zod/src/orpc.test.ts create mode 100644 packages/temporal-zod/src/orpc.ts diff --git a/.changeset/orpc-support.md b/.changeset/orpc-support.md new file mode 100644 index 0000000..0f7477e --- /dev/null +++ b/.changeset/orpc-support.md @@ -0,0 +1,10 @@ +--- +"temporal-zod": minor +--- + +Add oRPC support via a new `temporal-zod/orpc` entry point. It exports +`temporalJsonSchemaInterceptor`, which you pass to oRPC's +`ZodToJsonSchemaConverter` so Temporal validators render as correct string JSON +Schemas (with `format`/`pattern`) instead of the `anyOf` oRPC would otherwise +produce. `@orpc/zod` is an optional peer dependency and only its types are +imported, so there is no new runtime dependency. diff --git a/bun.lock b/bun.lock index ee76436..204c6d4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "monorepo", @@ -117,6 +118,7 @@ "devDependencies": { "@macalinao/eslint-config": "^8.1.0", "@macalinao/tsconfig": "^3.2.5", + "@orpc/zod": "^1.14.0", "@types/bun": "latest", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", @@ -126,9 +128,13 @@ "zod": "^4.1.11", }, "peerDependencies": { + "@orpc/zod": "^1.14.0", "temporal-polyfill": "^0.3.0", "zod": "^4.0.0", }, + "optionalPeers": [ + "@orpc/zod", + ], }, }, "packages": { @@ -230,6 +236,36 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@orpc/client": ["@orpc/client@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "@orpc/standard-server-fetch": "1.14.7", "@orpc/standard-server-peer": "1.14.7" } }, "sha512-/yloM96/6Z3qlmEgW4/VMYUnhxXbCr6lIJiKSHmsvhgKcwrUPOPJrL3p0ZpItz5SKVtQSwXIhT5KcT/3va/Aew=="], + + "@orpc/contract": ["@orpc/contract@1.14.7", "", { "dependencies": { "@orpc/client": "1.14.7", "@orpc/shared": "1.14.7", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-LVAzjOfwW+5L4z5hGOkGGsocFF4URLmQBujrvUr6/TLeDmiiY2kB89jyJ793U40ol9/Ba935g/b0pBajiQhPWA=="], + + "@orpc/interop": ["@orpc/interop@1.14.7", "", {}, "sha512-2evzgNl6CMh5MZ3xHLHYX01siGZCQu9E6mcuPTSM3G7wQKG9ozvtGjAiH6dBWKfNUQRAavjJpQVoEW9LJ5VICA=="], + + "@orpc/json-schema": ["@orpc/json-schema@1.14.7", "", { "dependencies": { "@orpc/contract": "1.14.7", "@orpc/interop": "1.14.7", "@orpc/openapi": "1.14.7", "@orpc/server": "1.14.7", "@orpc/shared": "1.14.7", "json-schema-typed": "^8.0.2" } }, "sha512-N4yvqv6f3BXLh5VABXiwUqhrEJFQLE/c8arIwa0tGRhXqAFXFQqaeGHYD+POGVQT7muu7l4yvnJd8jBJOkcsfQ=="], + + "@orpc/openapi": ["@orpc/openapi@1.14.7", "", { "dependencies": { "@orpc/client": "1.14.7", "@orpc/contract": "1.14.7", "@orpc/interop": "1.14.7", "@orpc/openapi-client": "1.14.7", "@orpc/server": "1.14.7", "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-elTxq+tWqQvUaU3nsGQclsJ5qkxUJe3lZxKQyCqmrt7r3oG3NMa0W8efWzXVrxJ6zMIav1l5lTKHaU/QIv6NOw=="], + + "@orpc/openapi-client": ["@orpc/openapi-client@1.14.7", "", { "dependencies": { "@orpc/client": "1.14.7", "@orpc/contract": "1.14.7", "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7" } }, "sha512-TRNFa1q/4OkhygvRqzlH7AVfSR52xbBNSWVIvEeJoYX4lZvFEXqlSvyiejQpoR5gjhqDvFMe3OAUMCpKvYP0Kw=="], + + "@orpc/server": ["@orpc/server@1.14.7", "", { "dependencies": { "@orpc/client": "1.14.7", "@orpc/contract": "1.14.7", "@orpc/interop": "1.14.7", "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "@orpc/standard-server-aws-lambda": "1.14.7", "@orpc/standard-server-fastify": "1.14.7", "@orpc/standard-server-fetch": "1.14.7", "@orpc/standard-server-node": "1.14.7", "@orpc/standard-server-peer": "1.14.7", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-bN38rwVELPx/AEHnOIKlJz0Vo2OnuzGpCMdMtspqKoiposkpB4jqx2CMFZCMzj4ew43m+sUSUmbQZ001Q4fgTA=="], + + "@orpc/shared": ["@orpc/shared@1.14.7", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-uEvbm+IGxUyQMpVYufU5mEPflyZoF1Nh2yFBqHgmu5QycXZ5GGb5GdwtMa+IE8WXZSHQ4wOgftBNtzGfX/rBIg=="], + + "@orpc/standard-server": ["@orpc/standard-server@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7" } }, "sha512-N+7I4SzYoSVyf5lRbANGz9oBVmZSM/nm2JlxAP+UPSbWnqrjltSFFHVlSdp4wXSb39LVjT74uSLZ9Da1wOyLeg=="], + + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "@orpc/standard-server-fetch": "1.14.7", "@orpc/standard-server-node": "1.14.7" } }, "sha512-Ov+xeJqga0S6346mxZ8xAyZVHudXDSnl9sKgZKYa1jmp8KV221Q9yYV7c2fmNE5NmDr+uNZS9nmUtB38Fz08mA=="], + + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "@orpc/standard-server-node": "1.14.7" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-f5TXZ/qU/L0mMpJ4sz3/YvBrXqKj4d5cgfx8uriA1sSqMTyuadYXp75pywnW6WQ8tuJ9rARVj35l7mx7K6VvpQ=="], + + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7" } }, "sha512-bkp9pDDX36enqVC5Q2+o4fofPFR9COI9FpdhLI3mGlQuV6NT9ZBjrscXv57hUPTc7g0aKDOzjiAo0NHoCfZc+g=="], + + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "@orpc/standard-server-fetch": "1.14.7" } }, "sha512-5ESH31uBbIkW3FyVmTz6cbdTAxm+y8gDhE7lXC3B6+SuZrNBetaPZmv9FjhyGHm2tIKUCjZfcf1bzXmCUV0vEw=="], + + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7" } }, "sha512-5YzX7goqeg5cZv+R58SCZHKo0Jk9CiHgepzJYM5oQYoLbdqoxGC4yZG12sqwN/CTX7nZkE2nnbfk6l3+YD/reg=="], + + "@orpc/zod": ["@orpc/zod@1.14.7", "", { "dependencies": { "@orpc/json-schema": "1.14.7", "@orpc/openapi": "1.14.7", "@orpc/shared": "1.14.7", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.7", "@orpc/server": "1.14.7", "zod": ">=3.25.0" } }, "sha512-t4JCHF70QrV9apYKuJE4dUl0AwoHcjenPbSPfP7j76u50qDlean3E5XGQkxTS4FHWbwlOVY5bhSQzKcegN/MBg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], @@ -240,6 +276,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg=="], @@ -314,6 +352,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -424,6 +464,8 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -456,6 +498,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], @@ -498,6 +542,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -506,6 +552,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -532,6 +580,8 @@ "superjson-temporal": ["superjson-temporal@workspace:packages/superjson-temporal"], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "temporal-polyfill": ["temporal-polyfill@0.3.0", "", { "dependencies": { "temporal-spec": "0.3.0" } }, "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g=="], "temporal-quarter-fns": ["temporal-quarter-fns@workspace:packages/temporal-quarter-fns"], @@ -554,6 +604,8 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -570,6 +622,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -592,6 +646,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@orpc/zod/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], diff --git a/packages/temporal-zod/README.md b/packages/temporal-zod/README.md index 55c4744..106e196 100644 --- a/packages/temporal-zod/README.md +++ b/packages/temporal-zod/README.md @@ -69,6 +69,36 @@ import { zPlainDate, zInstant } from "temporal-zod/base"; This is backwards-compatible with the pre-JSON Schema versions of `temporal-zod`. +### With oRPC + +[oRPC](https://orpc.unnoq.com) generates its OpenAPI documents with its own +`ZodToJsonSchemaConverter` (from `@orpc/zod/zod4`), which re-implements the +Zod → JSON Schema conversion instead of calling `z.toJSONSchema()`. Because a +Temporal validator is a `z.union([...])` under the hood, the converter would +otherwise emit a messy `anyOf` and drop the `format`/`pattern` metadata. + +`temporal-zod/orpc` exports an interceptor that fixes this. Pass it to the +converter and every Temporal validator renders as the correct string schema: + +```typescript +import { OpenAPIGenerator } from "@orpc/openapi"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { temporalJsonSchemaInterceptor } from "temporal-zod/orpc"; +// Import the main entry once so the JSON Schema metadata is registered. +import "temporal-zod"; + +const generator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter({ + interceptors: [temporalJsonSchemaInterceptor], + }), + ], +}); +``` + +`@orpc/zod` is an optional peer dependency — the `temporal-zod/orpc` module only +imports its types, so it adds no runtime dependency to `temporal-zod`. + ### With tRPC If you are using [tRPC](https://trpc.io/), you likely use Zod to validate your inputs and outputs. However, when using it with [Tanstack Query](https://tanstack.com/query), since the Temporal types get mapped to an object, you should ensure that you are using the instance of the Temporal type rather than the one with type coercion. Otherwise, the query cache will not work as expected. diff --git a/packages/temporal-zod/package.json b/packages/temporal-zod/package.json index a5a22c0..e4bb9e8 100644 --- a/packages/temporal-zod/package.json +++ b/packages/temporal-zod/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@macalinao/eslint-config": "^8.1.0", "@macalinao/tsconfig": "^3.2.5", + "@orpc/zod": "^1.14.0", "@types/bun": "latest", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", @@ -47,6 +48,10 @@ "./base": { "types": "./dist/base/index.d.ts", "import": "./dist/base/index.js" + }, + "./orpc": { + "types": "./dist/orpc.d.ts", + "import": "./dist/orpc.js" } }, "scripts": { @@ -60,7 +65,13 @@ "access": "public" }, "peerDependencies": { + "@orpc/zod": "^1.14.0", "temporal-polyfill": "^0.3.0", "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "@orpc/zod": { + "optional": true + } } } diff --git a/packages/temporal-zod/src/orpc.test.ts b/packages/temporal-zod/src/orpc.test.ts new file mode 100644 index 0000000..b67c89e --- /dev/null +++ b/packages/temporal-zod/src/orpc.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import * as z from "zod"; +import { + DURATION_PATTERN, + INSTANT_PATTERN, + PLAIN_DATE_PATTERN, + zDuration, + zInstant, + zInstantInstance, + zPlainDate, + zPlainTime, +} from "./json-schemas.js"; +import { temporalJsonSchemaInterceptor } from "./orpc.js"; + +const converter = new ZodToJsonSchemaConverter({ + interceptors: [temporalJsonSchemaInterceptor], +}); +// Without the interceptor, to prove it is what fixes the output. +const bareConverter = new ZodToJsonSchemaConverter(); + +function convert(schema: z.ZodType): Record { + const [, json] = converter.convert(schema, { strategy: "input" }); + return json as Record; +} + +describe("temporalJsonSchemaInterceptor", () => { + test("zInstant converts to a clean string schema with format + pattern", () => { + expect(convert(zInstant)).toEqual({ + type: "string", + description: + "An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)", + format: "date-time", + pattern: INSTANT_PATTERN, + }); + }); + + test("instance variant is fixed too (no id in metadata)", () => { + expect(convert(zInstantInstance)).toEqual({ + type: "string", + description: + "An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)", + format: "date-time", + pattern: INSTANT_PATTERN, + }); + }); + + test("no leftover anyOf and no dangling $ref/id", () => { + const json = convert(zPlainDate); + expect(json).not.toHaveProperty("anyOf"); + expect(json).not.toHaveProperty("$ref"); + expect(json).not.toHaveProperty("id"); + expect(json).toMatchObject({ + type: "string", + format: "date", + pattern: PLAIN_DATE_PATTERN, + }); + }); + + test("types without a format omit it", () => { + const json = convert(zPlainTime); + expect(json).not.toHaveProperty("format"); + expect(json.type).toBe("string"); + }); + + test("format: duration is preserved", () => { + expect(convert(zDuration)).toMatchObject({ + type: "string", + format: "duration", + pattern: DURATION_PATTERN, + }); + }); + + test("works inside a z.object()", () => { + const [, json] = converter.convert( + z.object({ start: zInstant, span: zDuration }), + { strategy: "input" }, + ); + expect(json).toMatchObject({ + type: "object", + properties: { + start: { type: "string", format: "date-time" }, + span: { type: "string", format: "duration" }, + }, + required: ["start", "span"], + }); + }); + + test("bare converter (no interceptor) produces the broken anyOf output", () => { + const [, json] = bareConverter.convert(zInstant, { strategy: "input" }); + // Demonstrates why the interceptor is needed: oRPC walks the underlying + // z.union and loses the format/pattern metadata. + expect(json).toHaveProperty("anyOf"); + expect(json).not.toHaveProperty("format"); + }); + + test("does not touch schemas whose meta has no JSON Schema type", () => { + const plain = z.string().meta({ examples: ["hello"] }); + const [, json] = converter.convert(plain, { strategy: "input" }); + expect(json).toMatchObject({ type: "string", examples: ["hello"] }); + }); +}); diff --git a/packages/temporal-zod/src/orpc.ts b/packages/temporal-zod/src/orpc.ts new file mode 100644 index 0000000..e5a657c --- /dev/null +++ b/packages/temporal-zod/src/orpc.ts @@ -0,0 +1,80 @@ +/** + * [oRPC](https://orpc.unnoq.com) support for `temporal-zod`. + * + * oRPC's `ZodToJsonSchemaConverter` (from `@orpc/zod/zod4`) does **not** use + * Zod's own `z.toJSONSchema()`. Instead it re-implements the conversion with its + * own tree walk, so the `.meta()` JSON Schema that `temporal-zod` attaches to + * each validator is ignored: a Temporal validator is a `z.union([...])` under the + * hood, so oRPC emits a messy `anyOf` and drops the `format`/`pattern` metadata. + * + * This module fixes that with a single {@link temporalJsonSchemaInterceptor} + * you pass to the converter: + * + * @example + * ```typescript + * import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; + * import { OpenAPIGenerator } from "@orpc/openapi"; + * import { temporalJsonSchemaInterceptor } from "temporal-zod/orpc"; + * // Import the main entry once so the JSON Schema metadata is registered. + * import "temporal-zod"; + * + * const generator = new OpenAPIGenerator({ + * schemaConverters: [ + * new ZodToJsonSchemaConverter({ + * interceptors: [temporalJsonSchemaInterceptor], + * }), + * ], + * }); + * ``` + * + * `@orpc/zod` is an optional peer dependency: this module only imports its + * *types*, so it adds no runtime dependency to `temporal-zod`. + * + * @module + * @see {@link https://github.com/macalinao/temporal-utils/tree/master/packages/temporal-zod | temporal-zod on GitHub} + */ +import type { ZodToJsonSchemaConverterOptions } from "@orpc/zod/zod4"; +import * as z from "zod"; + +/** + * The interceptor type accepted by oRPC's `ZodToJsonSchemaConverter`. + * + * Derived from `@orpc/zod`'s own option types so it stays in sync across + * versions without depending on any non-exported symbol. + */ +export type TemporalSchemaInterceptor = NonNullable< + ZodToJsonSchemaConverterOptions["interceptors"] +>[number]; + +/** + * Makes oRPC's `ZodToJsonSchemaConverter` honor a `temporal-zod` validator's + * full JSON Schema metadata. + * + * oRPC's `interceptors` are onion-middleware around every node's conversion: an + * interceptor may short-circuit by returning its own `[required, jsonSchema]` + * instead of calling `next()`, fully replacing the structural conversion (so no + * leftover `anyOf` from the underlying `z.union`). For any schema whose + * global-registry metadata carries a JSON Schema `type` — which every + * `temporal-zod` validator populates — we return that metadata directly, so + * `zInstant` → `{ type: "string", format: "date-time", pattern, … }`. This is + * general over every Temporal type (`zPlainDate` → `date`, `zDuration` → + * `duration`, …). + * + * Schemas without a `type` in their metadata (e.g. a consumer's own + * `.meta({ examples })` / `.meta({ id })`) fall through to `next()` untouched, + * so oRPC's `$ref` dedup and example rendering are unaffected. The registry `id` + * is dropped: we inline the schema rather than emit a `$ref` to a `$def` the + * converter never registers. + */ +export const temporalJsonSchemaInterceptor: TemporalSchemaInterceptor = ( + options, +) => { + const meta = z.globalRegistry.get(options.schema) as + | (Record & { type?: unknown }) + | undefined; + if (meta && typeof meta.type === "string") { + const { id: _id, ...jsonSchema } = meta; + return [true, jsonSchema] as ReturnType; + } + return options.next(); +}; From b1eda1cb51572920772cbe4d17fca7393a0e447c Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Mon, 13 Jul 2026 22:47:06 +0800 Subject: [PATCH 2/6] refactor(temporal-zod): export oRPC interceptor from main entry, drop @orpc dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the temporal-zod/orpc subpath export in favour of exporting temporalJsonSchemaInterceptor from the main entry. A type-only import still has to resolve in the shipped .d.ts, so re-exporting the previous @orpc/zod-typed interceptor from the main entry would have broken consumers who don't install @orpc/zod and don't set skipLibCheck (verified: TS2307). Instead the interceptor is now typed structurally and imports nothing from @orpc/* — so temporal-zod drops the optional peer dependency entirely and consumers who don't use oRPC have nothing to resolve. A test asserts the structural type is still assignable to oRPC's real interceptor signature, so drift fails the build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/orpc-support.md | 14 +++--- packages/temporal-zod/README.md | 11 +++-- packages/temporal-zod/package.json | 10 ----- packages/temporal-zod/src/index.ts | 1 + packages/temporal-zod/src/orpc.test.ts | 15 +++++++ packages/temporal-zod/src/orpc.ts | 60 ++++++++++++++++++++------ 6 files changed, 75 insertions(+), 36 deletions(-) diff --git a/.changeset/orpc-support.md b/.changeset/orpc-support.md index 0f7477e..47354cc 100644 --- a/.changeset/orpc-support.md +++ b/.changeset/orpc-support.md @@ -2,9 +2,11 @@ "temporal-zod": minor --- -Add oRPC support via a new `temporal-zod/orpc` entry point. It exports -`temporalJsonSchemaInterceptor`, which you pass to oRPC's -`ZodToJsonSchemaConverter` so Temporal validators render as correct string JSON -Schemas (with `format`/`pattern`) instead of the `anyOf` oRPC would otherwise -produce. `@orpc/zod` is an optional peer dependency and only its types are -imported, so there is no new runtime dependency. +Add oRPC support: `temporal-zod` now exports `temporalJsonSchemaInterceptor`, +which you pass to oRPC's `ZodToJsonSchemaConverter` so Temporal validators render +as correct string JSON Schemas (with `format`/`pattern`) instead of the `anyOf` +oRPC would otherwise produce. + +`temporal-zod` takes on no dependency on `@orpc/*` — not even a type-only one. +The interceptor is typed structurally, so consumers who don't use oRPC pay +nothing and nothing needs to resolve at typecheck time. diff --git a/packages/temporal-zod/README.md b/packages/temporal-zod/README.md index 106e196..de13e96 100644 --- a/packages/temporal-zod/README.md +++ b/packages/temporal-zod/README.md @@ -77,15 +77,13 @@ Zod → JSON Schema conversion instead of calling `z.toJSONSchema()`. Because a Temporal validator is a `z.union([...])` under the hood, the converter would otherwise emit a messy `anyOf` and drop the `format`/`pattern` metadata. -`temporal-zod/orpc` exports an interceptor that fixes this. Pass it to the +`temporal-zod` exports `temporalJsonSchemaInterceptor` to fix this. Pass it to the converter and every Temporal validator renders as the correct string schema: ```typescript import { OpenAPIGenerator } from "@orpc/openapi"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; -import { temporalJsonSchemaInterceptor } from "temporal-zod/orpc"; -// Import the main entry once so the JSON Schema metadata is registered. -import "temporal-zod"; +import { temporalJsonSchemaInterceptor } from "temporal-zod"; const generator = new OpenAPIGenerator({ schemaConverters: [ @@ -96,8 +94,9 @@ const generator = new OpenAPIGenerator({ }); ``` -`@orpc/zod` is an optional peer dependency — the `temporal-zod/orpc` module only -imports its types, so it adds no runtime dependency to `temporal-zod`. +`temporal-zod` does not depend on `@orpc/*` at all — not even for types. The +interceptor is typed structurally, so if you don't use oRPC you pay nothing and +nothing needs to resolve. ### With tRPC diff --git a/packages/temporal-zod/package.json b/packages/temporal-zod/package.json index e4bb9e8..0c1e6c1 100644 --- a/packages/temporal-zod/package.json +++ b/packages/temporal-zod/package.json @@ -48,10 +48,6 @@ "./base": { "types": "./dist/base/index.d.ts", "import": "./dist/base/index.js" - }, - "./orpc": { - "types": "./dist/orpc.d.ts", - "import": "./dist/orpc.js" } }, "scripts": { @@ -65,13 +61,7 @@ "access": "public" }, "peerDependencies": { - "@orpc/zod": "^1.14.0", "temporal-polyfill": "^0.3.0", "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "@orpc/zod": { - "optional": true - } } } diff --git a/packages/temporal-zod/src/index.ts b/packages/temporal-zod/src/index.ts index dbdba6d..a884b05 100644 --- a/packages/temporal-zod/src/index.ts +++ b/packages/temporal-zod/src/index.ts @@ -25,3 +25,4 @@ * @see {@link https://github.com/macalinao/temporal-utils/tree/master/packages/temporal-zod | temporal-zod on GitHub} */ export * from "./json-schemas.js"; +export * from "./orpc.js"; diff --git a/packages/temporal-zod/src/orpc.test.ts b/packages/temporal-zod/src/orpc.test.ts index b67c89e..20470f6 100644 --- a/packages/temporal-zod/src/orpc.test.ts +++ b/packages/temporal-zod/src/orpc.test.ts @@ -1,3 +1,4 @@ +import type { ZodToJsonSchemaConverterOptions } from "@orpc/zod/zod4"; import { describe, expect, test } from "bun:test"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import * as z from "zod"; @@ -25,6 +26,20 @@ function convert(schema: z.ZodType): Record { } describe("temporalJsonSchemaInterceptor", () => { + /** + * `orpc.ts` types the interceptor structurally so `temporal-zod` imports + * nothing from `@orpc/*` — even an `import type` would have to resolve in the + * shipped `.d.ts`, breaking consumers without `@orpc/zod` who don't set + * `skipLibCheck`. This assignment is what keeps the structural type honest: + * it fails to compile if oRPC's real interceptor signature ever drifts. + */ + test("structural type is assignable to oRPC's interceptor signature", () => { + const asOrpcInterceptor: NonNullable< + ZodToJsonSchemaConverterOptions["interceptors"] + >[number] = temporalJsonSchemaInterceptor; + expect(asOrpcInterceptor).toBe(temporalJsonSchemaInterceptor); + }); + test("zInstant converts to a clean string schema with format + pattern", () => { expect(convert(zInstant)).toEqual({ type: "string", diff --git a/packages/temporal-zod/src/orpc.ts b/packages/temporal-zod/src/orpc.ts index e5a657c..f5757f9 100644 --- a/packages/temporal-zod/src/orpc.ts +++ b/packages/temporal-zod/src/orpc.ts @@ -12,11 +12,9 @@ * * @example * ```typescript - * import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; * import { OpenAPIGenerator } from "@orpc/openapi"; - * import { temporalJsonSchemaInterceptor } from "temporal-zod/orpc"; - * // Import the main entry once so the JSON Schema metadata is registered. - * import "temporal-zod"; + * import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; + * import { temporalJsonSchemaInterceptor } from "temporal-zod"; * * const generator = new OpenAPIGenerator({ * schemaConverters: [ @@ -27,24 +25,51 @@ * }); * ``` * - * `@orpc/zod` is an optional peer dependency: this module only imports its - * *types*, so it adds no runtime dependency to `temporal-zod`. + * This module deliberately imports **nothing** from `@orpc/*` — not even types. + * {@link TemporalSchemaInterceptor} is typed structurally, so it is assignable to + * oRPC's `interceptors` option without `temporal-zod` depending on oRPC at all. + * A `import type` would still have to resolve in the shipped `.d.ts`, which would + * break consumers who don't install `@orpc/zod` and don't set `skipLibCheck`. + * The compatibility of this structural type with oRPC's real one is asserted in + * `orpc.test.ts`, where `@orpc/zod` is available as a dev dependency. * * @module * @see {@link https://github.com/macalinao/temporal-utils/tree/master/packages/temporal-zod | temporal-zod on GitHub} */ -import type { ZodToJsonSchemaConverterOptions } from "@orpc/zod/zod4"; import * as z from "zod"; /** - * The interceptor type accepted by oRPC's `ZodToJsonSchemaConverter`. + * The JSON Schema a Temporal validator converts to. Every `temporal-zod` + * validator is an ISO 8601 string, optionally with a `format` and a `pattern`. + */ +export interface TemporalJsonSchema { + type: "string"; + description?: string; + format?: string; + pattern?: string; +} + +/** + * The subset of oRPC's interceptor context that {@link temporalJsonSchemaInterceptor} + * actually uses. oRPC passes additional fields (`options`, `lazyDepth`, + * `isHandledCustomJSONSchema`); they are accepted and ignored. + */ +export interface TemporalSchemaInterceptorOptions { + schema: z.core.$ZodType; + next: () => TResult; +} + +/** + * Structural type for an interceptor accepted by oRPC's `ZodToJsonSchemaConverter`. * - * Derived from `@orpc/zod`'s own option types so it stays in sync across - * versions without depending on any non-exported symbol. + * Generic over oRPC's result tuple so it stays assignable without naming any + * `@orpc/*` type. See the module docs for why this is not imported from oRPC. */ -export type TemporalSchemaInterceptor = NonNullable< - ZodToJsonSchemaConverterOptions["interceptors"] ->[number]; +export type TemporalSchemaInterceptor = < + TResult extends [required: boolean, jsonSchema: unknown], +>( + options: TemporalSchemaInterceptorOptions, +) => TResult | [required: true, jsonSchema: TemporalJsonSchema]; /** * Makes oRPC's `ZodToJsonSchemaConverter` honor a `temporal-zod` validator's @@ -65,6 +90,13 @@ export type TemporalSchemaInterceptor = NonNullable< * so oRPC's `$ref` dedup and example rendering are unaffected. The registry `id` * is dropped: we inline the schema rather than emit a `$ref` to a `$def` the * converter never registers. + * + * @example + * ```typescript + * new ZodToJsonSchemaConverter({ + * interceptors: [temporalJsonSchemaInterceptor], + * }); + * ``` */ export const temporalJsonSchemaInterceptor: TemporalSchemaInterceptor = ( options, @@ -74,7 +106,7 @@ export const temporalJsonSchemaInterceptor: TemporalSchemaInterceptor = ( | undefined; if (meta && typeof meta.type === "string") { const { id: _id, ...jsonSchema } = meta; - return [true, jsonSchema] as ReturnType; + return [true, jsonSchema as unknown as TemporalJsonSchema]; } return options.next(); }; From c31b2cd5d2df549b7527d4c6f98821312214b47a Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Mon, 20 Jul 2026 10:33:38 +0800 Subject: [PATCH 3/6] chore: sync bun.lock after merging origin/master Regenerated lockfile drops the stale @orpc/zod optionalPeer entry left over from the drop-@orpc-dep refactor. --- bun.lock | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bun.lock b/bun.lock index 204c6d4..5b20c92 100644 --- a/bun.lock +++ b/bun.lock @@ -128,13 +128,9 @@ "zod": "^4.1.11", }, "peerDependencies": { - "@orpc/zod": "^1.14.0", "temporal-polyfill": "^0.3.0", "zod": "^4.0.0", }, - "optionalPeers": [ - "@orpc/zod", - ], }, }, "packages": { From 58b4bbcc457c177cb4f359db59e873f50687b5f2 Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Tue, 28 Jul 2026 17:46:51 +0800 Subject: [PATCH 4/6] test(temporal-zod): cover every Temporal type in the oRPC interceptor tests The interceptor is metadata-driven rather than per-type, but the tests only exercised Instant, PlainDate, PlainTime and Duration. Extend them to all eight types, in both the coercing and instance variants, asserting a clean string schema (no anyOf, no $ref, no dangling id), the advertised pattern and format, and that the bare converter is still broken without it. Also add the wire round trip each type goes through in practice: the server serializes with toJSON(), the advertised pattern accepts that string, and the client revives it by parsing with the same validator. ZonedDateTime gets dedicated cases for its bracketed IANA annotation, a non-ISO calendar suffix, and a DST fall-back instant. Record one pre-existing mismatch as characterization tests: the validators parse the [u-ca=...] annotation that toJSON() emits for a non-ISO calendar, but the exported patterns reject it, so the published JSON Schema is narrower than what the server emits and the client accepts. Same for extended (BCE) years. These patterns predate oRPC support and are unchanged here; the tests document the gap where it becomes a published contract. Formatted with biome, this branch's formatter (master's oxfmt pre-commit hook does not resolve here). --- packages/temporal-zod/src/orpc.test.ts | 290 +++++++++++++++++++++++++ 1 file changed, 290 insertions(+) diff --git a/packages/temporal-zod/src/orpc.test.ts b/packages/temporal-zod/src/orpc.test.ts index 20470f6..4392262 100644 --- a/packages/temporal-zod/src/orpc.test.ts +++ b/packages/temporal-zod/src/orpc.test.ts @@ -1,16 +1,33 @@ import type { ZodToJsonSchemaConverterOptions } from "@orpc/zod/zod4"; import { describe, expect, test } from "bun:test"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { Temporal } from "temporal-polyfill"; import * as z from "zod"; import { DURATION_PATTERN, INSTANT_PATTERN, PLAIN_DATE_PATTERN, + PLAIN_DATE_TIME_PATTERN, + PLAIN_MONTH_DAY_PATTERN, + PLAIN_TIME_PATTERN, + PLAIN_YEAR_MONTH_PATTERN, + ZONED_DATE_TIME_PATTERN, zDuration, + zDurationInstance, zInstant, zInstantInstance, zPlainDate, + zPlainDateInstance, + zPlainDateTime, + zPlainDateTimeInstance, + zPlainMonthDay, + zPlainMonthDayInstance, zPlainTime, + zPlainTimeInstance, + zPlainYearMonth, + zPlainYearMonthInstance, + zZonedDateTime, + zZonedDateTimeInstance, } from "./json-schemas.js"; import { temporalJsonSchemaInterceptor } from "./orpc.js"; @@ -115,3 +132,276 @@ describe("temporalJsonSchemaInterceptor", () => { expect(json).toMatchObject({ type: "string", examples: ["hello"] }); }); }); + +/** + * The interceptor is metadata-driven rather than per-type, so it should cover + * every Temporal type rather than just the handful spot-checked above. These + * cases pin that down for all eight, in both the coercing and instance + * variants, and check the wire round trip each type actually goes through: + * the server serializes with `toJSON()` and the client revives the string by + * parsing it with the same validator. + */ +const zoned = Temporal.ZonedDateTime.from( + "2023-01-15T13:45:30+08:00[Asia/Manila]", +); + +interface TemporalTypeCase { + name: string; + coerce: z.ZodType; + instance: z.ZodType; + pattern: string; + format?: string; + /** A representative value a server would send. */ + value: { toJSON: () => string }; +} + +const ALL_TEMPORAL_TYPES: TemporalTypeCase[] = [ + { + name: "Instant", + coerce: zInstant, + instance: zInstantInstance, + pattern: INSTANT_PATTERN, + format: "date-time", + value: Temporal.Instant.from("2022-01-28T18:53:00.123456789Z"), + }, + { + name: "ZonedDateTime", + coerce: zZonedDateTime, + instance: zZonedDateTimeInstance, + pattern: ZONED_DATE_TIME_PATTERN, + value: zoned, + }, + { + name: "PlainDate", + coerce: zPlainDate, + instance: zPlainDateInstance, + pattern: PLAIN_DATE_PATTERN, + format: "date", + value: zoned.toPlainDate(), + }, + { + name: "PlainTime", + coerce: zPlainTime, + instance: zPlainTimeInstance, + pattern: PLAIN_TIME_PATTERN, + value: Temporal.PlainTime.from("01:02:03.123456789"), + }, + { + name: "PlainDateTime", + coerce: zPlainDateTime, + instance: zPlainDateTimeInstance, + pattern: PLAIN_DATE_TIME_PATTERN, + value: zoned.toPlainDateTime(), + }, + { + name: "PlainYearMonth", + coerce: zPlainYearMonth, + instance: zPlainYearMonthInstance, + pattern: PLAIN_YEAR_MONTH_PATTERN, + value: zoned.toPlainDate().toPlainYearMonth(), + }, + { + name: "PlainMonthDay", + coerce: zPlainMonthDay, + instance: zPlainMonthDayInstance, + pattern: PLAIN_MONTH_DAY_PATTERN, + value: zoned.toPlainDate().toPlainMonthDay(), + }, + { + name: "Duration", + coerce: zDuration, + instance: zDurationInstance, + pattern: DURATION_PATTERN, + format: "duration", + value: Temporal.Duration.from("-P1Y2M3DT4H5M6.789S"), + }, +]; + +describe("temporalJsonSchemaInterceptor covers every Temporal type", () => { + test("the case list is exhaustive", () => { + expect(ALL_TEMPORAL_TYPES).toHaveLength(8); + }); + + for (const { + name, + coerce, + instance, + pattern, + format, + } of ALL_TEMPORAL_TYPES) { + test(`${name} converts to a clean string schema`, () => { + for (const schema of [coerce, instance]) { + const json = convert(schema); + + expect(json.type).toBe("string"); + expect(json.pattern).toBe(pattern); + expect(json).not.toHaveProperty("anyOf"); + expect(json).not.toHaveProperty("$ref"); + expect(json).not.toHaveProperty("id"); + expect(typeof json.description).toBe("string"); + + if (format === undefined) { + expect(json).not.toHaveProperty("format"); + } else { + expect(json.format).toBe(format); + } + } + }); + + test(`${name} is still broken without the interceptor`, () => { + const [, json] = bareConverter.convert(coerce, { strategy: "input" }); + + expect(json).toHaveProperty("anyOf"); + }); + } + + for (const { name, coerce, pattern, value } of ALL_TEMPORAL_TYPES) { + test(`${name} round-trips server output through client-side parse`, () => { + // What the server puts on the wire. + const wire = value.toJSON(); + + // The advertised contract accepts it. + expect(new RegExp(pattern, "u").test(wire)).toBe(true); + + // The client revives it, and re-serializing is byte-identical. + const revived = coerce.parse(wire) as { toJSON: () => string }; + expect(revived.toJSON()).toBe(wire); + }); + } + + test("a value that is already an instance passes through parse unchanged", () => { + for (const { coerce, value } of ALL_TEMPORAL_TYPES) { + const parsed = coerce.parse(value) as { toJSON: () => string }; + + expect(parsed.toJSON()).toBe(value.toJSON()); + } + }); + + test("all eight compose into one object schema without collisions", () => { + // Each validator carries a registry `id`; several in one object is where + // duplicate-id handling would surface. + const shape = Object.fromEntries( + ALL_TEMPORAL_TYPES.map(({ name, coerce }) => [name, coerce]), + ); + const [, json] = converter.convert(z.object(shape), { strategy: "input" }); + const properties = (json as { properties: Record }) + .properties; + + for (const { name, pattern } of ALL_TEMPORAL_TYPES) { + expect(properties[name]).toMatchObject({ type: "string", pattern }); + } + }); +}); + +describe("ZonedDateTime string annotations", () => { + test("the bracketed IANA time zone survives the round trip", () => { + const wire = zoned.toJSON(); + + expect(wire).toBe("2023-01-15T13:45:30+08:00[Asia/Manila]"); + expect(new RegExp(ZONED_DATE_TIME_PATTERN, "u").test(wire)).toBe(true); + + const revived = zZonedDateTime.parse(wire); + expect(revived.timeZoneId).toBe("Asia/Manila"); + expect(revived.equals(zoned)).toBe(true); + }); + + test("a non-ISO calendar annotation survives the round trip", () => { + const wire = zoned.withCalendar("hebrew").toJSON(); + + expect(wire).toBe("2023-01-15T13:45:30+08:00[Asia/Manila][u-ca=hebrew]"); + + const revived = zZonedDateTime.parse(wire); + expect(revived.calendarId).toBe("hebrew"); + expect(revived.timeZoneId).toBe("Asia/Manila"); + }); + + test("the offset disambiguates a DST fall-back instant", () => { + const ambiguous = Temporal.ZonedDateTime.from( + "2023-11-05T01:30:00-05:00[America/New_York]", + ); + const revived = zZonedDateTime.parse(ambiguous.toJSON()); + + expect(revived.epochNanoseconds).toBe(ambiguous.epochNanoseconds); + }); +}); + +/** + * Characterization tests, not endorsements. + * + * The validators parse via `Temporal.X.from()`, which accepts the `[u-ca=…]` + * annotation that `toJSON()` emits for a non-ISO calendar. The exported + * patterns do not, so the JSON Schema the interceptor publishes is narrower + * than what the server emits and the client accepts. Anything enforcing the + * advertised contract — generated clients, an API gateway, ajv over the + * OpenAPI document — would reject values that work end to end. + * + * These patterns predate oRPC support and are unchanged by it; the mismatch is + * recorded here because this is where the patterns become a published contract. + * Should the patterns be widened, these expectations flip to `true` and this + * block should be deleted. + */ +describe("known limitation: patterns reject non-ISO calendar annotations", () => { + // PlainYearMonth and PlainMonthDay have no `withCalendar`, so each value is + // derived from a PlainDate that already carries the calendar. + const hebrewDate = zoned.toPlainDate().withCalendar("hebrew"); + const withHebrew: [string, z.ZodType, string, { toJSON: () => string }][] = [ + ["PlainDate", zPlainDate, PLAIN_DATE_PATTERN, hebrewDate], + [ + "PlainDateTime", + zPlainDateTime, + PLAIN_DATE_TIME_PATTERN, + zoned.toPlainDateTime().withCalendar("hebrew"), + ], + [ + "PlainYearMonth", + zPlainYearMonth, + PLAIN_YEAR_MONTH_PATTERN, + hebrewDate.toPlainYearMonth(), + ], + [ + "PlainMonthDay", + zPlainMonthDay, + PLAIN_MONTH_DAY_PATTERN, + hebrewDate.toPlainMonthDay(), + ], + ]; + + for (const [name, schema, pattern, value] of withHebrew) { + test(`${name} parses a hebrew-calendar string the pattern rejects`, () => { + const wire = value.toJSON(); + expect(wire).toContain("[u-ca=hebrew]"); + + // Parsing works end to end... + const revived = schema.parse(wire) as { + toJSON: () => string; + calendarId: string; + }; + expect(revived.calendarId).toBe("hebrew"); + expect(revived.toJSON()).toBe(wire); + + // ...but the advertised pattern would reject the very same string. + expect(new RegExp(pattern, "u").test(wire)).toBe(false); + }); + } + + test("PlainDate rejects an extended (BCE) year it can still parse", () => { + const wire = Temporal.PlainDate.from("-000753-04-21").toJSON(); + + expect(zPlainDate.parse(wire).year).toBe(-753); + expect(new RegExp(PLAIN_DATE_PATTERN, "u").test(wire)).toBe(false); + }); + + test("ZonedDateTime tolerates its calendar suffix only incidentally", () => { + // The pattern ends in `\[.+\]`, and `.+` greedily spans the second bracket + // group — so it matches, but only because the check is that loose. The + // same pattern accepts an obviously malformed annotation. + const wire = zoned.withCalendar("hebrew").toJSON(); + + expect(new RegExp(ZONED_DATE_TIME_PATTERN, "u").test(wire)).toBe(true); + expect( + new RegExp(ZONED_DATE_TIME_PATTERN, "u").test( + "2023-01-15T13:45:30+08:00[not a time zone!][]", + ), + ).toBe(true); + }); +}); From 43a0ab0d67f0370b190b2727b4c52905b024b77d Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Tue, 28 Jul 2026 17:57:37 +0800 Subject: [PATCH 5/6] fix(temporal-zod): accept every string toJSON() emits in the published patterns Covering all eight Temporal types against oRPC's real converter flushed out a mismatch between what the validators parse and what they advertise. The validators defer to Temporal.X.from(), which accepts strings the exported patterns rejected. That was survivable while the patterns only fed z.toJSONSchema(), but the interceptor publishes them as an OpenAPI contract, so a generated client or gateway would reject values that work end to end. Widen the patterns to accept: - The [u-ca=...] annotation appended under a non-ISO calendar, for PlainDate, PlainDateTime, PlainYearMonth, PlainMonthDay and ZonedDateTime. - The full reference-date form PlainYearMonth and PlainMonthDay serialize to under a non-ISO calendar (e.g. 2022-12-25[u-ca=hebrew]). The annotation is required in that form, so a bare calendar date is still rejected for both. - Signed six-digit years outside 0000-9999 (e.g. -000753-04-21). ZONED_DATE_TIME_PATTERN no longer ends in \[.+\]: the greedy .+ spanned both bracket groups, so it matched the calendar suffix only by accident and also accepted malformed annotations like [not a time zone!][]. The patterns are now composed from shared internal parts rather than eight hand-copied regexes, so the grammar is stated once. The parts are not exported; the public surface stays the eight *_PATTERN strings. Add explicit converter tests for ZonedDateTime, PlainDateTime, PlainYearMonth and PlainMonthDay alongside the existing four, pattern vectors for the widened forms, and an ajv check that serialized non-ISO values validate against the generated schema. One mismatch is left deliberately: PlainDate also advertises format: "date", which is RFC 3339 full-date and cannot hold an annotation. Dropping the format would degrade every ISO consumer to accommodate a rare one, so it stays, with a test and README note marking the boundary. --- .changeset/orpc-support.md | 19 +++ packages/temporal-zod/README.md | 17 ++ packages/temporal-zod/src/base/instant.ts | 14 +- .../src/base/iso-pattern-parts.ts | 52 +++++++ .../temporal-zod/src/base/plain-date-time.ts | 11 +- packages/temporal-zod/src/base/plain-date.ts | 8 +- .../temporal-zod/src/base/plain-month-day.ts | 15 +- packages/temporal-zod/src/base/plain-time.ts | 7 +- .../temporal-zod/src/base/plain-year-month.ts | 14 +- .../temporal-zod/src/base/zoned-date-time.ts | 14 +- packages/temporal-zod/src/json-schema.test.ts | 93 ++++++++++- packages/temporal-zod/src/orpc.test.ts | 145 ++++++++++++++---- 12 files changed, 361 insertions(+), 48 deletions(-) create mode 100644 packages/temporal-zod/src/base/iso-pattern-parts.ts diff --git a/.changeset/orpc-support.md b/.changeset/orpc-support.md index 47354cc..c97c6a6 100644 --- a/.changeset/orpc-support.md +++ b/.changeset/orpc-support.md @@ -10,3 +10,22 @@ oRPC would otherwise produce. `temporal-zod` takes on no dependency on `@orpc/*` — not even a type-only one. The interceptor is typed structurally, so consumers who don't use oRPC pay nothing and nothing needs to resolve at typecheck time. + +Widen the exported `*_PATTERN` regexes to accept everything `toJSON()` can emit, +which matters now that they are published as an OpenAPI contract. They previously +rejected strings the validators themselves parse: + +- The `[u-ca=…]` annotation appended under a non-ISO calendar, for `PlainDate`, + `PlainDateTime`, `PlainYearMonth`, `PlainMonthDay`, and `ZonedDateTime`. +- The full reference-date form `PlainYearMonth` and `PlainMonthDay` serialize to + under a non-ISO calendar (e.g. `2022-12-25[u-ca=hebrew]`). A bare calendar date + is still rejected for both, since the annotation is required in that form. +- Signed six-digit years for years outside 0000–9999 (e.g. `-000753-04-21`). + +`ZONED_DATE_TIME_PATTERN` also no longer ends in `\[.+\]`, whose greedy `.+` +spanned both bracket groups and accepted malformed annotations such as +`[not a time zone!][]`. + +Note that `PlainDate` still advertises `format: "date"`, which is RFC 3339 +full-date and cannot carry an annotation, so a validator that asserts `format` +will reject a non-ISO `PlainDate` even though the `pattern` accepts it. diff --git a/packages/temporal-zod/README.md b/packages/temporal-zod/README.md index de13e96..cb77d3a 100644 --- a/packages/temporal-zod/README.md +++ b/packages/temporal-zod/README.md @@ -98,6 +98,23 @@ const generator = new OpenAPIGenerator({ interceptor is typed structurally, so if you don't use oRPC you pay nothing and nothing needs to resolve. +The interceptor is driven by each validator's metadata rather than by a +per-type list, so all eight Temporal types are covered — `Instant`, +`ZonedDateTime`, `PlainDate`, `PlainTime`, `PlainDateTime`, `PlainYearMonth`, +`PlainMonthDay`, and `Duration` — in both the coercing and `*Instance` variants. + +Values travel as the plain ISO strings `toJSON()` produces, and the validator on +the receiving end revives them. The published `pattern` accepts everything +`toJSON()` can emit, including the `[u-ca=…]` annotation added under a non-ISO +calendar, the full reference-date form `PlainYearMonth` and `PlainMonthDay` take +under such a calendar, and signed six-digit years. + +One boundary is worth knowing: `PlainDate` also advertises `format: "date"`, +which is RFC 3339 full-date and cannot carry an annotation. A validator that +asserts `format` will therefore reject a non-ISO `PlainDate` even though the +`pattern` accepts it. The `format` is kept because it is correct and useful for +the ISO case, which is the overwhelmingly common one. + ### With tRPC If you are using [tRPC](https://trpc.io/), you likely use Zod to validate your inputs and outputs. However, when using it with [Tanstack Query](https://tanstack.com/query), since the Temporal types get mapped to an object, you should ensure that you are using the instance of the Temporal type rather than the one with type coercion. Otherwise, the query cache will not work as expected. diff --git a/packages/temporal-zod/src/base/instant.ts b/packages/temporal-zod/src/base/instant.ts index b488fe4..46dfe0e 100644 --- a/packages/temporal-zod/src/base/instant.ts +++ b/packages/temporal-zod/src/base/instant.ts @@ -1,5 +1,12 @@ import { Temporal } from "temporal-polyfill"; import * as z from "zod"; +import { + DATE_PART, + OFFSET_PART, + OPTIONAL_CALENDAR_PART, + TIME_PART, + TIME_ZONE_PART, +} from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const Instant: typeof Temporal.Instant = Temporal.Instant; @@ -10,9 +17,12 @@ export const Instant: typeof Temporal.Instant = Temporal.Instant; * Validates month (01–12), day (01–31), hours (00–23), minutes/seconds (00–59), * up to 9 fractional digits, and a required UTC offset (Z or ±HH:MM). * An optional IANA timezone annotation in brackets is permitted. + * + * The annotations are matched as distinct bracket groups rather than `\[.+\]`, + * so a `[u-ca=…]` suffix is recognized instead of being absorbed by the time + * zone group. */ -export const INSTANT_PATTERN = - "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?(Z|[+-]([01]\\d|2[0-3]):[0-5]\\d)(\\[.+\\])?$"; +export const INSTANT_PATTERN: string = `^${DATE_PART}T${TIME_PART}${OFFSET_PART}(${TIME_ZONE_PART})?${OPTIONAL_CALENDAR_PART}$`; const validators = temporalValidators(Instant, [ z diff --git a/packages/temporal-zod/src/base/iso-pattern-parts.ts b/packages/temporal-zod/src/base/iso-pattern-parts.ts new file mode 100644 index 0000000..87377de --- /dev/null +++ b/packages/temporal-zod/src/base/iso-pattern-parts.ts @@ -0,0 +1,52 @@ +/** + * Shared building blocks for the exported `*_PATTERN` regexes. + * + * Each Temporal type publishes a `pattern` in its JSON Schema metadata, which + * becomes the advertised contract once that schema reaches an OpenAPI document. + * The contract has to accept everything `toJSON()` can emit, which is wider than + * the common case: + * + * - Under a non-ISO calendar, `toJSON()` appends a `[u-ca=…]` annotation. + * - Under a non-ISO calendar, `PlainYearMonth` and `PlainMonthDay` serialize as + * a **full reference date** rather than `YYYY-MM` / `MM-DD`. + * - Years outside 0000–9999 use the six-digit signed form (e.g. `-000753`). + * + * These parts are deliberately internal — they are composed into the exported + * patterns rather than exported themselves, so the public surface stays the + * eight `*_PATTERN` strings. + * + * @module + */ + +/** Four-digit year, or the signed six-digit form for years outside 0000–9999. */ +export const YEAR_PART = "([+-]\\d{6}|\\d{4})"; + +/** Month, `01`–`12`. */ +export const MONTH_PART = "(0[1-9]|1[0-2])"; + +/** Day of month, `01`–`31`. */ +export const DAY_PART = "(0[1-9]|[12]\\d|3[01])"; + +/** Wall-clock time with optional seconds and up to nanosecond precision. */ +export const TIME_PART = "([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?"; + +/** UTC designator or a `±HH:MM` offset. */ +export const OFFSET_PART = "(Z|[+-]([01]\\d|2[0-3]):[0-5]\\d)"; + +/** + * Bracketed time zone annotation, e.g. `[Asia/Manila]`, `[UTC]`, `[+08:00]`. + * + * The leading `!` marks a critical annotation. The character class excludes `[` + * and `]` so this cannot swallow a following `[u-ca=…]` group — the reason the + * previous `\[.+\]` accepted malformed input like `[not a time zone!][]`. + */ +export const TIME_ZONE_PART = "\\[!?[A-Za-z0-9_+.:/-]+\\]"; + +/** Calendar annotation, e.g. `[u-ca=hebrew]`, `[u-ca=islamic-umalqura]`. */ +export const CALENDAR_PART = "\\[u-ca=[A-Za-z0-9]+(-[A-Za-z0-9]+)*\\]"; + +/** {@link CALENDAR_PART}, optional. */ +export const OPTIONAL_CALENDAR_PART: string = `(${CALENDAR_PART})?`; + +/** Calendar date, `YYYY-MM-DD`. */ +export const DATE_PART: string = `${YEAR_PART}-${MONTH_PART}-${DAY_PART}`; diff --git a/packages/temporal-zod/src/base/plain-date-time.ts b/packages/temporal-zod/src/base/plain-date-time.ts index 3af76a6..e057192 100644 --- a/packages/temporal-zod/src/base/plain-date-time.ts +++ b/packages/temporal-zod/src/base/plain-date-time.ts @@ -1,6 +1,11 @@ import type { z } from "zod"; import type { ZodTemporal } from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; +import { + DATE_PART, + OPTIONAL_CALENDAR_PART, + TIME_PART, +} from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const PlainDateTime: typeof Temporal.PlainDateTime = @@ -10,9 +15,11 @@ export const PlainDateTime: typeof Temporal.PlainDateTime = * Regex pattern for {@link Temporal.PlainDateTime} ISO 8601 strings (e.g. `2023-01-15T13:45:30`). * Validates month (01–12), day (01–31), hours (00–23), minutes/seconds (00–59), * and up to 9 fractional digits. No timezone offset. + * + * Also accepts the trailing `[u-ca=…]` annotation that `toJSON()` emits under a + * non-ISO calendar (e.g. `2023-01-15T13:45:30[u-ca=hebrew]`). */ -export const PLAIN_DATE_TIME_PATTERN = - "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?$"; +export const PLAIN_DATE_TIME_PATTERN: string = `^${DATE_PART}T${TIME_PART}${OPTIONAL_CALENDAR_PART}$`; const validators = temporalValidators(PlainDateTime); diff --git a/packages/temporal-zod/src/base/plain-date.ts b/packages/temporal-zod/src/base/plain-date.ts index 998c3d9..99e0978 100644 --- a/packages/temporal-zod/src/base/plain-date.ts +++ b/packages/temporal-zod/src/base/plain-date.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { ZodTemporal } from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; +import { DATE_PART, OPTIONAL_CALENDAR_PART } from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const PlainDate: typeof Temporal.PlainDate = Temporal.PlainDate; @@ -8,9 +9,12 @@ export const PlainDate: typeof Temporal.PlainDate = Temporal.PlainDate; /** * Regex pattern for {@link Temporal.PlainDate} ISO 8601 strings (e.g. `2023-01-15`). * Validates month (01–12) and day (01–31). + * + * Also accepts the trailing `[u-ca=…]` annotation that `toJSON()` emits under a + * non-ISO calendar (e.g. `2023-01-15[u-ca=hebrew]`) and the signed six-digit + * year form (e.g. `-000753-04-21`). */ -export const PLAIN_DATE_PATTERN = - "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"; +export const PLAIN_DATE_PATTERN: string = `^${DATE_PART}${OPTIONAL_CALENDAR_PART}$`; const validators = temporalValidators(PlainDate); diff --git a/packages/temporal-zod/src/base/plain-month-day.ts b/packages/temporal-zod/src/base/plain-month-day.ts index e6e93ff..f817c63 100644 --- a/packages/temporal-zod/src/base/plain-month-day.ts +++ b/packages/temporal-zod/src/base/plain-month-day.ts @@ -1,6 +1,13 @@ import type { z } from "zod"; import type { ZodTemporal } from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; +import { + CALENDAR_PART, + DATE_PART, + DAY_PART, + MONTH_PART, + OPTIONAL_CALENDAR_PART, +} from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const PlainMonthDay: typeof Temporal.PlainMonthDay = @@ -9,9 +16,13 @@ export const PlainMonthDay: typeof Temporal.PlainMonthDay = /** * Regex pattern for {@link Temporal.PlainMonthDay} ISO 8601 strings (e.g. `--01-15` or `01-15`). * Validates month (01–12) and day (01–31). The `--` prefix is optional per ISO 8601. + * + * Under a non-ISO calendar `toJSON()` emits a full reference date rather than + * `MM-DD` (e.g. `1972-12-27[u-ca=hebrew]`), so that form is accepted too — but + * only with the calendar annotation present, so a bare `2023-01-15` is still + * rejected. */ -export const PLAIN_MONTH_DAY_PATTERN = - "^(--)?(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"; +export const PLAIN_MONTH_DAY_PATTERN: string = `^((--)?${MONTH_PART}-${DAY_PART}${OPTIONAL_CALENDAR_PART}|${DATE_PART}${CALENDAR_PART})$`; const validators = temporalValidators(PlainMonthDay); diff --git a/packages/temporal-zod/src/base/plain-time.ts b/packages/temporal-zod/src/base/plain-time.ts index 78ab89f..a314a86 100644 --- a/packages/temporal-zod/src/base/plain-time.ts +++ b/packages/temporal-zod/src/base/plain-time.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { ZodTemporal } from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; +import { TIME_PART } from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const PlainTime: typeof Temporal.PlainTime = Temporal.PlainTime; @@ -10,9 +11,11 @@ export const PlainTime: typeof Temporal.PlainTime = Temporal.PlainTime; * (e.g. `13:45:30` or `13:45:30.123456789`). * Validates hours (00–23), minutes (00–59), seconds (00–59), and up to 9 fractional digits. * Note: Unlike RFC 3339 "full-time", PlainTime has no timezone offset. + * + * `PlainTime` carries no calendar, so unlike the date-bearing types this takes + * no `[u-ca=…]` annotation. */ -export const PLAIN_TIME_PATTERN = - "^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?$"; +export const PLAIN_TIME_PATTERN: string = `^${TIME_PART}$`; const validators = temporalValidators(PlainTime); diff --git a/packages/temporal-zod/src/base/plain-year-month.ts b/packages/temporal-zod/src/base/plain-year-month.ts index 8eccb60..b2bcdf6 100644 --- a/packages/temporal-zod/src/base/plain-year-month.ts +++ b/packages/temporal-zod/src/base/plain-year-month.ts @@ -1,6 +1,13 @@ import type { z } from "zod"; import type { ZodTemporal } from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; +import { + CALENDAR_PART, + DATE_PART, + MONTH_PART, + OPTIONAL_CALENDAR_PART, + YEAR_PART, +} from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const PlainYearMonth: typeof Temporal.PlainYearMonth = @@ -9,8 +16,13 @@ export const PlainYearMonth: typeof Temporal.PlainYearMonth = /** * Regex pattern for {@link Temporal.PlainYearMonth} ISO 8601 strings (e.g. `2023-01`). * Validates month (01–12). + * + * Under a non-ISO calendar `toJSON()` emits a full reference date rather than + * `YYYY-MM` (e.g. `2022-12-25[u-ca=hebrew]`), so that form is accepted too — + * but only with the calendar annotation present, so a bare `2023-01-15` is + * still rejected. */ -export const PLAIN_YEAR_MONTH_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])$"; +export const PLAIN_YEAR_MONTH_PATTERN: string = `^(${YEAR_PART}-${MONTH_PART}${OPTIONAL_CALENDAR_PART}|${DATE_PART}${CALENDAR_PART})$`; const validators = temporalValidators(PlainYearMonth); diff --git a/packages/temporal-zod/src/base/zoned-date-time.ts b/packages/temporal-zod/src/base/zoned-date-time.ts index c82a142..922f0b8 100644 --- a/packages/temporal-zod/src/base/zoned-date-time.ts +++ b/packages/temporal-zod/src/base/zoned-date-time.ts @@ -1,6 +1,13 @@ import type { z } from "zod"; import type { ZodTemporal } from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; +import { + DATE_PART, + OFFSET_PART, + OPTIONAL_CALENDAR_PART, + TIME_PART, + TIME_ZONE_PART, +} from "./iso-pattern-parts.js"; import { temporalValidators } from "./temporal-validator.js"; export const ZonedDateTime: typeof Temporal.ZonedDateTime = @@ -12,9 +19,12 @@ export const ZonedDateTime: typeof Temporal.ZonedDateTime = * Validates month (01–12), day (01–31), hours (00–23), minutes/seconds (00–59), * up to 9 fractional digits, a UTC offset (Z or ±HH:MM), and a required IANA timezone * annotation in brackets. + * + * The time zone annotation is matched as a single bracket group rather than + * `\[.+\]`, so it no longer swallows a following `[u-ca=…]` — which is accepted + * on its own, as `toJSON()` emits it under a non-ISO calendar. */ -export const ZONED_DATE_TIME_PATTERN = - "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?(Z|[+-]([01]\\d|2[0-3]):[0-5]\\d)\\[.+\\]$"; +export const ZONED_DATE_TIME_PATTERN: string = `^${DATE_PART}T${TIME_PART}${OFFSET_PART}${TIME_ZONE_PART}${OPTIONAL_CALENDAR_PART}$`; const validators = temporalValidators(ZonedDateTime); diff --git a/packages/temporal-zod/src/json-schema.test.ts b/packages/temporal-zod/src/json-schema.test.ts index 2958962..d29897c 100644 --- a/packages/temporal-zod/src/json-schema.test.ts +++ b/packages/temporal-zod/src/json-schema.test.ts @@ -247,6 +247,32 @@ describe("JSON.parse(JSON.stringify(...)) round-trip validates against generated expect(validate(serialized)).toBe(true); }); + test("serialized non-ISO calendar values validate", () => { + // toJSON() appends [u-ca=…] here, and PlainYearMonth/PlainMonthDay + // switch to a full reference date. The generated schema has to accept + // all of it, or an OpenAPI consumer would reject what the server sends. + // + // plainDate stays ISO: it is the one calendar-bearing type that also + // declares `format: "date"`, which by definition cannot hold an + // annotation. See the dedicated test below. + const hebrewDate = + Temporal.PlainDate.from("2023-01-15").withCalendar("hebrew"); + const serialized: unknown = JSON.parse( + JSON.stringify({ + ...temporalObject, + plainDateTime: Temporal.PlainDateTime.from( + "2023-01-15T13:45:30", + ).withCalendar("hebrew"), + plainYearMonth: hebrewDate.toPlainYearMonth(), + plainMonthDay: hebrewDate.toPlainMonthDay(), + zonedDateTime: Temporal.ZonedDateTime.from( + "2023-01-15T13:45:30+08:00[Asia/Manila]", + ).withCalendar("hebrew"), + }), + ); + expect(validate(serialized)).toBe(true); + }); + test("rejects object with invalid field values", () => { const bad = { ...(JSON.parse(JSON.stringify(temporalObject)) as Record< @@ -265,6 +291,36 @@ describe("JSON.parse(JSON.stringify(...)) round-trip validates against generated } }); +/** + * `PLAIN_DATE_PATTERN` accepts the `[u-ca=…]` annotation, but `format: "date"` + * is RFC 3339 full-date and cannot. PlainDate is the only calendar-bearing type + * that declares a `format`, so it is the only place the two disagree. + * + * The `format` is kept because it is correct and useful for the overwhelmingly + * common ISO case, and because `format` is an annotation rather than an + * assertion unless a validator opts in (as ajv-formats does here). Dropping it + * would degrade every ISO consumer to fix a rare one. Worth revisiting if + * non-ISO calendars become a first-class use case. + */ +describe("PlainDate format vs. calendar annotation", () => { + const ajv = addFormats(new Ajv2020(), { mode: "fast" }); + const validate: ValidateFunction = ajv.compile( + stripZodId(z.toJSONSchema(zPlainDate)) as Record, + ); + const hebrew = Temporal.PlainDate.from("2023-01-15") + .withCalendar("hebrew") + .toJSON(); + + test("the pattern accepts an annotated date", () => { + expect(new RegExp(PLAIN_DATE_PATTERN).test(hebrew)).toBe(true); + }); + + test('but format: "date" does not', () => { + expect(validate(hebrew)).toBe(false); + expect(validate("2023-01-15")).toBe(true); + }); +}); + describe("full JSON Schema snapshot for all types", () => { const allTypes = z.object({ instant: zInstant, @@ -425,8 +481,24 @@ describe("regex patterns validate correctly", () => { { name: "PlainDate", pattern: PLAIN_DATE_PATTERN, - valid: ["2023-01-15", "2023-12-31"], - invalid: ["2023-00-15", "2023-13-15", "2023-01-32", "2023-01-15T13:45"], + valid: [ + "2023-01-15", + "2023-12-31", + // Non-ISO calendar annotation, as emitted by toJSON(). + "2023-01-15[u-ca=hebrew]", + "2023-01-15[u-ca=islamic-umalqura]", + // Signed six-digit year, for years outside 0000-9999. + "-000753-04-21", + "+010000-01-01", + ], + invalid: [ + "2023-00-15", + "2023-13-15", + "2023-01-32", + "2023-01-15T13:45", + "2023-01-15[u-ca=]", + "2023-01-15[nonsense]", + ], }, { name: "PlainTime", @@ -441,20 +513,23 @@ describe("regex patterns validate correctly", () => { "2023-01-15T13:45", "2023-01-15T13:45:30", "2023-01-15T13:45:30.123", + "2023-01-15T13:45:30[u-ca=hebrew]", ], invalid: ["2023-01-15T13:45:30Z", "2023-13-15T13:45:30"], }, { name: "PlainYearMonth", pattern: PLAIN_YEAR_MONTH_PATTERN, - valid: ["2023-01", "2023-12"], - invalid: ["2023-00", "2023-13", "2023-01-15"], + // Under a non-ISO calendar toJSON() emits a full reference date, so that + // form is valid — but only with the annotation, so a bare date is not. + valid: ["2023-01", "2023-12", "2022-12-25[u-ca=hebrew]"], + invalid: ["2023-00", "2023-13", "2023-01-15", "2023-01-15[u-ca=]"], }, { name: "PlainMonthDay", pattern: PLAIN_MONTH_DAY_PATTERN, - valid: ["01-15", "--01-15"], - invalid: ["13-15", "01-32"], + valid: ["01-15", "--01-15", "1972-12-27[u-ca=hebrew]"], + invalid: ["13-15", "01-32", "2023-01-15", "1972-12-27"], }, { name: "ZonedDateTime", @@ -463,10 +538,16 @@ describe("regex patterns validate correctly", () => { "2023-01-15T13:45:30+08:00[Asia/Manila]", "2023-01-15T13:45:30Z[UTC]", "2023-01-15T13:45:30.123+00:00[Europe/London]", + "2023-01-15T13:45:30+08:00[Asia/Manila][u-ca=hebrew]", + "2023-01-15T13:45:30-03:00[America/Argentina/Buenos_Aires]", + "2023-01-15T13:45:30+08:00[+08:00]", ], invalid: [ "2023-01-15T13:45:30+08:00", "2023-01-15T13:45:30[Asia/Manila]", + // The time zone group must not swallow a second bracket group. + "2023-01-15T13:45:30+08:00[not a time zone!][]", + "2023-01-15T13:45:30+08:00[Asia/Manila][u-ca=]", ], }, { diff --git a/packages/temporal-zod/src/orpc.test.ts b/packages/temporal-zod/src/orpc.test.ts index 4392262..b7840ee 100644 --- a/packages/temporal-zod/src/orpc.test.ts +++ b/packages/temporal-zod/src/orpc.test.ts @@ -131,6 +131,58 @@ describe("temporalJsonSchemaInterceptor", () => { const [, json] = converter.convert(plain, { strategy: "input" }); expect(json).toMatchObject({ type: "string", examples: ["hello"] }); }); + + // The four types above (Instant, PlainDate, PlainTime, Duration) left these + // unchecked against the real converter. + + test("zZonedDateTime converts to a clean string schema", () => { + expect(convert(zZonedDateTime)).toEqual({ + type: "string", + description: + "An ISO 8601 date-time string with timezone offset and IANA annotation (e.g. 2023-01-15T13:45:30+08:00[Asia/Manila])", + pattern: ZONED_DATE_TIME_PATTERN, + }); + }); + + test("zPlainDateTime converts to a clean string schema", () => { + expect(convert(zPlainDateTime)).toEqual({ + type: "string", + description: + "An ISO 8601 date-time string without timezone (e.g. 2023-01-15T13:45:30)", + pattern: PLAIN_DATE_TIME_PATTERN, + }); + }); + + test("zPlainYearMonth converts to a clean string schema", () => { + expect(convert(zPlainYearMonth)).toEqual({ + type: "string", + description: "An ISO 8601 year-month string (e.g. 2023-01)", + pattern: PLAIN_YEAR_MONTH_PATTERN, + }); + }); + + test("zPlainMonthDay converts to a clean string schema", () => { + expect(convert(zPlainMonthDay)).toEqual({ + type: "string", + description: "An ISO 8601 month-day string (e.g. --01-15 or 01-15)", + pattern: PLAIN_MONTH_DAY_PATTERN, + }); + }); + + test("the four remaining instance variants convert too", () => { + for (const schema of [ + zZonedDateTimeInstance, + zPlainDateTimeInstance, + zPlainYearMonthInstance, + zPlainMonthDayInstance, + ]) { + const json = convert(schema); + + expect(json.type).toBe("string"); + expect(json).not.toHaveProperty("anyOf"); + expect(json).not.toHaveProperty("id"); + } + }); }); /** @@ -326,21 +378,15 @@ describe("ZonedDateTime string annotations", () => { }); /** - * Characterization tests, not endorsements. - * - * The validators parse via `Temporal.X.from()`, which accepts the `[u-ca=…]` - * annotation that `toJSON()` emits for a non-ISO calendar. The exported - * patterns do not, so the JSON Schema the interceptor publishes is narrower - * than what the server emits and the client accepts. Anything enforcing the - * advertised contract — generated clients, an API gateway, ajv over the - * OpenAPI document — would reject values that work end to end. - * - * These patterns predate oRPC support and are unchanged by it; the mismatch is - * recorded here because this is where the patterns become a published contract. - * Should the patterns be widened, these expectations flip to `true` and this - * block should be deleted. + * A non-ISO calendar makes `toJSON()` append a `[u-ca=…]` annotation, and makes + * `PlainYearMonth` / `PlainMonthDay` serialize as a full reference date instead + * of `YYYY-MM` / `MM-DD`. The validators have always parsed those strings, since + * they defer to `Temporal.X.from()`; the published patterns did not, so the + * advertised contract was narrower than what the server emits. Anything + * enforcing it — a generated client, an API gateway, ajv over the OpenAPI + * document — would have rejected values that work end to end. */ -describe("known limitation: patterns reject non-ISO calendar annotations", () => { +describe("non-ISO calendars round-trip through the published contract", () => { // PlainYearMonth and PlainMonthDay have no `withCalendar`, so each value is // derived from a PlainDate that already carries the calendar. const hebrewDate = zoned.toPlainDate().withCalendar("hebrew"); @@ -364,44 +410,85 @@ describe("known limitation: patterns reject non-ISO calendar annotations", () => PLAIN_MONTH_DAY_PATTERN, hebrewDate.toPlainMonthDay(), ], + [ + "ZonedDateTime", + zZonedDateTime, + ZONED_DATE_TIME_PATTERN, + zoned.withCalendar("hebrew"), + ], ]; for (const [name, schema, pattern, value] of withHebrew) { - test(`${name} parses a hebrew-calendar string the pattern rejects`, () => { + test(`${name} emits, advertises and revives a hebrew-calendar string`, () => { const wire = value.toJSON(); expect(wire).toContain("[u-ca=hebrew]"); - // Parsing works end to end... + // The advertised pattern accepts what the server emits... + expect(new RegExp(pattern, "u").test(wire)).toBe(true); + + // ...and the client revives it losslessly. const revived = schema.parse(wire) as { toJSON: () => string; calendarId: string; }; expect(revived.calendarId).toBe("hebrew"); expect(revived.toJSON()).toBe(wire); - - // ...but the advertised pattern would reject the very same string. - expect(new RegExp(pattern, "u").test(wire)).toBe(false); }); } - test("PlainDate rejects an extended (BCE) year it can still parse", () => { + test("PlainYearMonth and PlainMonthDay still reject a bare calendar date", () => { + // Their non-ISO form is a full date, but only with the annotation present — + // a plain `2023-01-15` must not pass as either type. + expect(new RegExp(PLAIN_YEAR_MONTH_PATTERN, "u").test("2023-01-15")).toBe( + false, + ); + expect(new RegExp(PLAIN_MONTH_DAY_PATTERN, "u").test("2023-01-15")).toBe( + false, + ); + }); + + test("PlainDate accepts an extended (BCE) year", () => { const wire = Temporal.PlainDate.from("-000753-04-21").toJSON(); + expect(new RegExp(PLAIN_DATE_PATTERN, "u").test(wire)).toBe(true); expect(zPlainDate.parse(wire).year).toBe(-753); - expect(new RegExp(PLAIN_DATE_PATTERN, "u").test(wire)).toBe(false); }); - test("ZonedDateTime tolerates its calendar suffix only incidentally", () => { - // The pattern ends in `\[.+\]`, and `.+` greedily spans the second bracket - // group — so it matches, but only because the check is that loose. The - // same pattern accepts an obviously malformed annotation. - const wire = zoned.withCalendar("hebrew").toJSON(); - - expect(new RegExp(ZONED_DATE_TIME_PATTERN, "u").test(wire)).toBe(true); + test("ZonedDateTime no longer lets its time zone group swallow the rest", () => { + // The old pattern ended in `\[.+\]`, whose greedy `.+` spanned both bracket + // groups — matching the calendar suffix only by accident, and accepting + // malformed annotations along the way. expect( new RegExp(ZONED_DATE_TIME_PATTERN, "u").test( "2023-01-15T13:45:30+08:00[not a time zone!][]", ), - ).toBe(true); + ).toBe(false); + expect( + new RegExp(ZONED_DATE_TIME_PATTERN, "u").test( + "2023-01-15T13:45:30+08:00[Asia/Manila][u-ca=]", + ), + ).toBe(false); + }); + + test("every calendar Temporal supports survives the round trip", () => { + for (const calendar of [ + "hebrew", + "japanese", + "islamic-umalqura", + "chinese", + "indian", + "persian", + "buddhist", + "coptic", + "ethiopic", + "roc", + "gregory", + ]) { + const date = zoned.toPlainDate().withCalendar(calendar); + const wire = date.toJSON(); + + expect(new RegExp(PLAIN_DATE_PATTERN, "u").test(wire)).toBe(true); + expect(zPlainDate.parse(wire).calendarId).toBe(calendar); + } }); }); From d8c7c5512a5da2d50056370e6bf6dc520f3d35f0 Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Sat, 1 Aug 2026 20:18:46 +0800 Subject: [PATCH 6/6] fix: declare temporal-polyfill at the workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root test preload (test-setup.ts) side-effect imports temporal-polyfill/full/global so Bun's tests get the full-ICU build, but only packages/ponyfill-temporal declared the dependency. Under bun's isolated linker — what CI resolves to — temporal-polyfill is not hoisted to the root node_modules, so oxlint's type-aware pass failed with TS2882 on the import. --- bun.lock | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index b45d696..a4273d7 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "oxfmt": "0.55.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", + "temporal-polyfill": "^1.0.1", "turbo": "^2.10.7", "typedoc": "^0.28.20", "typescript": "^5.9.3", diff --git a/package.json b/package.json index 998abf1..ee1f15a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "oxfmt": "0.55.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", + "temporal-polyfill": "^1.0.1", "turbo": "^2.10.7", "typedoc": "^0.28.20", "typescript": "^5.9.3"