Skip to content

Commit c9312eb

Browse files
Copilothotlong
andcommitted
Clarify default behavior: customizable defaults to true when not specified
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 0d736b0 commit c9312eb

File tree

3 files changed

+148
-10
lines changed

3 files changed

+148
-10
lines changed

docs/spec/metadata-format.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Files should use **Snake Case** filenames (e.g., `project_tasks.object.yml`).
7979
| `description` | `string` | Internal description of the object. |
8080
| `fields` | `Map` | Dictionary of field definitions. |
8181
| `actions` | `Map` | Dictionary of custom action definitions. |
82-
| `customizable` | `boolean` | Whether this object can be modified or deleted. System objects (e.g., `user`, `session`) should be marked as `false`. Defaults to `true`. |
82+
| `customizable` | `boolean` | Whether this object can be modified or deleted. System objects (e.g., `user`, `session`) should be set to `false`. **Default: `true`** (if not specified, the object is customizable). |
8383

8484
## 4. Field Definitions
8585

@@ -104,7 +104,7 @@ fields:
104104
| `searchable` | `boolean` | Hint to include this field in global search. |
105105
| `sortable` | `boolean` | Hint that this field can be used for sorting in UI. |
106106
| `description` | `string` | Help text or documentation for the field. |
107-
| `customizable` | `boolean` | Whether this field can be modified or deleted. System fields (e.g., `_id`, `createdAt`, `updatedAt`) should be marked as `false`. Defaults to `true`. |
107+
| `customizable` | `boolean` | Whether this field can be modified or deleted. System fields (e.g., `_id`, `createdAt`, `updatedAt`) should be set to `false`. **Default: `true`** (if not specified, the field is customizable). |
108108

109109
### 4.2 Supported Field Types
110110

