From 518de8b4e5b14c553c8900c13a5849aae61f873a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:29:49 +0000 Subject: [PATCH 1/3] fix(downgrader): keep additionalProperties: true beside patternProperties in 3.1 to 3.0 Effect 4 (via @orpc/effect) writes `additionalProperties: true` beside `patternProperties` for records with number or template-literal keys and for StructWithRest. Dropping it left a bare `{ type: object }`, which openapi-typescript types as `Record`. `true` allows every property, so it never contradicts `patternProperties`; only schemas and `false` are dropped now. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FZoWKNpDyGBHkdaur4kKtA --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 4 ++-- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 1f618a0..3c059a1 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -190,7 +190,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | -| [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | +| [`patternProperties`][js-pattern-properties] | Removed, with an `additionalProperties` schema beside it, which would reject the properties it allowed. `additionalProperties: true` stays. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 0b8ff35..63fde26 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -144,9 +144,9 @@ const convertReference = refOr(clone) const SCHEMA_FIELDS = defineFields({ ...Object.fromEntries(REMOVED_KEYWORDS.map((key) => [key, DROP])), - // It would reject the properties that `patternProperties` allowed. + // A schema would reject the properties that `patternProperties` allowed. additionalProperties: (item, ctx, schema) => { - if (schema.patternProperties !== undefined) { + if (schema.patternProperties !== undefined && item !== true) { return DROP } return typeof item === 'boolean' ? item : convertSchema(item, ctx) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 9b9d80a..7df17ff 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1100,7 +1100,7 @@ describe('downgradeSchemaV31ToV30', () => { }) }) - it('drops additionalProperties beside patternProperties, which it would contradict', () => { + it('drops an additionalProperties schema beside patternProperties, which it would contradict', () => { expect( downgradeSchemaV31ToV30({ type: 'object', @@ -1108,6 +1108,21 @@ describe('downgradeSchemaV31ToV30', () => { additionalProperties: false, }), ).toEqual({ type: 'object' }) + expect( + downgradeSchemaV31ToV30({ + patternProperties: { '^x-': { type: 'string' } }, + additionalProperties: { type: 'number' }, + }), + ).toEqual({}) + // As Effect writes a record with number keys. Kept, so code generators + // do not read the object as empty. + expect( + downgradeSchemaV31ToV30({ + type: 'object', + patternProperties: { '^\\d+$': { type: 'string' } }, + additionalProperties: true, + }), + ).toEqual({ type: 'object', additionalProperties: true }) }) describe('a schema that lost a restriction', () => { From 26bc8d68e26baa77e9d3082f10ab266c4782cb0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:57:59 +0000 Subject: [PATCH 2/3] fix(downgrader): give patternProperties schemas additionalProperties: true in 3.0 The previous commit only kept an existing `additionalProperties: true`. Every other object with `patternProperties` still came out of 3.0 as a bare `{ type: object }`, which openapi-typescript types as `Record`: Zod `z.looseRecord` (no `additionalProperties`), Effect records with `onExcessProperty: "error"` (`false`), an `additionalProperties` schema, and `properties` beside `patternProperties`. `finishSchema` now sets `additionalProperties: true` whenever `patternProperties` is present, and the field converter drops the input one as on main. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FZoWKNpDyGBHkdaur4kKtA --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 10 ++++-- .../downgrader/tests/v3.1-to-v3.0.test.ts | 32 ++++++------------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 3c059a1..75a5d96 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -190,7 +190,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | -| [`patternProperties`][js-pattern-properties] | Removed, with an `additionalProperties` schema beside it, which would reject the properties it allowed. `additionalProperties: true` stays. | +| [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` beside it, which would reject the properties it allowed, becomes `true`, so code generators do not read the object as closed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 63fde26..d25e2e2 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -144,9 +144,9 @@ const convertReference = refOr(clone) const SCHEMA_FIELDS = defineFields({ ...Object.fromEntries(REMOVED_KEYWORDS.map((key) => [key, DROP])), - // A schema would reject the properties that `patternProperties` allowed. + // It would reject the properties that `patternProperties` allowed. additionalProperties: (item, ctx, schema) => { - if (schema.patternProperties !== undefined && item !== true) { + if (schema.patternProperties !== undefined) { return DROP } return typeof item === 'boolean' ? item : convertSchema(item, ctx) @@ -648,6 +648,12 @@ function finishSchema( if (Array.isArray(schema.examples) && schema.examples.length > 0 && out.example === undefined) { out.example = clone(schema.examples[0]) } + // 3.0 lacks `patternProperties`, and `additionalProperties` beside it is + // dropped, as it would reject the properties `patternProperties` allowed. + // Code generators read a missing one as allowing no other properties. + if (schema.patternProperties !== undefined) { + out.additionalProperties = true + } const format = binaryFormat(schema) if (format !== undefined && (schema.type === undefined || hasType(schema.type, 'string'))) { // A `format` that only repeats the encoding, as Zod writes, gives way. diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 7df17ff..ead90d9 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1100,29 +1100,17 @@ describe('downgradeSchemaV31ToV30', () => { }) }) - it('drops an additionalProperties schema beside patternProperties, which it would contradict', () => { - expect( - downgradeSchemaV31ToV30({ - type: 'object', - patternProperties: { '^x-': { type: 'string' } }, - additionalProperties: false, - }), - ).toEqual({ type: 'object' }) - expect( - downgradeSchemaV31ToV30({ - patternProperties: { '^x-': { type: 'string' } }, - additionalProperties: { type: 'number' }, - }), - ).toEqual({}) - // As Effect writes a record with number keys. Kept, so code generators - // do not read the object as empty. + it('makes additionalProperties beside patternProperties true, which allows what patternProperties did', () => { + const patternProperties = { '^x-': { type: 'string' } } as const + // As Zod writes a loose record, and Effect a record with number keys. + for (const additionalProperties of [undefined, true, false, { type: 'number' }] as const) { + expect( + downgradeSchemaV31ToV30({ type: 'object', patternProperties, additionalProperties }), + ).toEqual({ type: 'object', additionalProperties: true }) + } expect( - downgradeSchemaV31ToV30({ - type: 'object', - patternProperties: { '^\\d+$': { type: 'string' } }, - additionalProperties: true, - }), - ).toEqual({ type: 'object', additionalProperties: true }) + downgradeSchemaV31ToV30({ properties: { a: { type: 'string' } }, patternProperties }), + ).toEqual({ properties: { a: { type: 'string' } }, additionalProperties: true }) }) describe('a schema that lost a restriction', () => { From 8af4ceb40e2f216e1b7e4af782804a04a1fa3080 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 21:03:11 +0000 Subject: [PATCH 3/3] docs(downgrader): say patternProperties always gives additionalProperties: true Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FZoWKNpDyGBHkdaur4kKtA --- packages/downgrader/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 75a5d96..a81b953 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -190,7 +190,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | -| [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` beside it, which would reject the properties it allowed, becomes `true`, so code generators do not read the object as closed. | +| [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` becomes `true`, as a schema or `false` there would reject the properties `patternProperties` allowed, and code generators read a missing one as closed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | @@ -246,7 +246,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, or into a `oneOf` that becomes `anyOf`, are left as written and dangle. - Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. - Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, and array-form `items` passes through. Convert such schemas to 2020-12 first. -- `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. +- `patternProperties` that matches every name, as TypeBox emits for a record, gives `additionalProperties: true`, so its value schema is lost. - An untyped schema that gains a `format` from `contentEncoding` or `contentMediaType` also gains `type: "string"`, as 3.0 binary strings need, so it rejects non-string values it accepted. 3.0 tools find file uploads only by `type: "string"` with `format: "binary"`. - OpenAPI Generator merges the `"null"` branch beside a `$ref` to a string enum into a new enum, so the reference is lost. Pass `--openapi-normalizer SIMPLIFY_ONEOF_ANYOF_ENUM=false` to keep it. - datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null.