Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions DX_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/spec/src/ai/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AIKnowledgeSchema,
StructuredOutputFormatSchema,
StructuredOutputConfigSchema,
defineAgent,
type Agent,
} from './agent.zod';

Expand Down Expand Up @@ -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();
});
});
20 changes: 20 additions & 0 deletions packages/spec/src/ai/agent.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof AgentSchema>): Agent {
return AgentSchema.parse(config);
}

export type Agent = z.infer<typeof AgentSchema>;
export type AITool = z.infer<typeof AIToolSchema>;
45 changes: 45 additions & 0 deletions packages/spec/src/automation/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FlowEdgeSchema,
FlowVariableSchema,
FlowNodeAction,
defineFlow,
type Flow,
type FlowNode,
type FlowEdge,
Expand Down Expand Up @@ -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();
});
});
23 changes: 23 additions & 0 deletions packages/spec/src/automation/flow.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof FlowSchema>): FlowParsed {
return FlowSchema.parse(config);
}

export type Flow = z.input<typeof FlowSchema>;
export type FlowParsed = z.infer<typeof FlowSchema>;
Comment on lines +169 to 174
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type FlowParsed is inconsistent with most other schemas, where the primary exported type name is the parsed (z.infer) shape and the input type is suffixed with Input. Consider renaming to export type Flow = z.infer<typeof FlowSchema> and export type FlowInput = z.input<typeof FlowSchema>, then have defineFlow() return Flow, to keep the public API consistent.

Suggested change
export function defineFlow(config: z.input<typeof FlowSchema>): FlowParsed {
return FlowSchema.parse(config);
}
export type Flow = z.input<typeof FlowSchema>;
export type FlowParsed = z.infer<typeof FlowSchema>;
export function defineFlow(config: FlowInput): Flow {
return FlowSchema.parse(config);
}
export type Flow = z.infer<typeof FlowSchema>;
export type FlowInput = z.input<typeof FlowSchema>;
export type FlowParsed = Flow;

Copilot uses AI. Check for mistakes.
export type FlowNode = z.input<typeof FlowNodeSchema>;
Expand Down
60 changes: 60 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
37 changes: 36 additions & 1 deletion packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('_')
Comment on lines +339 to +340
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snakeCaseToLabel() can produce leading/trailing or double spaces when name contains leading underscores or consecutive underscores (allowed by the current /^[a-z_][a-z0-9_]*$/ regex). Consider normalizing by splitting on /_+/, filtering empty segments, and trimming the result before joining.

Suggested change
return name
.split('_')
const segments = name
.split(/_+/)
.map(segment => segment.trim())
.filter(segment => segment.length > 0);
if (segments.length === 0) {
return '';
}
return segments

Copilot uses AI. Check for mistakes.
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

/**
* Enhanced ObjectSchema with Factory
*/
export const ObjectSchema = Object.assign(ObjectSchemaBase, {
create: <T extends z.input<typeof ObjectSchemaBase>>(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<typeof ObjectSchemaBase>): ServiceObject => {
const withDefaults = {
...config,
label: config.label ?? snakeCaseToLabel(config.name),
};
return ObjectSchemaBase.parse(withDefaults);
},
});

export type ServiceObject = z.infer<typeof ObjectSchemaBase>;
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

31 changes: 31 additions & 0 deletions packages/spec/src/ui/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PageNavItemSchema,
UrlNavItemSchema,
GroupNavItemSchema,
defineApp,
type App,
type NavigationItem,
} from './app.zod';
Expand Down Expand Up @@ -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();
});
});
21 changes: 21 additions & 0 deletions packages/spec/src/ui/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,27 @@ export const App = {
create: (config: z.input<typeof AppSchema>): 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<typeof AppSchema>): App {
return AppSchema.parse(config);
}
Comment on lines +244 to +246
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defineApp() duplicates the existing App.create() helper above. To avoid two factory entry points drifting over time, consider exporting defineApp as an alias of App.create (or removing one of them) so there’s a single source of truth for app parsing/defaults.

Suggested change
export function defineApp(config: z.input<typeof AppSchema>): App {
return AppSchema.parse(config);
}
export const defineApp = App.create;

Copilot uses AI. Check for mistakes.

// Main Types
export type App = z.infer<typeof AppSchema>;
export type AppInput = z.input<typeof AppSchema>;
Expand Down
Loading
Loading