Add serverless function in twenty-cli (#14819)

- add serverlessFunction schema in twenty-cli
- add trigger schema in twenty-cli
- update serverless function code save and get
- sync serverless function
This commit is contained in:
martmull
2025-10-01 22:11:03 +02:00
committed by GitHub
parent 64dc726f50
commit 6fdd7894a1
47 changed files with 853 additions and 159 deletions
@@ -4,17 +4,21 @@
"title": "Twenty Agent Manifest",
"description": "Schema for Twenty AI agent configuration files",
"type": "object",
"required": ["standardId", "name", "label", "prompt", "modelId"],
"required": ["standardId", "universalIdentifier", "name", "label", "prompt", "modelId"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"standardId": {
"universalIdentifier": {
"type": "string",
"description": "Unique identifier for the agent (UUID format recommended)",
"description": "Unique identifier (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"standardId": {
"const": { "$data": "1/universalIdentifier" },
"description": "Should be the same as universalIdentifier"
},
"name": {
"type": "string",
"description": "Internal name for the agent (camelCase, used in code)",
@@ -1,18 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"title": "Twenty App Manifest",
"description": "Schema for Twenty application manifest files",
"type": "object",
"required": ["standardId", "label", "version", "license", "engines"],
"required": ["universalIdentifier", "label", "version", "license", "engines", "packageManager"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"standardId": {
"universalIdentifier": {
"type": "string",
"description": "Unique identifier for the application (UUID format recommended)",
"description": "Unique identifier (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"label": {
@@ -41,6 +41,10 @@
"title": "The application's engines",
"description": "Define engines here"
},
"packageManager": {
"const": "yarn@4.9.2",
"title": "Package manager of the application"
},
"version": {
"type": "string",
"description": "Semantic version of the application",
@@ -6,6 +6,7 @@
"type": "object",
"required": [
"standardId",
"universalIdentifier",
"nameSingular",
"namePlural",
"labelSingular",
@@ -16,11 +17,15 @@
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"standardId": {
"universalIdentifier": {
"type": "string",
"description": "Unique identifier for the object (UUID format recommended)",
"description": "Unique identifier (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"standardId": {
"const": { "$data": "1/universalIdentifier" },
"description": "Should be the same as universalIdentifier"
},
"nameSingular": {
"type": "string",
"description": "Name singular for the object",
@@ -0,0 +1,110 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"title": "Twenty Serverless Function Manifest",
"description": "Schema for Twenty AI serverless function configuration files",
"type": "object",
"required": [
"universalIdentifier",
"name"
],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"universalIdentifier": {
"type": "string",
"description": "Unique identifier (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"name": {
"type": "string",
"description": "Name singular for the serverless function",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
"minLength": 1,
"maxLength": 100
},
"description": {
"type": "string",
"description": "Brief description of the serverless function",
"maxLength": 500
},
"timeoutSeconds": {
"type": "number",
"description": "Serverless function timeout in seconds, between 1 and 900",
"min": 1,
"max": 900
},
"triggers": {
"type": "array",
"description": "Serverless function's triggers",
"items": {
"anyOf": [
{ "$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/trigger.schema.json" },
{
"type": "object",
"required": ["$ref"],
"properties": { "$ref": { "type": "string" } },
"additionalProperties": false
}
]
}
},
"code": {
"type": "object",
"description": "Serverless function's code",
"required": ["src"],
"properties": {
"src": {
"type": "object",
"description":"Serverless function source folder",
"required": ["index.ts"],
"properties": {
"index.ts": {
"type": "string",
"description":"Serverless function index.ts file"
},
"additionalProperties": true
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"examples": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440001",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440001",
"name": "My serverless function",
"triggers": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440002",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440002",
"type": "cron",
"schedule": "0 9 * * *"
},
{
"standardId": "550e8400-e29b-41d4-a716-446655440003",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440003",
"type": "databaseEvent",
"eventName": "company.created"
},
{
"standardId": "550e8400-e29b-41d4-a716-446655440004",
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440004",
"type": "route",
"path": "test-route",
"httpMethod": "GET",
"isAuthRequired": false
}
],
"code": {
"src": {
"index.ts": "{\n \"code\": \"import axios from 'axios';\\n\\nexport const main = async (params) => {\\n const { a, b } = params;\\n const message = \\\"toto\\\";\\n return { message };\\n};\",\n \"params\": { \"a\": \"1\", \"b\": 2 }\n}\n"
}
}
}
]
}
@@ -0,0 +1,43 @@
{
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/trigger.schema.json",
"type": "object",
"required": ["universalIdentifier", "type"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"universalIdentifier": {
"type": "string",
"description": "Unique identifier (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"type": { "enum": ["cron", "databaseEvent", "route"] },
"schedule": { "type": "string" },
"eventName": { "type": "string" },
"path": { "type": "string" },
"httpMethod": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"isAuthRequired": { "type": "boolean" }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "cron" } } },
"then": { "required": ["schedule"] }
},
{
"if": { "properties": { "type": { "const": "databaseEvent" } } },
"then": { "required": ["eventName"] }
},
{
"if": { "properties": { "type": { "const": "route" } } },
"then": { "required": ["path", "httpMethod", "isAuthRequired"] }
}
]
}
@@ -7,14 +7,20 @@ import { resolveAppPath } from '../utils/app-path-resolver';
import { writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
type SyncableEntity = 'agent' | 'object';
enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
SERVERLESS_FUNCTION = 'serverlessFunction',
}
const getFolderName = (entity: SyncableEntity) => {
switch (entity) {
case 'agent':
case SyncableEntity.AGENT:
return 'agents';
case 'object':
case SyncableEntity.OBJECT:
return 'objects';
case SyncableEntity.SERVERLESS_FUNCTION:
return 'serverlessFunctions';
default:
throw new Error(`Unknown entity type: ${entity}`);
}
@@ -40,13 +46,16 @@ export class AppAddCommand {
const folderName = getFolderName(entity);
const entitiesDir = path.join(appPath, folderName);
const entitiesDir = path.join(appPath, folderName, entityName);
await fs.ensureDir(entitiesDir);
const entityPath = path.join(entitiesDir, `${entityName}.jsonc`);
await writeJsoncFile(
path.join(entitiesDir, `${entity}.manifest.jsonc`),
entityData,
);
await writeJsoncFile(entityPath, entityData);
await this.addEntityInitFiles(entity, entitiesDir);
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
@@ -56,13 +65,39 @@ export class AppAddCommand {
}
}
private async addEntityInitFiles(entity: SyncableEntity, entityPath: string) {
switch (entity) {
case SyncableEntity.SERVERLESS_FUNCTION: {
const srcPath = path.join(entityPath, 'src');
await fs.ensureDir(srcPath);
await fs.writeFile(
path.join(srcPath, 'index.ts'),
'export const main = async (params: {\n a: string;\n b: number;\n}): Promise<object> => {\n const { a, b } = params;\n\n // Rename the parameters and code below with your own logic\n // This is just an example\n const message = `Hello, input: ${a} and ${b}`;\n\n\n\n return { message };\n};',
);
return;
}
case SyncableEntity.AGENT:
case SyncableEntity.OBJECT:
return;
default:
throw new Error(`Unknown entity type: ${entity}`);
}
}
private async getEntity() {
const { entity } = await inquirer.prompt([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
choices: ['agent', 'object'],
default: '',
choices: [
SyncableEntity.AGENT,
SyncableEntity.OBJECT,
SyncableEntity.SERVERLESS_FUNCTION,
],
},
]);
@@ -90,7 +125,7 @@ export class AppAddCommand {
},
]);
return name;
return name as string;
}
private async getEntityToCreateData(
@@ -99,11 +134,17 @@ export class AppAddCommand {
) {
const schemas = getSchemaUrls();
const uuid = v4();
const entityToCreateData: Record<string, string> = {
$schema: schemas[entity],
standardId: v4(),
universalIdentifier: uuid,
};
if (entity === SyncableEntity.OBJECT || entity === SyncableEntity.AGENT) {
entityToCreateData.standardId = uuid;
}
const schemasDir = path.join(__dirname, '../../schemas');
const schemaPath = path.join(schemasDir, `${entity}.schema.json`);
+3 -1
View File
@@ -1,6 +1,8 @@
const SCHEMA_BASE_URL =
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas';
export const APP_MANIFEST_SCHEMA_URL = `${SCHEMA_BASE_URL}/app-manifest.schema.json`;
export const APP_MANIFEST_SCHEMA_URL = `${SCHEMA_BASE_URL}/appManifest.schema.json`;
export const AGENT_SCHEMA_URL = `${SCHEMA_BASE_URL}/agent.schema.json`;
export const OBJECT_SCHEMA_URL = `${SCHEMA_BASE_URL}/object.schema.json`;
export const TRIGGER_SCHEMA_URL = `${SCHEMA_BASE_URL}/trigger.schema.json`;
export const SERVERLESS_FUNCTION_SCHEMA_URL = `${SCHEMA_BASE_URL}/serverlessFunction.schema.json`;
+41 -2
View File
@@ -6,7 +6,7 @@ export interface TwentyConfig {
export type PackageJson = {
$schema?: string;
standardId: string;
universalIdentifier: string;
label: string;
license: string;
description?: string;
@@ -15,6 +15,7 @@ export type PackageJson = {
npm: string;
yarn: string;
};
packageManager: string;
icon?: string;
version: string;
dependencies?: object;
@@ -24,9 +25,47 @@ export type PackageJson = {
export type AppManifest = PackageJson & {
agents: AgentManifest[];
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
};
export type CoreEntityManifest = AgentManifest | ObjectManifest;
export type CoreEntityManifest =
| AgentManifest
| ObjectManifest
| ServerlessFunctionManifest;
export type ServerlessFunctionManifest = {
$schema?: string;
universalIdentifier: string;
name: string;
description?: string;
timeoutSeconds?: number;
triggers: ServerlessFunctionTriggerManifest[];
code: ServerlessFunctionCodeManifest;
};
export type ServerlessFunctionTriggerManifest =
| {
type: 'cron';
schedule: string;
}
| {
type: 'databaseEvent';
eventName: string;
}
| {
type: 'route';
path: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
isAuthRequired: boolean;
};
type Sources = { [key: string]: string | Sources };
export type ServerlessFunctionCodeManifest = {
src: {
'index.ts': string;
} & Sources;
};
export type ObjectManifest = {
$schema?: string;
@@ -22,7 +22,7 @@ describe('app-template', () => {
expect(basePackageJson).toEqual({
$schema: APP_MANIFEST_SCHEMA_URL,
standardId: 'mocked-uuid-12345',
universalIdentifier: 'mocked-uuid-12345',
label: 'My Test App',
description: 'A Twenty application for my-test-app',
version: '0.0.1',
@@ -31,6 +31,7 @@ describe('app-template', () => {
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
license: 'MIT',
});
});
@@ -40,7 +41,7 @@ describe('app-template', () => {
const basePackageJson = createBasePackageJson(appName, '');
expect(basePackageJson.label).toBe('Calculator');
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
expect(basePackageJson.universalIdentifier).toBe('mocked-uuid-12345');
});
it('should handle kebab-case app names correctly', () => {
@@ -48,14 +49,14 @@ describe('app-template', () => {
const basePackageJson = createBasePackageJson(appName, '');
expect(basePackageJson.label).toBe('User Management System');
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
expect(basePackageJson.universalIdentifier).toBe('mocked-uuid-12345');
});
it('should generate unique standardIds', () => {
it('should generate unique universalIdentifiers', () => {
const basePackageJson = createBasePackageJson('test-app', '');
expect(basePackageJson.standardId).toBeDefined();
expect(typeof basePackageJson.standardId).toBe('string');
expect(basePackageJson.universalIdentifier).toBeDefined();
expect(typeof basePackageJson.universalIdentifier).toBe('string');
});
});
@@ -1,3 +1,4 @@
import assert from 'assert';
import * as fs from 'fs-extra';
import * as path from 'path';
import {
@@ -8,6 +9,8 @@ import {
import { parseJsoncFile } from './jsonc-parser';
import { validateSchema } from '../utils/schema-validator';
type Sources = { [key: string]: string | Sources };
const findPathFile = async (
appPath: string,
fileName: string,
@@ -28,16 +31,49 @@ const loadCoreEntity = async (
const coreEntities: CoreEntityManifest[] = [];
if (await fs.pathExists(coreEntityPath)) {
const files = await fs.readdir(coreEntityPath);
const coreEntityFileNames = files.filter(
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
);
const entities = await fs.readdir(coreEntityPath);
for (const fileName of coreEntityFileNames) {
const coreEntityManifest = await parseJsoncFile(
path.join(coreEntityPath, fileName),
for (const entity of entities) {
const entityPath = path.join(coreEntityPath, entity);
const entityResources = await fs.readdir(entityPath);
const entityManifests = entityResources.filter(
(file) =>
file.endsWith('.manifest.jsonc') || file.endsWith('.manifest.json'),
);
assert(
entityManifests.length === 1,
'Entity should have strictly one manifest file',
);
const entityManifest = entityManifests[0];
const coreEntityManifest = await parseJsoncFile(
path.join(coreEntityPath, entity, entityManifest),
);
const entitySources = entityResources.filter(
(folder) => folder === 'src',
);
assert(
entitySources.length <= 1,
'Entity should have less than one src folder or file',
);
if (entitySources.length === 1) {
const entitySourcePath = path.join(
coreEntityPath,
entity,
entitySources[0],
);
const sources = await loadFolderContentIntoJson(entitySourcePath);
coreEntityManifest['code'] = { src: sources };
}
await validator(coreEntityManifest, coreEntityPath);
coreEntities.push(coreEntityManifest);
@@ -47,6 +83,26 @@ const loadCoreEntity = async (
return coreEntities;
};
const loadFolderContentIntoJson = async (
sourcePath: string,
): Promise<Sources> => {
const sources: Sources = {};
const resources = await fs.readdir(sourcePath);
for (const resource of resources) {
const resourcePath = path.join(sourcePath, resource);
const stats = await fs.stat(resourcePath);
if (stats.isFile()) {
sources[resource] = await fs.readFile(resourcePath, 'utf8');
} else {
sources[resource] = await loadFolderContentIntoJson(resourcePath);
}
}
return sources;
};
export const loadManifest = async (
appPath: string,
): Promise<{
@@ -60,7 +116,7 @@ export const loadManifest = async (
const yarnLockPath = await findPathFile(appPath, 'yarn.lock');
const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8');
await validateSchema('app-manifest', rawPackageJson, packageJsonPath);
await validateSchema('appManifest', rawPackageJson, packageJsonPath);
const agents = await loadCoreEntity(
path.join(appPath, 'agents'),
@@ -72,6 +128,11 @@ export const loadManifest = async (
(manifest, path) => validateSchema('object', manifest, path),
);
const serverlessFunctions = await loadCoreEntity(
path.join(appPath, 'serverlessFunctions'),
(manifest, path) => validateSchema('serverlessFunction', manifest, path),
);
return {
packageJson: rawPackageJson,
yarnLock: rawYarnLock,
@@ -79,6 +140,7 @@ export const loadManifest = async (
...rawPackageJson,
agents,
objects,
serverlessFunctions,
},
};
};
@@ -10,7 +10,7 @@ export const createBasePackageJson = (
return {
$schema: schemas.appManifest,
standardId: randomUUID(),
universalIdentifier: randomUUID(),
label: appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@@ -20,6 +20,7 @@ export const createBasePackageJson = (
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
description,
license: 'MIT',
version: '0.0.1',
@@ -5,6 +5,8 @@ import {
AGENT_SCHEMA_URL,
APP_MANIFEST_SCHEMA_URL,
OBJECT_SCHEMA_URL,
SERVERLESS_FUNCTION_SCHEMA_URL,
TRIGGER_SCHEMA_URL,
} from '../constants/schemas';
export class SchemaValidationError extends Error {
@@ -31,7 +33,7 @@ const formatErrors = (errors: any[]): string => {
};
export const validateSchema = async (
schemaName: 'app-manifest' | 'agent' | 'object',
schemaName: 'appManifest' | 'agent' | 'object' | 'serverlessFunction',
manifest: any,
filePath?: string,
): Promise<void> => {
@@ -39,18 +41,19 @@ export const validateSchema = async (
allErrors: true,
verbose: true,
strict: false,
$data: true,
});
const schemaUrls = getSchemaUrls();
let schema;
for (const name of Object.keys(schemaUrls) as (keyof typeof schemaUrls)[]) {
const formattedName = name === 'appManifest' ? 'app-manifest' : name;
const schemasDir = path.join(__dirname, '../../schemas');
const schemaPath = path.join(schemasDir, `${formattedName}.schema.json`);
const schemaPath = path.join(schemasDir, `${name}.schema.json`);
ajv.addSchema(await fs.readJson(schemaPath));
if (formattedName === schemaName) {
if (name === schemaName) {
schema = ajv.getSchema(schemaUrls[name])?.schema;
}
}
@@ -71,8 +74,10 @@ export const validateSchema = async (
export const getSchemaUrls = () => {
return {
trigger: TRIGGER_SCHEMA_URL,
agent: AGENT_SCHEMA_URL,
object: OBJECT_SCHEMA_URL,
serverlessFunction: SERVERLESS_FUNCTION_SCHEMA_URL,
appManifest: APP_MANIFEST_SCHEMA_URL,
};
};
@@ -837,6 +837,7 @@ export type CreateRoleInput = {
};
export type CreateServerlessFunctionInput = {
code?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
timeoutSeconds?: InputMaybe<Scalars['Float']>;
@@ -791,6 +791,7 @@ export type CreateRoleInput = {
};
export type CreateServerlessFunctionInput = {
code?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
timeoutSeconds?: InputMaybe<Scalars['Float']>;
@@ -0,0 +1 @@
export const INDEX_FILE_NAME = 'index.ts';
@@ -1 +0,0 @@
export const INDEX_FILE_PATH = 'src/index.ts';
@@ -0,0 +1 @@
export const SOURCE_FOLDER_NAME = 'src';
@@ -32,7 +32,7 @@ describe('useServerlessFunctionUpdateFormState', () => {
);
useGetOneServerlessFunctionSourceCodeMock.useGetOneServerlessFunctionSourceCode.mockReturnValue(
{
code: 'export const handler = () => {}',
code: { src: { 'index.ts': 'export const handler = () => {}' } },
},
);
const { result } = renderHook(
@@ -44,6 +44,10 @@ describe('useServerlessFunctionUpdateFormState', () => {
const { formValues } = result.current;
expect(formValues).toEqual({ name: '', description: '', code: undefined });
expect(formValues).toEqual({
name: '',
description: '',
code: { src: { 'index.ts': '' } },
});
});
});
@@ -1,4 +1,4 @@
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
@@ -6,6 +6,7 @@ import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useRecoilState } from 'recoil';
import { type FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
export type ServerlessFunctionNewFormValues = {
name: string;
@@ -13,7 +14,12 @@ export type ServerlessFunctionNewFormValues = {
};
export type ServerlessFunctionFormValues = ServerlessFunctionNewFormValues & {
code: { [filePath: string]: string } | undefined;
code: {
src: {
'index.ts': string;
} & { [key: string]: string };
'.env'?: string;
};
};
type SetServerlessFunctionFormValues = Dispatch<
@@ -34,7 +40,7 @@ export const useServerlessFunctionUpdateFormState = ({
const [formValues, setFormValues] = useState<ServerlessFunctionFormValues>({
name: '',
description: '',
code: undefined,
code: { src: { 'index.ts': '' } },
});
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
@@ -61,7 +67,9 @@ export const useServerlessFunctionUpdateFormState = ({
if (serverlessFunctionTestData.shouldInitInput) {
const sourceCode =
data?.getServerlessFunctionSourceCode?.[INDEX_FILE_PATH];
data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[
INDEX_FILE_NAME
];
const functionInput = await getFunctionInputFromSourceCode(sourceCode);
@@ -12,7 +12,7 @@ import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code-
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
@@ -50,6 +50,7 @@ import { IconCode, IconPlayerPlay, useIcons } from 'twenty-ui/display';
import { CodeEditor } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { useDebouncedCallback } from 'use-debounce';
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
const CODE_EDITOR_MIN_HEIGHT = 343;
@@ -159,7 +160,12 @@ export const WorkflowEditActionServerlessFunction = ({
}
setFormValues((prevState) => ({
...prevState,
code: { ...prevState.code, [INDEX_FILE_PATH]: newCode },
code: {
...prevState.code,
[SOURCE_FOLDER_NAME]: {
[INDEX_FILE_NAME]: newCode,
},
},
}));
await handleSave();
await handleUpdateFunctionInputSchema(newCode);
@@ -381,7 +387,7 @@ export const WorkflowEditActionServerlessFunction = ({
<StyledFullScreenCodeEditorContainer>
<CodeEditor
height="100%"
value={formValues.code?.[INDEX_FILE_PATH]}
value={formValues.code?.[SOURCE_FOLDER_NAME]?.[INDEX_FILE_NAME]}
language="typescript"
onChange={handleCodeChange}
onMount={handleEditorDidMount}
@@ -428,7 +434,7 @@ export const WorkflowEditActionServerlessFunction = ({
readonly={actionOptions.readonly}
/>
<WorkflowServerlessFunctionCodeEditor
value={formValues.code?.[INDEX_FILE_PATH]}
value={formValues.code?.[SOURCE_FOLDER_NAME]?.[INDEX_FILE_NAME]}
onChange={handleCodeChange}
onMount={handleEditorDidMount}
options={{
@@ -3,7 +3,7 @@ import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-func
import { type WorkflowCodeAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunctionFields';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWrongExportedFunctionMarkers';
@@ -17,6 +17,7 @@ import { AutoTypings } from 'monaco-editor-auto-typings';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { CodeEditor } from 'twenty-ui/input';
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
const StyledCodeEditorContainer = styled.div`
display: flex;
@@ -85,7 +86,7 @@ export const WorkflowReadonlyActionServerlessFunction = ({
<StyledCodeEditorContainer>
<CodeEditor
height={343}
value={formValues.code?.[INDEX_FILE_PATH]}
value={formValues.code?.[SOURCE_FOLDER_NAME]?.[INDEX_FILE_NAME]}
language="typescript"
onMount={handleEditorDidMount}
setMarkers={getWrongExportedFunctionMarkers}
@@ -21,6 +21,7 @@ import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail';
@@ -124,15 +125,20 @@ export const SettingsServerlessFunctionDetail = () => {
];
const files = formValues.code
? Object.keys(formValues.code)
.map((key) => {
? [
{
path: '.env',
language: 'ini',
content: formValues.code?.['.env'] || '',
},
...Object.keys(formValues.code?.[SOURCE_FOLDER_NAME]).map((key) => {
return {
path: key,
language: key === '.env' ? 'ini' : 'typescript',
content: formValues.code?.[key] || '',
language: 'typescript',
content: formValues.code?.[SOURCE_FOLDER_NAME]?.[key] || '',
};
})
.reverse()
}),
].reverse()
: [];
const renderActiveTabContent = () => {
@@ -2,22 +2,26 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
AgentManifest,
ObjectManifest,
ServerlessFunctionManifest,
} from 'src/engine/core-modules/application/types/application.types';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service.';
import type { FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import {
AgentManifest,
ObjectManifest,
} from 'src/engine/core-modules/application/types/application.types';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service.';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import type { FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class ApplicationSyncService {
@@ -27,6 +31,7 @@ export class ApplicationSyncService {
private readonly applicationService: ApplicationService,
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly dataSourceService: DataSourceService,
private readonly agentService: AgentService,
@@ -40,7 +45,7 @@ export class ApplicationSyncService {
}: ApplicationInput & {
workspaceId: string;
}) {
const applicationId = await this.syncApplication({
const application = await this.syncApplication({
workspaceId,
manifest,
packageJson,
@@ -50,13 +55,20 @@ export class ApplicationSyncService {
await this.syncAgents({
agentsToSync: manifest.agents,
workspaceId,
applicationId,
applicationId: application.id,
});
await this.syncObjects({
objectsToSync: manifest.objects,
workspaceId,
applicationId,
applicationId: application.id,
});
await this.syncServerlessFunctions({
serverlessFunctionsToSync: manifest.serverlessFunctions,
workspaceId,
applicationId: application.id,
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
});
this.logger.log('✅ Application sync from manifest completed');
@@ -69,9 +81,9 @@ export class ApplicationSyncService {
yarnLock,
}: ApplicationInput & {
workspaceId: string;
}): Promise<string> {
}): Promise<ApplicationEntity> {
const application = await this.applicationService.findByUniversalIdentifier(
manifest.standardId,
manifest.universalIdentifier,
workspaceId,
);
@@ -84,8 +96,9 @@ export class ApplicationSyncService {
},
workspaceId,
);
const createdApplication = await this.applicationService.create({
universalIdentifier: manifest.standardId,
return await this.applicationService.create({
universalIdentifier: manifest.universalIdentifier,
label: manifest.label,
description: manifest.description,
version: manifest.version,
@@ -93,8 +106,6 @@ export class ApplicationSyncService {
serverlessFunctionLayerId: serverlessFunctionLayer.id,
workspaceId,
});
return createdApplication.id;
}
await this.serverlessFunctionLayerService.update(
@@ -111,7 +122,7 @@ export class ApplicationSyncService {
version: manifest.version,
});
return application.id;
return application;
}
private async syncAgents({
@@ -265,4 +276,116 @@ export class ApplicationSyncService {
});
}
}
private async syncServerlessFunctions({
serverlessFunctionsToSync,
workspaceId,
applicationId,
serverlessFunctionLayerId,
}: {
serverlessFunctionsToSync: ServerlessFunctionManifest[];
workspaceId: string;
applicationId: string;
serverlessFunctionLayerId: string;
}) {
const { flatServerlessFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatEntities: ['flatServerlessFunctionMaps'],
},
);
const applicationServerlessFunctions = Object.values(
flatServerlessFunctionMaps.byId,
).filter(
(serverlessFunction) =>
isDefined(serverlessFunction) &&
serverlessFunction.applicationId === applicationId,
) as FlatServerlessFunction[];
const serverlessFunctionsToSyncUniversalIdentifiers =
serverlessFunctionsToSync.map(
(serverlessFunction) => serverlessFunction.universalIdentifier,
);
const applicationServerlessFunctionsUniversalIdentifiers =
applicationServerlessFunctions.map(
(serverlessFunction) => serverlessFunction.universalIdentifier,
);
const serverlessFunctionsToDelete = applicationServerlessFunctions.filter(
(serverlessFunction) =>
isDefined(serverlessFunction.universalIdentifier) &&
!serverlessFunctionsToSyncUniversalIdentifiers.includes(
serverlessFunction.universalIdentifier,
),
);
const serverlessFunctionsToUpdate = applicationServerlessFunctions.filter(
(serverlessFunction) =>
isDefined(serverlessFunction.universalIdentifier) &&
serverlessFunctionsToSyncUniversalIdentifiers.includes(
serverlessFunction.universalIdentifier,
),
);
const serverlessFunctionsToCreate = serverlessFunctionsToSync.filter(
(serverlessFunctionToSync) =>
!applicationServerlessFunctionsUniversalIdentifiers.includes(
serverlessFunctionToSync.universalIdentifier,
),
);
for (const serverlessFunctionToDelete of serverlessFunctionsToDelete) {
await this.serverlessFunctionV2Service.destroyOne({
destroyServerlessFunctionInput: { id: serverlessFunctionToDelete.id },
workspaceId,
isSystemBuild: true,
});
}
for (const serverlessFunctionToUpdate of serverlessFunctionsToUpdate) {
const serverlessFunctionToSync = serverlessFunctionsToSync.find(
(serverlessFunction) =>
serverlessFunction.universalIdentifier ===
serverlessFunctionToUpdate.universalIdentifier,
);
if (!serverlessFunctionToSync) {
throw new ApplicationException(
`Failed to find serverlessFunction to sync with universalIdentifier ${serverlessFunctionToUpdate.universalIdentifier}`,
ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
const updateServerlessFunctionInput = {
id: serverlessFunctionToUpdate.id,
name: serverlessFunctionToSync.name,
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
code: serverlessFunctionToSync.code,
};
await this.serverlessFunctionV2Service.updateOne(
updateServerlessFunctionInput,
workspaceId,
);
}
for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) {
const createServerlessFunctionInput = {
name: serverlessFunctionToCreate.name,
code: serverlessFunctionToCreate.code,
universalIdentifier: serverlessFunctionToCreate.universalIdentifier,
timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds,
applicationId,
serverlessFunctionLayerId,
};
await this.serverlessFunctionV2Service.createOne(
createServerlessFunctionInput,
workspaceId,
);
}
}
}
@@ -4,4 +4,5 @@ export class ApplicationException extends CustomException<ApplicationExceptionCo
export enum ApplicationExceptionCode {
OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND',
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
}
@@ -12,6 +12,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
@Module({
imports: [
@@ -21,6 +22,7 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
DataSourceModule,
AgentModule,
ServerlessFunctionLayerModule,
ServerlessFunctionModule,
],
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
})
@@ -1,6 +1,8 @@
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
export type PackageJson = {
$schema?: string;
standardId: string;
universalIdentifier: string;
label: string;
description?: string;
engines: {
@@ -17,8 +19,35 @@ export type PackageJson = {
export type AppManifest = PackageJson & {
agents: AgentManifest[];
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
};
export type ServerlessFunctionManifest = {
$schema?: string;
universalIdentifier: string;
name: string;
description?: string;
timeoutSeconds?: number;
triggers: ServerlessFunctionTriggerManifest[];
code: ServerlessFunctionCode;
};
export type ServerlessFunctionTriggerManifest =
| {
type: 'cron';
schedule: string;
}
| {
type: 'databaseEvent';
eventName: string;
}
| {
type: 'route';
path: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
isAuthRequired: boolean;
};
export type ObjectManifest = {
$schema?: string;
standardId: string;
@@ -1,14 +1,18 @@
import { type Readable } from 'stream';
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
export interface StorageDriver {
delete(params: { folderPath: string; filename?: string }): Promise<void>;
read(params: { folderPath: string; filename: string }): Promise<Readable>;
readFolder(folderPath: string): Promise<Sources>;
write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void>;
writeFolder(sources: Sources, folderPath: string): Promise<void>;
move(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
@@ -21,6 +25,7 @@ export interface StorageDriver {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
}): Promise<void>;
checkFileExists(params: {
folderPath: string;
filename: string;
@@ -1,14 +1,18 @@
import { createReadStream, existsSync } from 'fs';
import * as fs from 'fs/promises';
import { dirname, join } from 'path';
import path, { dirname, join } from 'path';
import { type Readable } from 'stream';
import { isObject } from '@sniptt/guards';
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
export interface LocalDriverOptions {
storagePath: string;
}
@@ -42,6 +46,21 @@ export class LocalDriver implements StorageDriver {
await fs.writeFile(filePath, params.file);
}
async writeFolder(sources: Sources, folderPath: string) {
for (const key of Object.keys(sources)) {
if (isObject(sources[key])) {
await this.writeFolder(sources[key], join(folderPath, key));
continue;
}
await this.write({
file: sources[key],
name: key,
mimeType: undefined,
folder: folderPath,
});
}
}
async delete(params: {
folderPath: string;
filename?: string;
@@ -86,6 +105,30 @@ export class LocalDriver implements StorageDriver {
}
}
async readFolder(folderPath: string): Promise<Sources> {
const sources: Sources = {};
const rootFolderPath = join(`${this.options.storagePath}/`, folderPath);
const resources = await fs.readdir(rootFolderPath);
for (const resource of resources) {
const resourcePath = path.join(rootFolderPath, resource);
const stats = await fs.stat(resourcePath);
if (stats.isFile()) {
sources[resource] = await fs.readFile(resourcePath, 'utf8');
} else {
sources[resource] = await this.readFolder(
path.join(folderPath, resource),
);
}
}
return sources;
}
async move(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
@@ -21,6 +21,7 @@ import {
type S3ClientConfig,
} from '@aws-sdk/client-s3';
import { isDefined } from 'twenty-shared/utils';
import { isObject } from '@sniptt/guards';
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
import {
@@ -28,6 +29,10 @@ import {
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import type { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content';
import { readS3FolderContent } from 'src/engine/core-modules/file-storage/utils/read-s3-folder-content';
export interface S3DriverOptions extends S3ClientConfig {
bucketName: string;
endpoint?: string;
@@ -70,6 +75,21 @@ export class S3Driver implements StorageDriver {
await this.s3Client.send(command);
}
async writeFolder(sources: Sources, folderPath: string) {
for (const key of Object.keys(sources)) {
if (isObject(sources[key])) {
await this.writeFolder(sources[key], join(folderPath, key));
continue;
}
await this.write({
file: sources[key],
name: key,
mimeType: undefined,
folder: folderPath,
});
}
}
private async fetchS3FolderContents(folderPath: string) {
const listParams = {
Bucket: this.bucketName,
@@ -177,6 +197,46 @@ export class S3Driver implements StorageDriver {
}
}
async readFolder(folderPath: string): Promise<Sources> {
const sources: Sources = {};
const listedObjects = await this.fetchS3FolderContents(folderPath);
if (!listedObjects.Contents || listedObjects.Contents.length === 0) {
return sources;
}
const files = (
await Promise.all(
listedObjects.Contents.map(async (object) => {
if (!object.Key) {
return;
}
const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key);
if (!isDefined(folderAndFilePaths)) {
return;
}
const { fromFolderPath, filename } = folderAndFilePaths;
const fileContent = await readFileContent(
await this.read({ folderPath: fromFolderPath, filename }),
);
const formattedObjectKey = object.Key.replace(
folderPath + '/',
'',
).replace(folderPath, '');
return { path: formattedObjectKey, fileContent };
}),
)
).filter(isDefined);
return readS3FolderContent(files);
}
async move(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
@@ -5,6 +5,7 @@ import { type Readable } from 'stream';
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
@Injectable()
export class FileStorageService implements StorageDriver {
@@ -23,12 +24,24 @@ export class FileStorageService implements StorageDriver {
return driver.write(params);
}
writeFolder(sources: Sources, folderPath: string): Promise<void> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.writeFolder(sources, folderPath);
}
read(params: { folderPath: string; filename: string }): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.read(params);
}
readFolder(folderPath: string): Promise<Sources> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.readFolder(folderPath);
}
delete(params: { folderPath: string; filename?: string }): Promise<void> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
@@ -0,0 +1 @@
export type Sources = { [key: string]: string | Sources };
@@ -0,0 +1,25 @@
import { readS3FolderContent } from 'src/engine/core-modules/file-storage/utils/read-s3-folder-content';
describe('read-s3-folder-content', () => {
it('should format files to sources properly', () => {
const files = [
{ path: 'f1/file1.ts', fileContent: 'content1' },
{ path: 'f1/f11/file11.ts', fileContent: 'content11' },
{ path: 'f1/file2.ts', fileContent: 'content2' },
{ path: 'file3.ts', fileContent: 'content3' },
];
const expectedResult = {
'file3.ts': 'content3',
f1: {
'file1.ts': 'content1',
'file2.ts': 'content2',
f11: { 'file11.ts': 'content11' },
},
};
const result = readS3FolderContent(files);
expect(result).toEqual(expectedResult);
});
});
@@ -0,0 +1,32 @@
import { isDefined } from 'twenty-shared/utils';
import { isObject } from '@sniptt/guards';
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
export const readS3FolderContent = (
files: { path: string; fileContent: string }[],
) => {
const result: Sources = {};
for (const { path, fileContent } of files) {
const segments = path.split('/');
const fileName = segments.pop();
if (!isDefined(fileName)) {
continue;
}
let cursor: Sources = result;
for (const segment of segments) {
if (!isObject(cursor[segment])) {
cursor[segment] = {};
}
cursor = cursor[segment] as Sources;
}
cursor[fileName] = fileContent;
}
return result;
};
@@ -3,11 +3,15 @@ import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
@InputType()
export class CreateServerlessFunctionInput {
@@ -31,6 +35,14 @@ export class CreateServerlessFunctionInput {
@HideField()
applicationId?: string;
@HideField()
universalIdentifier?: string;
@HideField()
serverlessFunctionLayerId?: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
code?: ServerlessFunctionCode;
}
@@ -13,6 +13,7 @@ import {
import graphqlTypeJson from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
@InputType()
export class UpdateServerlessFunctionInput {
@@ -41,5 +42,5 @@ export class UpdateServerlessFunctionInput {
@Field(() => graphqlTypeJson)
@IsObject()
code: JSON;
code: ServerlessFunctionCode;
}
@@ -39,6 +39,6 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
ServerlessFunctionResolver,
WorkspaceFlatServerlessFunctionMapCacheService,
],
exports: [ServerlessFunctionService],
exports: [ServerlessFunctionService, ServerlessFunctionV2Service],
})
export class ServerlessFunctionModule {}
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { basename, dirname, join } from 'path';
import { join } from 'path';
import deepEqual from 'deep-equal';
import { isDefined } from 'twenty-shared/utils';
@@ -13,9 +13,6 @@ import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { SERVERLESS_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/serverless-function/serverless-function-executed';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content';
import { ENV_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/env-file-name';
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
import { getBaseTypescriptProjectFiles } from 'src/engine/core-modules/serverless/drivers/utils/get-base-typescript-project-files';
import { getLayerDependencies } from 'src/engine/core-modules/serverless/drivers/utils/get-last-layer-dependencies';
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
@@ -34,6 +31,7 @@ import {
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
@Injectable()
export class ServerlessFunctionService {
@@ -66,7 +64,7 @@ export class ServerlessFunctionService {
workspaceId: string,
id: string,
version: string,
): Promise<{ [filePath: string]: string } | undefined> {
): Promise<Sources | undefined> {
const serverlessFunction =
await this.serverlessFunctionRepository.findOneOrFail({
where: {
@@ -81,20 +79,7 @@ export class ServerlessFunctionService {
version,
});
const indexFileStream = await this.fileStorageService.read({
folderPath: join(folderPath, 'src'),
filename: INDEX_FILE_NAME,
});
const envFileStream = await this.fileStorageService.read({
folderPath: folderPath,
filename: ENV_FILE_NAME,
});
return {
'.env': await readFileContent(envFileStream),
'src/index.ts': await readFileContent(indexFileStream),
};
return await this.fileStorageService.readFolder(folderPath);
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
return;
@@ -289,15 +274,10 @@ export class ServerlessFunctionService {
version: 'draft',
});
for (const key of Object.keys(serverlessFunctionInput.code)) {
await this.fileStorageService.write({
// @ts-expect-error legacy noImplicitAny
file: serverlessFunctionInput.code[key],
name: basename(key),
mimeType: undefined,
folder: join(fileFolder, dirname(key)),
});
}
await this.fileStorageService.writeFolder(
serverlessFunctionInput.code,
fileFolder,
);
return this.serverlessFunctionRepository.findOneBy({
id: existingServerlessFunction.id,
@@ -168,9 +168,11 @@ export class ServerlessFunctionV2Service {
async deleteOne({
deleteServerlessFunctionInput,
workspaceId,
isSystemBuild = false,
}: {
deleteServerlessFunctionInput: ServerlessFunctionIdInput;
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<FlatServerlessFunction> {
const { flatServerlessFunctionMaps: existingFlatServerlessFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
@@ -211,8 +213,8 @@ export class ServerlessFunctionV2Service {
},
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
isSystemBuild,
},
workspaceId,
},
@@ -244,9 +246,11 @@ export class ServerlessFunctionV2Service {
async destroyOne({
destroyServerlessFunctionInput,
workspaceId,
isSystemBuild = false,
}: {
destroyServerlessFunctionInput: ServerlessFunctionIdInput;
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<FlatServerlessFunction> {
const { flatServerlessFunctionMaps: existingFlatServerlessFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
@@ -288,7 +292,7 @@ export class ServerlessFunctionV2Service {
},
},
buildOptions: {
isSystemBuild: false,
isSystemBuild,
inferDeletionFromMissingEntities: true,
},
workspaceId,
@@ -6,6 +6,7 @@ import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serve
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
export type ServerlessFunctionEntityRelationProperties =
ExtractRecordTypeOrmRelationProperties<
@@ -23,5 +24,5 @@ export type FlatServerlessFunction = Omit<
ServerlessFunctionEntityRelationProperties
> & {
universalIdentifier: string;
code?: JSON;
code?: ServerlessFunctionCode;
};
@@ -0,0 +1,8 @@
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
export type ServerlessFunctionCode = {
src: {
'index.ts': string;
} & Sources;
'.env'?: string;
};
@@ -4,6 +4,7 @@ import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/l
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import { ServerlessFunctionRuntime } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
createServerlessFunctionInput,
@@ -13,14 +14,14 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
workspaceId: string;
}): FlatServerlessFunction => {
const id = v4();
const universalIdentifier = v4();
const currentDate = new Date();
return {
id,
name: createServerlessFunctionInput.name,
description: createServerlessFunctionInput.description ?? null,
universalIdentifier,
universalIdentifier:
createServerlessFunctionInput.universalIdentifier ?? v4(),
createdAt: currentDate,
updatedAt: currentDate,
deletedAt: null,
@@ -34,6 +35,11 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
serverlessFunctionLayerId:
createServerlessFunctionInput.serverlessFunctionLayerId ?? null,
workspaceId,
checksum: null,
code: createServerlessFunctionInput?.code,
checksum: createServerlessFunctionInput?.code
? serverlessFunctionCreateHash(
JSON.stringify(createServerlessFunctionInput.code),
)
: null,
};
};
@@ -13,8 +13,8 @@ import {
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { serverlessFunctionCreateCodeChecksum } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
export const fromUpdateServerlessFunctionInputToFlatServerlessFunctionToUpdateOrThrow =
({
@@ -42,8 +42,8 @@ export const fromUpdateServerlessFunctionInputToFlatServerlessFunctionToUpdateOr
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
{
...rawUpdateServerlessFunctionInput,
checksum: serverlessFunctionCreateCodeChecksum(
rawUpdateServerlessFunctionInput.code,
checksum: serverlessFunctionCreateHash(
JSON.stringify(rawUpdateServerlessFunctionInput.code),
),
},
FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES,
@@ -1,8 +1,11 @@
import { isDefined } from 'twenty-shared/utils';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
export const serverlessFunctionCreateCodeChecksum = (code: JSON): string => {
export const serverlessFunctionCreateCodeChecksum = (
code: ServerlessFunctionCode,
): string => {
if (!isDefined(code) || typeof code !== 'object') {
return serverlessFunctionCreateHash('');
}
@@ -1,6 +1,7 @@
import { type FlatServerlessFunctionPropertiesToCompare } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function-properties-to-compare.type';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/types/property-update.type';
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
export type CreateServerlessFunctionAction = {
type: 'create_serverless_function';
@@ -10,7 +11,7 @@ export type CreateServerlessFunctionAction = {
export type UpdateServerlessFunctionAction = {
type: 'update_serverless_function';
serverlessFunctionId: string;
code?: JSON;
code?: ServerlessFunctionCode;
updates: Array<
{
[P in FlatServerlessFunctionPropertiesToCompare]: PropertyUpdate<
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
import { join } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -40,13 +42,20 @@ export class CreateServerlessFunctionActionHandlerService extends WorkspaceMigra
version: 'draft',
});
for (const file of await getBaseTypescriptProjectFiles) {
await this.fileStorageService.write({
file: file.content,
name: file.name,
mimeType: undefined,
folder: join(draftFileFolder, file.path),
});
if (isDefined(serverlessFunction?.code)) {
await this.fileStorageService.writeFolder(
serverlessFunction.code,
draftFileFolder,
);
} else {
for (const file of await getBaseTypescriptProjectFiles) {
await this.fileStorageService.write({
file: file.content,
name: file.name,
mimeType: undefined,
folder: join(draftFileFolder, file.path),
});
}
}
}
@@ -1,7 +1,5 @@
import { Injectable } from '@nestjs/common';
import { basename, dirname, join } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
@@ -15,6 +13,7 @@ import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-f
import { UpdateServerlessFunctionAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-serverless-function-action-v2.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
import { fromWorkspaceMigrationUpdateActionToPartialEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-workspace-migration-update-action-to-partial-field-or-object-entity.util';
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
@Injectable()
export class UpdateServerlessFunctionActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
@@ -78,21 +77,13 @@ export class UpdateServerlessFunctionActionHandlerService extends WorkspaceMigra
code,
}: {
serverlessFunction: FlatServerlessFunction;
code: JSON;
code: ServerlessFunctionCode;
}) {
const fileFolder = getServerlessFolder({
serverlessFunction,
version: 'draft',
});
for (const key of Object.keys(code)) {
await this.fileStorageService.write({
// @ts-expect-error legacy noImplicitAny
file: code[key],
name: basename(key),
mimeType: undefined,
folder: join(fileFolder, dirname(key)),
});
}
await this.fileStorageService.writeFolder(code, fileFolder);
}
}
+17 -18
View File
@@ -18,54 +18,53 @@ export { UndecoratedLink } from './link/components/UndecoratedLink';
export { CAL_LINK } from './link/constants/Cal';
export { GITHUB_LINK } from './link/constants/GithubLink';
export { TWENTY_PRICING_LINK } from './link/constants/TwentyPricingLink';
export { MenuPicker } from './menu/components/MenuPicker';
export type { MenuPickerProps } from './menu/components/MenuPicker';
export { MenuItem } from './menu/menu-item/components/MenuItem';
export { MenuPicker } from './menu/components/MenuPicker';
export type {
MenuItemIconButton,
MenuItemProps
MenuItemProps,
} from './menu/menu-item/components/MenuItem';
export { MenuItemAvatar } from './menu/menu-item/components/MenuItemAvatar';
export { MenuItem } from './menu/menu-item/components/MenuItem';
export type { MenuItemAvatarProps } from './menu/menu-item/components/MenuItemAvatar';
export { MenuItemDraggable } from './menu/menu-item/components/MenuItemDraggable';
export { MenuItemAvatar } from './menu/menu-item/components/MenuItemAvatar';
export type { MenuItemDraggableProps } from './menu/menu-item/components/MenuItemDraggable';
export { MenuItemHotKeys } from './menu/menu-item/components/MenuItemHotKeys';
export { MenuItemDraggable } from './menu/menu-item/components/MenuItemDraggable';
export type { MenuItemHotKeysProps } from './menu/menu-item/components/MenuItemHotKeys';
export { MenuItemHotKeys } from './menu/menu-item/components/MenuItemHotKeys';
export { MenuItemMultiSelect } from './menu/menu-item/components/MenuItemMultiSelect';
export { MenuItemMultiSelectAvatar } from './menu/menu-item/components/MenuItemMultiSelectAvatar';
export { MenuItemMultiSelectTag } from './menu/menu-item/components/MenuItemMultiSelectTag';
export { MenuItemNavigate } from './menu/menu-item/components/MenuItemNavigate';
export type { MenuItemNavigateProps } from './menu/menu-item/components/MenuItemNavigate';
export { MenuItemNavigate } from './menu/menu-item/components/MenuItemNavigate';
export {
StyledMenuItemSelect,
MenuItemSelect,
StyledMenuItemSelect
} from './menu/menu-item/components/MenuItemSelect';
export { MenuItemSelectAvatar } from './menu/menu-item/components/MenuItemSelectAvatar';
export {
colorLabels,
MenuItemSelectColor
MenuItemSelectColor,
} from './menu/menu-item/components/MenuItemSelectColor';
export { MenuItemSelectTag } from './menu/menu-item/components/MenuItemSelectTag';
export { MenuItemSuggestion } from './menu/menu-item/components/MenuItemSuggestion';
export type { MenuItemSuggestionProps } from './menu/menu-item/components/MenuItemSuggestion';
export { MenuItemSuggestion } from './menu/menu-item/components/MenuItemSuggestion';
export { MenuItemToggle } from './menu/menu-item/components/MenuItemToggle';
export { MenuItemLeftContent } from './menu/menu-item/internals/components/MenuItemLeftContent';
export type { MenuItemBaseProps } from './menu/menu-item/internals/components/StyledMenuItemBase';
export {
StyledDraggableItem,
StyledHoverableMenuItemBase,
StyledMenuItemBase,
StyledMenuItemContextualText,
StyledMenuItemIconCheck,
StyledMenuItemLabel,
StyledMenuItemLabelLight,
StyledNoIconFiller,
StyledMenuItemLeftContent,
StyledMenuItemRightContent,
StyledNoIconFiller,
StyledRightMenuItemContextualText
StyledDraggableItem,
StyledHoverableMenuItemBase,
StyledMenuItemIconCheck,
StyledMenuItemContextualText,
StyledRightMenuItemContextualText,
} from './menu/menu-item/internals/components/StyledMenuItemBase';
export type { MenuItemBaseProps } from './menu/menu-item/internals/components/StyledMenuItemBase';
export type { MenuItemAccent } from './menu/menu-item/types/MenuItemAccent';
export { NavigationBar } from './navigation-bar/components/NavigationBar';
export { NavigationBarItem } from './navigation-bar/components/NavigationBarItem';
export { NotificationCounter } from './notification-counter/components/NotificationCounter';