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
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const result = ApiMethodSchema.parse(data);
| **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) |
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
| **tableName** | `string` | optional | Physical table/collection name in the target datasource |
| **fields** | `Record<string, object>` | ✅ | Field definitions map |
| **fields** | `Record<string, object>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
| **indexes** | `object[]` | optional | Database performance indexes |
| **tenancy** | `object` | optional | Multi-tenancy configuration for SaaS applications |
| **softDelete** | `object` | optional | Soft delete (trash/recycle bin) configuration |
Expand Down
4 changes: 2 additions & 2 deletions examples/msw-react-crud/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const TaskObject = {
id: { name: 'id', label: 'ID', type: 'text', required: true },
subject: { name: 'subject', label: 'Subject', type: 'text', required: true },
priority: { name: 'priority', label: 'Priority', type: 'number', defaultValue: 5 },
isCompleted: { name: 'isCompleted', label: 'Completed', type: 'boolean', defaultValue: false },
createdAt: { name: 'createdAt', label: 'Created At', type: 'datetime' }
is_completed: { name: 'is_completed', label: 'Completed', type: 'boolean', defaultValue: false },
created_at: { name: 'created_at', label: 'Created At', type: 'datetime' }
}
};

Expand Down
5 changes: 4 additions & 1 deletion packages/spec/json-schema/data/Object.json
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,10 @@
],
"additionalProperties": false
},
"description": "Field definitions map"
"propertyNames": {
"pattern": "^[a-z_][a-z0-9_]*$"
},
"description": "Field definitions map. Keys must be snake_case identifiers."
},
"indexes": {
"type": "array",
Expand Down
123 changes: 123 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,129 @@ describe('ObjectSchema', () => {

expect(() => ObjectSchema.parse(objectWithFields)).not.toThrow();
});

it('should enforce snake_case for field names', () => {
// Valid snake_case field names
const validFieldNames = ['first_name', 'last_name', 'email', 'company_name', 'annual_revenue', '_system_id'];

validFieldNames.forEach(fieldName => {
const obj = {
name: 'test_object',
fields: {
[fieldName]: {
type: 'text' as const,
label: 'Test Field',
},
},
};
expect(() => ObjectSchema.parse(obj)).not.toThrow();
});
});

it('should reject PascalCase field names', () => {
const invalidObject = {
name: 'lead',
fields: {
FirstName: {
type: 'text' as const,
label: '名',
},
},
};

expect(() => ObjectSchema.parse(invalidObject)).toThrow();
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
});

it('should reject camelCase field names', () => {
const invalidObject = {
name: 'lead',
fields: {
firstName: {
type: 'text' as const,
label: 'First Name',
},
},
};

expect(() => ObjectSchema.parse(invalidObject)).toThrow();
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
});

it('should reject kebab-case field names', () => {
const invalidObject = {
name: 'lead',
fields: {
'first-name': {
type: 'text' as const,
label: 'First Name',
},
},
};

expect(() => ObjectSchema.parse(invalidObject)).toThrow();
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
});

it('should reject field names with spaces', () => {
const invalidObject = {
name: 'lead',
fields: {
'first name': {
type: 'text' as const,
label: 'First Name',
},
},
};

expect(() => ObjectSchema.parse(invalidObject)).toThrow();
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
});

it('should reject field names starting with numbers', () => {
const invalidObject = {
name: 'lead',
fields: {
'123field': {
type: 'text' as const,
label: 'Field',
},
},
};

expect(() => ObjectSchema.parse(invalidObject)).toThrow();
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
});

it('should reject mixed-case field names like in AI-generated objects', () => {
// This is the exact problem from the issue
const aiGeneratedObject = {
name: 'lead',
label: '线索',
fields: {
FirstName: {
type: 'text' as const,
label: '名',
maxLength: 40,
},
LastName: {
type: 'text' as const,
label: '姓',
required: true,
maxLength: 80,
},
Company: {
type: 'text' as const,
label: '公司',
required: true,
maxLength: 255,
},
},
};

expect(() => ObjectSchema.parse(aiGeneratedObject)).toThrow();
expect(() => ObjectSchema.parse(aiGeneratedObject)).toThrow(/Field names must be lowercase snake_case/);
});
});

describe('Object Metadata', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ const ObjectSchemaBase = z.object({
/**
* Data Model
*/
fields: z.record(FieldSchema).describe('Field definitions map'),
fields: z.record(z.string().regex(/^[a-z_][a-z0-9_]*$/, {
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
}), FieldSchema).describe('Field definitions map. Keys must be snake_case identifiers.'),
Comment on lines +220 to +222
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The PR description says field.zod.ts was updated so field.name must match the corresponding key in fields when provided, but the current change only validates the fields object keys. As a result, configs like fields: { first_name: { name: 'FirstName', ... } } will still pass validation, which can create ambiguous/contradictory identifiers.

Consider adding an ObjectSchema-level refinement to enforce field.name === key when name is present (or update the PR description if that behavior is no longer intended).

Copilot uses AI. Check for mistakes.
indexes: z.array(IndexSchema).optional().describe('Database performance indexes'),

/**
Expand Down
Loading