diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index 5ca727fc8a..b2e044d31a 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -28,13 +28,20 @@ export const PRIMITIVE_TYPES = [ ] as const; export type PrimitiveType = (typeof PRIMITIVE_TYPES)[number]; -// Mirrors ColumnType = PrimitiveTypes | [ColumnType] | { [key: string]: ColumnType } +// Mirrors ColumnType = PrimitiveTypes | [ColumnType] | { [key: string]: ColumnType }. +// The orchestrator additionally serializes composite types as { fields: [{ field, type }] } +// (array-of-entries wire form) — accepted here and normalized to the native record shape. // eslint-disable-next-line @typescript-eslint/no-explicit-any const ColumnTypeSchema: z.ZodType = z.lazy(() => z.union([ z.enum(PRIMITIVE_TYPES), z.tuple([ColumnTypeSchema]), z.record(z.string(), ColumnTypeSchema), + z + .object({ + fields: z.array(z.object({ field: z.string(), type: ColumnTypeSchema })), + }) + .transform(obj => Object.fromEntries(obj.fields.map(f => [f.field, f.type]))), ]), ); diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index 30c8a4eaca..55eb0c58f8 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -818,6 +818,102 @@ describe('ForestServerWorkflowPort', () => { }); }); + it('accepts a composite type sent as the native record form { data: String }', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'embedded', + displayName: 'Embedded', + isRelationship: false, + type: { data: 'String' }, + }, + ], + actions: [], + }); + + const result = await port.getCollectionSchema('users', '42'); + + expect(result.fields[0].type).toEqual({ data: 'String' }); + }); + + it('accepts the orchestrator wire form { fields: [{ field, type }] } and normalizes it to a record', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'embedded', + displayName: 'Embedded', + isRelationship: false, + type: { fields: [{ field: 'data', type: 'String' }] }, + }, + ], + actions: [], + }); + + const result = await port.getCollectionSchema('users', '42'); + + expect(result.fields[0].type).toEqual({ data: 'String' }); + }); + + it('normalizes a multi-entry wire-form composite type to its full record form', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'profile', + displayName: 'Profile', + isRelationship: false, + type: { + fields: [ + { field: 'firstName', type: 'String' }, + { field: 'age', type: 'Number' }, + ], + }, + }, + ], + actions: [], + }); + + const result = await port.getCollectionSchema('users', '42'); + + expect(result.fields[0].type).toEqual({ firstName: 'String', age: 'Number' }); + }); + + it('accepts a nested wire-form composite type (composite inside composite)', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'meta', + displayName: 'Meta', + isRelationship: false, + type: { + fields: [ + { + field: 'inner', + type: { fields: [{ field: 'leaf', type: 'Boolean' }] }, + }, + ], + }, + }, + ], + actions: [], + }); + + const result = await port.getCollectionSchema('users', '42'); + + expect(result.fields[0].type).toEqual({ inner: { leaf: 'Boolean' } }); + }); + it('rejects enumValues: [] (empty enum is invalid)', async () => { mockQuery.mockResolvedValue({ collectionName: 'users',