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:
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+3
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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/',
|
||||
});
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+90
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user