Allow app to extend object (#17116)

- exports `extendObject` utils from `twenty-sdk`
- define `*.object-extension.ts` filename pattern to extend and object
- handle object-extension configs in manifest
This commit is contained in:
martmull
2026-01-13 16:07:14 +01:00
committed by GitHub
parent f1be7129cb
commit d55b83375d
23 changed files with 1300 additions and 88 deletions
+1
View File
@@ -31,6 +31,7 @@
"license": "AGPL-3.0",
"dependencies": {
"@genql/cli": "^3.0.3",
"@sniptt/guards": "^0.2.0",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
@@ -114,38 +114,6 @@ describe('defineObject', () => {
expect(result.fields).toBeUndefined();
});
it('should throw error when field is missing universalIdentifier', () => {
const config = {
...validConfig,
fields: [
{
type: FieldMetadataType.TEXT,
label: 'Content',
},
],
};
expect(() => defineObject(config as any)).toThrow(
'Field "Content" must have a universalIdentifier',
);
});
it('should throw error when field is missing type', () => {
const config = {
...validConfig,
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
label: 'Content',
},
],
};
expect(() => defineObject(config as any)).toThrow(
'Field "Content" must have a type',
);
});
it('should throw error when field is missing label', () => {
const config = {
...validConfig,
@@ -175,7 +143,26 @@ describe('defineObject', () => {
],
};
expect(() => defineObject(config as any)).toThrow('Field must have a name');
expect(() => defineObject(config as any)).toThrow(
'Field "Content" must have a name',
);
});
it('should throw error when field is missing universalIdentifier', () => {
const config = {
...validConfig,
fields: [
{
type: FieldMetadataType.TEXT,
name: 'content',
label: 'Content',
},
],
};
expect(() => defineObject(config as any)).toThrow(
'Field "Content" must have a universalIdentifier',
);
});
it('should throw error when SELECT field has no options', () => {
@@ -0,0 +1,303 @@
import { extendObject } from '@/application/objects/extend-object';
import { FieldMetadataType } from 'twenty-shared/types';
import { type ObjectExtensionManifest } from 'twenty-shared/application';
describe('extendObject', () => {
const validConfigByName: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
},
],
};
const validConfigByUniversalId: ObjectExtensionManifest = {
targetObject: {
universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440002',
type: FieldMetadataType.TEXT,
name: 'customNote',
label: 'Custom Note',
},
],
};
describe('valid configurations', () => {
it('should return the config when targeting by nameSingular', () => {
const result = extendObject(validConfigByName);
expect(result).toEqual(validConfigByName);
});
it('should return the config when targeting by universalIdentifier', () => {
const result = extendObject(validConfigByUniversalId);
expect(result).toEqual(validConfigByUniversalId);
});
it('should pass through optional field properties', () => {
const config: ObjectExtensionManifest = {
...validConfigByName,
fields: [
{
...validConfigByName.fields[0],
description: 'A health score from 0-100',
icon: 'IconHeart',
},
],
};
const result = extendObject(config);
expect(result.fields[0].description).toBe('A health score from 0-100');
expect(result.fields[0].icon).toBe('IconHeart');
});
it('should accept SELECT field with options', () => {
const config: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440003',
type: FieldMetadataType.SELECT,
name: 'churnRisk',
label: 'Churn Risk',
options: [
{ value: 'low', label: 'Low', color: 'green', position: 0 },
{
value: 'medium',
label: 'Medium',
color: 'yellow',
position: 1,
},
{ value: 'high', label: 'High', color: 'red', position: 2 },
],
},
],
};
const result = extendObject(config);
expect(result.fields[0].label).toBe('Churn Risk');
});
it('should accept multiple fields', () => {
const config: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'person',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440004',
type: FieldMetadataType.NUMBER,
name: 'score',
label: 'Score',
},
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440005',
type: FieldMetadataType.TEXT,
name: 'notes',
label: 'Notes',
},
],
};
const result = extendObject(config);
expect(result.fields).toHaveLength(2);
});
});
describe('targetObject validation', () => {
it('should throw error when targetObject is missing', () => {
const config = {
fields: validConfigByName.fields,
};
expect(() => extendObject(config as any)).toThrow(
'Object extension must have a targetObject',
);
});
it('should throw error when targetObject has neither nameSingular nor universalIdentifier', () => {
const config = {
targetObject: {},
fields: validConfigByName.fields,
};
expect(() => extendObject(config as any)).toThrow(
'targetObject must have either nameSingular or universalIdentifier',
);
});
it('should throw error when targetObject has both nameSingular and universalIdentifier', () => {
const config = {
targetObject: {
nameSingular: 'company',
universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
},
fields: validConfigByName.fields,
};
expect(() => extendObject(config as any)).toThrow(
'targetObject cannot have both nameSingular and universalIdentifier',
);
});
});
describe('fields validation', () => {
it('should throw error when fields is missing', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
};
expect(() => extendObject(config as any)).toThrow(
'Object extension must have at least one field',
);
});
it('should throw error when fields is empty', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [],
};
expect(() => extendObject(config as any)).toThrow(
'Object extension must have at least one field',
);
});
it('should throw error when field is missing label', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
},
],
};
expect(() => extendObject(config as any)).toThrow(
'Field must have a label',
);
});
it('should throw error when field is missing name', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
label: 'Health Score',
},
],
};
expect(() => extendObject(config as any)).toThrow(
'Field "Health Score" must have a name',
);
});
it('should throw error when field is missing universalIdentifier', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
},
],
};
expect(() => extendObject(config as any)).toThrow(
'Field "Health Score" must have a universalIdentifier',
);
});
it('should throw error when SELECT field has no options', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
},
],
};
expect(() => extendObject(config as any)).toThrow(
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
);
});
it('should throw error when MULTI_SELECT field has no options', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
label: 'Tags',
},
],
};
expect(() => extendObject(config as any)).toThrow(
'Field "Tags" is a SELECT/MULTI_SELECT type and must have options',
);
});
it('should throw error when SELECT field has empty options array', () => {
const config = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
options: [],
},
],
};
expect(() => extendObject(config as any)).toThrow(
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
);
});
});
});
@@ -42,8 +42,10 @@ export type {
ObjectRecordUpsertEvent,
} from './functions/triggers/database-event-payload-type';
export { defineObject } from './objects/define-object';
export { extendObject } from './objects/extend-object';
export { Object } from './objects/object.decorator';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { validateFieldsOrThrow } from './objects/validate-fields';
export { PermissionFlag } from './permission-flag-type';
export type { RoleConfig } from './role-config';
export { defineRole } from './roles/define-role';
@@ -1,5 +1,6 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type ObjectManifest } from 'twenty-shared/application';
import { isNonEmptyString } from '@sniptt/guards';
import { validateFieldsOrThrow } from './validate-fields';
/**
* Define an object configuration with validation.
@@ -35,55 +36,27 @@ import { type ObjectManifest } from 'twenty-shared/application';
* ```
*/
export const defineObject = <T extends ObjectManifest>(config: T): T => {
if (!config.universalIdentifier) {
if (!isNonEmptyString(config.universalIdentifier)) {
throw new Error('Object must have a universalIdentifier');
}
if (!config.nameSingular) {
if (!isNonEmptyString(config.nameSingular)) {
throw new Error('Object must have a nameSingular');
}
if (!config.namePlural) {
if (!isNonEmptyString(config.namePlural)) {
throw new Error('Object must have a namePlural');
}
if (!config.labelSingular) {
if (!isNonEmptyString(config.labelSingular)) {
throw new Error('Object must have a labelSingular');
}
if (!config.labelPlural) {
if (!isNonEmptyString(config.labelPlural)) {
throw new Error('Object must have a labelPlural');
}
// Validate each field
for (const field of config.fields ?? []) {
if (!field.universalIdentifier) {
throw new Error(`Field "${field.label}" must have a universalIdentifier`);
}
if (!field.type) {
throw new Error(`Field "${field.label}" must have a type`);
}
if (!field.name) {
throw new Error('Field must have a name');
}
if (!field.label) {
throw new Error('Field must have a label');
}
// Validate SELECT fields have options
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!field.options || field.options.length === 0)
) {
throw new Error(
`Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`,
);
}
}
validateFieldsOrThrow(config.fields);
return config;
};
@@ -0,0 +1,79 @@
import { type ObjectExtensionManifest } from 'twenty-shared/application';
import { isNonEmptyString } from '@sniptt/guards';
import { validateFieldsOrThrow } from './validate-fields';
/**
* Extend an existing object with additional fields.
*
* @example
* ```typescript
* import { extendObject, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
*
* // Extend by object name
* export const companyExtension = extendObject({
* targetObject: {
* nameSingular: 'company',
* },
* fields: [
* {
* universalIdentifier: '...',
* name: 'healthScore',
* type: FieldType.NUMBER,
* label: 'Health Score',
* description: 'Calculated customer health metric',
* },
* {
* universalIdentifier: '...',
* name: 'churnRisk',
* type: FieldType.SELECT,
* label: 'Churn Risk',
* options: [
* { label: 'Low', value: 'low', color: 'green' },
* { label: 'Medium', value: 'medium', color: 'yellow' },
* { label: 'High', value: 'high', color: 'red' },
* ],
* },
* ],
* });
*
* // Or extend by universal identifier
* export const companyExtensionById = extendObject({
* targetObject: {
* universalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company,
* },
* fields: [...],
* });
* ```
*/
export const extendObject = <T extends ObjectExtensionManifest>(
config: T,
): T => {
if (!config.targetObject) {
throw new Error('Object extension must have a targetObject');
}
const { nameSingular, universalIdentifier } = config.targetObject;
if (
!isNonEmptyString(nameSingular) &&
!isNonEmptyString(universalIdentifier)
) {
throw new Error(
'targetObject must have either nameSingular or universalIdentifier',
);
}
if (isNonEmptyString(nameSingular) && isNonEmptyString(universalIdentifier)) {
throw new Error(
'targetObject cannot have both nameSingular and universalIdentifier - they are mutually exclusive',
);
}
if (!config.fields || config.fields.length === 0) {
throw new Error('Object extension must have at least one field');
}
validateFieldsOrThrow(config.fields);
return config;
};
@@ -0,0 +1,42 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
type FieldManifest,
type RelationFieldManifest,
} from 'twenty-shared/application';
import { isNonEmptyString } from '@sniptt/guards';
/**
* Validates an array of fields and throws an error if any field is invalid.
* This validation is shared between defineObject and extendObject.
*/
export const validateFieldsOrThrow = (
fields: (FieldManifest | RelationFieldManifest)[] | undefined,
): void => {
if (!fields) {
return;
}
for (const field of fields) {
if (!isNonEmptyString(field.label)) {
throw new Error('Field must have a label');
}
if (!isNonEmptyString(field.name)) {
throw new Error(`Field "${field.label}" must have a name`);
}
if (!isNonEmptyString(field.universalIdentifier)) {
throw new Error(`Field "${field.label}" must have a universalIdentifier`);
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
throw new Error(
`Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`,
);
}
}
};
@@ -2,6 +2,9 @@ import { existsSync } from 'fs';
import { AppSyncCommand } from '@/cli/commands/app-sync.command';
import { AppUninstallCommand } from '@/cli/commands/app-uninstall.command';
import { getTestedApplicationPath } from '@/cli/__tests__/e2e/utils/get-tested-application-path.util';
import { inspect } from 'util';
inspect.defaultOptions.depth = 10;
describe('Application: install delete and reinstall test-app', () => {
const applicationName = 'test-app';
@@ -0,0 +1,40 @@
import { extendObject } from '@/application/objects/extend-object';
import { FieldType } from '@/application/fields/field-type';
export const POST_CARD_EXTENSION_PRIORITY_FIELD_ID =
'7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d';
export const POST_CARD_EXTENSION_CATEGORY_FIELD_ID =
'8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e';
export default extendObject({
targetObject: {
nameSingular: 'postCard',
},
fields: [
{
universalIdentifier: POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
description: 'Priority level for the post card (1-10)',
},
{
universalIdentifier: POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
type: FieldType.SELECT,
name: 'category',
label: 'Category',
description: 'Post card category',
options: [
{ value: 'PERSONAL', label: 'Personal', color: 'blue', position: 0 },
{ value: 'BUSINESS', label: 'Business', color: 'green', position: 1 },
{
value: 'PROMOTIONAL',
label: 'Promotional',
color: 'orange',
position: 2,
},
],
},
],
});
+3
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { inspect } from 'util';
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { AppCommand } from '@/cli/commands/app.command';
@@ -6,6 +7,8 @@ import { AuthCommand } from '@/cli/commands/auth.command';
import { ConfigService } from '@/cli/services/config.service';
import packageJson from '../../package.json';
inspect.defaultOptions.depth = 10;
const program = new Command();
program
@@ -1,4 +1,3 @@
import { inspect } from 'util';
import chalk from 'chalk';
import { Command } from 'commander';
import {
@@ -13,8 +12,6 @@ import { formatPath } from '@/cli/utils/format-path';
import { AppGenerateCommand } from '@/cli/commands/app-generate.command';
import { AppLogsCommand } from '@/cli/commands/app-logs.command';
inspect.defaultOptions.depth = 10;
export class AppCommand {
private devCommand = new AppDevCommand();
private syncCommand = new AppSyncCommand();
@@ -4,6 +4,10 @@ import {
type LoadManifestResult,
} from '@/cli/utils/load-manifest';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
import {
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
} from '@/cli/__tests__/test-app/src/app/postCard.object-extension';
const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app');
@@ -22,7 +26,7 @@ describe('loadManifest with test-app', () => {
yarnLock = result.yarnLock;
warnings = result.warnings;
shouldGenerate = result.shouldGenerate;
});
}, 15_000);
it('should load manifest from test-app directory', async () => {
// Check package.json loaded correctly
@@ -70,7 +74,6 @@ describe('loadManifest with test-app', () => {
const statusField = postCard.fields?.find((f) => f.name === 'status');
expect(statusField).toBeDefined();
expect(statusField?.type).toBe('SELECT');
expect(statusField?.options).toHaveLength(4);
expect(manifest.serverlessFunctions).toHaveLength(2);
@@ -153,6 +156,41 @@ describe('loadManifest with test-app', () => {
expect(appSources['postCard.object.ts']).toContain('defineObject');
expect(appSources['test-function.function.ts']).toContain('defineFunction');
expect(appSources['default-function.role.ts']).toContain('defineRole');
expect(appSources['postCard.object-extension.ts']).toContain(
'extendObject',
);
// Check object extensions
expect(manifest.objectExtensions).toBeDefined();
expect(manifest.objectExtensions).toHaveLength(1);
const postCardExtension = manifest.objectExtensions![0];
expect(postCardExtension.targetObject.nameSingular).toBe('postCard');
expect(postCardExtension.fields).toHaveLength(2);
const priorityField = postCardExtension.fields.find(
(f) => f.name === 'priority',
);
expect(priorityField).toBeDefined();
expect(priorityField?.universalIdentifier).toBe(
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
);
expect(priorityField?.type).toBe('NUMBER');
expect(priorityField?.label).toBe('Priority');
expect(priorityField?.description).toBe(
'Priority level for the post card (1-10)',
);
const categoryField = postCardExtension.fields.find(
(f) => f.name === 'category',
);
expect(categoryField).toBeDefined();
expect(categoryField?.universalIdentifier).toBe(
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
);
expect(categoryField?.type).toBe('SELECT');
expect(categoryField?.label).toBe('Category');
expect((categoryField as any)?.options).toHaveLength(3);
expect(shouldGenerate).toBe(true);
@@ -0,0 +1,471 @@
import { validateManifest } from '../validate-manifest';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type Application,
type ObjectExtensionManifest,
} from 'twenty-shared/application';
describe('validateManifest - objectExtensions', () => {
const validApplication: Application = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Test App',
};
const validObjectExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
},
],
};
describe('valid object extensions', () => {
it('should pass validation with valid object extension by nameSingular', () => {
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [validObjectExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should pass validation with valid object extension by universalIdentifier', () => {
const extensionByUuid: ObjectExtensionManifest = {
targetObject: {
universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440002',
type: FieldMetadataType.TEXT,
name: 'customNote',
label: 'Custom Note',
},
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [extensionByUuid],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should pass validation with multiple object extensions', () => {
const anotherExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'person',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440003',
type: FieldMetadataType.TEXT,
name: 'nickname',
label: 'Nickname',
},
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [validObjectExtension, anotherExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should pass validation with SELECT field having options', () => {
const extensionWithSelect: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440004',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
options: [
{ value: 'active', label: 'Active', color: 'green', position: 0 },
{
value: 'inactive',
label: 'Inactive',
color: 'red',
position: 1,
},
],
},
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [extensionWithSelect],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
describe('targetObject validation', () => {
it('should fail when targetObject is missing', () => {
const invalidExtension = {
fields: validObjectExtension.fields,
} as ObjectExtensionManifest;
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Object extension must have a targetObject',
}),
);
});
it('should fail when targetObject has neither nameSingular nor universalIdentifier', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {} as any,
fields: validObjectExtension.fields,
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message:
'Object extension targetObject must have either nameSingular or universalIdentifier',
}),
);
});
it('should fail when targetObject has both nameSingular and universalIdentifier', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
} as any,
fields: validObjectExtension.fields,
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message:
'Object extension targetObject cannot have both nameSingular and universalIdentifier',
}),
);
});
});
describe('fields validation', () => {
it('should fail when fields array is empty', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Object extension must have at least one field',
}),
);
});
it('should fail when field is missing universalIdentifier', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Field must have a universalIdentifier',
}),
);
});
it('should fail when field is missing type', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
name: 'healthScore',
label: 'Health Score',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Field must have a type',
}),
);
});
it('should fail when field is missing label', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'Field must have a label',
}),
);
});
it('should fail when SELECT field has no options', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'SELECT/MULTI_SELECT field must have options',
}),
);
});
it('should fail when MULTI_SELECT field has empty options', () => {
const invalidExtension: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
label: 'Tags',
options: [],
} as any,
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [invalidExtension],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: 'SELECT/MULTI_SELECT field must have options',
}),
);
});
});
describe('duplicate universalIdentifier detection', () => {
it('should fail when extension field has duplicate universalIdentifier', () => {
const duplicateId = '550e8400-e29b-41d4-a716-446655440001';
const extensionWithDuplicates: ObjectExtensionManifest = {
targetObject: {
nameSingular: 'company',
},
fields: [
{
universalIdentifier: duplicateId,
type: FieldMetadataType.NUMBER,
name: 'field1',
label: 'Field 1',
},
{
universalIdentifier: duplicateId, // Same ID!
type: FieldMetadataType.TEXT,
name: 'field2',
label: 'Field 2',
},
],
};
const result = validateManifest({
application: validApplication,
objects: [],
objectExtensions: [extensionWithDuplicates],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: expect.stringContaining('Duplicate universalIdentifier'),
}),
);
});
it('should fail when extension field ID conflicts with object field ID', () => {
const sharedId = '550e8400-e29b-41d4-a716-446655440001';
const result = validateManifest({
application: validApplication,
objects: [
{
universalIdentifier: 'obj-uuid',
nameSingular: 'myObject',
namePlural: 'myObjects',
labelSingular: 'My Object',
labelPlural: 'My Objects',
fields: [
{
universalIdentifier: sharedId,
type: FieldMetadataType.TEXT,
name: 'existingField',
label: 'Existing Field',
},
],
},
],
objectExtensions: [
{
targetObject: { nameSingular: 'company' },
fields: [
{
universalIdentifier: sharedId, // Same as object field!
type: FieldMetadataType.NUMBER,
name: 'extensionField',
label: 'Extension Field',
},
],
},
],
serverlessFunctions: [],
roles: [],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
message: expect.stringContaining('Duplicate universalIdentifier'),
}),
);
});
});
});
@@ -4,6 +4,7 @@ import path, { posix, relative, sep } from 'path';
import {
type Application,
type ApplicationManifest,
type ObjectExtensionManifest,
type ObjectManifest,
type PackageJson,
type RoleManifest,
@@ -83,6 +84,35 @@ const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
return objects;
};
/**
* Load all object extension definitions from src/app/ (any *.object-extension.ts file).
*/
const loadObjectExtensions = async (
appPath: string,
): Promise<ObjectExtensionManifest[]> => {
const extensionFiles = await loadFiles(
['src/app/**/*.object-extension.ts'],
appPath,
);
const extensions: ObjectExtensionManifest[] = [];
for (const filepath of extensionFiles) {
try {
const manifest = await loadConfig<ObjectExtensionManifest>(filepath);
extensions.push(manifest);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return extensions;
};
/**
* Load all function definitions from src/app/ (any *.function.ts file).
*/
@@ -217,8 +247,11 @@ export type LoadManifestResult = {
/**
* Load an application manifest using the folder structure with jiti runtime evaluation.
*
* Files are detected by their suffix (*.object.ts, *.function.ts, *.role.ts)
* and can be placed anywhere within src/app/.
* Files are detected by their suffix and can be placed anywhere within src/app/:
* - *.object.ts - Custom object definitions
* - *.object-extension.ts - Extensions to existing objects (adding fields)
* - *.function.ts - Serverless function definitions
* - *.role.ts - Role definitions
*
* Example structures:
* ```
@@ -229,6 +262,8 @@ export type LoadManifestResult = {
* │ ├── application.config.ts
* │ ├── objects/
* │ │ └── postCard.object.ts
* │ ├── object-extensions/
* │ │ └── company.object-extension.ts
* │ ├── functions/
* │ │ └── createPostCard.function.ts
* │ └── roles/
@@ -241,6 +276,7 @@ export type LoadManifestResult = {
* │ ├── application.config.ts
* │ └── post-card/
* │ ├── postCard.object.ts
* │ ├── company.object-extension.ts
* │ ├── createPostCard.function.ts
* │ └── postCardAdmin.role.ts
* ```
@@ -270,19 +306,28 @@ export const loadManifest = async (
const application = await loadConfig<Application>(applicationConfigPath);
// Load all entities in parallel
const [objects, serverlessFunctions, roles, sources, shouldGenerate] =
await Promise.all([
loadObjects(appPath),
loadFunctions(appPath),
loadRoles(appPath),
loadSources(appPath),
checkShouldGenerate(appPath),
]);
const [
objects,
objectExtensions,
serverlessFunctions,
roles,
sources,
shouldGenerate,
] = await Promise.all([
loadObjects(appPath),
loadObjectExtensions(appPath),
loadFunctions(appPath),
loadRoles(appPath),
loadSources(appPath),
checkShouldGenerate(appPath),
]);
// Build manifest
const manifest: ApplicationManifest = {
application,
objects,
objectExtensions:
objectExtensions.length > 0 ? objectExtensions : undefined,
serverlessFunctions,
roles,
sources,
@@ -292,6 +337,7 @@ export const loadManifest = async (
const validation = validateManifest({
application,
objects,
objectExtensions,
serverlessFunctions,
roles,
});
@@ -1,6 +1,7 @@
import {
type ApplicationManifest,
type ServerlessFunctionManifest,
type ObjectExtensionManifest,
type ObjectManifest,
type RoleManifest,
type Application,
@@ -25,7 +26,9 @@ export type ValidationResult = {
export class ManifestValidationError extends Error {
constructor(public readonly errors: ValidationError[]) {
const messages = errors.map((e) => `${e.path}: ${e.message}`).join('\n');
const messages = errors
.map((e) => `${e.path}: ${e.message}`)
.join('\n');
super(`Manifest validation failed:\n${messages}`);
this.name = 'ManifestValidationError';
}
@@ -80,6 +83,22 @@ const collectAllIds = (
}
}
for (const ext of manifest.objectExtensions ?? []) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
// Extension fields
for (const field of ext.fields ?? []) {
if (field.universalIdentifier) {
ids.push({
id: field.universalIdentifier,
location: `object-extensions/${targetName}.fields.${field.label}`,
});
}
}
}
// Functions
for (const fn of manifest.serverlessFunctions ?? []) {
if (fn.universalIdentifier) {
@@ -214,7 +233,94 @@ const validateObjects = (
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!field.options || (field.options as unknown[]).length === 0)
(!Array.isArray(field.options) || field.options.length === 0)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
};
/**
* Validate object extensions and their fields.
*/
const validateObjectExtensions = (
extensions: ObjectExtensionManifest[],
errors: ValidationError[],
): void => {
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
const extPath = `object-extensions/${targetName}`;
if (!ext.targetObject) {
errors.push({
path: extPath,
message: 'Object extension must have a targetObject',
});
continue;
}
const { nameSingular, universalIdentifier } = ext.targetObject;
if (!nameSingular && !universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject must have either nameSingular or universalIdentifier',
});
}
if (nameSingular && universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject cannot have both nameSingular and universalIdentifier',
});
}
if (!ext.fields || ext.fields.length === 0) {
errors.push({
path: extPath,
message: 'Object extension must have at least one field',
});
}
// Validate fields
for (const field of ext.fields ?? []) {
const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`;
if (!field.universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
});
}
if (!field.type) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
});
}
if (!field.label) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
});
}
// Check SELECT/MULTI_SELECT fields have options
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
errors.push({
path: fieldPath,
@@ -340,6 +446,9 @@ export const validateManifest = (
// Validate objects
validateObjects(manifest.objects ?? [], errors);
// Validate object extensions
validateObjectExtensions(manifest.objectExtensions ?? [], errors);
// Validate functions
validateFunctions(manifest.serverlessFunctions ?? [], errors);
@@ -363,7 +472,10 @@ export const validateManifest = (
});
}
if (!manifest.serverlessFunctions || manifest.serverlessFunctions.length === 0) {
if (
!manifest.serverlessFunctions ||
manifest.serverlessFunctions.length === 0
) {
warnings.push({
message: 'No functions defined in src/app/functions/',
});
@@ -22,6 +22,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND:
throw new NotFoundError(exception);
case ApplicationExceptionCode.FORBIDDEN:
case ApplicationExceptionCode.INVALID_INPUT:
throw new UserInputError(exception);
default: {
assertUnreachable(exception.code);
@@ -5,6 +5,7 @@ import { parse } from 'path';
import {
ApplicationManifest,
FieldManifest,
ObjectExtensionManifest,
ObjectManifest,
RelationFieldManifest,
RoleManifest,
@@ -96,6 +97,14 @@ export class ApplicationSyncService {
applicationId: application.id,
});
if (manifest.objectExtensions && manifest.objectExtensions.length > 0) {
await this.syncObjectExtensionsOrThrow({
objectExtensionsToSync: manifest.objectExtensions,
workspaceId,
applicationId: application.id,
});
}
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(application.serverlessFunctionLayerId)) {
throw new ApplicationException(
@@ -772,6 +781,87 @@ export class ApplicationSyncService {
}
}
private async syncObjectExtensionsOrThrow({
objectExtensionsToSync,
workspaceId,
applicationId,
}: {
objectExtensionsToSync: ObjectExtensionManifest[];
workspaceId: string;
applicationId: string;
}) {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const { idByNameSingular: objectIdByNameSingular } =
buildObjectIdByNameMaps(flatObjectMetadataMaps);
for (const objectExtension of objectExtensionsToSync) {
const { targetObject, fields } = objectExtension;
let targetObjectId: string | undefined;
if (isDefined(targetObject.nameSingular)) {
targetObjectId = objectIdByNameSingular[targetObject.nameSingular];
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
`Failed to find target object with nameSingular "${targetObject.nameSingular}" for object extension`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
} else if (isDefined(targetObject.universalIdentifier)) {
targetObjectId =
flatObjectMetadataMaps.idByUniversalIdentifier[
targetObject.universalIdentifier
];
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
`Failed to find target object with universalIdentifier "${targetObject.universalIdentifier}" for object extension`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
}
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
'Object extension must specify either nameSingular or universalIdentifier in targetObject',
ApplicationExceptionCode.INVALID_INPUT,
);
}
// Sync regular fields for this extension
await this.syncFields({
objectId: targetObjectId,
fieldsToSync: fields,
workspaceId,
applicationId,
});
// Sync relation fields for this extension
const relationFields = fields.filter(
(field): field is RelationFieldManifest =>
this.isRelationFieldManifest(field),
);
if (relationFields.length > 0) {
await this.syncRelationFields({
objectId: targetObjectId,
relationsToSync: relationFields,
workspaceId,
applicationId,
flatObjectMetadataMaps,
});
}
}
}
private async syncServerlessFunctions({
serverlessFunctionsToSync,
code,
@@ -11,6 +11,7 @@ export enum ApplicationExceptionCode {
ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND',
APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
FORBIDDEN = 'FORBIDDEN',
INVALID_INPUT = 'INVALID_INPUT',
}
const getApplicationExceptionUserFriendlyMessage = (
@@ -29,6 +30,8 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`Application not found.`;
case ApplicationExceptionCode.FORBIDDEN:
return msg`You do not have permission to perform this action.`;
case ApplicationExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
default:
assertUnreachable(code);
}
@@ -3,12 +3,14 @@ import {
type ServerlessFunctionManifest,
type Application,
} from '@/application';
import { type Sources } from '@/types';
import { type ObjectExtensionManifest } from '@/application/objectExtensionManifestType';
import { type RoleManifest } from '@/application/roleManifestType';
import { type Sources } from '@/types';
export type ApplicationManifest = {
application: Application;
objects: ObjectManifest[];
objectExtensions?: ObjectExtensionManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
roles?: RoleManifest[];
sources: Sources;
@@ -13,6 +13,7 @@ export type { ApplicationVariables } from './applicationVariablesType';
export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName';
export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName';
export type { FieldManifest } from './fieldManifestType';
export type { ObjectExtensionManifest } from './objectExtensionManifestType';
export type { ObjectManifest } from './objectManifestType';
export type { PackageJson } from './packageJsonType';
export type { RelationFieldManifest } from './relationFieldManifestType';
@@ -0,0 +1,17 @@
import { type FieldManifest } from '@/application/fieldManifestType';
import { type RelationFieldManifest } from '@/application/relationFieldManifestType';
type TargetObjectByNameSingular = {
nameSingular: string;
universalIdentifier?: never;
};
type TargetObjectByUniversalIdentifier = {
nameSingular?: never;
universalIdentifier: string;
};
export type ObjectExtensionManifest = {
targetObject: TargetObjectByNameSingular | TargetObjectByUniversalIdentifier;
fields: (FieldManifest | RelationFieldManifest)[];
};
@@ -13,5 +13,5 @@ export type RelationFieldManifest = SyncableEntityOptions & {
targetFieldLabel: string;
targetFieldIcon?: string;
onDelete?: RelationOnDeleteAction;
type: FieldMetadataType;
type: FieldMetadataType.RELATION;
};
+1
View File
@@ -57532,6 +57532,7 @@ __metadata:
resolution: "twenty-sdk@workspace:packages/twenty-sdk"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@sniptt/guards": "npm:^0.2.0"
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/jest": "npm:^29.5.0"