Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5382810
fix(downgrader): a $ref cut at recursion in a copy reused later tells…
claude Nov 1, 2026
479b9ac
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 1, 2026
34ef504
fix(downgrader): keep a oneOf of object literals, and an additionalPr…
claude Nov 1, 2026
c750171
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 1, 2026
39f4ca4
fix(downgrader): follow a propertyNames $ref on only through $refs be…
claude Nov 1, 2026
f695074
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 1, 2026
c09da8c
fix(downgrader): rerun the conversion for a schema that loses a restr…
claude Nov 1, 2026
e9f7f25
fix(downgrader): follow an alias chain through inlined $refs and tagg…
claude Nov 1, 2026
905a75a
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 1, 2026
997f21b
perf(downgrader): skip the second pass when no not or oneOf kept its …
claude Nov 1, 2026
cb16050
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 1, 2026
7d46cbc
fix(downgrader): count an object that requires a readOnly or writeOnl…
claude Nov 1, 2026
9962db1
perf(downgrader): check one-way required properties without a closure…
claude Nov 1, 2026
9811aeb
refactor(downgrader): count any readOnly or writeOnly schema as loose
claude Nov 1, 2026
b320e25
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 1, 2026
6de99ef
refactor(downgrader): count a schema met again mid-cycle as loose, in…
claude Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc
| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. |
| A boolean `exclusiveMinimum` or `exclusiveMaximum`, the 3.0 form, as NestJS writes in 3.1 | Kept beside a numeric `minimum` or `maximum`, where it means in 3.0 what its author meant. Removed otherwise. |
| [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] | Kept. 3.0 requires such a property in responses or in requests only, where 3.1 requires it everywhere, so the schema counts as having lost a restriction, and the property tells no `oneOf` branch apart. |
| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. A schema that loses one only around a cycle, of `$ref`s or of objects, counts too, which converts the input a second time. |
| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. A recursive schema, which a cycle of `$ref`s or of objects reaches again before its conversion is done, counts too. |
| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. |

[js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type
Expand Down Expand Up @@ -315,7 +315,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc

Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output.

Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first.
Three shapes no generator emits fall outside this. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first.

[Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions.

Expand Down
4 changes: 2 additions & 2 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function convertObject(
ctx: Context,
fields: Fields,
finish?: Finish,
revisit?: (value: object, out: object) => void,
revisit?: (out: object) => void,
): unknown {
if (!isRecord(value)) {
return clone(value)
Expand All @@ -135,7 +135,7 @@ export function convertObject(
}
const known = seen.get(value)
if (known !== undefined) {
revisit?.(value, known)
revisit?.(known)
return known
}
const out: Record<string, unknown> = {}
Expand Down
Loading
Loading