Skip to content

Commit 537aff1

Browse files
Merge pull request #66 from IntersectMBO/fix/tschema-field-order
fix/tschema field order
2 parents ac83d0f + bf86748 commit 537aff1

File tree

6 files changed

+119
-23
lines changed

6 files changed

+119
-23
lines changed

.changeset/chilly-brooms-end.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@evolution-sdk/evolution": patch
3+
---
4+
5+
Fixed field ordering bug in TSchema.Struct encode function that caused fields to be swapped during CBOR encoding when using NullOr/UndefinedOr.
6+
7+
**Before:**
8+
```typescript
9+
const CredentialSchema = TSchema.Union(
10+
TSchema.Struct({ pubKeyHash: TSchema.ByteArray }, { flatFields: true }),
11+
TSchema.Struct({ scriptHash: TSchema.ByteArray }, { flatFields: true })
12+
)
13+
14+
const AddressSchema = TSchema.Struct({
15+
paymentCredential: CredentialSchema,
16+
stakeCredential: TSchema.NullOr(TSchema.Integer)
17+
})
18+
19+
const Foo = TSchema.Union(
20+
TSchema.Struct({ foo: AddressSchema }, { flatFields: true })
21+
)
22+
23+
const input = {
24+
foo: {
25+
paymentCredential: { pubKeyHash: fromHex("deadbeef") },
26+
stakeCredential: null
27+
}
28+
}
29+
30+
const encoded = Data.withSchema(Foo).toData(input)
31+
// BUG: Fields were swapped in innerStruct!
32+
// innerStruct.fields[0] = Constr(1, []) // stakeCredential (null) - WRONG!
33+
// innerStruct.fields[1] = Constr(0, [...]) // paymentCredential - WRONG!
34+
```
35+
36+
**After:**
37+
```typescript
38+
const CredentialSchema = TSchema.Union(
39+
TSchema.Struct({ pubKeyHash: TSchema.ByteArray }, { flatFields: true }),
40+
TSchema.Struct({ scriptHash: TSchema.ByteArray }, { flatFields: true })
41+
)
42+
43+
const AddressSchema = TSchema.Struct({
44+
paymentCredential: CredentialSchema,
45+
stakeCredential: TSchema.NullOr(TSchema.Integer)
46+
})
47+
48+
const Foo = TSchema.Union(
49+
TSchema.Struct({ foo: AddressSchema }, { flatFields: true })
50+
)
51+
52+
const input = {
53+
foo: {
54+
paymentCredential: { pubKeyHash: fromHex("deadbeef") },
55+
stakeCredential: null
56+
}
57+
}
58+
59+
const encoded = Data.withSchema(Foo).toData(input)
60+
// FIXED: Fields now in correct order matching schema!
61+
// innerStruct.fields[0] = Constr(0, [...]) // paymentCredential - CORRECT!
62+
// innerStruct.fields[1] = Constr(1, []) // stakeCredential (null) - CORRECT!
63+
```
64+

docs/content/docs/modules/core/TSchema.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ while maintaining single-level CBOR encoding compatible with Aiken.
100100
**Signature**
101101
102102
```ts
103-
export declare const Variant: <Variants extends Record<string, Schema.Struct.Fields>>(
103+
export declare const Variant: <const Variants extends Record<PropertyKey, Schema.Struct.Fields>>(
104104
variants: Variants
105-
) => Schema.Schema<VariantType<Variants>, Data.Data, never>
105+
) => Union<ReadonlyArray<{ [K in keyof Variants]: Struct<{ readonly [P in K]: Struct<Variants[K]> }> }[keyof Variants]>>
106106
```
107107
108108
Added in v2.0.0

docs/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./out/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

packages/evolution/docs/modules/core/TSchema.ts.md

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,9 @@ while maintaining single-level CBOR encoding compatible with Aiken.
100100
**Signature**
101101
102102
```ts
103-
export declare function Variant<const Variants extends Record<PropertyKey, Schema.Struct.Fields>>(
103+
export declare const Variant: <const Variants extends Record<PropertyKey, Schema.Struct.Fields>>(
104104
variants: Variants
105-
): Union<
106-
ReadonlyArray<
107-
{
108-
[K in keyof Variants]: Struct<{ readonly [P in K]: Struct<Variants[K]> }>
109-
}[keyof Variants]
110-
>
111-
>
105+
) => Union<ReadonlyArray<{ [K in keyof Variants]: Struct<{ readonly [P in K]: Struct<Variants[K]> }> }[keyof Variants]>>
112106
```
113107
114108
Added in v2.0.0
@@ -303,10 +297,10 @@ Objects are represented as a constructor with index (default 0) and fields as an
303297
**Signature**
304298

305299
```ts
306-
export declare function Struct<Fields extends Schema.Struct.Fields>(
300+
export declare const Struct: <Fields extends Schema.Struct.Fields>(
307301
fields: Fields,
308-
options: StructOptions = {}
309-
): Struct<Fields>
302+
options?: StructOptions
303+
) => Struct<Fields>
310304
```
311305
312306
Added in v2.0.0

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+
// rather than Object.entries(encodedStruct) which would use runtime object 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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,43 @@ 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 Foo = TSchema.Union(
308+
TSchema.Struct({ foo: AddressSchema }, { flatFields: true })
309+
)
310+
311+
const input = {
312+
foo: {
313+
paymentCredential: { pubKeyHash: fromHex("deadbeef") },
314+
stakeCredential: null
315+
}
316+
}
317+
318+
const encoded = Data.withSchema(Foo).toData(input)
319+
const decoded = Data.withSchema(Foo).fromData(encoded)
320+
321+
// Verify roundtrip
322+
expect(decoded).toEqual(input)
323+
324+
// Verify field order in CBOR: paymentCredential should be field 0, stakeCredential field 1
325+
const innerStruct = (encoded.fields[0] as Data.Constr).fields[0] as Data.Constr
326+
expect(innerStruct.fields.length).toBe(2)
327+
expect(innerStruct.fields[0]).toBeInstanceOf(Data.Constr) // paymentCredential
328+
expect(innerStruct.fields[1]).toBeInstanceOf(Data.Constr) // stakeCredential (null)
329+
expect((innerStruct.fields[1] as Data.Constr).index).toBe(1n) // null is Constr(1, [])
330+
})
294331
})
295332

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

0 commit comments

Comments
 (0)