fix(downgrader): convert shared schemas once in dereferenced documents - #23
Conversation
Dereferenced documents no longer blow up: a record reached along several paths is converted once per call and its copies are shared, so a depth-22 schema diamond converts in 1 ms instead of 10.7 s and 3.2 to 3.1 on a dereferenced document drops from 8.7 s to 61 ms.
# Conflicts: # packages/downgrader/src/v3.1-to-v3.0.test.ts
…gaps Drop the depth counter in favour of the conversions map size, and add tests that fail when reuse ignores the finish function, when deepClone shares copies outside a conversion, or when either cache survives a throw.
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
Reviewed all three commits on claude/conversion-performance-fix-6ba180 against main.
- Per-call conversion cache (
shared.ts) —convertRecordnow reuses a finished result for a source object only whenfieldsandfinishare the same references; in-progress entries still terminate cycles.deepCloneborrows a sharedclonesmap for the duration of a call. Both module-level maps are cleared by the outermostconvertRecordframe, including on throw. - Shared output — because
v3.1-to-v3.0.tsdrives every schema through the stableSCHEMA_FIELDS/finishSchemapair, a schema reached from multiple paths yields one converted object. Context-dependent converters (xml) allocate fresh field/finish closures per call and so are never reused.v3.2-to-v3.1.tsbuilds every field table inline, so it reuses nothing from the conversion cache — its win is entirely shareddeepCloneoutput. - Tests — unit tests pin reuse-per-call, fresh-result-per-call, the same-table/same-finish gate, and cache clearing after a throw; e2e tests pin a shared schema across
components+ a response and a depth-64 diamond in both converters. - README — the
Never mutatesbullet is updated to note that objects shared in the input may stay shared in the result.
I traced the reuse condition against every field converter in both modules and could not construct an input where a context-dependent result is wrongly reused, and verified the module-level state cannot be left uncleared via the public API. pnpm test (368 passing), pnpm run lint, and pnpm run type:check are green.
ℹ️ Output is no longer guaranteed to be an unshared tree
The converted document can now contain the same object reference at multiple positions (e.g. a dereferenced schema under components and inside a response), which is the intended fix and is documented in the README. Worth keeping in mind for any downstream consumer that post-processes the converted document by mutating it in place, since edits to one position would now be visible at the others.
Technical details
# Output reference sharing is a documented behavior change
## Affected sites
- `packages/downgrader/src/shared.ts:84` — `deepClone` reuses the per-call `clones` map, so a
repeated source object yields the same clone.
- `packages/downgrader/src/shared.ts:92` — finished results are reused for the same
`(fields, finish)` reference pair.
- `packages/downgrader/src/v3.1-to-v3.0.ts:190` — module-level `SCHEMA_FIELDS`/`finishSchema`
make every schema cache-eligible.
- `packages/downgrader/README.md:26` — documents the loosened guarantee.
## Required outcome
- None required for this PR. This entry exists only to make the semantic change explicit for
release notes / downstream maintainers.
## Suggested approach (optional)
- Mention the shared-output behavior in the release notes for the next published version.DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏
3.2 → 3.1 no longer leaves `$ref`s pointing at parts it removes. A reference into `components.mediaTypes`, a `query` or `additionalOperations` operation, a removed parameter, or a moved `itemSchema` is now replaced by its converted target, so the output validates as 3.1. Documents without such references convert exactly as before. ## Fixes - Reference Objects and Schema Object `$ref`s into removed parts are inlined in converted form, following reference chains. `#/components/mediaTypes/Pet/schema` becomes the Pet schema; beside other schema keywords the target is appended to `allOf`. - A ref loop through a removed part is cut by dropping the reference, instead of producing a circular object that `JSON.stringify` cannot serialize. - References into a parameter list that lost a `querystring` entry no longer point at the wrong parameter. - References that resolve to a removed parameter or header through any pointer are removed, not only aliases inside `components`. - Content-map `$ref`s resolve any local media type, including escaped names such as `a~1b`. - Shared schemas in dereferenced documents are still converted once (#23), in both passes. ## Performance | Document (200 schemas, 100 paths) | main | this PR | | --- | --- | --- | | No dangling refs | 2.57 ms | 3.28 ms | | One dangling ref | 2.58 ms | 6.67 ms | Every distinct `$ref` is checked against the first-pass result, and schemas are walked rather than copied. A second pass runs only when something dangles. ## Testing - 400 tests pass with 100% coverage of `packages/downgrader/src`; lint and type-check pass. - An end-to-end 3.2 document with refs into `mediaTypes`, `query`, `additionalOperations` and a shifted parameter index validates as 3.1 and, chained, as 3.0. - Both routes into the ref-cycle bug have regression tests that fail with the old guard; a depth-40 shared schema diamond converts once with a dangling ref present. ## Known limits - A recursive schema defined only under a removed part keeps one level and its repeat becomes `{}`, since 3.1 has nowhere to keep it without inventing a component name. Where the cut lands can depend on key order. - Link `operationRef` and discriminator `mapping` values pointing into removed parts pass through unchanged; the README lists them. - 3.1 → 3.0 has the same class of problem (refs into dropped `$defs` dangle). That is a separate change.

Both downgraders now convert a schema that is shared in the input only once per call, so the output of a
$refdereferencer no longer blows up. Before, a shared schema was converted again for every path that reached it. A depth-22 schema diamond took 10.7 s and 750 MB going from 3.1 to 3.0. It now takes 1 ms. The README already said dereferenced documents were supported. This makes that true.Fixes
xmlunder an array schema, are still converted separately.Performance
$refdocument, 2,000 operationsThe cost on ordinary documents is extra garbage collection from the per-call cache. The fastest runs take the same time as before. Four alternatives were measured and none of them removed it.
Testing
componentsand a response in both converters.packages/downgrader/src.pnpm lintandpnpm type:checkpass.Known limits
Nested, shared callbacks and headers → content → encoding → headers chains are still converted once per path. Their field tables are built on each call, so they are never reused. Real documents don't nest these deeply.