From 1884fdbb3812c4cc3f59952a2b8977f0bacb12e4 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 1 Aug 2026 19:36:21 +0530 Subject: [PATCH 1/2] Carry httpApiStatus in key context so named schemas don't fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpApiSchema.status annotated the schema with httpApiStatus, forking the AST while the fork kept its identifier. A named schema reused with a non-default status then published duplicate OpenAPI components for one declared name (previously: Duplicate identifier throw). The status describes how one endpoint uses the schema, not what the schema is, so it now rides the AST key context — which representation identity already excludes — via annotateKey. Declaration-site statuses (the HttpApiError classes) keep working through the annotation fallback. --- .changeset/httpapi-status-key-context.md | 5 ++++ .../src/unstable/httpapi/HttpApiSchema.ts | 20 +++++++++++-- .../test/unstable/httpapi/OpenApi.test.ts | 29 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 .changeset/httpapi-status-key-context.md diff --git a/.changeset/httpapi-status-key-context.md b/.changeset/httpapi-status-key-context.md new file mode 100644 index 00000000000..c5d66b00882 --- /dev/null +++ b/.changeset/httpapi-status-key-context.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +unstable/httpapi HttpApiSchema: carry `httpApiStatus` in the key context so a named schema reused with `status()` keeps one representation identity — one OpenAPI component referenced by every status — instead of throwing `Duplicate identifier` diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index 599e7c9904b..c1ad2614c53 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -150,10 +150,16 @@ export type StatusLiteral = keyof typeof statusCodeByLiteral * * **Details** * - * This is equivalent to calling `.annotate({ httpApiStatus: code })` on the + * This is equivalent to calling `.annotateKey({ httpApiStatus: code })` on the * schema. You can pass either a numeric status code (for example, `201`) or a * common literal name (for example, `"Created"`). * + * The status is carried in the AST's key context rather than its annotations: + * the status describes how one endpoint uses the schema, not what the schema + * is, and context is excluded from representation identity. A named schema + * reused with different statuses therefore stays one schema — and one OpenAPI + * component — instead of forking into duplicate identifiers. + * * @category schemas * @since 4.0.0 */ @@ -165,7 +171,7 @@ export function status(code: StatusLiteral): { } export function status(code: number | StatusLiteral) { const statusCode = typeof code === "string" ? statusCodeByLiteral[code] : code - return (self: S): S["Rebuild"] => self.annotate({ httpApiStatus: statusCode }) + return (self: S): S["Rebuild"] => self.annotateKey({ httpApiStatus: statusCode }) } /** @@ -667,7 +673,15 @@ export const isNoContent = (ast: SchemaAST.AST): boolean => { const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") -const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiStatusAnnotation = SchemaAST.resolveAt("httpApiStatus") + +// `status` stores the code in the key context (see `status` for why); the +// annotation fallback keeps declaration-site statuses like the `HttpApiError` +// classes working. +function resolveHttpApiStatus(ast: SchemaAST.AST): number | undefined { + const fromContext = ast.context?.annotations?.httpApiStatus + return typeof fromContext === "number" ? fromContext : resolveHttpApiStatusAnnotation(ast) +} const defaultJsonEncoding: Encoding = { _tag: "Json", diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 72e2f2e97fc..3d758bd6c52 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -260,4 +260,33 @@ describe("OpenApi", () => { assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: __proto__/) }) + + it("emits one component for a named schema reused with HttpApiSchema.status", () => { + const Widget = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Widget" }) + + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("widgets").add( + HttpApiEndpoint.get("get", "/widgets/:id", { + params: { id: Schema.String }, + success: Widget + }), + HttpApiEndpoint.post("create", "/widgets", { + success: Widget.pipe(HttpApiSchema.status(201)) + }) + ) + ) + + const spec = OpenApi.fromApi(Api) + const schemaNames = Object.keys(spec.components.schemas).filter((name) => name.startsWith("Widget")) + + assert.deepStrictEqual(schemaNames, ["Widget"]) + assert.deepStrictEqual( + spec.paths["/widgets/{id}"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema, + { $ref: "#/components/schemas/Widget" } + ) + assert.deepStrictEqual( + spec.paths["/widgets"]?.post?.responses?.["201"]?.content?.["application/json"]?.schema, + { $ref: "#/components/schemas/Widget" } + ) + }) }) From 571d0c74480e891b6125b297d09c312d8fb0877c Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 1 Aug 2026 20:36:33 +0530 Subject: [PATCH 2/2] Share representation identity across rebuilt containers of one declaration A memoized AST derivation (toCodecJson) rebuilds a node whenever any child changes - e.g. Unknown lowering to Json - allocating fresh property-signature wrappers and arrays per top-level input. Two ASTs that differ only in their key context (a named schema and its HttpApiSchema.status copy) are separate derivation inputs, so their rebuilt containers can never be reference-equal and representation identity split them into Widget and Widget_1. Container wrappers are pure structure with no identity of their own. When two ASTs hold the same annotations object - the token of one user declaration - their container slots now compare by contents instead of by reference. Anonymous nodes and nodes with different annotation objects keep pure reference identity, so referentially distinct declarations still suffix. --- ...esentation-annotated-container-identity.md | 5 +++ .../src/internal/schema/toRepresentation.ts | 32 ++++++++++++++++++- .../representation/toRepresentations.test.ts | 20 ++++++++++++ .../test/unstable/httpapi/OpenApi.test.ts | 31 ++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .changeset/representation-annotated-container-identity.md diff --git a/.changeset/representation-annotated-container-identity.md b/.changeset/representation-annotated-container-identity.md new file mode 100644 index 00000000000..523a29af31c --- /dev/null +++ b/.changeset/representation-annotated-container-identity.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +SchemaRepresentation: compare container slots structurally when two ASTs carry the same annotations object, so a derivation of a context-only copy (e.g. the JSON codec of a schema containing `Unknown` reused with `HttpApiSchema.status`) no longer publishes duplicate components for one declared identifier diff --git a/packages/effect/src/internal/schema/toRepresentation.ts b/packages/effect/src/internal/schema/toRepresentation.ts index c8264508cf7..810621b946c 100644 --- a/packages/effect/src/internal/schema/toRepresentation.ts +++ b/packages/effect/src/internal/schema/toRepresentation.ts @@ -60,6 +60,7 @@ function fromASTs( const anonymousReferences = new Map() const referenceOwners = new Map() const valueIds = new Map() + const compositeIds = new Map() const canonicalByKey = new Map() let nextValueId = 0 const buildingReferences = new Set() @@ -95,10 +96,39 @@ function fromASTs( return id } + // Identity of a container slot on an annotated node: arrays and property/index + // signature wrappers are pure structure with no identity of their own, so they + // compare by their contents. AST nodes (and any other value) keep reference identity. + function getSlotId(value: unknown): number { + let composite: string | undefined + if (globalThis.Array.isArray(value)) { + composite = `[${value.map(getSlotId).join(",")}]` + } else if (value instanceof SchemaAST.PropertySignature) { + composite = `ps:${getValueId(value.name)}:${getValueId(value.type)}` + } else if (value instanceof SchemaAST.IndexSignature) { + composite = `is:${getValueId(value.parameter)}:${getValueId(value.type)}` + } else { + return getValueId(value) + } + const existing = compositeIds.get(composite) + if (existing !== undefined) return existing + const id = nextValueId++ + compositeIds.set(composite, id) + return id + } + function getIdentityKey(ast: SchemaAST.AST): string { let identity = ast._tag + // Two ASTs holding the same annotations object are claims to one declared schema: + // rebuilds of it (e.g. a codec derivation of a context-only copy) allocate fresh + // container wrappers, and those must not read as a second schema. Anonymous nodes + // and nodes with different annotation objects keep pure reference identity, so + // referentially distinct declarations stay distinct. + const structural = ast.annotations !== undefined for (const [key, value] of Object.entries(ast)) { - if (key !== "_tag" && key !== "context") identity += `:${getValueId(value)}` + if (key !== "_tag" && key !== "context") { + identity += `:${structural ? getSlotId(value) : getValueId(value)}` + } } return identity } diff --git a/packages/effect/test/schema/representation/toRepresentations.test.ts b/packages/effect/test/schema/representation/toRepresentations.test.ts index bad41ed644b..c758eca8bbc 100644 --- a/packages/effect/test/schema/representation/toRepresentations.test.ts +++ b/packages/effect/test/schema/representation/toRepresentations.test.ts @@ -167,6 +167,26 @@ describe("SchemaRepresentation.toRepresentations", () => { ) }) + it("shares a named reference across context-only copies whose derivation rebuilds a container", () => { + const Widget = Schema.Struct({ + id: Schema.String, + metadata: Schema.Unknown + }).annotate({ identifier: "Widget" }) + const forked = Widget.annotateKey({ description: "context-only fork" }) + + const base = Schema.toCodecJson(Widget) + const fork = Schema.toCodecJson(forked) + assert.notStrictEqual(base.ast, fork.ast) + + const document = SchemaRepresentation.toRepresentations([base.ast, fork.ast]) + + assert.deepStrictEqual(Object.keys(document.references), ["Widget"]) + assert.deepStrictEqual(document.representations, [ + { _tag: "Reference", $ref: "Widget" }, + { _tag: "Reference", $ref: "Widget" } + ]) + }) + it("suffixes referentially distinct ASTs with equal representations", () => { const first = Schema.String.annotate({ identifier: "Value" }) const second = Schema.String.annotate({ identifier: "Value" }) diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 3d758bd6c52..ab8b9b5d1ca 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -279,6 +279,37 @@ describe("OpenApi", () => { const spec = OpenApi.fromApi(Api) const schemaNames = Object.keys(spec.components.schemas).filter((name) => name.startsWith("Widget")) + assert.deepStrictEqual(schemaNames, ["Widget"]) + assert.deepStrictEqual( + spec.paths["/widgets/{id}"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema, + { $ref: "#/components/schemas/Widget" } + ) + assert.deepStrictEqual( + spec.paths["/widgets"]?.post?.responses?.["201"]?.content?.["application/json"]?.schema, + { $ref: "#/components/schemas/Widget" } + ) + }) + it("emits one component for a named schema containing Unknown reused with HttpApiSchema.status", () => { + const Widget = Schema.Struct({ + id: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Unknown) + }).annotate({ identifier: "Widget" }) + + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("widgets").add( + HttpApiEndpoint.get("get", "/widgets/:id", { + params: { id: Schema.String }, + success: Widget + }), + HttpApiEndpoint.post("create", "/widgets", { + success: Widget.pipe(HttpApiSchema.status(201)) + }) + ) + ) + + const spec = OpenApi.fromApi(Api) + const schemaNames = Object.keys(spec.components.schemas).filter((name) => name.startsWith("Widget")) + assert.deepStrictEqual(schemaNames, ["Widget"]) assert.deepStrictEqual( spec.paths["/widgets/{id}"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema,