Skip to content

Commit d767b65

Browse files
authored
fix(schema): resolve representation identifiers by AST identity (#6782)
1 parent aeba0c8 commit d767b65

15 files changed

Lines changed: 2239 additions & 2123 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
SchemaRepresentation: generate references from encoded AST identity, suffix colliding identifiers instead of throwing, and preserve sharing across property-key context. This avoids false-positive duplicate identifier errors while keeping referentially distinct schemas addressable; generated fallback definitions now use the clearer `Encoded` suffix.

‎packages/effect/SCHEMA.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6299,10 +6299,11 @@ const multiDocument = SchemaRepresentation.toRepresentations([
62996299
Repeated structural nodes, identifiers, and recursive schemas are placed in `references`. `toMultiDocument(document)`
63006300
wraps a single document when a compiler requires multiple roots.
63016301

6302-
An explicit `identifier` uniquely names one schema within a conversion. Reusing the same schema shares its reference, but
6303-
two distinct schemas with the same explicit `identifier` cause `toRepresentation` or `toRepresentations` to throw a
6304-
`Duplicate identifier` error. Internal `~identifier` annotations are allocation hints rather than uniqueness claims; when
6305-
a derived name is already occupied, the generated reference receives a numeric suffix.
6302+
An explicit `identifier` requests a reference name within a conversion. Reusing the same schema shares its reference. Copies
6303+
whose AST fields are referentially identical once property-key context is ignored are canonicalized and also share a
6304+
reference. Otherwise, when referentially distinct schemas request the same name, the first schema keeps it and later schemas
6305+
receive numeric suffixes in encounter order, such as `Value_1` and `Value_2`. Internal `~identifier` annotations are fallback
6306+
allocation hints; their generated names use the `Encoded` suffix and follow the same collision rules.
63066307

63076308
## JSON persistence
63086309

‎packages/effect/src/Schema.ts‎

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14964,24 +14964,7 @@ const toCodecJsonASTBase = SchemaAST.applyToSelfOrLastLinkEncoding((ast) => {
1496414964
})
1496514965

1496614966
/** @internal */
14967-
export const toCodecJsonAST = memoize((ast: SchemaAST.AST): SchemaAST.AST => {
14968-
const identifier = InternalAnnotations.resolveIdentifier(ast)
14969-
const out = toCodecJsonASTBase(ast)
14970-
if (identifier === undefined || out.encoding === undefined) return out
14971-
14972-
const encoded = SchemaAST.getLastEncoding(out)
14973-
if (
14974-
InternalAnnotations.resolveIdentifier(encoded) !== undefined ||
14975-
InternalAnnotations.resolveIdentifierFallback(encoded) === identifier
14976-
) {
14977-
return out
14978-
}
14979-
14980-
const annotated = SchemaAST.annotate(encoded, {
14981-
[InternalAnnotations.IDENTIFIER_FALLBACK_KEY]: identifier
14982-
})
14983-
return SchemaAST.applyToSelfOrLastLinkEncoding(() => annotated)(out)
14984-
})
14967+
export const toCodecJsonAST = memoize(toCodecJsonASTBase)
1498514968

1498614969
function withoutConstructorDefault(context: SchemaAST.Context): SchemaAST.Context {
1498714970
return context.defaultValue === undefined ?

‎packages/effect/src/internal/schema/toRepresentation.ts‎

Lines changed: 102 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function toRepresentation(
1717
export function toRepresentations(
1818
asts: readonly [SchemaAST.AST, ...Array<SchemaAST.AST>]
1919
): SchemaRepresentation.MultiDocument {
20-
return lowerASTs(asts)
20+
return fromASTs(asts)
2121
}
2222

2323
type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation<
@@ -37,34 +37,32 @@ function isShareable(ast: SchemaAST.AST): boolean {
3737

3838
interface ReferenceIdentifier {
3939
readonly identifier: string
40-
readonly isFallback: boolean
40+
readonly fallback?: string | undefined
4141
}
4242

43-
function resolveReferenceIdentifier(ast: SchemaAST.AST): ReferenceIdentifier | undefined {
44-
const identifier = InternalAnnotations.resolveIdentifier(ast)
45-
if (identifier !== undefined) return { identifier, isFallback: false }
46-
const fallback = InternalAnnotations.resolveIdentifierFallback(ast)
47-
return fallback === undefined ? undefined : { identifier: `${fallback}JsonEncoding`, isFallback: true }
43+
function resolveReferenceIdentifier(
44+
input: SchemaAST.AST,
45+
encoded: SchemaAST.AST
46+
): ReferenceIdentifier | undefined {
47+
const identifier = InternalAnnotations.resolveIdentifier(encoded)
48+
if (identifier !== undefined) return { identifier }
49+
const fallback = (encoded !== input ? InternalAnnotations.resolveIdentifier(input) : undefined) ??
50+
InternalAnnotations.resolveIdentifierFallback(encoded)
51+
return fallback === undefined
52+
? undefined
53+
: { identifier: `${fallback}Encoded`, fallback }
4854
}
4955

50-
function hasSameReferenceOwner(self: SchemaAST.AST, that: SchemaAST.AST): boolean {
51-
if (self === that) return true
52-
const selfKeys = Reflect.ownKeys(self)
53-
const thatKeys = Reflect.ownKeys(that)
54-
if (selfKeys.length !== thatKeys.length) return false
55-
for (const key of selfKeys) {
56-
if (key !== "context" && (self as any)[key] !== (that as any)[key]) return false
57-
}
58-
return true
59-
}
60-
61-
function lowerASTs(
56+
function fromASTs(
6257
asts: readonly [SchemaAST.AST, ...Array<SchemaAST.AST>]
6358
): SchemaRepresentation.MultiDocument {
6459
const references: Record<string, SchemaRepresentation.Representation> = {}
65-
const referenceMap = new Map<SchemaAST.AST, string>()
66-
const fallbackReferences: Array<readonly [SchemaAST.AST, string]> = []
60+
const anonymousReferences = new Map<SchemaAST.AST, string>()
6761
const referenceOwners = new Map<string, SchemaAST.AST>()
62+
const valueIds = new Map<unknown, number>()
63+
const canonicalByKey = new Map<string, SchemaAST.AST>()
64+
let nextValueId = 0
65+
const buildingReferences = new Set<string>()
6866
const visiting = new Set<SchemaAST.AST>()
6967
const visited = new Set<SchemaAST.AST>()
7068
const shared = new Set<SchemaAST.AST>()
@@ -75,35 +73,82 @@ function lowerASTs(
7573

7674
return { representations, references }
7775

78-
function generateReference(prefix: string, owner: SchemaAST.AST): string {
76+
function getReference(prefix: string, owner: SchemaAST.AST, separator = "_"): string {
7977
let candidate = prefix
8078
let suffix = 0
8179
while (referenceOwners.has(candidate)) {
82-
candidate = `${prefix}${++suffix}`
80+
if (referenceOwners.get(candidate) === owner) return candidate
81+
candidate = `${prefix}${separator}${++suffix}`
8382
}
8483
referenceOwners.set(candidate, owner)
8584
return candidate
8685
}
8786

87+
function getValueId(value: unknown): number {
88+
if (typeof value === "number" && globalThis.Number.isNaN(value)) {
89+
return nextValueId++
90+
}
91+
const found = valueIds.get(value)
92+
if (found !== undefined) return found
93+
const id = nextValueId++
94+
valueIds.set(value, id)
95+
return id
96+
}
97+
98+
function getIdentityKey(ast: SchemaAST.AST): string {
99+
let identity = ast._tag
100+
for (const [key, value] of Object.entries(ast)) {
101+
if (key !== "_tag" && key !== "context") identity += `:${getValueId(value)}`
102+
}
103+
return identity
104+
}
105+
106+
function getCanonicalAST(ast: SchemaAST.AST): SchemaAST.AST {
107+
const key = getIdentityKey(ast)
108+
const canonical = canonicalByKey.get(key)
109+
if (canonical === undefined) {
110+
canonicalByKey.set(key, ast)
111+
return ast
112+
}
113+
return canonical
114+
}
115+
116+
function annotateReference(
117+
ast: SchemaAST.AST,
118+
referenceIdentifier: ReferenceIdentifier,
119+
reference: string
120+
): SchemaAST.AST {
121+
const fallback = referenceIdentifier.fallback
122+
if (fallback !== undefined) {
123+
return InternalAnnotations.resolveIdentifierFallback(ast) === fallback
124+
? ast
125+
: SchemaAST.annotate(ast, {
126+
[InternalAnnotations.IDENTIFIER_FALLBACK_KEY]: fallback
127+
})
128+
}
129+
return reference === referenceIdentifier.identifier
130+
? ast
131+
: SchemaAST.annotate(ast, { identifier: reference })
132+
}
133+
134+
function makeReference(reference: string, ast: SchemaAST.AST): SchemaRepresentation.Reference {
135+
if (!Object.hasOwn(references, reference) && !buildingReferences.has(reference)) {
136+
buildingReferences.add(reference)
137+
const representation = on(ast)
138+
buildingReferences.delete(reference)
139+
InternalRecord.assignProperty(references, reference, representation)
140+
}
141+
return { _tag: "Reference", $ref: reference }
142+
}
143+
88144
function visit(input: SchemaAST.AST): void {
89145
const ast = SchemaAST.getLastEncoding(input)
90-
if (visited.has(ast)) {
91-
if (isShareable(ast)) shared.add(ast)
146+
const owner = getCanonicalAST(ast)
147+
if (visited.has(owner)) {
148+
if (isShareable(ast)) shared.add(owner)
92149
return
93150
}
94-
visited.add(ast)
95-
const referenceIdentifier = resolveReferenceIdentifier(ast)
96-
if (referenceIdentifier !== undefined && !referenceIdentifier.isFallback) {
97-
const owner = referenceOwners.get(referenceIdentifier.identifier)
98-
if (owner === undefined) {
99-
referenceOwners.set(referenceIdentifier.identifier, ast)
100-
} else if (
101-
referenceMap.get(ast) !== referenceIdentifier.identifier &&
102-
!hasSameReferenceOwner(owner, ast)
103-
) {
104-
throw new Error(`Duplicate identifier: ${JSON.stringify(referenceIdentifier.identifier)}`)
105-
}
106-
}
151+
visited.add(owner)
107152
visitChecks(ast.checks)
108153
switch (ast._tag) {
109154
case "Declaration":
@@ -131,45 +176,34 @@ function lowerASTs(
131176
})
132177
}
133178

134-
function recur(ast: SchemaAST.AST): SchemaRepresentation.Representation {
135-
const found = referenceMap.get(ast)
136-
if (found !== undefined) {
137-
return { _tag: "Reference", $ref: found }
138-
}
139-
140-
const projected = SchemaAST.getLastEncoding(ast)
141-
if (projected !== ast) {
142-
return recur(projected)
143-
}
144-
145-
const referenceIdentifier = resolveReferenceIdentifier(ast)
179+
function recur(input: SchemaAST.AST): SchemaRepresentation.Representation {
180+
const ast = SchemaAST.getLastEncoding(input)
181+
const owner = getCanonicalAST(ast)
182+
const referenceIdentifier = resolveReferenceIdentifier(input, ast)
146183
if (referenceIdentifier !== undefined) {
147-
const reference = getReference(referenceIdentifier, ast)
148-
referenceMap.set(ast, reference)
149-
if (!Object.hasOwn(references, reference)) {
150-
InternalRecord.assignProperty(references, reference, on(ast))
151-
}
152-
return { _tag: "Reference", $ref: reference }
184+
const reference = getReference(referenceIdentifier.identifier, owner)
185+
return makeReference(reference, annotateReference(ast, referenceIdentifier, reference))
153186
}
154187

155-
if (shared.has(ast)) {
156-
const reference = generateReference(`${ast._tag}_`, ast)
157-
referenceMap.set(ast, reference)
158-
InternalRecord.assignProperty(references, reference, on(ast))
159-
return { _tag: "Reference", $ref: reference }
188+
const found = anonymousReferences.get(owner)
189+
if (found !== undefined) {
190+
return { _tag: "Reference", $ref: found }
160191
}
161192

162-
if (visiting.has(ast)) {
163-
const reference = generateReference(`${ast._tag}_`, ast)
164-
referenceMap.set(ast, reference)
165-
return { _tag: "Reference", $ref: reference }
193+
const isShared = shared.has(owner)
194+
if (isShared || visiting.has(owner)) {
195+
const reference = getReference(`${ast._tag}_`, owner, "")
196+
anonymousReferences.set(owner, reference)
197+
return isShared
198+
? makeReference(reference, ast)
199+
: { _tag: "Reference", $ref: reference }
166200
}
167201

168-
visiting.add(ast)
202+
visiting.add(owner)
169203
const representation = on(ast)
170-
visiting.delete(ast)
204+
visiting.delete(owner)
171205

172-
const reference = referenceMap.get(ast)
206+
const reference = anonymousReferences.get(owner)
173207
if (reference !== undefined) {
174208
InternalRecord.assignProperty(references, reference, representation)
175209
return { _tag: "Reference", $ref: reference }
@@ -178,18 +212,6 @@ function lowerASTs(
178212
return representation
179213
}
180214

181-
function getReference(referenceIdentifier: ReferenceIdentifier, ast: SchemaAST.AST): string {
182-
if (!referenceIdentifier.isFallback) {
183-
return referenceIdentifier.identifier
184-
}
185-
for (const [owner, reference] of fallbackReferences) {
186-
if (hasSameReferenceOwner(owner, ast)) return reference
187-
}
188-
const reference = generateReference(referenceIdentifier.identifier, ast)
189-
fallbackReferences.push([ast, reference])
190-
return reference
191-
}
192-
193215
function on(ast: SchemaAST.AST): SchemaRepresentation.Representation {
194216
const checks = fromChecks(ast.checks)
195217
switch (ast._tag) {

0 commit comments

Comments
 (0)