Skip to content

Commit cfe89f8

Browse files
authored
Merge pull request #643 from objectstack-ai/copilot/continue-road-map-development
2 parents 8864c7e + f0a6d6c commit cfe89f8

12 files changed

Lines changed: 370 additions & 5 deletions

File tree

DX_ROADMAP.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ This roadmap prioritizes improvements based on the **"Time to First Wow"** metri
107107

108108
### Phase 2 Checklist
109109

110-
- [ ] Enhance `ObjectSchema.create()` with auto-label, common fields, and validation
111-
- [ ] Implement `defineView()` with column type inference
112-
- [ ] Implement `defineApp()` with navigation builder
113-
- [ ] Implement `defineFlow()` with step type inference
110+
- [x] Enhance `ObjectSchema.create()` with auto-label, common fields, and validation
111+
- [x] Implement `defineView()` with column type inference
112+
- [x] Implement `defineApp()` with navigation builder
113+
- [x] Implement `defineFlow()` with step type inference
114114
- [ ] Create custom Zod error map with contextual messages
115115
- [ ] Add "Did you mean?" suggestions for FieldType typos
116116
- [ ] Create pretty-print validation error formatter for CLI

packages/spec/src/ai/agent.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
AIKnowledgeSchema,
77
StructuredOutputFormatSchema,
88
StructuredOutputConfigSchema,
9+
defineAgent,
910
type Agent,
1011
} from './agent.zod';
1112

@@ -685,3 +686,51 @@ describe('StructuredOutputConfigSchema', () => {
685686
expect(config.fallbackFormat).toBe('json_object');
686687
});
687688
});
689+
690+
describe('defineAgent', () => {
691+
it('should return a parsed agent', () => {
692+
const result = defineAgent({
693+
name: 'support_agent',
694+
label: 'Support Agent',
695+
role: 'Senior Support Engineer',
696+
instructions: 'You help customers resolve technical issues.',
697+
});
698+
expect(result.name).toBe('support_agent');
699+
expect(result.label).toBe('Support Agent');
700+
expect(result.role).toBe('Senior Support Engineer');
701+
});
702+
703+
it('should apply defaults', () => {
704+
const result = defineAgent({
705+
name: 'test_agent',
706+
label: 'Test',
707+
role: 'Tester',
708+
instructions: 'Testing agent.',
709+
});
710+
expect(result.active).toBe(true);
711+
expect(result.visibility).toBe('organization');
712+
});
713+
714+
it('should accept agent with tools', () => {
715+
const result = defineAgent({
716+
name: 'smart_agent',
717+
label: 'Smart Agent',
718+
role: 'Analyst',
719+
instructions: 'Analyze data.',
720+
tools: [
721+
{ type: 'action', name: 'create_report' },
722+
{ type: 'query', name: 'search_records' },
723+
],
724+
});
725+
expect(result.tools).toHaveLength(2);
726+
});
727+
728+
it('should throw on invalid agent name', () => {
729+
expect(() => defineAgent({
730+
name: 'INVALID',
731+
label: 'Test',
732+
role: 'Tester',
733+
instructions: 'Test.',
734+
})).toThrow();
735+
});
736+
});

packages/spec/src/ai/agent.zod.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,5 +191,25 @@ export const AgentSchema = z.object({
191191
structuredOutput: StructuredOutputConfigSchema.optional().describe('Structured output format and validation configuration'),
192192
});
193193

194+
/**
195+
* Type-safe factory for creating AI agent definitions.
196+
*
197+
* Validates the config at creation time using Zod `.parse()`.
198+
*
199+
* @example
200+
* ```ts
201+
* const supportAgent = defineAgent({
202+
* name: 'support_agent',
203+
* label: 'Support Agent',
204+
* role: 'Senior Support Engineer',
205+
* instructions: 'You help customers resolve technical issues.',
206+
* tools: [{ type: 'action', name: 'create_ticket' }],
207+
* });
208+
* ```
209+
*/
210+
export function defineAgent(config: z.input<typeof AgentSchema>): Agent {
211+
return AgentSchema.parse(config);
212+
}
213+
194214
export type Agent = z.infer<typeof AgentSchema>;
195215
export type AITool = z.infer<typeof AIToolSchema>;

