Skip to content

Commit 8fa379c

Browse files
committed
fix(TSchema): preserve field order during Struct encoding
1 parent c4f650c commit 8fa379c

File tree

2 files changed

+139
-9
lines changed

2 files changed

+139
-9
lines changed

packages/evolution/src/core/TSchema.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,10 @@ export interface StructOptions {
280280
*
281281
* @since 2.0.0
282282
*/
283-
export function Struct<Fields extends Schema.Struct.Fields>(
283+
export const Struct = <Fields extends Schema.Struct.Fields>(
284284
fields: Fields,
285285
options: StructOptions = {}
286-
): Struct<Fields> {
286+
): Struct<Fields> => {
287287
const { flatFields, flatInUnion, index = 0, tagField } = options
288288

289289
// flatInUnion defaults to true when index is specified
@@ -325,9 +325,10 @@ export function Struct<Fields extends Schema.Struct.Fields>(
325325
encode: (encodedStruct) => {
326326
// encodedStruct is the result of Schema.Struct(fields), which has already transformed all fields
327327

328-
// Filter out the tag field if detected (it's metadata, not data)
329-
const fieldEntries = Object.entries(encodedStruct).filter(([key]) => key !== detectedTagField)
330-
const fieldValues = fieldEntries.map(([_, value]) => value) as ReadonlyArray<Data.Data>
328+
// Use Object.keys(fields) to preserve schema definition order
329+
// (Object.entries doesn't guarantee property order)
330+
const orderedKeys = Object.keys(fields).filter((key) => key !== detectedTagField)
331+
const fieldValues = orderedKeys.map((key) => encodedStruct[key as keyof typeof encodedStruct]) as ReadonlyArray<Data.Data>
331332

332333
// Check if any field values are Constrs with flatFields:true
333334
// If so, spread their fields into this Struct's field array
@@ -793,24 +794,24 @@ export const Tuple = <Elements extends Schema.TupleType.Elements>(element: [...E
793794
* @since 2.0.0
794795
* @category constructors
795796
*/
796-
export function Variant<const Variants extends Record<PropertyKey, Schema.Struct.Fields>>(
797+
export const Variant = <const Variants extends Record<PropertyKey, Schema.Struct.Fields>>(
797798
variants: Variants
798799
): Union<
799800
ReadonlyArray<
800801
{
801802
[K in keyof Variants]: Struct<{ readonly [P in K]: Struct<Variants[K]> }>
802803
}[keyof Variants]
803804
>
804-
> {
805+
> => {
805806
return Union(
806-
...Object.entries(variants).map(([name, fields], index) =>
807+
...(Object.entries(variants).map(([name, fields], index) =>
807808
Struct(
808809
{
809810
[name]: Struct(fields, { flatFields: true })
810811
} as any,
811812
{ flatInUnion: true, index }
812813
)
813-
) as any
814+
) as any)
814815
)
815816
}
816817

packages/evolution/test/TSchema.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,135 @@ describe("TypeTaggedSchema Tests", () => {
291291
expect(encoded).toEqual("d87a80")
292292
expect(decoded).toBeNull()
293293
})
294+
295+
it("should preserve field order in structs with NullOr fields (regression test)", () => {
296+
// Regression test for field ordering bug with NullOr/UndefinedOr
297+
const CredentialSchema = TSchema.Union(
298+
TSchema.Struct({ pubKeyHash: TSchema.ByteArray }, { flatFields: true }),
299+
TSchema.Struct({ scriptHash: TSchema.ByteArray }, { flatFields: true })
300+
)
301+
302+
const AddressSchema = TSchema.Struct({
303+
paymentCredential: CredentialSchema,
304+
stakeCredential: TSchema.NullOr(TSchema.Integer)
305+
})
306+
307+
const input = {
308+
paymentCredential: { pubKeyHash: fromHex("deadbeef") },
309+
stakeCredential: null
310+
}
311+
312+
const encoded = Data.withSchema(AddressSchema).toData(input)
313+
const decoded = Data.withSchema(AddressSchema).fromData(encoded)
314+
315+
// Verify roundtrip
316+
expect(decoded).toEqual(input)
317+
318+
// Verify field order in CBOR: paymentCredential should be field 0, stakeCredential field 1
319+
expect(encoded.fields.length).toBe(2)
320+
expect(encoded.fields[0]).toBeInstanceOf(Data.Constr) // paymentCredential
321+
expect(encoded.fields[1]).toBeInstanceOf(Data.Constr) // stakeCredential (null)
322+
expect((encoded.fields[1] as Data.Constr).index).toBe(1n) // null is Constr(1, [])
323+
})
324+
325+
it("should preserve field order with multiple NullOr fields", () => {
326+
const TestSchema = TSchema.Struct({
327+
first: TSchema.Integer,
328+
second: TSchema.NullOr(TSchema.ByteArray),
329+
third: TSchema.Boolean,
330+
fourth: TSchema.NullOr(TSchema.Integer)
331+
})
332+
333+
const input = {
334+
first: 100n,
335+
second: fromHex("cafe"),
336+
third: true,
337+
fourth: null
338+
}
339+
340+
const encoded = Data.withSchema(TestSchema).toData(input)
341+
const decoded = Data.withSchema(TestSchema).fromData(encoded)
342+
343+
expect(decoded).toEqual(input)
344+
345+
// Verify field order
346+
expect(encoded.fields[0]).toBe(100n) // first
347+
expect(encoded.fields[1]).toBeInstanceOf(Data.Constr) // second (NullOr with value)
348+
expect(encoded.fields[2]).toBeInstanceOf(Data.Constr) // third (Boolean)
349+
expect(encoded.fields[3]).toBeInstanceOf(Data.Constr) // fourth (null)
350+
expect((encoded.fields[3] as Data.Constr).index).toBe(1n) // null
351+
})
352+
353+
it("should preserve field order with UndefinedOr fields", () => {
354+
const TestSchema = TSchema.Struct({
355+
first: TSchema.ByteArray,
356+
second: TSchema.UndefinedOr(TSchema.Integer),
357+
third: TSchema.Boolean
358+
})
359+
360+
const inputWithValue = {
361+
first: fromHex("deadbeef"),
362+
second: 42n,
363+
third: false
364+
}
365+
366+
const encodedWithValue = Data.withSchema(TestSchema).toData(inputWithValue)
367+
const decodedWithValue = Data.withSchema(TestSchema).fromData(encodedWithValue)
368+
369+
expect(decodedWithValue).toEqual(inputWithValue)
370+
371+
// Verify field order when value is present
372+
expect(encodedWithValue.fields[0]).toBeInstanceOf(Uint8Array) // first
373+
expect(encodedWithValue.fields[1]).toBeInstanceOf(Data.Constr) // second (UndefinedOr with value)
374+
expect(encodedWithValue.fields[2]).toBeInstanceOf(Data.Constr) // third (Boolean)
375+
376+
const inputUndefined = {
377+
first: fromHex("cafebabe"),
378+
second: undefined,
379+
third: true
380+
}
381+
382+
const encodedUndefined = Data.withSchema(TestSchema).toData(inputUndefined)
383+
const decodedUndefined = Data.withSchema(TestSchema).fromData(encodedUndefined)
384+
385+
expect(decodedUndefined).toEqual(inputUndefined)
386+
387+
// Verify field order when value is undefined
388+
expect(encodedUndefined.fields[0]).toBeInstanceOf(Uint8Array) // first
389+
expect(encodedUndefined.fields[1]).toBeInstanceOf(Data.Constr) // second (undefined)
390+
expect((encodedUndefined.fields[1] as Data.Constr).index).toBe(1n) // undefined is Constr(1, [])
391+
expect(encodedUndefined.fields[2]).toBeInstanceOf(Data.Constr) // third (Boolean)
392+
})
393+
394+
it("should preserve field order with mixed NullOr and UndefinedOr fields", () => {
395+
const TestSchema = TSchema.Struct({
396+
a: TSchema.Integer,
397+
b: TSchema.NullOr(TSchema.ByteArray),
398+
c: TSchema.UndefinedOr(TSchema.Integer),
399+
d: TSchema.Boolean
400+
})
401+
402+
const input = {
403+
a: 999n,
404+
b: null,
405+
c: undefined,
406+
d: true
407+
}
408+
409+
const encoded = Data.withSchema(TestSchema).toData(input)
410+
const decoded = Data.withSchema(TestSchema).fromData(encoded)
411+
412+
expect(decoded).toEqual(input)
413+
414+
// Verify all fields are in correct order
415+
expect(encoded.fields[0]).toBe(999n) // a
416+
expect(encoded.fields[1]).toBeInstanceOf(Data.Constr) // b (null)
417+
expect((encoded.fields[1] as Data.Constr).index).toBe(1n) // null
418+
expect(encoded.fields[2]).toBeInstanceOf(Data.Constr) // c (undefined)
419+
expect((encoded.fields[2] as Data.Constr).index).toBe(1n) // undefined
420+
expect(encoded.fields[3]).toBeInstanceOf(Data.Constr) // d (Boolean)
421+
expect((encoded.fields[3] as Data.Constr).index).toBe(1n) // true
422+
})
294423
})
295424

296425
describe("Literal Schema", () => {

0 commit comments

Comments
 (0)