Skip to content

fix(schema): share representation identity across rebuilt containers of one declaration - #6838

Closed
aryasaatvik wants to merge 2 commits into
Effect-TS:mainfrom
aryasaatvik:fix/representation-structural-containers
Closed

fix(schema): share representation identity across rebuilt containers of one declaration#6838
aryasaatvik wants to merge 2 commits into
Effect-TS:mainfrom
aryasaatvik:fix/representation-structural-containers

Conversation

@aryasaatvik

Copy link
Copy Markdown

Stacked on #6837 — the branch includes its commit; only the last commit (Share representation identity across rebuilt containers of one declaration) is new here. The two are companion halves of one invariant: #6837 stops HttpApiSchema.status from forking the annotations slot; this stops derivation rebuilds from forking the container slots.

Problem

SchemaRepresentation resolves component identity by reference: an AST's identity key is the
composition of the object identities of its slots, with context excluded. That works as long as
every reuse of a declared schema reaches the representation as reference-shared structure.

Memoized derivations break that assumption whenever the derivation is non-identity. toCodecJson
lowers Unknown to Json by rewriting the child node, which forces the parent rebuild to allocate
fresh PropertySignature wrappers and a fresh propertySignatures array — once per top-level
derivation input. A named schema and its context-only copy (HttpApiSchema.status after #6837) are
two derivation inputs, so their rebuilt containers can never be reference-equal, and the identity
key diverges at exactly that slot:

const Widget = Schema.Struct({
  id: Schema.String,
  metadata: Schema.Record(Schema.String, Schema.Unknown)
}).annotate({ identifier: "Widget" })

HttpApiEndpoint.get("get", "/widgets/:id", { params: { id: Schema.String }, success: Widget })
HttpApiEndpoint.post("create", "/widgets", { success: Widget.pipe(HttpApiSchema.status(201)) })

OpenApi.fromApi(Api).components.schemas
// { Widget: …, Widget_1: … } — two identical components for one declared name
// (on 4.0.0-beta.102 this is a `Duplicate identifier: "Widget"` throw instead)

Remove the metadata field and the pair unifies — the failure is keyed to whether any child's
derivation is non-identity, which makes it look nondeterministic from the API author's seat. Any
schema carrying Schema.Unknown (a JSON metadata column, a Record(String, Unknown)) hits it in
the default CRUD shape.

Fix

Container wrappers — propertySignatures / indexSignatures arrays, PropertySignature,
IndexSignature — are pure structure: they carry no annotations, checks, or context of their own,
so they have no identity to defend. When two ASTs hold the same annotations object — the token
of one user declaration — their container slots now compare by contents (element-wise reference
identity) instead of by container reference.

The gate is what keeps existing semantics intact:

  • Anonymous nodes keep pure reference identity — "does not extract structurally equivalent schemas
    with distinct ASTs" is unchanged.
  • Two .annotate({ identifier: "Value" }) calls create two annotations objects, so referentially
    distinct declarations still suffix (Value / Value_1).
  • Two composites sharing a declaration but with genuinely different recursive dependencies still
    split: their containers differ at the child references themselves ("does not deduplicate fallback
    definitions with distinct recursive dependencies" is unchanged).

Scope: one function in internal/schema/toRepresentation.ts; no AST or derivation changes.

Tests

  • toRepresentations.test.ts: "shares a named reference across context-only copies whose
    derivation rebuilds a container" — pure representation-level regression, fails with
    ["Widget", "Widget_1"] refs before the fix.
  • OpenApi.test.ts: "emits one component for a named schema containing Unknown reused with
    HttpApiSchema.status" — end-to-end CRUD shape, both 200 and 201 referencing one Widget
    component. Depends on fix(httpapi): reuse named schemas across response statuses #6837 (without it the copies also differ at the annotations slot).

packages/effect/test/schema + packages/effect/test/unstable/httpapi: 2220 tests green;
tsc -b packages/effect and lint clean.

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.
…ation

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.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 571d0c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 1, 2026
@aryasaatvik

Copy link
Copy Markdown
Author

Consolidated into #6837 — that PR now carries both commits (the httpapi key-context fix and this representation-identity fix) as companion halves of one invariant.

@aryasaatvik aryasaatvik closed this Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.63 KB 6.63 KB 0.00 KB (0.00%)
batching.ts 9.42 KB 9.42 KB 0.00 KB (0.00%)
brand.ts 6.31 KB 6.31 KB 0.00 KB (0.00%)
cache.ts 10.16 KB 10.16 KB 0.00 KB (0.00%)
config.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
differ.ts 19.93 KB 19.93 KB 0.00 KB (0.00%)
http-client.ts 21.02 KB 21.02 KB 0.00 KB (0.00%)
logger.ts 10.32 KB 10.32 KB 0.00 KB (0.00%)
metric.ts 8.55 KB 8.55 KB 0.00 KB (0.00%)
optic.ts 7.33 KB 7.33 KB 0.00 KB (0.00%)
pubsub.ts 14.46 KB 14.46 KB 0.00 KB (0.00%)
queue.ts 11.13 KB 11.13 KB 0.00 KB (0.00%)
schedule.ts 10.31 KB 10.31 KB 0.00 KB (0.00%)
schema-class.ts 18.86 KB 18.86 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 28.67 KB 28.67 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.10 KB 24.99 KB +0.11 KB (+0.45%)
schema-string-transformation.ts 12.99 KB 12.99 KB 0.00 KB (0.00%)
schema-string.ts 10.65 KB 10.65 KB 0.00 KB (0.00%)
schema-template-literal.ts 14.85 KB 14.85 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 21.66 KB 21.66 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.18 KB 24.08 KB +0.11 KB (+0.44%)
schema-toCodecJson.ts 18.91 KB 18.91 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.73 KB 18.73 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.59 KB 18.59 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.45 KB 22.33 KB +0.12 KB (+0.52%)
schema-toRepresentation.ts 19.36 KB 19.25 KB +0.11 KB (+0.58%)
schema.ts 18.12 KB 18.12 KB 0.00 KB (0.00%)
stm.ts 12.11 KB 12.11 KB 0.00 KB (0.00%)
stream.ts 9.37 KB 9.37 KB 0.00 KB (0.00%)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — two commits that fix duplicate OpenAPI component generation when a declared schema is reused with HttpApiSchema.status().

  • HttpApiSchema.status switched to annotateKey: Stores httpApiStatus in key context (excluded from representation identity) instead of annotations, with an annotation fallback for HttpApiError classes.
  • toRepresentation.ts structural slot comparison: When two ASTs share the same annotations object, container slots (PropertySignature, IndexSignature, arrays) compare structurally rather than by reference, so derivation rebuilds (e.g. toCodecJson of Unknown → Json) don't fork identity.
  • Tests: One regression test at the representation level, two end-to-end OpenAPI tests. All existing tests pass — invariants for anonymous nodes, referentially distinct declarations, and recursive fallback definitions are preserved.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) (Kimi K2 not used — the program covers this model; add its provider key to run your pick) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant