From 6fdd7894a145863af48e4f5addec6004ca8c9b1b Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 1 Oct 2025 22:11:03 +0200 Subject: [PATCH] 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 --- packages/twenty-cli/schemas/agent.schema.json | 10 +- ...st.schema.json => appManifest.schema.json} | 12 +- .../twenty-cli/schemas/object.schema.json | 9 +- .../schemas/serverlessFunction.schema.json | 110 ++++++++++++ .../twenty-cli/schemas/trigger.schema.json | 43 +++++ .../src/commands/app-add.command.ts | 59 ++++++- packages/twenty-cli/src/constants/schemas.ts | 4 +- packages/twenty-cli/src/types/config.types.ts | 43 ++++- .../src/utils/__tests__/app-template.test.ts | 13 +- .../src/utils/app-manifest-loader.ts | 78 ++++++++- packages/twenty-cli/src/utils/app-template.ts | 3 +- .../twenty-cli/src/utils/schema-validator.ts | 13 +- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.ts | 1 + .../constants/IndexFileName.ts | 1 + .../constants/IndexFilePath.ts | 1 - .../constants/SourceFolderName.ts | 1 + ...eServerlessFunctionUpdateFormState.test.ts | 8 +- .../useServerlessFunctionUpdateFormState.ts | 16 +- .../WorkflowEditActionServerlessFunction.tsx | 14 +- ...rkflowReadonlyActionServerlessFunction.tsx | 5 +- .../SettingsServerlessFunctionDetail.tsx | 18 +- .../application/application-sync.service.ts | 163 +++++++++++++++--- .../application/application.exception.ts | 1 + .../application/application.module.ts | 2 + .../application/types/application.types.ts | 31 +++- .../interfaces/storage-driver.interface.ts | 5 + .../file-storage/drivers/local.driver.ts | 45 ++++- .../file-storage/drivers/s3.driver.ts | 60 +++++++ .../file-storage/file-storage.service.ts | 13 ++ .../file-storage/types/source.type.ts | 1 + .../__tests__/read-s3-folder-content.spec.ts | 25 +++ .../utils/read-s3-folder-content.ts | 32 ++++ .../dtos/create-serverless-function.input.ts | 12 ++ .../dtos/update-serverless-function.input.ts | 3 +- .../serverless-function.module.ts | 2 +- .../serverless-function.service.ts | 36 +--- .../serverless-function-v2.service.ts | 8 +- .../types/flat-serverless-function.type.ts | 3 +- .../types/serverless-function-code.type.ts | 8 + ...-input-to-flat-serverless-function.util.ts | 12 +- ...erless-function-to-update-or-throw.util.ts | 6 +- ...ess-function-create-code-checksum.utils.ts | 5 +- ...tion-serverless-function-action-v2.type.ts | 3 +- ...verless-function-action-handler.service.ts | 23 ++- ...verless-function-action-handler.service.ts | 15 +- packages/twenty-ui/src/navigation/index.ts | 35 ++-- 47 files changed, 853 insertions(+), 159 deletions(-) rename packages/twenty-cli/schemas/{app-manifest.schema.json => appManifest.schema.json} (89%) create mode 100644 packages/twenty-cli/schemas/serverlessFunction.schema.json create mode 100644 packages/twenty-cli/schemas/trigger.schema.json create mode 100644 packages/twenty-front/src/modules/serverless-functions/constants/IndexFileName.ts delete mode 100644 packages/twenty-front/src/modules/serverless-functions/constants/IndexFilePath.ts create mode 100644 packages/twenty-front/src/modules/serverless-functions/constants/SourceFolderName.ts create mode 100644 packages/twenty-server/src/engine/core-modules/file-storage/types/source.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/read-s3-folder-content.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/file-storage/utils/read-s3-folder-content.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts diff --git a/packages/twenty-cli/schemas/agent.schema.json b/packages/twenty-cli/schemas/agent.schema.json index 3364a6ae23..a2cae83d34 100644 --- a/packages/twenty-cli/schemas/agent.schema.json +++ b/packages/twenty-cli/schemas/agent.schema.json @@ -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)", diff --git a/packages/twenty-cli/schemas/app-manifest.schema.json b/packages/twenty-cli/schemas/appManifest.schema.json similarity index 89% rename from packages/twenty-cli/schemas/app-manifest.schema.json rename to packages/twenty-cli/schemas/appManifest.schema.json index ab9689e739..b50d13c4c7 100644 --- a/packages/twenty-cli/schemas/app-manifest.schema.json +++ b/packages/twenty-cli/schemas/appManifest.schema.json @@ -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", diff --git a/packages/twenty-cli/schemas/object.schema.json b/packages/twenty-cli/schemas/object.schema.json index a527f7df95..2354f7bbae 100644 --- a/packages/twenty-cli/schemas/object.schema.json +++ b/packages/twenty-cli/schemas/object.schema.json @@ -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", diff --git a/packages/twenty-cli/schemas/serverlessFunction.schema.json b/packages/twenty-cli/schemas/serverlessFunction.schema.json new file mode 100644 index 0000000000..adf55a69c5 --- /dev/null +++ b/packages/twenty-cli/schemas/serverlessFunction.schema.json @@ -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" + } + } + } + ] +} diff --git a/packages/twenty-cli/schemas/trigger.schema.json b/packages/twenty-cli/schemas/trigger.schema.json new file mode 100644 index 0000000000..7ee88f13c4 --- /dev/null +++ b/packages/twenty-cli/schemas/trigger.schema.json @@ -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"] } + } + ] +} diff --git a/packages/twenty-cli/src/commands/app-add.command.ts b/packages/twenty-cli/src/commands/app-add.command.ts index 236a6f1147..9961d0488d 100644 --- a/packages/twenty-cli/src/commands/app-add.command.ts +++ b/packages/twenty-cli/src/commands/app-add.command.ts @@ -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 => {\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 = { $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`); diff --git a/packages/twenty-cli/src/constants/schemas.ts b/packages/twenty-cli/src/constants/schemas.ts index b6d0c4a0f5..39dbbdebbf 100644 --- a/packages/twenty-cli/src/constants/schemas.ts +++ b/packages/twenty-cli/src/constants/schemas.ts @@ -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`; diff --git a/packages/twenty-cli/src/types/config.types.ts b/packages/twenty-cli/src/types/config.types.ts index d6d75cbc9e..69a237e4a9 100644 --- a/packages/twenty-cli/src/types/config.types.ts +++ b/packages/twenty-cli/src/types/config.types.ts @@ -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; diff --git a/packages/twenty-cli/src/utils/__tests__/app-template.test.ts b/packages/twenty-cli/src/utils/__tests__/app-template.test.ts index 320d28aa42..35505d059a 100644 --- a/packages/twenty-cli/src/utils/__tests__/app-template.test.ts +++ b/packages/twenty-cli/src/utils/__tests__/app-template.test.ts @@ -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'); }); }); diff --git a/packages/twenty-cli/src/utils/app-manifest-loader.ts b/packages/twenty-cli/src/utils/app-manifest-loader.ts index c8f166c69f..e5a85d5895 100644 --- a/packages/twenty-cli/src/utils/app-manifest-loader.ts +++ b/packages/twenty-cli/src/utils/app-manifest-loader.ts @@ -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 => { + 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, }, }; }; diff --git a/packages/twenty-cli/src/utils/app-template.ts b/packages/twenty-cli/src/utils/app-template.ts index a5a23ebaad..ea16c4399c 100644 --- a/packages/twenty-cli/src/utils/app-template.ts +++ b/packages/twenty-cli/src/utils/app-template.ts @@ -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', diff --git a/packages/twenty-cli/src/utils/schema-validator.ts b/packages/twenty-cli/src/utils/schema-validator.ts index 042ddd3648..6eedf577f6 100644 --- a/packages/twenty-cli/src/utils/schema-validator.ts +++ b/packages/twenty-cli/src/utils/schema-validator.ts @@ -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 => { @@ -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, }; }; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3ef6d6154c..146b0daf51 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -837,6 +837,7 @@ export type CreateRoleInput = { }; export type CreateServerlessFunctionInput = { + code?: InputMaybe; description?: InputMaybe; name: Scalars['String']; timeoutSeconds?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 8cd8558cf9..8926a366a0 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -791,6 +791,7 @@ export type CreateRoleInput = { }; export type CreateServerlessFunctionInput = { + code?: InputMaybe; description?: InputMaybe; name: Scalars['String']; timeoutSeconds?: InputMaybe; diff --git a/packages/twenty-front/src/modules/serverless-functions/constants/IndexFileName.ts b/packages/twenty-front/src/modules/serverless-functions/constants/IndexFileName.ts new file mode 100644 index 0000000000..d32b30a492 --- /dev/null +++ b/packages/twenty-front/src/modules/serverless-functions/constants/IndexFileName.ts @@ -0,0 +1 @@ +export const INDEX_FILE_NAME = 'index.ts'; diff --git a/packages/twenty-front/src/modules/serverless-functions/constants/IndexFilePath.ts b/packages/twenty-front/src/modules/serverless-functions/constants/IndexFilePath.ts deleted file mode 100644 index 7a5671d7ea..0000000000 --- a/packages/twenty-front/src/modules/serverless-functions/constants/IndexFilePath.ts +++ /dev/null @@ -1 +0,0 @@ -export const INDEX_FILE_PATH = 'src/index.ts'; diff --git a/packages/twenty-front/src/modules/serverless-functions/constants/SourceFolderName.ts b/packages/twenty-front/src/modules/serverless-functions/constants/SourceFolderName.ts new file mode 100644 index 0000000000..cc32cfe395 --- /dev/null +++ b/packages/twenty-front/src/modules/serverless-functions/constants/SourceFolderName.ts @@ -0,0 +1 @@ +export const SOURCE_FOLDER_NAME = 'src'; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/__tests__/useServerlessFunctionUpdateFormState.test.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/__tests__/useServerlessFunctionUpdateFormState.test.ts index fe403dee3a..97dd03ac78 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/__tests__/useServerlessFunctionUpdateFormState.test.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/__tests__/useServerlessFunctionUpdateFormState.test.ts @@ -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': '' } }, + }); }); }); diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts index f77bcc2b7f..693c999f55 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.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({ 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); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx index 80573e74ba..2f740008de 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx @@ -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 = ({ { ]; 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 = () => { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index 9d6f9fedfb..d078ddbd92 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -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 { + }): Promise { 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, + ); + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index 3e3dfd24f4..7cee061781 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -4,4 +4,5 @@ export class ApplicationException extends CustomException; read(params: { folderPath: string; filename: string }): Promise; + readFolder(folderPath: string): Promise; write(params: { file: Buffer | Uint8Array | string; name: string; folder: string; mimeType: string | undefined; }): Promise; + writeFolder(sources: Sources, folderPath: string): Promise; 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; + checkFileExists(params: { folderPath: string; filename: string; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts index d665577cbf..80df5abb89 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts @@ -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 { + 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 }; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts index 2d6a391c32..d47097b05d 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts @@ -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 { + 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 }; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index 0a354dabd4..bd6135cd96 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -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 { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + return driver.writeFolder(sources, folderPath); + } + read(params: { folderPath: string; filename: string }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); return driver.read(params); } + readFolder(folderPath: string): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + return driver.readFolder(folderPath); + } + delete(params: { folderPath: string; filename?: string }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/types/source.type.ts b/packages/twenty-server/src/engine/core-modules/file-storage/types/source.type.ts new file mode 100644 index 0000000000..55873723ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/types/source.type.ts @@ -0,0 +1 @@ +export type Sources = { [key: string]: string | Sources }; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/read-s3-folder-content.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/read-s3-folder-content.spec.ts new file mode 100644 index 0000000000..5c78077f56 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/read-s3-folder-content.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/read-s3-folder-content.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/read-s3-folder-content.ts new file mode 100644 index 0000000000..4829bdd3ea --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/read-s3-folder-content.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts index ff37a9ed97..52a2885c92 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts index 4b176f2d33..0858f42aed 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts index 062fc3e81f..d977a40351 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts @@ -39,6 +39,6 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve ServerlessFunctionResolver, WorkspaceFlatServerlessFunctionMapCacheService, ], - exports: [ServerlessFunctionService], + exports: [ServerlessFunctionService, ServerlessFunctionV2Service], }) export class ServerlessFunctionModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts index 7809444edd..3ba8ec2e66 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts @@ -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 { 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, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service.ts index 7e7a56873f..37c115438a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service.ts @@ -168,9 +168,11 @@ export class ServerlessFunctionV2Service { async deleteOne({ deleteServerlessFunctionInput, workspaceId, + isSystemBuild = false, }: { deleteServerlessFunctionInput: ServerlessFunctionIdInput; workspaceId: string; + isSystemBuild?: boolean; }): Promise { 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 { const { flatServerlessFunctionMaps: existingFlatServerlessFunctionMaps } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( @@ -288,7 +292,7 @@ export class ServerlessFunctionV2Service { }, }, buildOptions: { - isSystemBuild: false, + isSystemBuild, inferDeletionFromMissingEntities: true, }, workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type.ts index ea5b5ad31b..522d997c19 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type.ts @@ -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; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts new file mode 100644 index 0000000000..126a045c0c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-create-serverless-function-input-to-flat-serverless-function.util.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-create-serverless-function-input-to-flat-serverless-function.util.ts index 96d97f62bc..bffe8b3e24 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-create-serverless-function-input-to-flat-serverless-function.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-create-serverless-function-input-to-flat-serverless-function.util.ts @@ -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, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-update-serverless-function-input-to-flat-serverless-function-to-update-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-update-serverless-function-input-to-flat-serverless-function-to-update-or-throw.util.ts index 04d07e6912..ff03171921 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-update-serverless-function-input-to-flat-serverless-function-to-update-or-throw.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/from-update-serverless-function-input-to-flat-serverless-function-to-update-or-throw.util.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils.ts index 64559d823f..c5685754e5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils.ts @@ -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(''); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-serverless-function-action-v2.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-serverless-function-action-v2.type.ts index 4d82c78233..92f4cd00dc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-serverless-function-action-v2.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-serverless-function-action-v2.type.ts @@ -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< diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/create-serverless-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/create-serverless-function-action-handler.service.ts index 90321aea00..999e6537c1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/create-serverless-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/create-serverless-function-action-handler.service.ts @@ -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), + }); + } } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/update-serverless-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/update-serverless-function-action-handler.service.ts index 41c947a046..bfd0360c39 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/update-serverless-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/serverless-function/services/update-serverless-function-action-handler.service.ts @@ -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); } } diff --git a/packages/twenty-ui/src/navigation/index.ts b/packages/twenty-ui/src/navigation/index.ts index b6601c90fd..3c882043e7 100644 --- a/packages/twenty-ui/src/navigation/index.ts +++ b/packages/twenty-ui/src/navigation/index.ts @@ -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'; -