packages/spec/src/automation/flow.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
FlowEdgeSchema,
66
FlowVariableSchema,
77
FlowNodeAction,
8+
defineFlow,
89
type Flow,
910
type FlowNode,
1011
type FlowEdge,
@@ -597,3 +598,47 @@ describe('FlowSchema - errorHandling', () => {
597598
expect(result.errorHandling).toBeUndefined();
598599
});
599600
});
601+
602+
describe('defineFlow', () => {
603+
it('should return a parsed flow', () => {
604+
const result = defineFlow({
605+
name: 'on_task_create',
606+
label: 'On Task Create',
607+
type: 'record_change',
608+
nodes: [
609+
{ id: 'start', type: 'start', label: 'Start' },
610+
{ id: 'end', type: 'end', label: 'End' },
611+
],
612+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
613+
});
614+
expect(result.name).toBe('on_task_create');
615+
expect(result.label).toBe('On Task Create');
616+
expect(result.type).toBe('record_change');
617+
expect(result.nodes).toHaveLength(2);
618+
expect(result.edges).toHaveLength(1);
619+
});
620+
621+
it('should apply defaults', () => {
622+
const result = defineFlow({
623+
name: 'simple',
624+
label: 'Simple',
625+
type: 'autolaunched',
626+
nodes: [{ id: 'start', type: 'start', label: 'Start' }],
627+
edges: [],
628+
});
629+
expect(result.version).toBe(1);
630+
expect(result.status).toBe('draft');
631+
expect(result.active).toBe(false);
632+
expect(result.runAs).toBe('user');
633+
});
634+
635+
it('should throw on invalid flow name', () => {
636+
expect(() => defineFlow({
637+
name: 'INVALID',
638+
label: 'Bad Flow',
639+
type: 'autolaunched',
640+
nodes: [],
641+
edges: [],
642+
})).toThrow();
643+
});
644+
});

packages/spec/src/automation/flow.zod.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ export const FlowSchema = z.object({
147147
}).optional().describe('Flow-level error handling configuration'),
148148
});
149149

150+
/**
151+
* Type-safe factory for creating flow definitions.
152+
*
153+
* Validates the config at creation time using Zod `.parse()`.
154+
*
155+
* @example
156+
* ```ts
157+
* const onCreateFlow = defineFlow({
158+
* name: 'on_task_create',
159+
* label: 'On Task Create',
160+
* type: 'record_change',
161+
* nodes: [
162+
* { id: 'start', type: 'start', label: 'Start' },
163+
* { id: 'end', type: 'end', label: 'End' },
164+
* ],
165+
* edges: [{ id: 'e1', source: 'start', target: 'end' }],
166+
* });
167+
* ```
168+
*/
169+
export function defineFlow(config: z.input<typeof FlowSchema>): FlowParsed {
170+
return FlowSchema.parse(config);
171+
}
172+
150173
export type Flow = z.input<typeof FlowSchema>;
151174
export type FlowParsed = z.infer<typeof FlowSchema>;
152175
export type FlowNode = z.input<typeof FlowNodeSchema>;

packages/spec/src/data/object.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,3 +662,63 @@ describe('ObjectSchema - recordName', () => {
662662
expect(result.recordName).toBeUndefined();
663663
});
664664
});
665+
666+
describe('ObjectSchema.create()', () => {
667+
it('should auto-generate label from snake_case name', () => {
668+
const result = ObjectSchema.create({
669+
name: 'project_task',
670+
fields: {
671+
title: { type: 'text' },
672+
},
673+
});
674+
expect(result.label).toBe('Project Task');
675+
});
676+
677+
it('should preserve explicitly provided label', () => {
678+
const result = ObjectSchema.create({
679+
name: 'project_task',
680+
label: 'My Custom Label',
681+
fields: {
682+
title: { type: 'text' },
683+
},
684+
});
685+
expect(result.label).toBe('My Custom Label');
686+
});
687+
688+
it('should auto-generate label from single-word name', () => {
689+
const result = ObjectSchema.create({
690+
name: 'account',
691+
fields: {
692+
name: { type: 'text' },
693+
},
694+
});
695+
expect(result.label).toBe('Account');
696+
});
697+
698+
it('should validate and apply defaults', () => {
699+
const result = ObjectSchema.create({
700+
name: 'task',
701+
fields: {
702+
title: { type: 'text' },
703+
},
704+
});
705+
expect(result.active).toBe(true);
706+
expect(result.isSystem).toBe(false);
707+
expect(result.abstract).toBe(false);
708+
expect(result.datasource).toBe('default');
709+
});
710+
711+
it('should throw on invalid name format', () => {
712+
expect(() => ObjectSchema.create({
713+
name: 'InvalidName',
714+
fields: { title: { type: 'text' } },
715+
})).toThrow();
716+
});
717+
718+
it('should throw on invalid field name format', () => {
719+
expect(() => ObjectSchema.create({
720+
name: 'task',
721+
fields: { InvalidField: { type: 'text' } },
722+
})).toThrow();
723+
});
724+
});

