Repository navigation
Conversation
…n through a kept $ref in 3.1 to 3.0 A `$ref` kept as written, such as one to `components.schemas`, was never marked as having lost a restriction, so a surrounding `oneOf` stayed `oneOf` and a `not` stayed. oRPC emits this for `z.xor` over named tuples or maps: both branches become arrays whose items match either type, so every valid value matched both, and 3.0 rejected all of them. A kept `$ref` now counts as loose when its converted target is. The target converts once, and `components.schemas` reuses it. A bare `$ref` target is cached per object as well, so a chain of them is followed once and a cycle of them ends. Parameters, Media Types and `components.schemas` skip the lookup for a bare `$ref`, since nothing reads whether their schema lost a restriction. A discriminated union over named branches that lost a restriction now becomes `anyOf` too, as the inline form already did. Its tags keep the branches exclusive, so this only loosens it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PzqwLjmPymmsM7dHmo6pSZ
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Kept
$refs now propagate looseness —hasLooseTargetresolves a kept$refand converts its target, marking the{ $ref }output loose when the target lost a restriction, so an enclosingoneOfbecomesanyOfand anotis dropped exactly as the inline path already did. - Bare-ref targets cached per object — a new
KEPT_REF_FIELDSconversion memoizes bare$reftargets, keeping alias chains linear and terminating bare-ref cycles (A ↔ B,S → S). - Top-level schemas skip the lookup —
convertTopSchemareturns a bare kept$refas written forcomponents.schemas, Parameter/MediaTypeschema, and thedowngradeSchemaV31ToV30root; the emitted object is unchanged from the oldconvertSchemapath, only the side-effect target conversion is skipped. - Tests and README — new cases cover named tuples/maps under
oneOf,notover a$refwith siblings, an exactly-converted tuple that staysoneOf, a discriminated union over named branches, an alias chain, and bare-ref cycles; the README limitation is narrowed to the cycle case.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…riction (#69) <!-- claude-projects-attribution: {"github_login":"dinwwwh"} --> _Requested by **dinwwwh** · [project thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYEGkD6EBG5t9tVGbsReCc6wo)_ ## Problem This is the bug from #62, with a smaller fix. In 3.1 → 3.0, a schema that loses a restriction is marked as loose, for example a tuple that becomes `items: { anyOf: [...] }`. A `oneOf` over a loose branch then becomes `anyOf`, and a `not` over a loose schema is removed. That only worked inline. A `$ref` kept as written, such as one to `components.schemas`, was never marked. So the `oneOf` or `not` stayed, and the 3.0 schema rejected values the original accepts. oRPC hits this with `z.xor` over registered tuples or maps: ```ts z.xor([ z.tuple([z.string(), z.number()]).meta({ id: 'StrNum' }), z.tuple([z.number(), z.string()]).meta({ id: 'NumStr' }), ]) ``` In 3.0, both components become "two items, each a string or a number". `["a", 1]` then matches both branches, so `oneOf` rejects it. ## Fix `convertSchemaRef` now converts the target of a kept `$ref` with the existing `inline` helper, and marks the `$ref` loose when the target is. That is 4 lines. - The target converts once, through the `seen` memo, so this reuses the copy `components.schemas` gets. The output is unchanged apart from the `oneOf` and `not` fixes. - `inline` already stops at a cycle, so bare-ref cycles (`A ↔ B`, `S → S`) end. - Alias chains, nested `items: { $ref }`, and a `$ref` with siblings that lose a restriction are all handled, because the target converts exactly as it does anywhere else. The README limitation is narrowed to what is left. Inside a cycle of schemas, a `$ref` back to a schema still being converted does not yet count what that schema lost. ## Tradeoff versus #62 #62 cached bare `$ref` targets so that a chain of n aliases, each referenced inside a `oneOf`, is followed once. This PR skips that cache to stay small, so such a chain costs O(n²). A 100-hop chain converts in about 20 ms, and a 1000-hop chain in about 1.7 s. oRPC and the schema libraries emit chains of 0 or 1 hops. A microbenchmark of `generated api, 100 resources`, alternating with `main`, showed no measurable difference. CodSpeed will confirm. ## Tests There is a new test under "a schema that lost a restriction › includes the target of a $ref kept in the output". It covers: - Named tuples under `oneOf`, as oRPC emits them. - A `not` over a kept `$ref`. - An exactly converted tuple, which stays `oneOf`. - An alias chain. - A nested `items` `$ref`. - A bare-ref cycle. The test fails on `main`. `pnpm run test:coverage` passes (199 tests, 100% lines), and so does `pnpm run check`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Wi2EdEpXYbC3zpW37w9z4Q)_ Co-authored-by: Claude <noreply@anthropic.com>
|
Superseded by #69, now merged. It fixes the same The one thing it gives up is this PR's bare- Generated by Claude Code |

Problem
In 3.1 → 3.0, a schema that loses a restriction (such as a tuple, which becomes
items: { anyOf: [...] }) is marked, so a surroundingoneOfbecomesanyOfand a surroundingnotis removed. That only worked inline. A$refkept as written, such as one tocomponents.schemas, came out as a fresh{ $ref }that was never marked, so theoneOfandnotstayed.That makes the 3.0 schema stricter than the original, not looser. oRPC triggers it with
@orpc/openapi2.0.0-beta.44 andZodToJsonSchemaConverter:oRPC emits
oneOf: [{ $ref: StrNum }, { $ref: NumStr }]. In 3.0, both components become "two items, each a string or a number", so every valid value matches both branches:main(oneOf)anyOf)["a", 1][1, "a"]["a", "b"]Named
z.maps fail the same way. Anotover a kept$refhas the same issue:not: { $ref: StrNum }rejected["b", "c"], which the original accepts.Fix (
packages/downgrader/src/v3.1-to-v3.0.ts)$refs.hasLooseTargetresolves a kept$refand converts its target. If the target lost a restriction, the$refis marked too. Theseencache makes that the same objectcomponents.schemasreuses.$reftargets are cached per object. Bare$refs skip theseencache, so without this every$refto an alias would walk the whole chain again. With 1000 hops that took ~1 s instead of 13 ms. With the cache, a chain is followed once. The cache also ends bare-ref cycles (A={$ref:B}, B={$ref:A},S={$ref:S}), which otherwise overflow the stack. So no separate visiting set is needed.schema, acomponents.schemasentry, or the root ofdowngradeSchemaV31ToV30. So a bare kept$refthere is left as written, without converting its target. This keeps ordinary documents close to their old cost, and their conversion order unchanged.$refin the output is still its own fresh object, as before.Side effect: a discriminated union over named branches that lost a restriction now becomes
anyOftoo, as the inline form already did. Itsconsttags keep the branches exclusive, soanyOfmeans the same thing there.Results
@orpc/openapi2.0.0-beta.44, through both downgrade steps): thez.xorover named tuples, thez.xorover named maps, and the discriminated union all go fromoneOftoanyOf. Components convert exactly as before, and the oRPC snapshot is unchanged.$refs, each referenced inside aoneOf: 1000 hops take ~20 ms, 2000 take ~30 ms, so the work stays linear. A version without the cache takes ~1 s at 1000 hops.main(noise is about ±5%):generated api, 100 resources(3.1 → 3.0): about +3%.itemSchemaputs$refs inside schemas, where the lookup has to run.Tests
In
packages/downgrader/tests/v3.1-to-v3.0.test.ts, under "a schema that lost a restriction › through a $ref kept in the output":oneOf(as oRPC emits them), anotover a$refwith siblings, and an exactly converted tuple that staysoneOf.A ↔ BandS → S.All three fail on
main. The cycle test overflows the stack if the per-object cache is replaced by a plainconvertSchemacall.pnpm testpasses (200 tests), and so doespnpm run check(sherif, oxlint, oxfmt, tsc).Known limits
The README limitation is narrowed to what is left. Around a cycle, a
$refor object pointing back into a schema still being converted does not yet count what that schema lost, so anotoroneOfover it can still reject values the original accepts. Which$refmisses it depends on which schema of the cycle is converted first. This does not arise in the oRPC cases above.Generated by Claude Code