@@ -798,10 +798,45 @@ try {
798798

799799
### 9.6 Default Behavior
800800

801-
If the `customizable` property is not specified:
802-
- **Objects**: Default to `true` (customizable)
803-
- **Fields**: Default to `true` (customizable)
801+
**When the `customizable` property is not specified, it defaults to `true` (customizable).**
804802

805-
This ensures backward compatibility while allowing explicit protection of system metadata.
803+
This means:
804+
- **Objects without `customizable` property**: Can be modified and deleted
805+
- **Fields without `customizable` property**: Can be modified and deleted
806+
807+
**Examples:**
808+
809+
```yaml
810+
# Object without customizable - defaults to true (customizable)
811+
name: my_custom_object
812+
fields:
813+
title:
814+
type: text
815+
# Field without customizable - defaults to true (customizable)
816+
description:
817+
type: textarea
818+
# Field without customizable - defaults to true (customizable)
819+
```
820+
821+
```yaml
822+
# Explicitly marking as non-customizable
823+
name: user
824+
customizable: false # Must be explicitly set to false to protect
825+
fields:
826+
email:
827+
type: email
828+
customizable: false # Must be explicitly set to false to protect
829+
createdAt:
830+
type: datetime
831+
customizable: false # Must be explicitly set to false to protect
832+
customField:
833+
type: text
834+
# No customizable property - defaults to true (customizable)
835+
```
836+
837+
This default behavior ensures:
838+
1. **Backward Compatibility**: Existing objects and fields without the `customizable` property continue to work as before
839+
2. **Opt-in Protection**: System objects and fields must explicitly opt-in to protection by setting `customizable: false`
840+
3. **Safe Defaults**: User-defined metadata is customizable by default, only system metadata needs protection
806841

807842

packages/metadata/src/registry.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export class MetadataRegistry {
2121
// Check if the metadata is customizable before allowing unregister
2222
if (type === 'object') {
2323
const existing = this.getEntry(type, id);
24-
if (existing && existing.content.customizable === false) {
24+
if (existing && !this.isObjectCustomizable(existing.content)) {
2525
throw new Error(`Cannot delete system object '${id}'. This object is marked as non-customizable.`);
2626
}
2727
}
@@ -35,7 +35,7 @@ export class MetadataRegistry {
3535
for (const [id, meta] of map.entries()) {
3636
if (meta.package === packageName) {
3737
// Check if the metadata is customizable before allowing unregister
38-
if (type === 'object' && meta.content.customizable === false) {
38+
if (type === 'object' && !this.isObjectCustomizable(meta.content)) {
3939
throw new Error(`Cannot unregister package '${packageName}'. It contains non-customizable object '${id}'.`);
4040
}
4141
entriesToDelete.push(id);
@@ -63,8 +63,31 @@ export class MetadataRegistry {
6363
return this.store.get(type)?.get(id);
6464
}
6565

66+
/**
67+
* Helper to check if an object is customizable.
68+
* If customizable property is not specified, defaults to true.
69+
* @param obj The object configuration to check
70+
* @returns true if object is customizable (can be modified/deleted)
71+
*/
72+
private isObjectCustomizable(obj: any): boolean {
73+
// Explicitly handle undefined: if not specified, default to true (customizable)
74+
return obj.customizable !== false;
75+
}
76+
77+
/**
78+
* Helper to check if a field is customizable.
79+
* If customizable property is not specified, defaults to true.
80+
* @param field The field configuration to check
81+
* @returns true if field is customizable (can be modified/deleted)
82+
*/
83+
private isFieldCustomizable(field: any): boolean {
84+
// Explicitly handle undefined: if not specified, default to true (customizable)
85+
return field.customizable !== false;
86+
}
87+
6688
/**
6789
* Validates if an object can be modified based on its customizable flag.
90+
* Objects without the customizable property default to true (customizable).
6891
* @param objectName The name of the object to check
6992
* @returns true if the object can be modified, throws an error if not
7093
*/
@@ -74,7 +97,7 @@ export class MetadataRegistry {
7497
return true; // Object doesn't exist yet, allow creation
7598
}
7699

77-
if (entry.content.customizable === false) {
100+
if (!this.isObjectCustomizable(entry.content)) {
78101
throw new Error(`Cannot modify system object '${objectName}'. This object is marked as non-customizable.`);
79102
}
80103

@@ -83,6 +106,7 @@ export class MetadataRegistry {
83106

84107
/**
85108
* Validates if a field can be modified based on its customizable flag.
109+
* Fields without the customizable property default to true (customizable).
86110
* @param objectName The name of the object containing the field
87111
* @param fieldName The name of the field to check
88112
* @returns true if the field can be modified, throws an error if not
@@ -98,7 +122,7 @@ export class MetadataRegistry {
98122
return true; // Field doesn't exist yet, allow creation
99123
}
100124

101-
if (field.customizable === false) {
125+
if (!this.isFieldCustomizable(field)) {
102126
throw new Error(`Cannot modify system field '${fieldName}' on object '${objectName}'. This field is marked as non-customizable.`);
103127
}
104128

packages/metadata/test/customizable.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,85 @@ describe('Metadata Customizable Protection', () => {
88
registry = new MetadataRegistry();
99
});
1010

11+
describe('Default behavior when customizable is not specified', () => {
12+
it('should treat objects without customizable property as customizable (default: true)', () => {
13+
const objectWithoutFlag: ObjectConfig = {
14+
name: 'default_object',
15+
fields: {}
16+
// customizable is NOT specified - should default to true
17+
};
18+
19+
registry.register('object', {
20+
type: 'object',
21+
id: 'default_object',
22+
content: objectWithoutFlag
23+
});
24+
25+
// Should allow validation (no error thrown)
26+
expect(registry.validateObjectCustomizable('default_object')).toBe(true);
27+
28+
// Should allow unregister (no error thrown)
29+
expect(() => {
30+
registry.unregister('object', 'default_object');
31+
}).not.toThrow();
32+
});
33+
34+
it('should treat fields without customizable property as customizable (default: true)', () => {
35+
const objectWithDefaultFields: ObjectConfig = {
36+
name: 'test_object',
37+
fields: {
38+
normalField: {
39+
type: 'text'
40+
// customizable is NOT specified - should default to true
41+
}
42+
}
43+
};
44+
45+
registry.register('object', {
46+
type: 'object',
47+
id: 'test_object',
48+
content: objectWithDefaultFields
49+
});
50+
51+
// Should allow field modification (no error thrown)
52+
expect(registry.validateFieldCustomizable('test_object', 'normalField')).toBe(true);
53+
});
54+
55+
it('should only protect when explicitly set to false', () => {
56+
const mixedObject: ObjectConfig = {
57+
name: 'mixed_object',
58+
// customizable not specified - defaults to true
59+
fields: {
60+
defaultField: {
61+
type: 'text'
62+
// not specified - defaults to true
63+
},
64+
protectedField: {
65+
type: 'text',
66+
customizable: false // explicitly protected
67+
}
68+
}
69+
};
70+
71+
registry.register('object', {
72+
type: 'object',
73+
id: 'mixed_object',
74+
content: mixedObject
75+
});
76+
77+
// Object is customizable
78+
expect(registry.validateObjectCustomizable('mixed_object')).toBe(true);
79+
80+
// Default field is customizable
81+
expect(registry.validateFieldCustomizable('mixed_object', 'defaultField')).toBe(true);
82+
83+
// Protected field is not customizable
84+
expect(() => {
85+
registry.validateFieldCustomizable('mixed_object', 'protectedField');
86+
}).toThrow(/Cannot modify system field 'protectedField'/);
87+
});
88+
});
89+
1190
describe('Object-level customizable flag', () => {
1291
it('should allow registering and unregistering customizable objects', () => {
1392
const customObject: ObjectConfig = {

0 commit comments

Comments
 (0)