diff --git a/.changeset/orpc-support.md b/.changeset/orpc-support.md new file mode 100644 index 0000000..c97c6a6 --- /dev/null +++ b/.changeset/orpc-support.md @@ -0,0 +1,31 @@ +--- +"temporal-zod": minor +--- + +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. + +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/bun.lock b/bun.lock index d0409b7..a4273d7 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "monorepo", @@ -8,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", @@ -105,6 +107,7 @@ }, "devDependencies": { "@macalinao/tsconfig": "^3.2.5", + "@orpc/zod": "^1.14.0", "@types/bun": "latest", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", @@ -169,6 +172,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.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-peer": "1.14.12" } }, "sha512-XMNjdNctSLLSaCmmWV/c4klMXU9xT0KfTFbC6lUwKNk8N5hPcue7EeP5EVVSV3cSquliRbyUbLovYqaVpuOt9Q=="], + + "@orpc/contract": ["@orpc/contract@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/shared": "1.14.12", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-+rf66G4tV074uEab0lkEy9kMgt2QA7FU8NfeCSt6kyYa1eKU5C6goz4OpvNcOFfk+eTGd5K01oVUiz2LSRfYrQ=="], + + "@orpc/interop": ["@orpc/interop@1.14.12", "", {}, "sha512-NdJTDW9JfBVrY7kv6wQHLT4avDlrCucakfCHPS8OI7pUPjUCa41yWnreIdIskKTgj9iBdbjFXOe6TVRQv/D6Iw=="], + + "@orpc/json-schema": ["@orpc/json-schema@1.14.12", "", { "dependencies": { "@orpc/contract": "1.14.12", "@orpc/interop": "1.14.12", "@orpc/openapi": "1.14.12", "@orpc/server": "1.14.12", "@orpc/shared": "1.14.12", "json-schema-typed": "^8.0.2" } }, "sha512-Wy6+/8PbQG2E1nl6pm3jPQ32Td9M3oi+0MkZ950ThDB97PfpmK3FvYR+wMssVM8xVu0AyVUeczHaKuK4RS+wyw=="], + + "@orpc/openapi": ["@orpc/openapi@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/contract": "1.14.12", "@orpc/interop": "1.14.12", "@orpc/openapi-client": "1.14.12", "@orpc/server": "1.14.12", "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-TsT6y2XnlK5pUDvoitkjGcvSAiKEgMO08XsCNhZhUDqUVcxBOQRNXDkLfTr8aJ2zCnOdrvLSv/WRS/nY2dJbJw=="], + + "@orpc/openapi-client": ["@orpc/openapi-client@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/contract": "1.14.12", "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-DfIfRn7qv081KQ3T5vxvMe6N5WpjoBd36K07KAQCejMhubPbO5Qad9Ktngr59Cq3/AolceHMh5PzXRhRY7LtIw=="], + + "@orpc/server": ["@orpc/server@1.14.12", "", { "dependencies": { "@orpc/client": "1.14.12", "@orpc/contract": "1.14.12", "@orpc/interop": "1.14.12", "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-aws-lambda": "1.14.12", "@orpc/standard-server-fastify": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-node": "1.14.12", "@orpc/standard-server-peer": "1.14.12", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-4uHxhkiA8KHVHaa4WwUtY08q0Qs8fcKVngJypbT20Joa/yifWoYufqtv2/sw9nwkuMAOL+kLGEI5EvZMkCXScg=="], + + "@orpc/shared": ["@orpc/shared@1.14.12", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-FHTFSEJoCcZIaJWSNr+FxRDrSO5yCYdf+6QRVM9EIKQqJJVkSjJP5AJNf9bd1Nm3jdDQrhsIBQq84HOJMfd0gg=="], + + "@orpc/standard-server": ["@orpc/standard-server@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12" } }, "sha512-9+XGxpmebDJxzneBS+K6f3EWaE5p2iYCBdMKnTRH6oU4TdJTw048DadZbfsg1dcf+FChRmkzlHhXXnqgdW2K6Q=="], + + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12", "@orpc/standard-server-node": "1.14.12" } }, "sha512-iJDzFKdsv7r83apCEm2HMWaZKOax6TCyAV31TT6p+AGau5GmayZ7yDMgoUZeADFp0KeHBZGWMNkQUnf/bIEbhw=="], + + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-node": "1.14.12" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-C6QirJkLawEsATwEj9QX0MYKPcUZvBx+Gv+D2uS61T8aEKx1mccVtnl/2taP162yU5rt1LtJAkuisPPxSn37xw=="], + + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-4VQaWHlCoROqtQzXVv1pt1jUs5c3r6OwO4h+545vT6dZoaiOoFEUimhYqSGSlt6MClJTQiuQotrkeb4NrLlniw=="], + + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12", "@orpc/standard-server-fetch": "1.14.12" } }, "sha512-/BfQUiL7f3aY+fMcka3IHgnov0YgDz4dQhBJ5Ku3p/nOg5iH+OLVwbQKYfC1et2uY4EBSWyNlKGHAlzwWKMR6A=="], + + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.12", "", { "dependencies": { "@orpc/shared": "1.14.12", "@orpc/standard-server": "1.14.12" } }, "sha512-i3L1jKIL4GV6j3mi08cLE5aXxPIWk/vGHvFwN5JYjBKTltFv5MORWYqwzH1Qx9kXg9dvr73fyNm6lPlvBqoxDQ=="], + + "@orpc/zod": ["@orpc/zod@1.14.12", "", { "dependencies": { "@orpc/json-schema": "1.14.12", "@orpc/openapi": "1.14.12", "@orpc/shared": "1.14.12", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.12", "@orpc/server": "1.14.12", "zod": ">=3.25.0" } }, "sha512-6uK0NWzy3i2TKhmLIPdL/mgfXNb4jPz2bw79PTAnC9Ug5OS80iX0F/SXAg4+xJP+zKOBTgdSmtOSLCg/LSp2yA=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], @@ -267,6 +300,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.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/c9cSBRermWDv85oufLhoH6XRLOVbvzJLRd+WLyfJCP+i0HFLQj4PVNDrHcY17/ve5l8X0Oua4bJBqJUgJPnZA=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8lpCCGWZBl9PIF8w8f2iEWrLMbHBWIfJeV6l2UEGqysD6HRIM4ySj/8R7HGEzbECJ4r/gnJcHmxEoG8yjFe64A=="], @@ -311,6 +346,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=="], @@ -323,6 +360,8 @@ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], @@ -375,6 +414,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=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], @@ -397,6 +438,8 @@ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxfmt": ["oxfmt@0.55.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.55.0", "@oxfmt/binding-android-arm64": "0.55.0", "@oxfmt/binding-darwin-arm64": "0.55.0", "@oxfmt/binding-darwin-x64": "0.55.0", "@oxfmt/binding-freebsd-x64": "0.55.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", "@oxfmt/binding-linux-arm64-gnu": "0.55.0", "@oxfmt/binding-linux-arm64-musl": "0.55.0", "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-musl": "0.55.0", "@oxfmt/binding-linux-s390x-gnu": "0.55.0", "@oxfmt/binding-linux-x64-gnu": "0.55.0", "@oxfmt/binding-linux-x64-musl": "0.55.0", "@oxfmt/binding-openharmony-arm64": "0.55.0", "@oxfmt/binding-win32-arm64-msvc": "0.55.0", "@oxfmt/binding-win32-ia32-msvc": "0.55.0", "@oxfmt/binding-win32-x64-msvc": "0.55.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], @@ -441,6 +484,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=="], @@ -449,6 +494,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=="], @@ -475,6 +522,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@1.0.1", "", { "dependencies": { "temporal-spec": "1.0.0", "temporal-utils": "1.0.1" } }, "sha512-N2SoI9olnW7BUsU8RosDphZQl9s+WJ8O7PoJMFCr/e5/1rFkVI4GNOWaSeySG+UoP04foPYsnLWbJmbXOiShZg=="], "temporal-quarter-fns": ["temporal-quarter-fns@workspace:packages/temporal-quarter-fns"], @@ -495,6 +544,8 @@ "turbo": ["turbo@2.10.7", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.7", "@turbo/darwin-arm64": "2.10.7", "@turbo/linux-64": "2.10.7", "@turbo/linux-arm64": "2.10.7", "@turbo/windows-64": "2.10.7", "@turbo/windows-arm64": "2.10.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-GHx6WExIFSKNJ5qMlzDpXBXlu9ApxaMjqxAVrCNcW94xf/+uqgIz41SAuRUMbXva2ExNAaY/h8V0q90SWSzmRw=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "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-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -507,6 +558,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=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..9e75dd2 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test-setup.ts"] 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" diff --git a/packages/temporal-zod/README.md b/packages/temporal-zod/README.md index 55c4744..cb77d3a 100644 --- a/packages/temporal-zod/README.md +++ b/packages/temporal-zod/README.md @@ -69,6 +69,52 @@ 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` 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"; + +const generator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter({ + interceptors: [temporalJsonSchemaInterceptor], + }), + ], +}); +``` + +`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. + +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/package.json b/packages/temporal-zod/package.json index f2ce7bf..18d3db9 100644 --- a/packages/temporal-zod/package.json +++ b/packages/temporal-zod/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@macalinao/tsconfig": "^3.2.5", + "@orpc/zod": "^1.14.0", "@types/bun": "latest", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", diff --git a/packages/temporal-zod/src/base/instant.ts b/packages/temporal-zod/src/base/instant.ts index e01527f..7e9dcd6 100644 --- a/packages/temporal-zod/src/base/instant.ts +++ b/packages/temporal-zod/src/base/instant.ts @@ -1,5 +1,12 @@ import { Temporal } from "ponyfill-temporal"; 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,14 @@ 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)(\\[.+\\])?$"; + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc isolatedDeclarations (TS9010) requires it + `^${DATE_PART}T${TIME_PART}${OFFSET_PART}(${TIME_ZONE_PART})?${OPTIONAL_CALENDAR_PART}$` as string; 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..b243958 --- /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 = `(${CALENDAR_PART})?` as string; + +/** Calendar date, `YYYY-MM-DD`. */ +export const DATE_PART = `${YEAR_PART}-${MONTH_PART}-${DAY_PART}` as string; diff --git a/packages/temporal-zod/src/base/plain-date-time.ts b/packages/temporal-zod/src/base/plain-date-time.ts index b4772bb..225203e 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 "ponyfill-temporal"; +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,13 @@ 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})?)?$"; + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc isolatedDeclarations (TS9010) requires it + `^${DATE_PART}T${TIME_PART}${OPTIONAL_CALENDAR_PART}$` as string; 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 f44e2f2..6d82f29 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 "ponyfill-temporal"; +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,14 @@ 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])$"; + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc isolatedDeclarations (TS9010) requires it + `^${DATE_PART}${OPTIONAL_CALENDAR_PART}$` as string; 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 4a56210..775514c 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 "ponyfill-temporal"; +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,15 @@ 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])$"; + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc isolatedDeclarations (TS9010) requires it + `^((--)?${MONTH_PART}-${DAY_PART}${OPTIONAL_CALENDAR_PART}|${DATE_PART}${CALENDAR_PART})$` as string; 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 01989ec..9fbf7ee 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 "ponyfill-temporal"; +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 = `^${TIME_PART}$` as string; 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 81e9c99..f8e1b69 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 "ponyfill-temporal"; +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,15 @@ 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 = + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc isolatedDeclarations (TS9010) requires it + `^(${YEAR_PART}-${MONTH_PART}${OPTIONAL_CALENDAR_PART}|${DATE_PART}${CALENDAR_PART})$` as string; 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 ea90fa1..00c24ab 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 "ponyfill-temporal"; +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,14 @@ 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)\\[.+\\]$"; + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc isolatedDeclarations (TS9010) requires it + `^${DATE_PART}T${TIME_PART}${OFFSET_PART}${TIME_ZONE_PART}${OPTIONAL_CALENDAR_PART}$` as string; const validators = temporalValidators(ZonedDateTime); 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/json-schema.test.ts b/packages/temporal-zod/src/json-schema.test.ts index 6336365..00269bd 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 new file mode 100644 index 0000000..86708f2 --- /dev/null +++ b/packages/temporal-zod/src/orpc.test.ts @@ -0,0 +1,494 @@ +import type { ZodToJsonSchemaConverterOptions } from "@orpc/zod/zod4"; +import { describe, expect, test } from "bun:test"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { Temporal } from "ponyfill-temporal"; +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"; + +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", () => { + /** + * `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", + 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"] }); + }); + + // 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"); + } + }); +}); + +/** + * 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); + }); +}); + +/** + * 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("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"); + 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(), + ], + [ + "ZonedDateTime", + zZonedDateTime, + ZONED_DATE_TIME_PATTERN, + zoned.withCalendar("hebrew"), + ], + ]; + + for (const [name, schema, pattern, value] of withHebrew) { + test(`${name} emits, advertises and revives a hebrew-calendar string`, () => { + const wire = value.toJSON(); + expect(wire).toContain("[u-ca=hebrew]"); + + // 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); + }); + } + + 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); + }); + + 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(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); + } + }); +}); diff --git a/packages/temporal-zod/src/orpc.ts b/packages/temporal-zod/src/orpc.ts new file mode 100644 index 0000000..f5757f9 --- /dev/null +++ b/packages/temporal-zod/src/orpc.ts @@ -0,0 +1,112 @@ +/** + * [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 { OpenAPIGenerator } from "@orpc/openapi"; + * import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; + * import { temporalJsonSchemaInterceptor } from "temporal-zod"; + * + * const generator = new OpenAPIGenerator({ + * schemaConverters: [ + * new ZodToJsonSchemaConverter({ + * interceptors: [temporalJsonSchemaInterceptor], + * }), + * ], + * }); + * ``` + * + * 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 * as z from "zod"; + +/** + * 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`. + * + * 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 = < + TResult extends [required: boolean, jsonSchema: unknown], +>( + options: TemporalSchemaInterceptorOptions, +) => TResult | [required: true, jsonSchema: TemporalJsonSchema]; + +/** + * 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. + * + * @example + * ```typescript + * new ZodToJsonSchemaConverter({ + * interceptors: [temporalJsonSchemaInterceptor], + * }); + * ``` + */ +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 unknown as TemporalJsonSchema]; + } + return options.next(); +}; diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..81aaa3a --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,5 @@ +// Bun has no native Temporal, so without this the ponyfill falls back to +// temporal-polyfill's default entry, which omits non-ISO calendar data. +// Install the full-ICU build globally so tests can exercise calendar +// annotations end to end; runtimes with native Temporal get this for free. +import "temporal-polyfill/full/global";