packages/spec/src/data/object.zod.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,11 +331,46 @@ const ObjectSchemaBase = z.object({
331331
keyPrefix: z.string().max(5).optional().describe('Short prefix for record IDs (e.g., "001" for Account)'),
332332
});
333333

334+
/**
335+
* Converts a snake_case name to a human-readable Title Case label.
336+
* @example snakeCaseToLabel('project_task') → 'Project Task'
337+
*/
338+
function snakeCaseToLabel(name: string): string {
339+
return name
340+
.split('_')
341+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
342+
.join(' ');
343+
}
344+
334345
/**
335346
* Enhanced ObjectSchema with Factory
336347
*/
337348
export const ObjectSchema = Object.assign(ObjectSchemaBase, {
338-
create: <T extends z.input<typeof ObjectSchemaBase>>(config: T) => config,
349+
/**
350+
* Type-safe factory for creating business object definitions.
351+
*
352+
* Enhancements over raw schema:
353+
* - **Auto-label**: Generates `label` from `name` if not provided (snake_case → Title Case).
354+
* - **Validation**: Runs Zod `.parse()` to validate the config at creation time.
355+
*
356+
* @example
357+
* ```ts
358+
* const Task = ObjectSchema.create({
359+
* name: 'project_task',
360+
* // label auto-generated as 'Project Task'
361+
* fields: {
362+
* subject: { type: 'text', label: 'Subject', required: true },
363+
* },
364+
* });
365+
* ```
366+
*/
367+
create: (config: z.input<typeof ObjectSchemaBase>): ServiceObject => {
368+
const withDefaults = {
369+
...config,
370+
label: config.label ?? snakeCaseToLabel(config.name),
371+
};
372+
return ObjectSchemaBase.parse(withDefaults);
373+
},
339374
});
340375

341376
export type ServiceObject = z.infer<typeof ObjectSchemaBase>;

packages/spec/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,11 @@ export {
7373

7474
export * from './stack.zod';
7575

76+
// DX Helper Functions (re-exported for convenience)
77+
export { defineView } from './ui/view.zod';
78+
export { defineApp } from './ui/app.zod';
79+
export { defineFlow } from './automation/flow.zod';
80+
export { defineAgent } from './ai/agent.zod';
81+
7682
export { type PluginContext } from './kernel/plugin.zod';
7783

packages/spec/src/ui/app.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
PageNavItemSchema,
99
UrlNavItemSchema,
1010
GroupNavItemSchema,
11+
defineApp,
1112
type App,
1213
type NavigationItem,
1314
} from './app.zod';
@@ -493,3 +494,33 @@ describe('App Mobile Navigation', () => {
493494
expect(app.mobileNavigation?.mode).toBe('drawer');
494495
});
495496
});
497+
498+
describe('defineApp', () => {
499+
it('should return a parsed app', () => {
500+
const result = defineApp({
501+
name: 'crm',
502+
label: 'CRM',
503+
navigation: [
504+
{ id: 'nav_accounts', label: 'Accounts', type: 'object', objectName: 'account' },
505+
],
506+
});
507+
expect(result.name).toBe('crm');
508+
expect(result.label).toBe('CRM');
509+
expect(result.navigation).toHaveLength(1);
510+
});
511+
512+
it('should apply defaults', () => {
513+
const result = defineApp({
514+
name: 'my_app',
515+
label: 'My App',
516+
});
517+
expect(result.name).toBe('my_app');
518+
});
519+
520+
it('should throw on invalid input', () => {
521+
expect(() => defineApp({
522+
name: 'INVALID NAME',
523+
label: 'Test',
524+
})).toThrow();
525+
});
526+
});

packages/spec/src/ui/app.zod.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,27 @@ export const App = {
224224
create: (config: z.input<typeof AppSchema>): App => AppSchema.parse(config),
225225
} as const;
226226

227+
/**
228+
* Type-safe factory for creating application definitions.
229+
*
230+
* Validates the config at creation time using Zod `.parse()`.
231+
*
232+
* @example
233+
* ```ts
234+
* const crmApp = defineApp({
235+
* name: 'crm',
236+
* label: 'CRM',
237+
* navigation: [
238+
* { id: 'nav_accounts', label: 'Accounts', type: 'object', objectName: 'account' },
239+
* { id: 'nav_contacts', label: 'Contacts', type: 'object', objectName: 'contact' },
240+
* ],
241+
* });
242+
* ```
243+
*/
244+
export function defineApp(config: z.input<typeof AppSchema>): App {
245+
return AppSchema.parse(config);
246+
}
247+
227248
// Main Types
228249
export type App = z.infer<typeof AppSchema>;
229250
export type AppInput = z.input<typeof AppSchema>;

0 commit comments

Comments
 (0)