diff --git a/DX_ROADMAP.md b/DX_ROADMAP.md index c67001329..5a5b0bd45 100644 --- a/DX_ROADMAP.md +++ b/DX_ROADMAP.md @@ -107,10 +107,10 @@ This roadmap prioritizes improvements based on the **"Time to First Wow"** metri ### Phase 2 Checklist -- [ ] Enhance `ObjectSchema.create()` with auto-label, common fields, and validation -- [ ] Implement `defineView()` with column type inference -- [ ] Implement `defineApp()` with navigation builder -- [ ] Implement `defineFlow()` with step type inference +- [x] Enhance `ObjectSchema.create()` with auto-label, common fields, and validation +- [x] Implement `defineView()` with column type inference +- [x] Implement `defineApp()` with navigation builder +- [x] Implement `defineFlow()` with step type inference - [ ] Create custom Zod error map with contextual messages - [ ] Add "Did you mean?" suggestions for FieldType typos - [ ] Create pretty-print validation error formatter for CLI diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 3a29a8737..c6e704f31 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -6,6 +6,7 @@ import { AIKnowledgeSchema, StructuredOutputFormatSchema, StructuredOutputConfigSchema, + defineAgent, type Agent, } from './agent.zod'; @@ -685,3 +686,51 @@ describe('StructuredOutputConfigSchema', () => { expect(config.fallbackFormat).toBe('json_object'); }); }); + +describe('defineAgent', () => { + it('should return a parsed agent', () => { + const result = defineAgent({ + name: 'support_agent', + label: 'Support Agent', + role: 'Senior Support Engineer', + instructions: 'You help customers resolve technical issues.', + }); + expect(result.name).toBe('support_agent'); + expect(result.label).toBe('Support Agent'); + expect(result.role).toBe('Senior Support Engineer'); + }); + + it('should apply defaults', () => { + const result = defineAgent({ + name: 'test_agent', + label: 'Test', + role: 'Tester', + instructions: 'Testing agent.', + }); + expect(result.active).toBe(true); + expect(result.visibility).toBe('organization'); + }); + + it('should accept agent with tools', () => { + const result = defineAgent({ + name: 'smart_agent', + label: 'Smart Agent', + role: 'Analyst', + instructions: 'Analyze data.', + tools: [ + { type: 'action', name: 'create_report' }, + { type: 'query', name: 'search_records' }, + ], + }); + expect(result.tools).toHaveLength(2); + }); + + it('should throw on invalid agent name', () => { + expect(() => defineAgent({ + name: 'INVALID', + label: 'Test', + role: 'Tester', + instructions: 'Test.', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index b4e906935..f8108de33 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -191,5 +191,25 @@ export const AgentSchema = z.object({ structuredOutput: StructuredOutputConfigSchema.optional().describe('Structured output format and validation configuration'), }); +/** + * Type-safe factory for creating AI agent definitions. + * + * Validates the config at creation time using Zod `.parse()`. + * + * @example + * ```ts + * const supportAgent = defineAgent({ + * name: 'support_agent', + * label: 'Support Agent', + * role: 'Senior Support Engineer', + * instructions: 'You help customers resolve technical issues.', + * tools: [{ type: 'action', name: 'create_ticket' }], + * }); + * ``` + */ +export function defineAgent(config: z.input): Agent { + return AgentSchema.parse(config); +} + export type Agent = z.infer; export type AITool = z.infer; diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index 940c2ad6f..a1e3c54b5 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -5,6 +5,7 @@ import { FlowEdgeSchema, FlowVariableSchema, FlowNodeAction, + defineFlow, type Flow, type FlowNode, type FlowEdge, @@ -597,3 +598,47 @@ describe('FlowSchema - errorHandling', () => { expect(result.errorHandling).toBeUndefined(); }); }); + +describe('defineFlow', () => { + it('should return a parsed flow', () => { + const result = defineFlow({ + name: 'on_task_create', + label: 'On Task Create', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); + expect(result.name).toBe('on_task_create'); + expect(result.label).toBe('On Task Create'); + expect(result.type).toBe('record_change'); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + }); + + it('should apply defaults', () => { + const result = defineFlow({ + name: 'simple', + label: 'Simple', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }], + edges: [], + }); + expect(result.version).toBe(1); + expect(result.status).toBe('draft'); + expect(result.active).toBe(false); + expect(result.runAs).toBe('user'); + }); + + it('should throw on invalid flow name', () => { + expect(() => defineFlow({ + name: 'INVALID', + label: 'Bad Flow', + type: 'autolaunched', + nodes: [], + edges: [], + })).toThrow(); + }); +}); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 98fb5255b..06a5b3eff 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -147,6 +147,29 @@ export const FlowSchema = z.object({ }).optional().describe('Flow-level error handling configuration'), }); +/** + * Type-safe factory for creating flow definitions. + * + * Validates the config at creation time using Zod `.parse()`. + * + * @example + * ```ts + * const onCreateFlow = defineFlow({ + * name: 'on_task_create', + * label: 'On Task Create', + * type: 'record_change', + * nodes: [ + * { id: 'start', type: 'start', label: 'Start' }, + * { id: 'end', type: 'end', label: 'End' }, + * ], + * edges: [{ id: 'e1', source: 'start', target: 'end' }], + * }); + * ``` + */ +export function defineFlow(config: z.input): FlowParsed { + return FlowSchema.parse(config); +} + export type Flow = z.input; export type FlowParsed = z.infer; export type FlowNode = z.input; diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 8951b050b..de11564b8 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -662,3 +662,63 @@ describe('ObjectSchema - recordName', () => { expect(result.recordName).toBeUndefined(); }); }); + +describe('ObjectSchema.create()', () => { + it('should auto-generate label from snake_case name', () => { + const result = ObjectSchema.create({ + name: 'project_task', + fields: { + title: { type: 'text' }, + }, + }); + expect(result.label).toBe('Project Task'); + }); + + it('should preserve explicitly provided label', () => { + const result = ObjectSchema.create({ + name: 'project_task', + label: 'My Custom Label', + fields: { + title: { type: 'text' }, + }, + }); + expect(result.label).toBe('My Custom Label'); + }); + + it('should auto-generate label from single-word name', () => { + const result = ObjectSchema.create({ + name: 'account', + fields: { + name: { type: 'text' }, + }, + }); + expect(result.label).toBe('Account'); + }); + + it('should validate and apply defaults', () => { + const result = ObjectSchema.create({ + name: 'task', + fields: { + title: { type: 'text' }, + }, + }); + expect(result.active).toBe(true); + expect(result.isSystem).toBe(false); + expect(result.abstract).toBe(false); + expect(result.datasource).toBe('default'); + }); + + it('should throw on invalid name format', () => { + expect(() => ObjectSchema.create({ + name: 'InvalidName', + fields: { title: { type: 'text' } }, + })).toThrow(); + }); + + it('should throw on invalid field name format', () => { + expect(() => ObjectSchema.create({ + name: 'task', + fields: { InvalidField: { type: 'text' } }, + })).toThrow(); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 1a1c96b48..b7e371adf 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -331,11 +331,46 @@ const ObjectSchemaBase = z.object({ keyPrefix: z.string().max(5).optional().describe('Short prefix for record IDs (e.g., "001" for Account)'), }); +/** + * Converts a snake_case name to a human-readable Title Case label. + * @example snakeCaseToLabel('project_task') → 'Project Task' + */ +function snakeCaseToLabel(name: string): string { + return name + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + /** * Enhanced ObjectSchema with Factory */ export const ObjectSchema = Object.assign(ObjectSchemaBase, { - create: >(config: T) => config, + /** + * Type-safe factory for creating business object definitions. + * + * Enhancements over raw schema: + * - **Auto-label**: Generates `label` from `name` if not provided (snake_case → Title Case). + * - **Validation**: Runs Zod `.parse()` to validate the config at creation time. + * + * @example + * ```ts + * const Task = ObjectSchema.create({ + * name: 'project_task', + * // label auto-generated as 'Project Task' + * fields: { + * subject: { type: 'text', label: 'Subject', required: true }, + * }, + * }); + * ``` + */ + create: (config: z.input): ServiceObject => { + const withDefaults = { + ...config, + label: config.label ?? snakeCaseToLabel(config.name), + }; + return ObjectSchemaBase.parse(withDefaults); + }, }); export type ServiceObject = z.infer; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index cfbae641d..cab190ad2 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -73,5 +73,11 @@ export { export * from './stack.zod'; +// DX Helper Functions (re-exported for convenience) +export { defineView } from './ui/view.zod'; +export { defineApp } from './ui/app.zod'; +export { defineFlow } from './automation/flow.zod'; +export { defineAgent } from './ai/agent.zod'; + export { type PluginContext } from './kernel/plugin.zod'; diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index f8bb39e72..06d561732 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -8,6 +8,7 @@ import { PageNavItemSchema, UrlNavItemSchema, GroupNavItemSchema, + defineApp, type App, type NavigationItem, } from './app.zod'; @@ -493,3 +494,33 @@ describe('App Mobile Navigation', () => { expect(app.mobileNavigation?.mode).toBe('drawer'); }); }); + +describe('defineApp', () => { + it('should return a parsed app', () => { + const result = defineApp({ + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_accounts', label: 'Accounts', type: 'object', objectName: 'account' }, + ], + }); + expect(result.name).toBe('crm'); + expect(result.label).toBe('CRM'); + expect(result.navigation).toHaveLength(1); + }); + + it('should apply defaults', () => { + const result = defineApp({ + name: 'my_app', + label: 'My App', + }); + expect(result.name).toBe('my_app'); + }); + + it('should throw on invalid input', () => { + expect(() => defineApp({ + name: 'INVALID NAME', + label: 'Test', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 01a0200cc..55ba26f44 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -224,6 +224,27 @@ export const App = { create: (config: z.input): App => AppSchema.parse(config), } as const; +/** + * Type-safe factory for creating application definitions. + * + * Validates the config at creation time using Zod `.parse()`. + * + * @example + * ```ts + * const crmApp = defineApp({ + * name: 'crm', + * label: 'CRM', + * navigation: [ + * { id: 'nav_accounts', label: 'Accounts', type: 'object', objectName: 'account' }, + * { id: 'nav_contacts', label: 'Contacts', type: 'object', objectName: 'contact' }, + * ], + * }); + * ``` + */ +export function defineApp(config: z.input): App { + return AppSchema.parse(config); +} + // Main Types export type App = z.infer; export type AppInput = z.input; diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index ca9867893..121c469e3 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -29,6 +29,7 @@ import { type FormField, type ViewData, type HttpRequest, + defineView, } from './view.zod'; describe('HttpMethodSchema', () => { @@ -1915,3 +1916,53 @@ describe('FormViewSchema - defaultSort', () => { expect(result.defaultSort).toBeUndefined(); }); }); + +describe('defineView', () => { + it('should return a parsed view with list config', () => { + const result = defineView({ + list: { + type: 'grid', + columns: ['name', 'status'], + }, + }); + expect(result.list).toBeDefined(); + expect(result.list?.type).toBe('grid'); + expect(result.list?.columns).toEqual(['name', 'status']); + }); + + it('should return a parsed view with form config', () => { + const result = defineView({ + form: { + type: 'simple', + sections: [{ fields: ['name', 'email'] }], + }, + }); + expect(result.form).toBeDefined(); + expect(result.form?.type).toBe('simple'); + }); + + it('should return a parsed view with list and form', () => { + const result = defineView({ + list: { type: 'kanban', columns: ['name'] }, + form: { type: 'tabbed', sections: [{ fields: ['name'] }] }, + }); + expect(result.list?.type).toBe('kanban'); + expect(result.form?.type).toBe('tabbed'); + }); + + it('should accept named list views', () => { + const result = defineView({ + list: { type: 'grid', columns: ['name'] }, + listViews: { + active: { type: 'grid', columns: ['name', 'status'] }, + }, + }); + expect(result.listViews?.active).toBeDefined(); + }); + + it('should throw on invalid view config', () => { + expect(() => defineView({ + list: { type: 'invalid_type' as 'grid', columns: ['name'] }, + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 46923a8d0..d92ef5e1d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -476,6 +476,30 @@ export const ViewSchema = z.object({ formViews: z.record(z.string(), FormViewSchema).optional().describe('Additional named form views'), }); +/** + * Type-safe factory for creating view definitions. + * + * Validates the config at creation time using Zod `.parse()`. + * + * @example + * ```ts + * const taskViews = defineView({ + * list: { + * type: 'grid', + * data: { provider: 'object', object: 'task' }, + * columns: ['subject', 'status', 'priority', 'due_date'], + * }, + * form: { + * type: 'simple', + * sections: [{ label: 'Details', fields: [{ field: 'subject' }] }], + * }, + * }); + * ``` + */ +export function defineView(config: z.input): View { + return ViewSchema.parse(config); +} + export type View = z.infer; export type ListView = z.infer; export type FormView = z.infer;