refactor(downgrader): slim the converters down to what oRPC and schema libraries emit - #50
Conversation
…a libraries emit
The downgrader exists so oRPC can generate 3.1 and 3.0 documents. It had
grown machinery for constructs almost no one writes, which made it large,
hard to follow, and slow on common documents. This rewrite keeps every
conversion that oRPC, Zod, Valibot, and ArkType output needs, and drops the
edge cases.
Engine (shared.ts, 1058 -> 199 lines):
- One table-driven `convertObject` with a per-table memo, so a shared object
is converted once and a cycle ends, plus a per-conversion pointer cache.
- `inline` replaces a `$ref` by its converted target, cutting recursion.
- Removed: `$id`/`$anchor` base resolution, exact-reuse tracing and its work
budget, alias chains, and the fixpoint that re-ran conversion until no
reference newly dangled.
3.2 -> 3.1: unchanged conversions, minus `$id` uniqueness when inlining,
pruning of links and mappings into removed parts, and dropping parameters
left without content. `allowReserved` is now kept only on query parameters,
since 3.1 and 3.2 forbid it on headers.
3.1 -> 3.0:
- Kept: type arrays, const, exclusive bounds, examples, $ref siblings,
$defs inlining, binary formats, webhooks and components.pathItems
inlining, Reference Object stripping, mutual TLS.
- Removed: oneOf/not loosening tracking, multipart octet-stream and
RFC6570 inference, non-OAuth scope clearing, link and mapping pruning,
empty enum and readOnly+writeOnly handling.
- Improved: tuples become `items` matching any item schema (Zod tuples and
maps keep their item types instead of `items: {}`), and a `$ref` into a
removed part that dangles stays as written.
Tests: 41 edge-case files are replaced by one file per step, the official
corpus (each step and chained), and a document generated by
@orpc/openapi 2.0.0-beta.41 with Zod, validated as 3.1 and 3.0.
Benches: add the oRPC document; drop `$id` from the generated API.
The README now describes the reduced scope and its limitations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
…tions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will improve performance by ×2.5
Performance ChangesTip Curious why performance improved? Comment Comparing |
There was a problem hiding this comment.
Caution
Two behavior regressions survive the refactor even though all 176 tests pass: a cyclic value in a copied position now throws RangeError, and dropping the loosening tracker makes not/oneOf unsatisfiable on valid input. Both are things the base converter handled.
Reviewed changes
- Minimal
Context—root/targets/seen/inliningreplace the location, resource, inlining, copy-tracking, and exact-reuse state; the fixpointdowngradedriver and itsdangles/alias machinery are gone. - Simpler core —
clonelost its memo,convertObjectcaches by(fields map, source object)and returns the in-progress object for cycles,Finishmutates in place and returnsvoid,map/listreworked. - Converters rebuilt —
$id/anchor/external ref resolution and loosening tracking removed;$defsand removed parts are inlined by local JSON Pointer; Path Item$refmerging, tuple rewriting, and multi-type handling rewritten in v3.1→3.0. - Tests replaced — granular
spec/schemasuites deleted and consolidated into snapshot-backedv3.1-to-v3.0.test.ts/v3.2-to-v3.1.test.ts, plus a new oRPC integration test.
⚠️ This changes behavior, not just structure
The PR body frames this as a refactor that "maintains backward compatibility of the public API", but the resource/$id resolution and the loosening tracker are removed, so documents the base converter turned into safe (looser) schemas now yield unsatisfiable schemas or throw on cycles — the two inline findings below. The README intro still leads with "loses detail, never meaning" while its new Limitations section says a not/oneOf "can then reject values the original accepts"; the deleted granular tests were the only thing pinning the old guarantee. Please confirm the guarantee is intentionally dropped for published consumers (oRPC) and reword the intro, rather than presenting this as behavior-preserving.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…the stack `clone` recursed without a memo, so a cycle reached through a copied position, such as `example`, `default`, or an extension, threw a RangeError. It now copies each object once and keeps the cycle, as converted positions already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
clonekeeps cycles in copied values.clonenow delegates to a memoizingcopy, which records each container's output before recursing, so a cyclic value reached through a copied position — an unlisted keyword,default,example, anx-extension, or a non-convertedmapentry — is reproduced as a cycle instead of overflowing the stack.- Regression tests added. One per converter, asserting a self-referential
example/defaultround-trips as a cycle while converted and copied positions both stay within their own graph. Both fail on the previous commit and pass now.
pnpm test (178 pass) and pnpm lint are clean. This is submitted as a comment rather than an approval only because the earlier not/oneOf loosening thread is still open; the new commit does not touch that code, so it introduces no new concerns.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…word is removed in 3.1 to 3.0
Removing a restricting keyword, such as `contains` or `propertyNames`,
loosens a schema. A `not` over it then rejected values the original
accepted, as `{ not: { contains: … } }` became `{ not: {} }`, and a
`oneOf` whose branches now overlap rejected values matching both.
A converted schema is now marked loose when it lost such a keyword, holds
a loose subschema, or is a `$defs` target cut at recursion. A `not` over
it is removed and a `oneOf` with a loose branch becomes `anyOf`. Only a
loose schema reached through a `$ref` kept in the output stays a
limitation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Restored
not/oneOfloosening tracking.v3.1-to-v3.0.tsnow definesRESTRICTING_KEYWORDSand a module-levelLOOSEWeakSet; a converted schema is marked loose when it lost a restricting keyword, holds a loose subschema, or is a$defstarget cut at recursion, andfinishSchemadeletes an enclosingnotand lowers aoneOfwith a loose branch toanyOf. - Updated the README. Added
not/oneOfrows to the removal table and narrowed the step's limitation to the$ref-kept case. - Added regression tests. Cover
notremoval (direct, throughproperties, and through the recursion cut), theoneOf→anyOfrewrite, and the case that staysnot.
This addresses the prior review's loosening finding: the new tracker is smaller than main's and its only remaining gap is the documented kept-$ref case. pnpm test (181), pnpm lint, and pnpm type:check pass; snapshots are unchanged.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
oRPC users hand-write parts of the 3.2 document too, in `base` and in
operation `spec` overrides. An audit with hand-written documents and 24
real 3.1/3.2 descriptions (GitHub, OpenAI, Discord, Adyen, Train Travel,
and others) found these gaps:
- Objects with a null-prototype class or from another realm, such as the
document oRPC's generate() returns, came back unconverted.
- 3.1 -> 3.0 dropped a 3.0-style `nullable: true` beside a type, so the
3.0 output rejected null. It is kept now, and counts as loosening.
- Scopes on apiKey and http requirements, which 3.0 forbids, are `[]`.
- Keywords 3.0 does not define, such as zod `.meta()` keys, made the
whole 3.0 document invalid. They become `x-` extensions.
- `unevaluatedProperties` with nothing else evaluating properties becomes
`additionalProperties` instead of being removed.
- `propertyNames: { type: 'string' }` and exactly converted tuples no
longer count as loosening, so a zod discriminated union keeps `oneOf`.
`items: false` becomes `maxItems`.
- A parameter with `content` loses `style`, `explode`, and
`allowReserved`, as oRPC's `queryStyles: 'json'` emits.
- Tag and Info summaries fill a missing description.
- A string with `contentSchema` no longer becomes `format: binary`.
- An empty `enum` becomes `allOf: [{ not: {} }]` instead of invalid 3.0.
The README lists each rule, and new limitations for external `$ref`s,
mTLS-only operations, shared inlined targets, and TypeBox records.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
There was a problem hiding this comment.
Important
The bdc2da3 loosening refinement is sound on its own, but the refactor dropped the guards (and the tests) that kept a contradictory const / type: "null" schema from being treated as exact, so an enclosing not / oneOf now rejects values the 3.1 source accepted. main handled both cases and pinned them in the deleted tests/v3.1-to-v3.0/schema/loosening.test.ts; the consolidated suite does not cover them.
Reviewed changes
- 3.0-only schema keywords — unknown keywords are renamed to
x-extensions, an emptyenumbecomesallOf: [{ not: {} }], and a 3.0-stylenullableis kept beside a singletype(and marks the schema loose). - Sharper loosening detection —
losesRestrictionno longer countspropertyNames: { type: "string" }, an exactly-converted tuple, orunevaluatedPropertiesalready expressed asadditionalProperties;convertTuplereturns whether the conversion is exact and mapsitems: falsetomaxItems. unevaluatedProperties→additionalProperties— converted when no in-place applicator evaluates properties.- Document-level niceties — a 3.1
info.summaryand a 3.2tag.summaryfill a missingdescription; a parameter withcontentalso dropsstyle,explode, andallowReserved; scopes onapiKey/httpsecurity requirements are emptied. - JSON-like inputs —
isRecordaccepts null-prototype and other-realm objects (oRPC documents) and excludes arrays.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
An adversarial review of bdc2da3 confirmed these: - A tuple's item schemas were deduplicated by their converted output, so an item that lost a restriction could hide behind an equal one, and the tuple counted as exact. An enclosing `not` or `oneOf` then rejected values the original accepts. Items are now deduplicated by their source schema, and a tuple with a loosened item is never exact. - An existing `x-` extension holding null was overwritten by the renamed keyword. - A `const` outside the `enum` beside it, and `type: 'null'` with an `enum` that lacks null, matched nothing; their 3.0 form matched values without counting as loosened. The first now counts, and the second keeps matching nothing. - `isRecord` accepted objects that inherit keys from a null-prototype parent and read those keys, which JSON ignores. - `definitions`, renamed to `x-definitions`, left `$ref`s into it dangling. It is now read as `$defs`, as 2020-12 still does. The README now says external content `$ref`s in 3.2 are removed, and that a cycle of objects can hide a loosened schema from `not` or `oneOf`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
Valid schemas that combine keywords unusually, own __proto__ keys, a cyclic tuple item, non-local or looping $refs, and malformed values the converters must pass through without throwing. Coverage of src is now 100% of lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed e1a1eb6, the fix commit that resolves the prior review's inline findings.
- Deduplicated tuple item schemas by source —
convertTuplekeys variants by the item's source schema (jsonKey(item)) and returns the tuple as inexact when the retained item is loose, so a loosened item can no longer hide behind an equal one. - Preserved an
x-extension holding null — the unknown-keyword renamer usesObject.hasOwnin place of??=, so an existingx-key valuednullis not overwritten. - Closed the
const/type: "null"inversions — aconstoutside theenumbeside it now marks the schema loose, andtype: "null"with anenumthat lacks null stays unsatisfiable viaallOf: [{ not: {} }](anenumcontaining null becomes[null]exactly). - Tightened
isRecordfor inherited keys — a null-prototype parent with own keys is no longer read as a JSON object, so keys an object would inherit are ignored as JSON ignores them. - Read
definitionsas$defs— added to the removed-keyword set and the inline check, so$refs into it are inlined instead of left dangling; README updated.
Verified: pnpm test (190 pass), pnpm lint, and pnpm type:check are clean. A differential Ajv 2020-12 fuzz over the new const/enum/type: "null"/prefixItems paths (269 schemas × 16 instances, ~3.8k checks) found no value the 3.1 source accepts that the 3.0 output rejects.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run covered 4dc47e2, a test-only commit layered on the e1a1eb6 fixes.
- Added unusual-combination coverage —
$refwith siblings plus atypearray andanyOf,type: "null"withallOf, emptyenumwithallOf, a multi-type array, a single-element type array, and amaximum/exclusiveMaximumconflict, each pinned to an exact output. - Added own-
__proto__coverage —__proto__keys inpropertiesand in a copiedexamplesurvive as data keys without touching the prototype. - Added a cyclic tuple-item test — two
prefixItemsentries sharing one cyclic schema deduplicate and the cycle is reproduced in the output. - Added
$ref-resolution boundaries — external and percent-invalid refs stay as written, andsecuritySchemesself-refs terminate. - Added malformed pass-through coverage — non-array
security/parameters/responsesand non-recordproperties/allOfare cloned through without throwing.
Every added assertion is an exact .toEqual or identity check that would fail if the pinned behavior regressed, so none is theatre. pnpm test (197 pass), pnpm lint, and pnpm type:check are clean.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

