Skip to content

Commit 3990d08

Browse files
committed
fix(TSchema): detect flat-to-flat index collisions in Union
1 parent 23f0d0f commit 3990d08

File tree

2 files changed

+50
-17
lines changed

2 files changed

+50
-17
lines changed

packages/evolution/src/core/TSchema.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -357,41 +357,63 @@ export const Union = <Members extends ReadonlyArray<Schema.Schema.Any>>(...membe
357357
})
358358

359359
// Detect index collisions
360-
// A collision occurs when a flat member's index equals the position of a non-flat member
360+
// Collisions can occur in two scenarios:
361+
// 1. A flat member's index equals the position of a non-flat member
362+
// 2. Two flat members have the same index (both would encode to same Constr index)
361363
const collisions = new globalThis.Array<{
362-
autoPosition: number
364+
type: "flat-to-nested" | "flat-to-flat"
365+
position1: number
366+
position2: number
363367
conflictingIndex: number
364-
customPosition: number
365368
}>()
366369

367-
memberInfos.forEach((flatMember, flatPos) => {
368-
if (flatMember.isFlat) {
369-
const flatIndex = flatMember.customIndex ?? flatMember.position
370+
memberInfos.forEach((member1, pos1) => {
371+
if (member1.isFlat) {
372+
const index1 = member1.customIndex ?? member1.position
370373

371-
// Check if this flat member's index conflicts with any non-flat member's position
372-
memberInfos.forEach((otherMember, otherPos) => {
373-
if (!otherMember.isFlat && flatIndex === otherMember.position) {
374+
// Check for flat-to-nested collisions
375+
memberInfos.forEach((member2, pos2) => {
376+
if (!member2.isFlat && index1 === member2.position) {
374377
collisions.push({
375-
autoPosition: otherPos,
376-
conflictingIndex: flatIndex,
377-
customPosition: flatPos
378+
type: "flat-to-nested",
379+
position1: pos1,
380+
position2: pos2,
381+
conflictingIndex: index1
378382
})
379383
}
380384
})
385+
386+
// Check for flat-to-flat collisions (only check positions after current to avoid duplicates)
387+
memberInfos.forEach((member2, pos2) => {
388+
if (pos2 > pos1 && member2.isFlat) {
389+
const index2 = member2.customIndex ?? member2.position
390+
if (index1 === index2) {
391+
collisions.push({
392+
type: "flat-to-flat",
393+
position1: pos1,
394+
position2: pos2,
395+
conflictingIndex: index1
396+
})
397+
}
398+
}
399+
})
381400
}
382401
})
383402

384403
if (collisions.length > 0) {
385404
const collisionDetails = collisions
386-
.map(
387-
({ autoPosition, conflictingIndex, customPosition }) =>
388-
`flat member at position ${customPosition} with index ${conflictingIndex} conflicts with nested member at position ${autoPosition}`
389-
)
405+
.map((collision) => {
406+
if (collision.type === "flat-to-nested") {
407+
return `flat member at position ${collision.position1} with index ${collision.conflictingIndex} conflicts with nested member at position ${collision.position2}`
408+
} else {
409+
return `flat members at positions ${collision.position1} and ${collision.position2} both use index ${collision.conflictingIndex}`
410+
}
411+
})
390412
.join("; ")
391413

392414
const errorMessage =
393415
`[TSchema.Union] Index collision detected: ${collisionDetails}. ` +
394-
`Flat members' indices must not equal the array position of nested members. ` +
416+
`Flat members' indices must not equal the array position of nested members, and each flat member must have a unique index. ` +
395417
`Recommendation: Use indices 100+ for flat members to avoid collision with auto-indices, or set flat: false.`
396418

397419
throw new Error(errorMessage)

packages/evolution/test/TSchema-flat-option.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ describe("TSchema.Struct with flat option", () => {
167167
)
168168
}).not.toThrow()
169169
})
170+
171+
it("should detect collision between two flat members with same custom index", () => {
172+
// This is the bug: two flat members with the same index would both encode to
173+
// Constr(100, [...]), making it impossible to distinguish them during decoding
174+
expect(() => {
175+
TSchema.Union(
176+
TSchema.Struct({ first: TSchema.Integer }, { index: 100, flat: true }),
177+
TSchema.Struct({ second: TSchema.Integer }, { index: 100, flat: true })
178+
)
179+
}).toThrow(/Index collision detected/)
180+
})
170181
})
171182

172183
describe("CBOR encoding/decoding", () => {

0 commit comments

Comments
 (0)