From de33f2ac6fed9f65d8012f4249dd5687d8a9faee Mon Sep 17 00:00:00 2001 From: Shohan RAHMAN Date: Fri, 5 Jun 2026 17:29:49 +0200 Subject: [PATCH] fix(workflow-executor): accept orchestrator wire form for composite columnType [PRD-464] The orchestrator serializes composite column types (e.g. an `embedded` computed field declared with `columnType: { data: "String" }`) as `{ fields: [{ field, type }] }` rather than the native datasource-toolkit record shape. The Zod `ColumnTypeSchema` had no branch for that shape, so every collection containing such a field rejected the step dispatch with a `DomainValidationError` at `fields.N.type`. Add a fourth union branch that accepts the wire form and `.transform`s it back to the canonical `Record` so downstream consumers stay untouched. fixes PRD-464 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/types/validated/collection.ts | 9 +- .../forest-server-workflow-port.test.ts | 96 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) 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',