Skip to content

Commit e041fb8

Browse files
Copilothotlong
andcommitted
feat: implement DX Phase 2 - branded types, suggestions, error map, strict defineStack
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 3558e40 commit e041fb8

10 files changed

Lines changed: 1222 additions & 4 deletions

File tree

packages/spec/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export {
7171
ObjectOSCapabilitiesSchema
7272
} from './stack.zod';
7373

74+
export type { DefineStackOptions } from './stack.zod';
75+
7476
export * from './stack.zod';
7577

7678
// DX Helper Functions (re-exported for convenience)
@@ -79,5 +81,9 @@ export { defineApp } from './ui/app.zod';
7981
export { defineFlow } from './automation/flow.zod';
8082
export { defineAgent } from './ai/agent.zod';
8183

84+
// DX Validation Utilities (re-exported for convenience)
85+
export { objectStackErrorMap, formatZodError, formatZodIssue, safeParsePretty } from './shared/error-map.zod';
86+
export { suggestFieldType, findClosestMatches, formatSuggestion } from './shared/suggestions.zod';
87+
8288
export { type PluginContext } from './kernel/plugin.zod';
8389

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
ObjectNameSchema,
4+
FieldNameSchema,
5+
ViewNameSchema,
6+
AppNameSchema,
7+
FlowNameSchema,
8+
RoleNameSchema,
9+
type ObjectName,
10+
type FieldName,
11+
type ViewName,
12+
type AppName,
13+
type FlowName,
14+
type RoleName,
15+
} from './branded-types.zod';
16+
17+
describe('ObjectNameSchema', () => {
18+
it('should accept valid snake_case object names', () => {
19+
const validNames = ['account', 'project_task', 'crm_lead', 'user_profile'];
20+
validNames.forEach((name) => {
21+
expect(() => ObjectNameSchema.parse(name)).not.toThrow();
22+
});
23+
});
24+
25+
it('should reject dots (not allowed in object names)', () => {
26+
expect(() => ObjectNameSchema.parse('user.created')).toThrow();
27+
});
28+
29+
it('should reject uppercase', () => {
30+
expect(() => ObjectNameSchema.parse('Account')).toThrow();
31+
expect(() => ObjectNameSchema.parse('projectTask')).toThrow();
32+
});
33+
34+
it('should reject too short names', () => {
35+
expect(() => ObjectNameSchema.parse('a')).toThrow();
36+
});
37+
38+
it('should produce branded type at runtime', () => {
39+
const name: ObjectName = ObjectNameSchema.parse('my_object');
40+
expect(name).toBe('my_object');
41+
});
42+
});
43+
44+
describe('FieldNameSchema', () => {
45+
it('should accept valid snake_case field names', () => {
46+
const validNames = ['first_name', 'created_at', 'total_amount', 'is_active'];
47+
validNames.forEach((name) => {
48+
expect(() => FieldNameSchema.parse(name)).not.toThrow();
49+
});
50+
});
51+
52+
it('should reject dots', () => {
53+
expect(() => FieldNameSchema.parse('user.name')).toThrow();
54+
});
55+
56+
it('should reject camelCase', () => {
57+
expect(() => FieldNameSchema.parse('firstName')).toThrow();
58+
});
59+
60+
it('should produce branded type at runtime', () => {
61+
const name: FieldName = FieldNameSchema.parse('task_name');
62+
expect(name).toBe('task_name');
63+
});
64+
});
65+
66+
describe('ViewNameSchema', () => {
67+
it('should accept valid system identifiers', () => {
68+
const validNames = ['all_tasks', 'my_open_deals', 'contact.recent'];
69+
validNames.forEach((name) => {
70+
expect(() => ViewNameSchema.parse(name)).not.toThrow();
71+
});
72+
});
73+
74+
it('should allow dots (namespacing)', () => {
75+
const name: ViewName = ViewNameSchema.parse('contact.recent');
76+
expect(name).toBe('contact.recent');
77+
});
78+
79+
it('should reject uppercase', () => {
80+
expect(() => ViewNameSchema.parse('AllTasks')).toThrow();
81+
});
82+
});
83+
84+
describe('AppNameSchema', () => {
85+
it('should accept valid app names', () => {
86+
const validNames = ['crm', 'helpdesk', 'project_management'];
87+
validNames.forEach((name) => {
88+
expect(() => AppNameSchema.parse(name)).not.toThrow();
89+
});
90+
});
91+
92+
it('should produce branded type at runtime', () => {
93+
const name: AppName = AppNameSchema.parse('crm');
94+
expect(name).toBe('crm');
95+
});
96+
});
97+
98+
describe('FlowNameSchema', () => {
99+
it('should accept valid flow names', () => {
100+
const validNames = ['approval_flow', 'onboarding_wizard', 'lead_qualification'];
101+
validNames.forEach((name) => {
102+
expect(() => FlowNameSchema.parse(name)).not.toThrow();
103+
});
104+
});
105+
106+
it('should produce branded type at runtime', () => {
107+
const name: FlowName = FlowNameSchema.parse('approval_flow');
108+
expect(name).toBe('approval_flow');
109+
});
110+
});
111+
112+
describe('RoleNameSchema', () => {
113+
it('should accept valid role names', () => {
114+
const validNames = ['admin', 'sales_manager', 'read_only'];
115+
validNames.forEach((name) => {
116+
expect(() => RoleNameSchema.parse(name)).not.toThrow();
117+
});
118+
});
119+
120+
it('should produce branded type at runtime', () => {
121+
const name: RoleName = RoleNameSchema.parse('admin');
122+
expect(name).toBe('admin');
123+
});
124+
});
125+
126+
describe('Branded type safety', () => {
127+
it('branded types parse the same underlying strings', () => {
128+
const objectName = ObjectNameSchema.parse('my_entity');
129+
const fieldName = FieldNameSchema.parse('my_entity');
130+
// At runtime both are 'my_entity' strings but TS types differ
131+
expect(objectName).toBe('my_entity');
132+
expect(fieldName).toBe('my_entity');
133+
});
134+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { z } from 'zod';
4+
import { SnakeCaseIdentifierSchema, SystemIdentifierSchema } from './identifiers.zod';
5+
6+
/**
7+
* Branded Types for ObjectStack Identifiers
8+
*
9+
* Branded types provide compile-time safety by preventing accidental mixing
10+
* of different identifier kinds. For example, you cannot pass an ObjectName
11+
* where a FieldName is expected, even though both are strings at runtime.
12+
*
13+
* @example
14+
* ```ts
15+
* import { ObjectNameSchema, FieldNameSchema } from '@objectstack/spec';
16+
*
17+
* const objName = ObjectNameSchema.parse('project_task'); // ObjectName
18+
* const fieldName = FieldNameSchema.parse('task_name'); // FieldName
19+
*
20+
* // TypeScript will catch this at compile time:
21+
* // const fn: FieldName = objName; // Error!
22+
* ```
23+
*/
24+
25+
/**
26+
* ObjectName — Branded type for business object names.
27+
*
28+
* Must be snake_case (no dots). Used for table/collection names.
29+
*
30+
* @example 'project_task', 'crm_account', 'user_profile'
31+
*/
32+
export const ObjectNameSchema = SnakeCaseIdentifierSchema
33+
.brand<'ObjectName'>()
34+
.describe('Branded object name (snake_case, no dots)');
35+
36+
export type ObjectName = z.infer<typeof ObjectNameSchema>;
37+
38+
/**
39+
* FieldName — Branded type for field (column) names.
40+
*
41+
* Must be snake_case (no dots). Used for column/property names within objects.
42+
*
43+
* @example 'first_name', 'created_at', 'total_amount'
44+
*/
45+
export const FieldNameSchema = SnakeCaseIdentifierSchema
46+
.brand<'FieldName'>()
47+
.describe('Branded field name (snake_case, no dots)');
48+
49+
export type FieldName = z.infer<typeof FieldNameSchema>;
50+
51+
/**
52+
* ViewName — Branded type for view identifiers.
53+
*
54+
* Must be a valid system identifier (lowercase, may contain dots for namespacing).
55+
*
56+
* @example 'all_tasks', 'my_open_deals', 'contact.recent'
57+
*/
58+
export const ViewNameSchema = SystemIdentifierSchema
59+
.brand<'ViewName'>()
60+
.describe('Branded view name (system identifier)');
61+
62+
export type ViewName = z.infer<typeof ViewNameSchema>;
63+
64+
/**
65+
* AppName — Branded type for application identifiers.
66+
*
67+
* Must be a valid system identifier.
68+
*
69+
* @example 'crm', 'helpdesk', 'project_management'
70+
*/
71+
export const AppNameSchema = SystemIdentifierSchema
72+
.brand<'AppName'>()
73+
.describe('Branded app name (system identifier)');
74+
75+
export type AppName = z.infer<typeof AppNameSchema>;
76+
77+
/**
78+
* FlowName — Branded type for flow identifiers.
79+
*
80+
* Must be a valid system identifier.
81+
*
82+
* @example 'approval_flow', 'onboarding_wizard', 'lead_qualification'
83+
*/
84+
export const FlowNameSchema = SystemIdentifierSchema
85+
.brand<'FlowName'>()
86+
.describe('Branded flow name (system identifier)');
87+
88+
export type FlowName = z.infer<typeof FlowNameSchema>;
89+
90+
/**
91+
* RoleName — Branded type for role identifiers.
92+
*
93+
* Must be a valid system identifier.
94+
*
95+
* @example 'admin', 'sales_manager', 'read_only'
96+
*/
97+
export const RoleNameSchema = SystemIdentifierSchema
98+
.brand<'RoleName'>()
99+
.describe('Branded role name (system identifier)');
100+
101+
export type RoleName = z.infer<typeof RoleNameSchema>;

0 commit comments

Comments
 (0)