40eef5c464
# Summary
- Introduces a new, flexible folder structure for Twenty SDK
applications using file suffix-based entity detection
- Adds defineApp, defineFunction, defineObject, and defineRole helper
functions with built-in validation
- Refactors manifest loading to use jiti runtime evaluation for
TypeScript config files
- Separates validation logic into dedicated module with comprehensive
error reporting
# New Application Folder Structure
Applications now use a convention-over-configuration approach where
entities are detected by their file suffix, allowing flexible
organization within the src/app/ folder.
# Required Structure
my-app/
├── package.json
├── yarn.lock
└── src/
├── app/
│ └── application.config.ts # Required - main application configuration
└── utils/ # Optional - handler implementations & utilities
# Entity Detection by File Suffix
- *.object.ts - Custom object definitions
- *.function.ts - Serverless function definitions
- *.role.ts - Role definitions
# Supported Folder Organizations
## Traditional (by type):
src/app/
├── application.config.ts
├── objects/
│ └── postCard.object.ts
├── functions/
│ └── createPostCard.function.ts
└── roles/
└── admin.role.ts
## Feature-based:
src/app/
├── application.config.ts
└── post-card/
├── postCard.object.ts
├── createPostCard.function.ts
└── postCardAdmin.role.ts
## Flat:
src/app/
├── application.config.ts
├── postCard.object.ts
├── createPostCard.function.ts
└── admin.role.ts
# New Helper Functions
## defineApp(config)
import { defineApp } from 'twenty-sdk';
export default defineApp({
universalIdentifier: '4ec0391d-...',
displayName: 'My App',
description: 'App description',
icon: 'IconWorld',
});
## defineObject(config)
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '54b589ca-...',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-...',
type: FieldType.TEXT,
name: 'content',
label: 'Content',
},
],
});
## defineFunction(config)
import { defineFunction } from 'twenty-sdk';
import { myHandler } from '../utils/my-handler';
export default defineFunction({
universalIdentifier: 'e56d363b-...',
name: 'My Function',
handler: myHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-...',
type: 'route',
path: '/my-route',
httpMethod: 'POST',
},
],
});
## defineRole(config)
import { defineRole, PermissionFlag } from 'twenty-sdk';
export default defineRole({
universalIdentifier: 'b648f87b-...',
label: 'App User',
objectPermissions: [
{
objectNameSingular: 'postCard',
canReadObjectRecords: true,
},
],
permissionFlags: [PermissionFlag.UPLOAD_FILE],
});
# Test plan
- Verify npx twenty app sync works with new folder structure
- Verify npx twenty app dev works with new folder structure
- Verify validation errors display correctly for invalid configs
- Verify all three folder organization styles work (traditional,
feature-based, flat)
- Run existing E2E tests to ensure backward compatibility
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
import { join } from 'path';
|
|
import {
|
|
loadManifest,
|
|
type LoadManifestResult,
|
|
} from '@/cli/utils/load-manifest';
|
|
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
|
|
|
|
const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app');
|
|
|
|
describe('loadManifest with test-app', () => {
|
|
let manifest: LoadManifestResult['manifest'];
|
|
let packageJson: LoadManifestResult['packageJson'];
|
|
let yarnLock: LoadManifestResult['yarnLock'];
|
|
let warnings: LoadManifestResult['warnings'];
|
|
let shouldGenerate: LoadManifestResult['shouldGenerate'];
|
|
|
|
beforeAll(async () => {
|
|
const result = await loadManifest(TEST_APP_PATH);
|
|
|
|
manifest = result.manifest;
|
|
packageJson = result.packageJson;
|
|
yarnLock = result.yarnLock;
|
|
warnings = result.warnings;
|
|
shouldGenerate = result.shouldGenerate;
|
|
});
|
|
|
|
it('should load manifest from test-app directory', async () => {
|
|
// Check package.json loaded correctly
|
|
expect(packageJson.name).toBe('test-app');
|
|
expect(packageJson.version).toBe('0.0.1');
|
|
|
|
// Check yarn.lock exists (can be empty)
|
|
expect(yarnLock).toBeDefined();
|
|
|
|
// Check no warnings
|
|
expect(warnings).toEqual([]);
|
|
|
|
// Check application config
|
|
expect(manifest.application).toBeDefined();
|
|
expect(manifest.application.universalIdentifier).toBe(
|
|
'4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
|
);
|
|
expect(manifest.application.displayName).toBe('Hello World');
|
|
expect(manifest.application.description).toBe('A simple hello world app');
|
|
expect(manifest.application.icon).toBe('IconWorld');
|
|
|
|
expect(manifest.objects).toHaveLength(1);
|
|
|
|
const postCard = manifest.objects[0];
|
|
expect(postCard.universalIdentifier).toBe(
|
|
'54b589ca-eeed-4950-a176-358418b85c05',
|
|
);
|
|
expect(postCard.nameSingular).toBe('postCard');
|
|
expect(postCard.namePlural).toBe('postCards');
|
|
expect(postCard.labelSingular).toBe('Post card');
|
|
expect(postCard.labelPlural).toBe('Post cards');
|
|
expect(postCard.icon).toBe('IconMail');
|
|
|
|
// Check fields
|
|
expect(postCard.fields).toHaveLength(5);
|
|
|
|
const contentField = postCard.fields?.find((f) => f.name === 'content');
|
|
expect(contentField).toBeDefined();
|
|
expect(contentField?.universalIdentifier).toBe(
|
|
'58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
|
);
|
|
expect(contentField?.type).toBe('TEXT');
|
|
expect(contentField?.label).toBe('Content');
|
|
|
|
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);
|
|
|
|
const testFunction = manifest.serverlessFunctions[1];
|
|
expect(testFunction.universalIdentifier).toBe(
|
|
'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
|
);
|
|
expect(testFunction.name).toBe('test-function');
|
|
expect(testFunction.timeoutSeconds).toBe(2);
|
|
expect(testFunction.handlerName).toBe('handler');
|
|
expect(testFunction.handlerPath).toBe('src/app/test-function.function.ts');
|
|
|
|
// Check triggers
|
|
expect(testFunction.triggers).toHaveLength(3);
|
|
|
|
const routeTrigger = testFunction.triggers.find((t) => t.type === 'route');
|
|
expect(routeTrigger).toBeDefined();
|
|
expect(routeTrigger?.universalIdentifier).toBe(
|
|
'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
|
);
|
|
expect(routeTrigger?.path).toBe('/post-card/create');
|
|
expect(routeTrigger?.httpMethod).toBe('GET');
|
|
|
|
const cronTrigger = testFunction.triggers.find((t) => t.type === 'cron');
|
|
expect(cronTrigger).toBeDefined();
|
|
expect(cronTrigger?.pattern).toBe('0 0 1 1 *');
|
|
|
|
const dbEventTrigger = testFunction.triggers.find(
|
|
(t) => t.type === 'databaseEvent',
|
|
);
|
|
expect(dbEventTrigger).toBeDefined();
|
|
expect(dbEventTrigger?.eventName).toBe('person.created');
|
|
|
|
// Second function
|
|
const testFunction2 = manifest.serverlessFunctions[0];
|
|
expect(testFunction2.universalIdentifier).toBe(
|
|
'eb3ffc98-88ec-45d4-9b4a-56833b219ccb',
|
|
);
|
|
expect(testFunction2.name).toBe('test-function-2');
|
|
expect(testFunction2.timeoutSeconds).toBe(2);
|
|
expect(testFunction2.handlerName).toBe('testFunction2');
|
|
expect(testFunction2.handlerPath).toBe('src/utils/test-function-2.util.ts');
|
|
|
|
expect(manifest.roles).toHaveLength(1);
|
|
|
|
const role = manifest.roles![0];
|
|
expect(role.universalIdentifier).toBe(
|
|
'b648f87b-1d26-4961-b974-0908fd991061',
|
|
);
|
|
expect(role.label).toBe('Default function role');
|
|
expect(role.description).toBe('Default role for function Twenty client');
|
|
expect(role.canReadAllObjectRecords).toBe(false);
|
|
expect(role.canUpdateAllObjectRecords).toBe(false);
|
|
|
|
// Check object permissions
|
|
expect(role.objectPermissions).toHaveLength(1);
|
|
expect(role.objectPermissions![0].objectNameSingular).toBe('postCard');
|
|
expect(role.objectPermissions![0].canReadObjectRecords).toBe(true);
|
|
expect(role.objectPermissions![0].canUpdateObjectRecords).toBe(true);
|
|
|
|
// Check field permissions
|
|
expect(role.fieldPermissions).toHaveLength(1);
|
|
expect(role.fieldPermissions![0].objectNameSingular).toBe('postCard');
|
|
expect(role.fieldPermissions![0].fieldName).toBe('content');
|
|
expect(role.fieldPermissions![0].canReadFieldValue).toBe(false);
|
|
|
|
expect(manifest.sources).toBeDefined();
|
|
expect(manifest.sources['src']).toBeDefined();
|
|
|
|
// Check that the source files are loaded
|
|
const srcSources = manifest.sources['src'] as Record<string, unknown>;
|
|
const appSources = srcSources['app'] as Record<string, string>;
|
|
expect(appSources['application.config.ts']).toBeDefined();
|
|
expect(appSources['postCard.object.ts']).toBeDefined();
|
|
expect(appSources['test-function.function.ts']).toBeDefined();
|
|
expect(appSources['default-function.role.ts']).toBeDefined();
|
|
|
|
// Verify source content contains expected code
|
|
expect(appSources['application.config.ts']).toContain('defineApp');
|
|
expect(appSources['postCard.object.ts']).toContain('defineObject');
|
|
expect(appSources['test-function.function.ts']).toContain('defineFunction');
|
|
expect(appSources['default-function.role.ts']).toContain('defineRole');
|
|
|
|
expect(shouldGenerate).toBe(false);
|
|
|
|
const expectedRoleId = DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER;
|
|
|
|
expect(manifest.application.functionRoleUniversalIdentifier).toBe(
|
|
expectedRoleId,
|
|
);
|
|
|
|
const linkedRole = manifest.roles?.find(
|
|
(r) => r.universalIdentifier === expectedRoleId,
|
|
);
|
|
expect(linkedRole).toBeDefined();
|
|
|
|
expect(manifest.application.applicationVariables).toBeDefined();
|
|
|
|
const defaultRecipient =
|
|
manifest.application.applicationVariables?.DEFAULT_RECIPIENT_NAME;
|
|
expect(defaultRecipient).toBeDefined();
|
|
expect(defaultRecipient?.universalIdentifier).toBe(
|
|
'19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
|
);
|
|
expect(defaultRecipient?.description).toBe(
|
|
'Default recipient name for postcards',
|
|
);
|
|
expect(defaultRecipient?.value).toBe('Alex Karp');
|
|
expect(defaultRecipient?.isSecret).toBe(false);
|
|
});
|
|
});
|