The downgrader exists so oRPC can generate 3.1 and 3.0 documents: it builds every document as 3.2 and runs
downgradeSpecV32ToV31, thendowngradeSpecV31ToV30. oRPC users also hand-write parts of that document, inbaseand in per-operationspecoverrides, so the downgrader has to cover what people commonly write, not just what oRPC generates.The package had grown machinery for constructs almost no one writes, which made it large and hard to follow, and slower than needed on ordinary documents. This drops that machinery. An audit then checked the result against hand-written and real-world documents, and this PR fixes the gaps it found.
mainEngine
shared.tsgoes from 1,058 to 227 lines.convertObjectwith a per-table memo, so a shared object is converted once and a cycle ends.clonekeeps cycles in copied values the same way.$refs.inlinereplaces a$refby its converted target and cuts recursion with{}.generate()returns.$id/$anchorbase resolutionBehavior changes
No longer handled. The README lists each of these under the step's limitations.
$refs into removed parts (components.mediaTypes), keeping$ids unique when inlining, pruning Links andmappingvalues into removed parts, dropping a parameter left withoutcontentapplication/octet-streamand RFC6570 inference, pruning Links andmappingvalues into removed parts,$id-relative$refs, percent-encoded removed-part pointers,readOnlywithwriteOnly, deduplicatingrequired, forcingrequired: trueon path parametersKept, in a smaller form. In 3.1 → 3.0, a schema that lost a restricting keyword is marked loose. A
notover it is removed, and aoneOfwith a loose branch becomesanyOf, so neither rejects values the original accepts. Keywords that restrict nothing, such as zod'spropertyNames: { type: 'string' }, and exactly converted tuples don't count, so a zod discriminated union keepsoneOf.Added or fixed after the audit. The audit used hand-written documents plus 24 real 3.1/3.2 descriptions: GitHub, OpenAI, Discord, Adyen, Train Travel, Airflow, Meilisearch and others.
nullable: a 3.0-stylenullable: truebeside a singletypein a 3.1/3.2 document is kept in 3.0. Before, the output rejected null..meta()keys or Pydantic extras, becomex-extensions. Before, they made the whole 3.0 document invalid.unevaluatedProperties: it becomesadditionalPropertieswhen nothing else evaluates properties.apiKey/httprequirements become[], as 3.0 requires.content: they losestyle,explodeandallowReserved, which oRPC'squeryStyles: 'json'emits.items: falsebecomesmaxItems.prefixItemsbecomesitemsmatching any item schema, instead ofitems: {}.contentSchemano longer becomesformat: binary, andcontentEncoding: binarybecomesformat: binary.enum: an emptyenumbecomesallOf: [{ not: {} }]instead of invalid 3.0.definitions: it's read as$defs.$refinto a removed part whose target is missing stays as written.Unchanged on purpose.
{ type: 'null' }stays{ enum: [null] }, withoutnullable, as decided in a34a9b9 (#20).For reviewers
items, summaries filling descriptions, key order, and thenullablethat a34a9b9 (fix(downgrader): stop emitting nullable without type in 3.1 to 3.0 #20) already removed onmain.$defstarget first converted inside a recursion cut is reused with that cut wherever else it's referenced. The README lists this.Testing
orpc-document.ts, generated by@orpc/openapi@2.0.0-beta.41with Zod, validated as 3.1 and 3.0 and snapshotted;generate()for 3.2.0, 3.1.0, 3.1.1, 3.0.0, 3.0.3 and 3.0.4 validates with this build, and so does downgradinggenerate()'s own 3.2 output directly.main. For example, 3.1→3.0 on the generated 100-resource API runs at about 63 ops/s against 20, and the chained official corpus at about 855 against 318. CodSpeed has the authoritative numbers.pnpm test(190),pnpm lintandpnpm type:checkpass.🤖 Generated with Claude Code
https://claude.ai/code/session_01PhK4SwK1yxF8aWvPBsNzVH