From a6cc80eedde0e09eeeeab3caef02860e426c6dc6 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 29 Oct 2025 17:51:43 +0100 Subject: [PATCH] 1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347) This PR adds 2 columns handlerPath and handlerName in serverlessFunction to locate the entrypoint of a serverless in a codebase It adds the following decorators in twenty-sdk: - ServerlessFunction - DatabaseEventTrigger - RouteTrigger - CronTrigger - ApplicationVariable It still supports deprecated entity.manifest.jsonc Overall code needs to be cleaned a little bit, but it should work properly so you can try to test if the DEVX fits your needs See updates in hello-world application ```typescript import axios from 'axios'; import { DatabaseEventTrigger, ServerlessFunction, RouteTrigger, CronTrigger, ApplicationVariable, } from 'twenty-sdk'; @ApplicationVariable({ universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305', key: 'TWENTY_API_KEY', description: 'Twenty API Key', isSecret: true, }) @DatabaseEventTrigger({ universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', eventName: 'person.created', }) @RouteTrigger({ universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', path: '/post-card/create', httpMethod: 'GET', isAuthRequired: false, }) @CronTrigger({ universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', pattern: '0 0 1 1 *', // Every year 1st of January }) @ServerlessFunction({ universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', }) class CreateNewPostCard { main = async (params: { recipient: string }): Promise => { const { recipient } = params; const options = { method: 'POST', url: 'http://localhost:3000/rest/postCards', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, }, data: { name: recipient ?? 'Unknown' }, }; try { const { data } = await axios.request(options); console.log(`New post card to "${recipient}" created`); return data; } catch (error) { console.error(error); throw error; } }; } export const createNewPostCardHandler = new CreateNewPostCard().main; ``` ### [edit] V2 After the v1 proposal, I see that using a class method to define the serverless function handler is pretty confusing. Lets leave serverlessFunction configuration decorators on the class, but move the handler like before. Here is the v2 hello-world serverless function: ```typescript import axios from 'axios'; import { DatabaseEventTrigger, ServerlessFunction, RouteTrigger, CronTrigger, ApplicationVariable, } from 'twenty-sdk'; @ApplicationVariable({ universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305', key: 'TWENTY_API_KEY', description: 'Twenty API Key', isSecret: true, }) @DatabaseEventTrigger({ universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', eventName: 'person.created', }) @RouteTrigger({ universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', path: '/post-card/create', httpMethod: 'GET', isAuthRequired: false, }) @CronTrigger({ universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', pattern: '0 0 1 1 *', // Every year 1st of January }) @ServerlessFunction({ universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', }) export class ServerlessFunctionDefinition {} export const main = async (params: { recipient: string }): Promise => { const { recipient } = params; const options = { method: 'POST', url: 'http://localhost:3000/rest/postCards', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, }, data: { name: recipient ?? 'Unknown' }, }; try { const { data } = await axios.request(options); console.log(`New post card to "${recipient}" created`); return data; } catch (error) { console.error(error); throw error; } }; ``` ### [edit] V3 After the v2 proposal, we don't really like decorators on empty classes. We decided to go with a Vercel approach with a config constant ```typescript import axios from 'axios'; import { ServerlessFunctionConfig } from 'twenty-sdk'; export const main = async (params: { recipient: string }): Promise => { const { recipient } = params; const options = { method: 'POST', url: 'http://localhost:3000/rest/postCards', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, }, data: { name: recipient ?? 'Unknown' }, }; try { const { data } = await axios.request(options); console.log(`New post card to "${recipient}" created`); return data; } catch (error) { console.error(error); throw error; } }; export const config: ServerlessFunctionConfig = { universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', routeTriggers: [ { universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', path: '/post-card/create', httpMethod: 'GET', isAuthRequired: false, } ], cronTriggers: [ { universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', pattern: '0 0 1 1 *', // Every year 1st of January } ], databaseEventTriggers: [ { universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', eventName: 'person.created', } ] } ``` --- eslint.config.mjs | 23 +- .../hello-world/application.config.ts | 17 + packages/twenty-apps/hello-world/package.json | 16 +- .../serverlessFunction.manifest.jsonc | 19 - .../create-new-post-card/src/index.ts | 26 - .../src/actions/create-new-post-card.ts | 52 ++ .../hello-world/src/{ => objects}/postCard.ts | 4 +- .../twenty-apps/hello-world/tsconfig.json | 8 +- packages/twenty-apps/hello-world/yarn.lock | 28 +- packages/twenty-apps/project.json | 1 + packages/twenty-cli/package.json | 6 +- packages/twenty-cli/project.json | 8 +- .../src/commands/app-add.command.ts | 239 +-------- .../src/commands/app-delete.command.ts | 8 +- .../src/commands/app-dev.command.ts | 9 +- .../src/commands/app-init.command.ts | 25 +- .../src/commands/app-sync.command.ts | 2 +- .../twenty-cli/src/commands/app.command.ts | 21 +- .../base-application-project/.env.example | 17 +- .../base-application-project/package.json | 3 +- .../base-application-project/tsconfig.json | 8 +- .../constants/schemas/appManifest.schema.json | 6 +- .../schemas/serverlessFunction.schema.json | 5 +- .../src/constants/schemas/trigger.schema.json | 4 +- .../twenty-cli/src/services/api.service.ts | 12 +- packages/twenty-cli/src/types/config.types.ts | 108 ++-- .../__tests__/get-decorated-class.spec.ts | 6 +- .../src/utils/__tests__/load-manifest.spec.ts | 276 ++++++++++ .../src/utils/app-manifest-loader.ts | 190 ------- packages/twenty-cli/src/utils/app-template.ts | 49 +- .../twenty-cli/src/utils/find-path-file.ts | 15 + packages/twenty-cli/src/utils/format-path.ts | 8 + ...=> get-object-metadata-decorated-class.ts} | 4 +- .../get-serverless-function-base-file.ts | 29 + packages/twenty-cli/src/utils/jsonc-parser.ts | 4 + .../src/utils/load-env-variables.ts | 17 + .../utils/load-manifest-from-decorators.ts | 175 ------ .../twenty-cli/src/utils/load-manifest.ts | 476 +++++++++++++++++ .../src/generated-metadata/graphql.ts | 26 +- .../twenty-front/src/generated/graphql.ts | 6 +- .../fragments/serverlessFunctionFragment.ts | 2 + .../useServerlessFunctionUpdateFormState.ts | 30 +- .../SettingsServerlessFunctionDetail.tsx | 18 +- packages/twenty-sdk/package.json | 24 +- packages/twenty-sdk/project.json | 30 +- .../twenty-sdk/scripts/generateBarrels.ts | 498 ++++++++++++++++++ .../decorators/object-metadata.decorator.ts | 5 +- packages/twenty-sdk/src/application/index.ts | 13 + .../application/types/application-config.ts | 14 + .../types/serverless-function-config.ts | 28 + .../types/syncable-entity-options.type.ts | 1 + packages/twenty-sdk/src/decorators/index.ts | 1 - packages/twenty-sdk/src/index.ts | 2 +- packages/twenty-sdk/tsconfig.json | 5 +- packages/twenty-sdk/vite.config.ts | 13 +- ...10191095-addHandlerToServerlessFunction.ts | 25 + .../application/application-sync.service.ts | 139 ++--- .../application/application.module.ts | 2 - .../application/application.resolver.ts | 4 +- .../dtos/deleteApplication.input.ts | 8 +- .../application/types/application.types.ts | 40 +- .../application-variable.service.ts | 43 +- .../drivers/constants/executor/index.mjs | 7 +- .../serverless/drivers/lambda.driver.ts | 16 +- .../serverless/drivers/local.driver.ts | 54 +- .../build-serverless-function-in-memory.ts | 12 +- .../call-database-event-trigger-jobs.job.ts | 6 +- ...s-function-editable-properties.constant.ts | 2 + .../dtos/create-serverless-function.input.ts | 4 +- .../dtos/serverless-function.dto.ts | 8 + .../dtos/update-serverless-function.input.ts | 17 +- .../serverless-function.entity.ts | 9 + .../types/flat-serverless-function.type.ts | 4 +- .../types/serverless-function-code.type.ts | 7 - ...-input-to-flat-serverless-function.util.ts | 8 +- ...ess-function-create-code-checksum.utils.ts | 20 - ...tion-serverless-function-action-v2.type.ts | 4 +- ...verless-function-action-handler.service.ts | 4 +- .../twenty-shared/scripts/generateBarrels.ts | 3 + packages/twenty-shared/vite.config.ts | 15 +- yarn.lock | 13 +- 81 files changed, 2070 insertions(+), 1044 deletions(-) create mode 100644 packages/twenty-apps/hello-world/application.config.ts delete mode 100644 packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/serverlessFunction.manifest.jsonc delete mode 100644 packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts create mode 100644 packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts rename packages/twenty-apps/hello-world/src/{ => objects}/postCard.ts (66%) create mode 100644 packages/twenty-apps/project.json create mode 100644 packages/twenty-cli/src/utils/__tests__/load-manifest.spec.ts delete mode 100644 packages/twenty-cli/src/utils/app-manifest-loader.ts create mode 100644 packages/twenty-cli/src/utils/find-path-file.ts create mode 100644 packages/twenty-cli/src/utils/format-path.ts rename packages/twenty-cli/src/utils/{get-decorated-class.ts => get-object-metadata-decorated-class.ts} (78%) create mode 100644 packages/twenty-cli/src/utils/get-serverless-function-base-file.ts create mode 100644 packages/twenty-cli/src/utils/load-env-variables.ts delete mode 100644 packages/twenty-cli/src/utils/load-manifest-from-decorators.ts create mode 100644 packages/twenty-cli/src/utils/load-manifest.ts create mode 100644 packages/twenty-sdk/scripts/generateBarrels.ts rename packages/twenty-sdk/src/{ => application}/decorators/object-metadata.decorator.ts (61%) create mode 100644 packages/twenty-sdk/src/application/index.ts create mode 100644 packages/twenty-sdk/src/application/types/application-config.ts create mode 100644 packages/twenty-sdk/src/application/types/serverless-function-config.ts create mode 100644 packages/twenty-sdk/src/application/types/syncable-entity-options.type.ts delete mode 100644 packages/twenty-sdk/src/decorators/index.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1761210191095-addHandlerToServerlessFunction.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index a8a8ca06f96..d599977a70d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,27 +19,28 @@ export default [ // Global ignores { - ignores: [ - '**/node_modules/**', - ], + ignores: ['**/node_modules/**'], }, // Base configuration for all files { files: ['**/*.{js,jsx,ts,tsx}'], plugins: { - 'prettier': prettierPlugin, - 'lingui': linguiPlugin, + prettier: prettierPlugin, + lingui: linguiPlugin, '@nx': nxPlugin, 'prefer-arrow': preferArrowPlugin, - 'import': importPlugin, + import: importPlugin, 'unused-imports': unusedImportsPlugin, - 'unicorn': unicornPlugin, + unicorn: unicornPlugin, }, rules: { // General rules 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], - 'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }], + 'no-console': [ + 'warn', + { allow: ['group', 'groupCollapsed', 'groupEnd'] }, + ], 'no-control-regex': 0, 'no-debugger': 'error', 'no-duplicate-imports': 'error', @@ -53,6 +54,10 @@ export default [ enforceBuildableLibDependency: true, allow: [], depConstraints: [ + { + sourceTag: 'scope:apps', + onlyDependOnLibsWithTags: ['scope:apps', 'scope:sdk'], + }, { sourceTag: 'scope:sdk', onlyDependOnLibsWithTags: ['scope:sdk'], @@ -129,7 +134,7 @@ export default [ 'error', { prefer: 'type-imports', - fixStyle: 'inline-type-imports' + fixStyle: 'inline-type-imports', }, ], '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/packages/twenty-apps/hello-world/application.config.ts b/packages/twenty-apps/hello-world/application.config.ts new file mode 100644 index 00000000000..7ca7f5bb78f --- /dev/null +++ b/packages/twenty-apps/hello-world/application.config.ts @@ -0,0 +1,17 @@ +import { type ApplicationConfig } from 'twenty-sdk/application'; + +const config: ApplicationConfig = { + universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', + displayName: 'Hello World', + description: 'A simple hello world app', + icon: 'IconWorld', + applicationVariables: { + TWENTY_API_KEY: { + universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305', + description: 'Twenty API Key', + isSecret: true, + }, + }, +}; + +export default config; diff --git a/packages/twenty-apps/hello-world/package.json b/packages/twenty-apps/hello-world/package.json index c1e8ed852e3..c2b182eb005 100644 --- a/packages/twenty-apps/hello-world/package.json +++ b/packages/twenty-apps/hello-world/package.json @@ -1,5 +1,6 @@ { - "version": "0.0.1", + "name": "hello-world", + "version": "0.0.2", "license": "MIT", "engines": { "node": "^24.5.0", @@ -7,20 +8,9 @@ "yarn": ">=4.0.2" }, "packageManager": "yarn@4.9.2", - "$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json", - "universalIdentifier": "4ec0391d-18d5-411c-b2f3-266ddc1c3ef7", - "name": "Hello world", - "description": "A hello-world application example", - "env": { - "TWENTY_API_KEY": { - "isSecret": true, - "value": "", - "description": "Twenty api key" - } - }, "dependencies": { "axios": "^1.12.2", - "twenty-sdk": "^0.0.2" + "twenty-sdk": "0.0.3" }, "devDependencies": { "@types/node": "^24.7.2" diff --git a/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/serverlessFunction.manifest.jsonc b/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/serverlessFunction.manifest.jsonc deleted file mode 100644 index 518908a84a5..00000000000 --- a/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/serverlessFunction.manifest.jsonc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json", - "universalIdentifier": "e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf", - "name": "create-new-post-card", - "triggers": [ - { - "universalIdentifier": "203f1df3-4a82-4d06-a001-b8cf22a31156", - "type": "databaseEvent", - "eventName": "person.created" - }, - { - "universalIdentifier": "c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6", - "type": "route", - "path": "/post-card/create", - "httpMethod": "GET", - "isAuthRequired": false - } - ] -} diff --git a/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts b/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts deleted file mode 100644 index 05f7a8f57bc..00000000000 --- a/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'axios'; - -export const main = async (params: { recipient: string }): Promise => { - const { recipient } = params; - - const options = { - method: 'POST', - url: 'http://localhost:3000/rest/postCards', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, - }, - data: { name: recipient ?? 'Unknown' }, - }; - - try { - const { data } = await axios.request(options); - - console.log(`New post card to "${recipient}" created`); - - return data; - } catch (error) { - console.error(error); - throw error; - } -}; diff --git a/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts b/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts new file mode 100644 index 00000000000..cc3ffec2c43 --- /dev/null +++ b/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; +import { type ServerlessFunctionConfig } from 'twenty-sdk/application'; + +export const main = async (params: { recipient: string }): Promise => { + const { recipient } = params; + + const options = { + method: 'POST', + url: 'http://localhost:3000/rest/postCards', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, + }, + data: { name: recipient ?? 'Unknown' }, + }; + + try { + const { data } = await axios.request(options); + + console.log(`New post card to "${recipient}" created`); + + return data; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const config: ServerlessFunctionConfig = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'create-new-post-card', + timeoutSeconds: 2, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route', + path: '/post-card/create', + httpMethod: 'GET', + isAuthRequired: false, + }, + { + universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', + type: 'cron', + pattern: '0 0 1 1 *', // Every year 1st of January + }, + { + universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', + type: 'databaseEvent', + eventName: 'person.created', + }, + ], +}; diff --git a/packages/twenty-apps/hello-world/src/postCard.ts b/packages/twenty-apps/hello-world/src/objects/postCard.ts similarity index 66% rename from packages/twenty-apps/hello-world/src/postCard.ts rename to packages/twenty-apps/hello-world/src/objects/postCard.ts index 84879d337b3..9b5b6aa2cf0 100644 --- a/packages/twenty-apps/hello-world/src/postCard.ts +++ b/packages/twenty-apps/hello-world/src/objects/postCard.ts @@ -1,4 +1,4 @@ -import { ObjectMetadata } from 'twenty-sdk'; +import { ObjectMetadata } from 'twenty-sdk/application'; @ObjectMetadata({ universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', @@ -6,5 +6,7 @@ import { ObjectMetadata } from 'twenty-sdk'; namePlural: 'postCards', labelSingular: 'Post card', labelPlural: 'Post cards', + description: ' A post card object', + icon: 'IconMail', }) export class PostCard {} diff --git a/packages/twenty-apps/hello-world/tsconfig.json b/packages/twenty-apps/hello-world/tsconfig.json index c34deabc8bc..17d258af134 100644 --- a/packages/twenty-apps/hello-world/tsconfig.json +++ b/packages/twenty-apps/hello-world/tsconfig.json @@ -17,6 +17,10 @@ "skipDefaultLibCheck": true, "resolveJsonModule": true }, - "exclude": ["node_modules", "dist"], - "include": ["**/*.ts"] + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts" + ] } diff --git a/packages/twenty-apps/hello-world/yarn.lock b/packages/twenty-apps/hello-world/yarn.lock index e6570c09941..755c8ef66ca 100644 --- a/packages/twenty-apps/hello-world/yarn.lock +++ b/packages/twenty-apps/hello-world/yarn.lock @@ -14,16 +14,6 @@ __metadata: languageName: node linkType: hard -"Hello world@workspace:.": - version: 0.0.0-use.local - resolution: "Hello world@workspace:." - dependencies: - "@types/node": "npm:^24.7.2" - axios: "npm:^1.12.2" - twenty-sdk: "npm:^0.0.2" - languageName: unknown - linkType: soft - "async-function@npm:^1.0.0": version: 1.0.0 resolution: "async-function@npm:1.0.0" @@ -228,6 +218,16 @@ __metadata: languageName: node linkType: hard +"hello-world@workspace:.": + version: 0.0.0-use.local + resolution: "hello-world@workspace:." + dependencies: + "@types/node": "npm:^24.7.2" + axios: "npm:^1.12.2" + twenty-sdk: "npm:0.0.3-alpha" + languageName: unknown + linkType: soft + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -258,10 +258,10 @@ __metadata: languageName: node linkType: hard -"twenty-sdk@npm:^0.0.2": - version: 0.0.2 - resolution: "twenty-sdk@npm:0.0.2" - checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e +"twenty-sdk@npm:0.0.3-alpha": + version: 0.0.3-alpha + resolution: "twenty-sdk@npm:0.0.3-alpha" + checksum: 10c0/e8028f47767e3fa6318100f26542e0477b68e36d363df5f3a6c20d8442951fecba60089d07e97224b0b3533a729bbd3526cca08728079577cfa204dbffaecb03 languageName: node linkType: hard diff --git a/packages/twenty-apps/project.json b/packages/twenty-apps/project.json new file mode 100644 index 00000000000..6fcd2cfe2f0 --- /dev/null +++ b/packages/twenty-apps/project.json @@ -0,0 +1 @@ +{"tags": ["scope:apps"]} diff --git a/packages/twenty-cli/package.json b/packages/twenty-cli/package.json index 2ffd6cfb040..7c4011a2e56 100644 --- a/packages/twenty-cli/package.json +++ b/packages/twenty-cli/package.json @@ -1,6 +1,6 @@ { "name": "twenty-cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Command-line interface for Twenty application development", "main": "dist/cli.js", "bin": { @@ -38,7 +38,8 @@ "lodash.camelcase": "^4.3.0", "lodash.capitalize": "^4.2.1", "lodash.kebabcase": "^4.1.1", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "uuid": "^13.0.0" }, "devDependencies": { "@types/fs-extra": "^11.0.0", @@ -50,6 +51,7 @@ "@types/node": "^20.0.0", "jest": "^29.5.0", "tsx": "^4.7.0", + "twenty-sdk": "workspace:*", "wait-on": "^7.2.0" }, "engines": { diff --git a/packages/twenty-cli/project.json b/packages/twenty-cli/project.json index 26aed8e04ad..f01f9325c69 100644 --- a/packages/twenty-cli/project.json +++ b/packages/twenty-cli/project.json @@ -4,14 +4,13 @@ "projectType": "application", "tags": ["scope:cli"], "targets": { - "after-build": { + "before-build": { "executor": "nx:run-commands", "cache": true, "options": { "cwd": "packages/twenty-cli", "commands": ["rimraf dist", "tsc --project tsconfig.lib.json"] - }, - "dependsOn": ["^after-build"] + } }, "build": { "executor": "nx:run-commands", @@ -23,7 +22,7 @@ "cp -R src/constants/schemas dist/constants" ] }, - "dependsOn": ["after-build"] + "dependsOn": ["^build", "before-build"] }, "dev": { "executor": "nx:run-commands", @@ -86,6 +85,7 @@ }, "parallel": false, "dependsOn": [ + "build", { "target": "database:reset", "projects": "twenty-server" diff --git a/packages/twenty-cli/src/commands/app-add.command.ts b/packages/twenty-cli/src/commands/app-add.command.ts index b3c8dc4c6e6..57adf66ddeb 100644 --- a/packages/twenty-cli/src/commands/app-add.command.ts +++ b/packages/twenty-cli/src/commands/app-add.command.ts @@ -5,32 +5,19 @@ import inquirer from 'inquirer'; import path from 'path'; import camelcase from 'lodash.camelcase'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; -import { HTTPMethod } from '../types/config.types'; -import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser'; import { getSchemaUrls } from '../utils/schema-validator'; import { BASE_SCHEMAS_PATH } from '../constants/constants-path'; -import { getDecoratedClass } from '../utils/get-decorated-class'; +import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class'; +import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file'; + +const ROOT_FOLDER = 'src'; export enum SyncableEntity { AGENT = 'agent', OBJECT = 'object', SERVERLESS_FUNCTION = 'serverlessFunction', - TRIGGER = 'trigger', } -const getFolderName = (entity: SyncableEntity) => { - switch (entity) { - case SyncableEntity.AGENT: - return 'agents'; - case SyncableEntity.OBJECT: - return 'objects'; - case SyncableEntity.SERVERLESS_FUNCTION: - return 'serverlessFunctions'; - default: - throw new Error(`Unknown entity type: ${entity}`); - } -}; - export const isSyncableEntity = (value: string): value is SyncableEntity => { return Object.values(SyncableEntity).includes(value as SyncableEntity); }; @@ -38,22 +25,12 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => { export class AppAddCommand { async execute(entityType?: SyncableEntity): Promise { try { - const appPath = CURRENT_EXECUTION_DIRECTORY; + const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER); + + await fs.ensureDir(appPath); const entity = entityType ?? (await this.getEntity()); - const appExists = await fs.pathExists(appPath); - - if (!appExists) { - console.error(chalk.red('App does not exist')); - process.exit(1); - } - - if (entity === SyncableEntity.TRIGGER) { - await this.addTriggerToServerlessFunction(appPath); - return; - } - const entityName = await this.getEntityName(entity); const entityData = await this.getEntityToCreateData(entity, entityName); @@ -64,7 +41,7 @@ export class AppAddCommand { const objectFileName = `${camelcase(entityName)}.ts`; - const decoratedObject = getDecoratedClass({ + const decoratedObject = getObjectMetadataDecoratedClass({ data: entityData, name: entityName, }); @@ -74,18 +51,20 @@ export class AppAddCommand { return; } - const folderName = getFolderName(entity); + if (entity === SyncableEntity.SERVERLESS_FUNCTION) { + const objectFileName = `${camelcase(entityName)}.ts`; - const entitiesDir = path.join(appPath, folderName, entityName); + const decoratedServerlessFunction = getServerlessFunctionBaseFile({ + name: entityName, + }); - await fs.ensureDir(entitiesDir); + await fs.writeFile( + path.join(appPath, objectFileName), + decoratedServerlessFunction, + ); - await writeJsoncFile( - path.join(entitiesDir, `${entity}.manifest.jsonc`), - entityData, - ); - - await this.addEntityInitFiles(entity, entitiesDir); + return; + } } catch (error) { console.error( chalk.red(`Add new entity failed:`), @@ -123,11 +102,7 @@ export class AppAddCommand { name: 'entity', message: `What entity do you want to create?`, default: '', - choices: [ - SyncableEntity.SERVERLESS_FUNCTION, - SyncableEntity.OBJECT, - SyncableEntity.TRIGGER, - ], + choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.OBJECT], }, ]); @@ -212,178 +187,4 @@ export class AppAddCommand { return entityToCreateData; } - - private async addTriggerToServerlessFunction(appPath: string) { - const serverlessFunctionsDir = path.join(appPath, 'serverlessFunctions'); - - if (!(await fs.pathExists(serverlessFunctionsDir))) { - console.error(chalk.red('No serverless functions found in this app')); - process.exit(1); - } - - const serverlessFunctions = await fs.readdir(serverlessFunctionsDir); - - if (serverlessFunctions.length === 0) { - console.error(chalk.red('No serverless functions found in this app')); - process.exit(1); - } - - const { serverlessFunctionName } = await inquirer.prompt<{ - serverlessFunctionName: string; - }>([ - { - type: 'list', - name: 'serverlessFunctionName', - message: 'Select a serverless function to add a trigger to:', - choices: serverlessFunctions, - }, - ]); - - const { triggerType } = await inquirer.prompt<{ triggerType: string }>([ - { - type: 'list', - name: 'triggerType', - message: 'Select the type of trigger:', - choices: ['databaseEvent', 'cron', 'route'], - }, - ]); - - let triggerData: any; - - if (triggerType === 'databaseEvent') { - triggerData = await this.createDatabaseEventTrigger(); - } else if (triggerType === 'cron') { - triggerData = await this.createCronTrigger(); - } else if (triggerType === 'route') { - triggerData = await this.createRouteTrigger(); - } - - const manifestPath = path.join( - serverlessFunctionsDir, - serverlessFunctionName, - 'serverlessFunction.manifest.jsonc', - ); - - const manifest = await parseJsoncFile(manifestPath); - - if (!manifest.triggers) { - manifest.triggers = []; - } - - manifest.triggers.push(triggerData); - - await writeJsoncFile(manifestPath, manifest); - - console.log( - chalk.green(`✅ Trigger added successfully to ${serverlessFunctionName}`), - ); - } - - private async createDatabaseEventTrigger() { - const uuid = randomUUID(); - - const { eventName } = await inquirer.prompt<{ eventName: string }>([ - { - type: 'input', - name: 'eventName', - message: - 'Enter the database event name (e.g. company.created, *.updated, person.*):', - validate: (input) => { - if (input.length === 0) { - return 'Event name is required'; - } - if (!/^(?:[a-zA-Z]+|\*)\.(created|updated|deleted|\*)$/.test(input)) { - return 'Event name must be in format: (objectName|*).(created|updated|deleted|*)'; - } - return true; - }, - }, - ]); - - return { - universalIdentifier: uuid, - type: 'databaseEvent', - eventName, - }; - } - - private async createCronTrigger() { - const uuid = randomUUID(); - - const { schedule } = await inquirer.prompt<{ schedule: string }>([ - { - type: 'input', - name: 'schedule', - message: 'Enter the cron schedule (e.g., 0 9 * * * for daily at 9 AM):', - validate: (input) => { - if (input.length === 0) { - return 'Schedule is required'; - } - - const parts = input.trim().split(/\s+/); - - if (parts.length < 5 || parts.length > 6) { - return 'Cron schedule must have 5 or 6 fields (e.g., 0 9 * * *)'; - } - return true; - }, - }, - ]); - - return { - universalIdentifier: uuid, - type: 'cron', - schedule, - }; - } - - private async createRouteTrigger() { - const uuid = randomUUID(); - - const { path } = await inquirer.prompt<{ path: string }>([ - { - type: 'input', - name: 'path', - message: 'Enter the route path (e.g., /webhook/company):', - validate: (input) => { - if (input.length === 0) { - return 'Path is required'; - } - if (!input.startsWith('/')) { - return 'Path must start with /'; - } - return true; - }, - }, - ]); - - const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([ - { - type: 'list', - name: 'httpMethod', - message: 'Select the HTTP method:', - choices: Object.values(HTTPMethod), - default: HTTPMethod.GET, - }, - ]); - - const { isAuthRequired } = await inquirer.prompt<{ - isAuthRequired: boolean; - }>([ - { - type: 'confirm', - name: 'isAuthRequired', - message: 'Is authentication required?', - default: true, - }, - ]); - - return { - universalIdentifier: uuid, - type: 'route', - path, - httpMethod, - isAuthRequired, - }; - } } diff --git a/packages/twenty-cli/src/commands/app-delete.command.ts b/packages/twenty-cli/src/commands/app-delete.command.ts index 5216343b791..8baaa73a076 100644 --- a/packages/twenty-cli/src/commands/app-delete.command.ts +++ b/packages/twenty-cli/src/commands/app-delete.command.ts @@ -3,7 +3,7 @@ import inquirer from 'inquirer'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; import { ApiService } from '../services/api.service'; import { ApiResponse } from '../types/config.types'; -import { loadManifest } from '../utils/app-manifest-loader'; +import { loadManifest } from '../utils/load-manifest'; export class AppDeleteCommand { private apiService = new ApiService(); @@ -25,9 +25,11 @@ export class AppDeleteCommand { process.exit(1); } - const { packageJson } = await loadManifest(appPath); + const { manifest } = await loadManifest(appPath); - const result = await this.apiService.deleteApplication(packageJson); + const result = await this.apiService.deleteApplication( + manifest.application.universalIdentifier, + ); if (!result.success) { console.error(chalk.red('❌ Deletion failed:'), result.error); diff --git a/packages/twenty-cli/src/commands/app-dev.command.ts b/packages/twenty-cli/src/commands/app-dev.command.ts index 166e30b3142..304004fa971 100644 --- a/packages/twenty-cli/src/commands/app-dev.command.ts +++ b/packages/twenty-cli/src/commands/app-dev.command.ts @@ -2,14 +2,17 @@ import chalk from 'chalk'; import * as chokidar from 'chokidar'; import { ApiService } from '../services/api.service'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; -import { loadManifest } from '../utils/app-manifest-loader'; +import { loadManifest } from '../utils/load-manifest'; export class AppDevCommand { private apiService = new ApiService(); - async execute(options: { debounce: string }): Promise { + async execute(options: { + appPath?: string; + debounce: string; + }): Promise { try { - const appPath = CURRENT_EXECUTION_DIRECTORY; + const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; const debounceMs = parseInt(options.debounce, 10); diff --git a/packages/twenty-cli/src/commands/app-init.command.ts b/packages/twenty-cli/src/commands/app-init.command.ts index 5d0c7e7549a..733b8272fe9 100644 --- a/packages/twenty-cli/src/commands/app-init.command.ts +++ b/packages/twenty-cli/src/commands/app-init.command.ts @@ -8,7 +8,7 @@ import kebabCase from 'lodash.kebabcase'; export class AppInitCommand { async execute(directory?: string): Promise { try { - const { appName, appDirectory, appDescription } = + const { appName, appDisplayName, appDirectory, appDescription } = await this.getAppInfos(directory); await this.validateDirectory(appDirectory); @@ -19,6 +19,7 @@ export class AppInitCommand { await copyBaseApplicationProject({ appName, + appDisplayName, appDescription, appDirectory, }); @@ -35,19 +36,31 @@ export class AppInitCommand { private async getAppInfos(directory?: string): Promise<{ appName: string; - appDirectory: string; + appDisplayName: string; appDescription: string; + appDirectory: string; }> { - const { name, description } = await inquirer.prompt([ + const { name, displayName, description } = await inquirer.prompt([ { type: 'input', name: 'name', - message: 'Application name (eg: My awesome application):', + message: 'Application name (eg: my-awesome-app):', validate: (input) => { if (input.length === 0) return 'Application name is required'; return true; }, }, + { + type: 'input', + name: 'displayName', + message: 'Display name (eg: My awesome app):', + default: (answers: any) => { + return answers.name + .split('-') + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }, + }, { type: 'input', name: 'description', @@ -58,13 +71,15 @@ export class AppInitCommand { const appName = name.trim(); + const appDisplayName = displayName.trim(); + const appDescription = description.trim(); const appDirectory = directory ? path.join(process.cwd(), kebabCase(directory)) : path.join(process.cwd(), kebabCase(appName)); - return { appName, appDirectory, appDescription }; + return { appName, appDisplayName, appDirectory, appDescription }; } private async validateDirectory(appDirectory: string): Promise { diff --git a/packages/twenty-cli/src/commands/app-sync.command.ts b/packages/twenty-cli/src/commands/app-sync.command.ts index 413581a7e69..492f905f218 100644 --- a/packages/twenty-cli/src/commands/app-sync.command.ts +++ b/packages/twenty-cli/src/commands/app-sync.command.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; import { ApiService } from '../services/api.service'; import { ApiResponse } from '../types/config.types'; -import { loadManifest } from '../utils/app-manifest-loader'; +import { loadManifest } from '../utils/load-manifest'; export class AppSyncCommand { private apiService = new ApiService(); diff --git a/packages/twenty-cli/src/commands/app.command.ts b/packages/twenty-cli/src/commands/app.command.ts index 06f09c5b7a7..7df185b2e62 100644 --- a/packages/twenty-cli/src/commands/app.command.ts +++ b/packages/twenty-cli/src/commands/app.command.ts @@ -9,6 +9,7 @@ import { AppDeleteCommand } from './app-delete.command'; import { AppDevCommand } from './app-dev.command'; import { AppInitCommand } from './app-init.command'; import { AppSyncCommand } from './app-sync.command'; +import { formatPath } from '../utils/format-path'; export class AppCommand { private devCommand = new AppDevCommand(); @@ -22,19 +23,22 @@ export class AppCommand { appCommand.description('Application development commands'); appCommand - .command('dev') + .command('dev [appPath]') .description('Watch and sync local application changes') .option('-d, --debounce ', 'Debounce delay in milliseconds', '1000') - .action(async (options) => { - await this.devCommand.execute(options); + .action(async (appPath, options) => { + await this.devCommand.execute({ + ...options, + appPath: formatPath(appPath), + }); }); appCommand - .command('sync') + .command('sync [appPath]') .description('Sync application to Twenty') - .action(async () => { + .action(async (appPath?: string) => { try { - const result = await this.syncCommand.execute(); + const result = await this.syncCommand.execute(formatPath(appPath)); if (!result.success) { process.exit(1); } @@ -44,11 +48,12 @@ export class AppCommand { }); appCommand - .command('delete') + .command('delete [appPath]') .description('Delete application from Twenty') - .action(async () => { + .action(async (appPath?: string) => { try { const result = await this.deleteCommand.execute({ + appPath: formatPath(appPath), askForConfirmation: true, }); if (!result.success) { diff --git a/packages/twenty-cli/src/constants/base-application-project/.env.example b/packages/twenty-cli/src/constants/base-application-project/.env.example index ef633038da2..ee48a6ccede 100644 --- a/packages/twenty-cli/src/constants/base-application-project/.env.example +++ b/packages/twenty-cli/src/constants/base-application-project/.env.example @@ -5,9 +5,20 @@ # You can access them directly in your code using: # const myValue = process.env.KEY; # -# To make these variables available to your application, -# add them to package.json "env" key. This "env" key defines all -# environment variables that will be provided to your serverless +# To make these variables available to your application, add them in application.config.ts file +# +# const config: ApplicationConfig = { +# ... +# applicationVariables: { +# KEY: { +# universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305', +# description: 'Description', +# isSecret: true, +# }, +# }, +# }; +# +# Those environment variables will be provided to your serverless # functions at runtime. # # Example: diff --git a/packages/twenty-cli/src/constants/base-application-project/package.json b/packages/twenty-cli/src/constants/base-application-project/package.json index faf08248c47..eadaeb27d0a 100644 --- a/packages/twenty-cli/src/constants/base-application-project/package.json +++ b/packages/twenty-cli/src/constants/base-application-project/package.json @@ -1,4 +1,5 @@ { + "name": "my-application", "version": "0.0.1", "license": "MIT", "engines": { @@ -8,7 +9,7 @@ }, "packageManager": "yarn@4.9.2", "dependencies": { - "twenty-sdk": "^0.0.2" + "twenty-sdk": "0.0.3" }, "devDependencies": { "@types/node": "^24.7.2" diff --git a/packages/twenty-cli/src/constants/base-application-project/tsconfig.json b/packages/twenty-cli/src/constants/base-application-project/tsconfig.json index c34deabc8bc..17d258af134 100644 --- a/packages/twenty-cli/src/constants/base-application-project/tsconfig.json +++ b/packages/twenty-cli/src/constants/base-application-project/tsconfig.json @@ -17,6 +17,10 @@ "skipDefaultLibCheck": true, "resolveJsonModule": true }, - "exclude": ["node_modules", "dist"], - "include": ["**/*.ts"] + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts" + ] } diff --git a/packages/twenty-cli/src/constants/schemas/appManifest.schema.json b/packages/twenty-cli/src/constants/schemas/appManifest.schema.json index 2e70fec378b..573d923090c 100644 --- a/packages/twenty-cli/src/constants/schemas/appManifest.schema.json +++ b/packages/twenty-cli/src/constants/schemas/appManifest.schema.json @@ -45,6 +45,11 @@ "type": "object", "title": "Environment Variable Definition", "properties": { + "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}$" + }, "description": { "type": "string", "description": "Description for this environment variable." @@ -58,7 +63,6 @@ "description": "If true, the value will be treated as sensitive and hidden from logs or UI." } }, - "required": ["isSecret"], "additionalProperties": false } }, diff --git a/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json b/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json index e2de1574355..92df2055e46 100644 --- a/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json +++ b/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json @@ -4,10 +4,7 @@ "title": "Twenty Serverless Function Manifest", "description": "Schema for Twenty AI serverless function configuration files", "type": "object", - "required": [ - "universalIdentifier", - "name" - ], + "required": ["universalIdentifier"], "properties": { "$schema": { "type": "string", diff --git a/packages/twenty-cli/src/constants/schemas/trigger.schema.json b/packages/twenty-cli/src/constants/schemas/trigger.schema.json index 1d1e5db4e13..38766fc72a5 100644 --- a/packages/twenty-cli/src/constants/schemas/trigger.schema.json +++ b/packages/twenty-cli/src/constants/schemas/trigger.schema.json @@ -16,7 +16,7 @@ "type": { "enum": ["cron", "databaseEvent", "route"] }, - "schedule": { "type": "string" }, + "pattern": { "type": "string" }, "eventName": { "type": "string" }, @@ -29,7 +29,7 @@ "allOf": [ { "if": { "properties": { "type": { "const": "cron" } } }, - "then": { "required": ["schedule"] } + "then": { "required": ["pattern"] } }, { "if": { "properties": { "type": { "const": "databaseEvent" } } }, diff --git a/packages/twenty-cli/src/services/api.service.ts b/packages/twenty-cli/src/services/api.service.ts index 03de86cb752..5670ac905d5 100644 --- a/packages/twenty-cli/src/services/api.service.ts +++ b/packages/twenty-cli/src/services/api.service.ts @@ -128,7 +128,7 @@ export class ApiService { return { success: true, data: response.data.data.syncApplication, - message: `Successfully synced application: ${manifest.name}`, + message: `Successfully synced application: ${packageJson.name}`, }; } catch (error) { if (axios.isAxiosError(error) && error.response) { @@ -141,15 +141,15 @@ export class ApiService { } } - async deleteApplication(packageJson: PackageJson): Promise { + async deleteApplication(universalIdentifier: string): Promise { try { const mutation = ` - mutation DeleteApplication($packageJson: JSON!) { - deleteApplication(packageJson: $packageJson) + mutation DeleteApplication($universalIdentifier: String!) { + deleteApplication(universalIdentifier: $universalIdentifier) } `; - const variables = { packageJson }; + const variables = { universalIdentifier }; const response: AxiosResponse = await this.client.post( '/metadata', @@ -176,7 +176,7 @@ export class ApiService { return { success: true, data: response.data.data.deleteApplication, - message: `Successfully deleted application: ${packageJson.name}`, + message: 'Successfully deleted application', }; } catch (error) { if (axios.isAxiosError(error) && error.response) { diff --git a/packages/twenty-cli/src/types/config.types.ts b/packages/twenty-cli/src/types/config.types.ts index 6cb9eb8be91..d5499dde171 100644 --- a/packages/twenty-cli/src/types/config.types.ts +++ b/packages/twenty-cli/src/types/config.types.ts @@ -5,79 +5,75 @@ export interface TwentyConfig { } export type PackageJson = { - $schema?: string; - universalIdentifier: string; name: string; license: string; - description?: string; engines: { node: string; npm: string; yarn: string; }; packageManager: string; - icon?: string; version: string; dependencies?: object; devDependencies?: object; }; -export type AppManifest = PackageJson & { - agents: AgentManifest[]; - objects: ObjectManifest[]; - serverlessFunctions: ServerlessFunctionManifest[]; +type ApplicationVariable = { + universalIdentifier: string; + value?: string; + description?: string; + isSecret?: boolean; }; -export type CoreEntityManifest = - | AgentManifest - | ObjectManifest - | ServerlessFunctionManifest; +export type Application = { + universalIdentifier: string; + displayName?: string; + description?: string; + icon?: string; + applicationVariables?: Record; +}; + +export type AppManifest = { + application: Application; + objects: ObjectManifest[]; + serverlessFunctions: ServerlessFunctionManifest[]; + sources: Sources; +}; export type ServerlessFunctionManifest = { - $schema?: string; universalIdentifier: string; - name: string; + name?: string; description?: string; timeoutSeconds?: number; triggers: ServerlessFunctionTriggerManifest[]; - code: ServerlessFunctionCodeManifest; + handlerPath: string; + handlerName: string; }; -export enum HTTPMethod { - GET = 'GET', - POST = 'POST', - PUT = 'PUT', - PATCH = 'PATCH', - DELETE = 'DELETE', -} - -export type ServerlessFunctionTriggerManifest = - | { - type: 'cron'; - schedule: string; - } - | { - type: 'databaseEvent'; - eventName: string; - } - | { - type: 'route'; - path: string; - httpMethod: HTTPMethod; - isAuthRequired: boolean; - }; - -type Sources = { [key: string]: string | Sources }; - -export type ServerlessFunctionCodeManifest = { - src: { - 'index.ts': string; - } & Sources; +export type DatabaseEventTrigger = { + type: 'databaseEvent'; + eventName: string; }; +export type CronTrigger = { + type: 'cron'; + pattern: string; +}; + +export type RouteTrigger = { + type: 'route'; + path: string; + httpMethod: string; + isAuthRequired: boolean; +}; + +export type ServerlessFunctionTriggerManifest = { + universalIdentifier: string; +} & (CronTrigger | DatabaseEventTrigger | RouteTrigger); + +export type Sources = { [key: string]: string | Sources }; + export type ObjectManifest = { - $schema?: string; - standardId: string; universalIdentifier: string; nameSingular: string; namePlural: string; @@ -87,24 +83,6 @@ export type ObjectManifest = { icon?: string; }; -export type AgentManifest = { - $schema?: string; - standardId: string; - universalIdentifier: string; - name: string; - label: string; - description?: string; - icon?: string; - prompt: string; - modelId: string; - responseFormat?: AgentResponseFormat; -}; - -export interface AgentResponseFormat { - type: 'json' | 'text'; - schema?: Record; -} - export interface ApiResponse { success: boolean; data?: T; diff --git a/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts b/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts index cd3c16dd768..0f14ac778d0 100644 --- a/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts +++ b/packages/twenty-cli/src/utils/__tests__/get-decorated-class.spec.ts @@ -1,13 +1,13 @@ -import { getDecoratedClass } from '../../utils/get-decorated-class'; +import { getObjectMetadataDecoratedClass } from '../../utils/get-object-metadata-decorated-class'; describe('getDecoratedClass', () => { it('should return properly formatted class', () => { - const result = getDecoratedClass({ + const result = getObjectMetadataDecoratedClass({ data: { nameSingular: 'Name', namePlural: 'Names' }, name: 'MyNewObject', }); - const expectedResult = `import { ObjectMetadata } from 'twenty-sdk'; + const expectedResult = `import { ObjectMetadata } from 'twenty-sdk/application'; @ObjectMetadata({ nameSingular: 'Name', diff --git a/packages/twenty-cli/src/utils/__tests__/load-manifest.spec.ts b/packages/twenty-cli/src/utils/__tests__/load-manifest.spec.ts new file mode 100644 index 00000000000..fde3010c4cf --- /dev/null +++ b/packages/twenty-cli/src/utils/__tests__/load-manifest.spec.ts @@ -0,0 +1,276 @@ +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra'; +import { copyBaseApplicationProject } from '../app-template'; +import { loadManifest } from '../load-manifest'; + +const write = (root: string, file: string, content: string) => { + const abs = join(root, file); + ensureDirSync(resolve(abs, '..')); + writeFileSync(abs, content, 'utf8'); +}; + +const tsLibMock = `declare module 'tslib' { + export const __decorate: any; + export const __metadata: any; + export const __param: any; + export const __awaiter: any; + export const __read: any; + export const __spread: any; + export const __spreadArray: any; + export const __assign: any; + }`; +const twentySdkTypesMock = ` +declare module 'twenty-sdk/application' { + export type SyncableEntityOptions = { universalIdentifier: string }; + + type ApplicationVariable = SyncableEntityOptions & { + value?: string; + description?: string; + isSecret?: boolean; + }; + + export type ApplicationConfig = SyncableEntityOptions & { + displayName?: string; + description?: string; + icon?: string; + applicationVariables?: Record; + }; + + type RouteTrigger = { + type: 'route'; + path: string; + httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + isAuthRequired: boolean; + }; + + type CronTrigger = { + type: 'cron'; + pattern: string; + }; + + type DatabaseEventTrigger = { + type: 'databaseEvent'; + eventName: string; + }; + + type ServerlessFunctionTrigger = SyncableEntityOptions & + (RouteTrigger | CronTrigger | DatabaseEventTrigger); + + export type ServerlessFunctionConfig = SyncableEntityOptions & { + name?: string; + description?: string; + timeoutSeconds?: number; + triggers?: ServerlessFunctionTrigger[]; + }; + + type ObjectMetadataOptions = SyncableEntityOptions & { + nameSingular: string; + namePlural: string; + labelSingular: string; + labelPlural: string; + description?: string; + icon?: string; + }; + + export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => { + return () => {}; + }; +} +`; + +const serverlessFunctionMock = ` +import { type ServerlessFunctionConfig } from 'twenty-sdk/application'; + +export const main = async (params: any): Promise => { + return {}; +} + +export const config: ServerlessFunctionConfig = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'hello', + timeoutSeconds: 2, + triggers: [ + { + universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', + type: 'route', + path: '/post-card/create', + httpMethod: 'GET', + isAuthRequired: false + }, + { + universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', + type: 'cron', + pattern: '0 0 1 1 *', // Every year 1st of January + }, + { + universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', + type: 'databaseEvent', + eventName: 'person.created' + } + ] +};`; + +const objectMock = `import { ObjectMetadata } from 'twenty-sdk/application'; + +@ObjectMetadata({ + universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', + nameSingular: 'postCard', + namePlural: 'postCards', + labelSingular: 'Post card', + labelPlural: 'Post cards', + description: ' A post card object', + icon: 'IconMail', +}) +export class PostCard {} +`; + +describe('loadManifest (integration)', () => { + const appName = 'my-app'; + const appDisplayName = 'My App'; + const appDescription = 'My app description'; + const appDirectory = join(tmpdir(), 'twenty-manifest-'); + + beforeEach(async () => { + await copyBaseApplicationProject({ + appName, + appDisplayName, + appDescription, + appDirectory, + }); + + write(appDirectory, 'src/Account.ts', objectMock); + + write(appDirectory, 'src/hello.ts', serverlessFunctionMock); + + write( + appDirectory, + 'src/types/twenty-sdk-application.d.ts', + twentySdkTypesMock, + ); + + write( + appDirectory, + 'src/types/tslib.d.ts', + // minimal + future-proof + tsLibMock, + ); + }); + + afterEach(() => { + removeSync(appDirectory); + }); + + it('builds a full manifest for a valid workspace', async () => { + const { packageJson, yarnLock, manifest } = + await loadManifest(appDirectory); + + expect(packageJson.name).toBe('my-app'); + expect(packageJson.version).toBe('0.0.1'); + expect(packageJson.license).toBe('MIT'); + expect(yarnLock).toContain('# This file is generated by running '); + + // application + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { universalIdentifier: _, ...otherInfo } = manifest.application; + expect(otherInfo).toEqual({ + displayName: 'My App', + description: 'My app description', + }); + + // objects collected from @ObjectMetadata + for (const object of manifest.objects) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { universalIdentifier: _, ...otherInfo } = object; + expect(otherInfo).toEqual({ + description: ' A post card object', + icon: 'IconMail', + labelPlural: 'Post cards', + labelSingular: 'Post card', + namePlural: 'postCards', + nameSingular: 'postCard', + }); + } + + // serverless functions + for (const serverlessFunction of manifest.serverlessFunctions) { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + universalIdentifier: _, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handlerPath: __, + triggers, + ...otherInfo + } = serverlessFunction; + + expect(otherInfo).toEqual({ + handlerName: 'main', + name: 'hello', + timeoutSeconds: 2, + }); + + for (const trigger of triggers) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { universalIdentifier: _, ...otherInfo } = trigger; + switch (trigger.type) { + case 'route': + expect(otherInfo).toEqual({ + isAuthRequired: false, + httpMethod: 'GET', + path: '/post-card/create', + type: 'route', + }); + break; + case 'cron': + expect(otherInfo).toEqual({ + pattern: '0 0 1 1 *', + type: 'cron', + }); + break; + case 'databaseEvent': + expect(otherInfo).toEqual({ + eventName: 'person.created', + type: 'databaseEvent', + }); + break; + } + } + } + }); + + it('should not define serverless for util file', async () => { + write( + appDirectory, + 'src/utils/format.ts', + ` +export const format = async (params: any): Promise => { + return {}; +} +`, + ); + + const { manifest } = await loadManifest(appDirectory); + expect(manifest.serverlessFunctions.length).toBe(1); + }); + + it('manifest should contains typescript sources', async () => { + const { manifest } = await loadManifest(appDirectory); + // the method is already exercised in loadManifest; just assert again: + expect(Object.keys(manifest.sources)).toEqual([ + 'application.config.ts', + 'src', + ]); + expect(Object.keys(manifest.sources['src'])).toEqual([ + 'Account.ts', + 'hello.ts', + ]); + }); + + it('fails fast if TS validation fails', async () => { + write(appDirectory, 'src/utils/broken.ts', `const x: number = 'oops';`); + + await expect(loadManifest(appDirectory)).rejects.toThrow( + /TypeScript validation failed/, + ); + }); +}); diff --git a/packages/twenty-cli/src/utils/app-manifest-loader.ts b/packages/twenty-cli/src/utils/app-manifest-loader.ts deleted file mode 100644 index 71f0707563f..00000000000 --- a/packages/twenty-cli/src/utils/app-manifest-loader.ts +++ /dev/null @@ -1,190 +0,0 @@ -import dotenv from 'dotenv'; -import assert from 'assert'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { - AppManifest, - CoreEntityManifest, - ObjectManifest, - PackageJson, -} from '../types/config.types'; -import { validateSchema } from '../utils/schema-validator'; -import { parseJsoncFile } from './jsonc-parser'; -import { loadManifestFromDecorators } from '../utils/load-manifest-from-decorators'; - -type Sources = { [key: string]: string | Sources }; - -const findPathFile = async ( - appPath: string, - fileName: string, -): Promise => { - const jsonPath = path.join(appPath, fileName); - - if (await fs.pathExists(jsonPath)) { - return jsonPath; - } - - throw new Error(`${fileName} not found in ${appPath}`); -}; - -const loadCoreEntity = async ( - coreEntityPath: string, - validator: (manifest: CoreEntityManifest, path: string) => Promise, -): Promise => { - const coreEntities: CoreEntityManifest[] = []; - - if (await fs.pathExists(coreEntityPath)) { - const entities = await fs.readdir(coreEntityPath); - - 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); - } - } - - 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<{ - packageJson: PackageJson; - yarnLock: string; - manifest: AppManifest; -}> => { - const packageJsonPath = await findPathFile(appPath, 'package.json'); - - const rawPackageJson = await parseJsoncFile(packageJsonPath); - - const yarnLockPath = await findPathFile(appPath, 'yarn.lock'); - - const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8'); - - let envFile = ''; - - try { - const envFilePath = await findPathFile(appPath, '.env'); - - envFile = await fs.readFile(envFilePath, 'utf8'); - } catch { - // Allow missing .env - } - - const envVariables = dotenv.parse(envFile); - - const packageJsonEnv = rawPackageJson.env || {}; - - for (const key of Object.keys(envVariables)) { - if (packageJsonEnv[key]) { - packageJsonEnv[key] = { - isSecret: false, - ...packageJsonEnv[key], - value: envVariables[key], - }; - } else { - throw new Error( - `Environment variable "${key}" is defined in .env but missing from package.json. Please add it to the "env" section in package.json.`, - ); - } - } - - const packageJson = { ...rawPackageJson, env: packageJsonEnv }; - - await validateSchema('appManifest', packageJson, packageJsonPath); - - const agents = await loadCoreEntity( - path.join(appPath, 'agents'), - (manifest, path) => validateSchema('agent', manifest, path), - ); - - const objectFromManifests = await loadCoreEntity( - path.join(appPath, 'objects'), - (manifest, path) => validateSchema('object', manifest, path), - ); - - const serverlessFunctions = await loadCoreEntity( - path.join(appPath, 'serverlessFunctions'), - (manifest, path) => validateSchema('serverlessFunction', manifest, path), - ); - - const { objects: objectsFromDecorators } = loadManifestFromDecorators(); - - const objects = ( - [...objectFromManifests, ...objectsFromDecorators] as ObjectManifest[] - ).map((object) => { - object.standardId = object.universalIdentifier; - return object; - }); - - return { - packageJson, - yarnLock: rawYarnLock, - manifest: { - ...packageJson, - agents, - objects, - serverlessFunctions, - }, - }; -}; diff --git a/packages/twenty-cli/src/utils/app-template.ts b/packages/twenty-cli/src/utils/app-template.ts index 1f4e240c8c8..f4c7061ce65 100644 --- a/packages/twenty-cli/src/utils/app-template.ts +++ b/packages/twenty-cli/src/utils/app-template.ts @@ -5,13 +5,16 @@ import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path'; import { writeJsoncFile } from '../utils/jsonc-parser'; import { join } from 'path'; import path from 'path'; +import { v4 } from 'uuid'; export const copyBaseApplicationProject = async ({ appName, + appDisplayName, appDescription, appDirectory, }: { appName: string; + appDisplayName: string; appDescription: string; appDirectory: string; }) => { @@ -26,24 +29,50 @@ export const copyBaseApplicationProject = async ({ await createBasePackageJson({ appName, - appDescription, + appDirectory, + }); + + await createApplicationConfig({ + displayName: appDisplayName, + description: appDescription, appDirectory, }); await createReadmeContent({ - appName, + displayName: appDisplayName, appDescription, appDirectory, }); }; +const createApplicationConfig = async ({ + displayName, + description, + appDirectory, +}: { + displayName: string; + description?: string; + appDirectory: string; +}) => { + const content = `import { type ApplicationConfig } from 'twenty-sdk/application'; + +const config: ApplicationConfig = { + universalIdentifier: '${v4()}', + displayName: '${displayName}', + description: '${description ?? ''}', +}; + +export default config; +`; + + await fs.writeFile(path.join(appDirectory, 'application.config.ts'), content); +}; + const createBasePackageJson = async ({ appName, - appDescription, appDirectory, }: { appName: string; - appDescription: string; appDirectory: string; }) => { const base = JSON.parse(await readBaseApplicationProjectFile('package.json')); @@ -52,27 +81,23 @@ const createBasePackageJson = async ({ base['$schema'] = schemas.appManifest; base['universalIdentifier'] = randomUUID(); - base['name'] = appName - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - base['description'] = appDescription; + base['name'] = appName; await writeJsoncFile(join(appDirectory, 'package.json'), base); }; const createReadmeContent = async ({ - appName, + displayName, appDescription, appDirectory, }: { - appName: string; + displayName: string; appDescription: string; appDirectory: string; }) => { let readmeContent = await readBaseApplicationProjectFile('README.md'); - readmeContent = readmeContent.replace(/\{title}/g, appName); + readmeContent = readmeContent.replace(/\{title}/g, displayName); readmeContent = readmeContent.replace(/\{description}/g, appDescription); diff --git a/packages/twenty-cli/src/utils/find-path-file.ts b/packages/twenty-cli/src/utils/find-path-file.ts new file mode 100644 index 00000000000..77154fb16f1 --- /dev/null +++ b/packages/twenty-cli/src/utils/find-path-file.ts @@ -0,0 +1,15 @@ +import path from 'path'; +import * as fs from 'fs-extra'; + +export const findPathFile = async ( + appPath: string, + fileName: string, +): Promise => { + const jsonPath = path.join(appPath, fileName); + + if (await fs.pathExists(jsonPath)) { + return jsonPath; + } + + throw new Error(`${fileName} not found in ${appPath}`); +}; diff --git a/packages/twenty-cli/src/utils/format-path.ts b/packages/twenty-cli/src/utils/format-path.ts new file mode 100644 index 00000000000..751a902af16 --- /dev/null +++ b/packages/twenty-cli/src/utils/format-path.ts @@ -0,0 +1,8 @@ +import { join } from 'path'; +import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory'; + +export const formatPath = (appPath?: string) => { + return appPath && !appPath?.startsWith('/') + ? join(CURRENT_EXECUTION_DIRECTORY, appPath) + : appPath; +}; diff --git a/packages/twenty-cli/src/utils/get-decorated-class.ts b/packages/twenty-cli/src/utils/get-object-metadata-decorated-class.ts similarity index 78% rename from packages/twenty-cli/src/utils/get-decorated-class.ts rename to packages/twenty-cli/src/utils/get-object-metadata-decorated-class.ts index 8bb606956d0..710a8babf86 100644 --- a/packages/twenty-cli/src/utils/get-decorated-class.ts +++ b/packages/twenty-cli/src/utils/get-object-metadata-decorated-class.ts @@ -1,6 +1,6 @@ import camelcase from 'lodash.camelcase'; -export const getDecoratedClass = ({ +export const getObjectMetadataDecoratedClass = ({ data, name, }: { @@ -15,7 +15,7 @@ export const getDecoratedClass = ({ const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1); - return `import { ObjectMetadata } from 'twenty-sdk'; + return `import { ObjectMetadata } from 'twenty-sdk/application'; @ObjectMetadata({ ${decoratorOptions} diff --git a/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts b/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts new file mode 100644 index 00000000000..19aea3a4318 --- /dev/null +++ b/packages/twenty-cli/src/utils/get-serverless-function-base-file.ts @@ -0,0 +1,29 @@ +import kebabCase from 'lodash.kebabcase'; +import { v4 } from 'uuid'; + +export const getServerlessFunctionBaseFile = ({ name }: { name: string }) => { + const kebabCaseName = kebabCase(name); + + return `import { ServerlessFunctionConfig } from 'twenty-sdk/application'; + +export const main = async (params: { + a: string; + b: number; +}): Promise<{ message: string }> => { + const { a, b } = params; + + // Rename the parameters and code below with your own logic + // This is just an example + const message = \`Hello, input: $\{a} and $\{b}\`; + + return { message }; +}; + +export const config: ServerlessFunctionConfig = { + universalIdentifier: '${v4()}', + name: '${kebabCaseName}', + timeoutSeconds: 5, +}; + +`; +}; diff --git a/packages/twenty-cli/src/utils/jsonc-parser.ts b/packages/twenty-cli/src/utils/jsonc-parser.ts index 67d13b84e81..91672ae90ce 100644 --- a/packages/twenty-cli/src/utils/jsonc-parser.ts +++ b/packages/twenty-cli/src/utils/jsonc-parser.ts @@ -43,6 +43,10 @@ export const parseJsoncString = ( return result; }; +export const parseTextFile = async (filePath: string) => { + return await fs.readFile(filePath, 'utf8'); +}; + export const parseJsoncFile = async ( filePath: string, options: JsoncParseOptions = {}, diff --git a/packages/twenty-cli/src/utils/load-env-variables.ts b/packages/twenty-cli/src/utils/load-env-variables.ts new file mode 100644 index 00000000000..2a288dc07b1 --- /dev/null +++ b/packages/twenty-cli/src/utils/load-env-variables.ts @@ -0,0 +1,17 @@ +import * as fs from 'fs-extra'; +import dotenv from 'dotenv'; +import { findPathFile } from './find-path-file'; + +export const loadEnvVariables = async (appPath: string) => { + let envFile = ''; + + try { + const envFilePath = await findPathFile(appPath, '.env'); + + envFile = await fs.readFile(envFilePath, 'utf8'); + } catch { + // Allow missing .env + } + + return dotenv.parse(envFile); +}; diff --git a/packages/twenty-cli/src/utils/load-manifest-from-decorators.ts b/packages/twenty-cli/src/utils/load-manifest-from-decorators.ts deleted file mode 100644 index 57c1fe1d0c5..00000000000 --- a/packages/twenty-cli/src/utils/load-manifest-from-decorators.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - sys, - getDecorators, - readConfigFile, - parseJsonConfigFileContent, - formatDiagnosticsWithColorAndContext, - createProgram, - Decorator, - isPropertyAccessExpression, - isNumericLiteral, - SyntaxKind, - isArrayLiteralExpression, - Expression, - isPropertyAssignment, - isComputedPropertyName, - isStringLiteralLike, - isShorthandPropertyAssignment, - isIdentifier, - Program, - Node, - isClassDeclaration, - isCallExpression, - isObjectLiteralExpression, - forEachChild, -} from 'typescript'; -import { AppManifest, ObjectManifest } from '../types/config.types'; - -type JSONValue = - | string - | number - | boolean - | null - | JSONValue[] - | { [k: string]: JSONValue }; - -const getProgramFromTsconfig = (tsconfigPath = 'tsconfig.json') => { - const basePath = process.cwd(); - const configFile = readConfigFile(tsconfigPath, sys.readFile); - if (configFile.error) - throw new Error( - formatDiagnosticsWithColorAndContext([configFile.error], { - getCanonicalFileName: (f) => f, - getCurrentDirectory: sys.getCurrentDirectory, - getNewLine: () => sys.newLine, - }), - ); - const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath); - if (parsed.errors.length) { - throw new Error( - formatDiagnosticsWithColorAndContext(parsed.errors, { - getCanonicalFileName: (f) => f, - getCurrentDirectory: sys.getCurrentDirectory, - getNewLine: () => sys.newLine, - }), - ); - } - return createProgram(parsed.fileNames, parsed.options); -}; - -const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => { - const expr = node.expression; - if (isCallExpression(expr)) { - if (isIdentifier(expr.expression)) return expr.expression.text === name; - if (isPropertyAccessExpression(expr.expression)) - return expr.expression.name.text === name; - } - return false; -}; - -const exprToValue = (expr: Expression): JSONValue => { - if (isStringLiteralLike(expr)) return expr.text; - if (isNumericLiteral(expr)) return Number(expr.text); - if (expr.kind === SyntaxKind.TrueKeyword) return true; - if (expr.kind === SyntaxKind.FalseKeyword) return false; - if (expr.kind === SyntaxKind.NullKeyword) return null; - - if (isArrayLiteralExpression(expr)) { - return expr.elements.map((e) => - e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e), - ); - } - - if (isObjectLiteralExpression(expr)) { - const obj: Record = {}; - for (const prop of expr.properties) { - if (isPropertyAssignment(prop)) { - const key = - isIdentifier(prop.name) || isStringLiteralLike(prop.name) - ? prop.name.text - : isComputedPropertyName(prop.name) && - isStringLiteralLike(prop.name.expression) - ? prop.name.expression.text - : undefined; - if (key) obj[key] = exprToValue(prop.initializer); - } else if (isShorthandPropertyAssignment(prop)) { - // Unsupported without a checker; skip to keep it "light". - // Could resolve via typechecker if needed. - } - // getters/setters/methods are ignored intentionally - } - return obj; - } - - // Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback. - // You can throw instead if you prefer to fail fast. - return isIdentifier(expr) - ? expr.text - : String((expr as any).getText?.() ?? ''); -}; - -const collectObjects = (program: Program) => { - const manifest: ObjectManifest[] = []; - - for (const sf of program.getSourceFiles()) { - if (sf.isDeclarationFile) { - continue; - } - - const visit = (node: Node) => { - if (isClassDeclaration(node) && getDecorators(node)?.length) { - const decorators = getDecorators(node); - const objectDec = decorators?.find((d) => - isDecoratorNamed(d, 'ObjectMetadata'), - ); - if (objectDec && isCallExpression(objectDec.expression)) { - const [firstArg] = objectDec.expression.arguments; - if (firstArg && isObjectLiteralExpression(firstArg)) { - const config = exprToValue(firstArg); - if ( - config && - typeof config === 'object' && - !Array.isArray(config) - ) { - manifest.push({ - ...config, - } as ObjectManifest); - } - } - } - } - forEachChild(node, visit); - }; - - visit(sf); - } - - return manifest; -}; - -const validateProgram = (program: Program) => { - const diagnostics = [ - ...program.getSyntacticDiagnostics(), - ...program.getSemanticDiagnostics(), - ...program.getGlobalDiagnostics(), - ]; - - if (diagnostics.length > 0) { - const formatted = formatDiagnosticsWithColorAndContext(diagnostics, { - getCanonicalFileName: (f) => f, - getCurrentDirectory: sys.getCurrentDirectory, - getNewLine: () => sys.newLine, - }); - throw new Error(`TypeScript validation failed:\n${formatted}`); - } -}; - -export const loadManifestFromDecorators = (): Pick => { - const program = getProgramFromTsconfig('tsconfig.json'); - - validateProgram(program); - - const objects = collectObjects(program); - - return { objects }; -}; diff --git a/packages/twenty-cli/src/utils/load-manifest.ts b/packages/twenty-cli/src/utils/load-manifest.ts new file mode 100644 index 00000000000..91d951a4382 --- /dev/null +++ b/packages/twenty-cli/src/utils/load-manifest.ts @@ -0,0 +1,476 @@ +import * as fs from 'fs-extra'; +import { + sys, + getDecorators, + readConfigFile, + parseJsonConfigFileContent, + formatDiagnosticsWithColorAndContext, + createProgram, + Decorator, + isPropertyAccessExpression, + isNumericLiteral, + SyntaxKind, + isArrayLiteralExpression, + Expression, + isPropertyAssignment, + isComputedPropertyName, + isStringLiteralLike, + isShorthandPropertyAssignment, + isIdentifier, + FunctionDeclaration, + VariableDeclaration, + Program, + Node, + isClassDeclaration, + isCallExpression, + isObjectLiteralExpression, + forEachChild, + SourceFile, + isVariableStatement, + isArrowFunction, + isFunctionExpression, + isExportAssignment, + Modifier, +} from 'typescript'; +import { + AppManifest, + Application, + ObjectManifest, + PackageJson, + ServerlessFunctionManifest, + Sources, +} from '../types/config.types'; +import { posix, relative, sep, resolve, join } from 'path'; +import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser'; +import { findPathFile } from '../utils/find-path-file'; + +type JSONValue = + | string + | number + | boolean + | null + | JSONValue[] + | { [k: string]: JSONValue }; + +const getProgramFromTsconfig = ( + appPath?: string, + tsconfigPath = 'tsconfig.json', +) => { + const basePath = appPath ?? process.cwd(); + const configFile = readConfigFile(join(basePath, tsconfigPath), sys.readFile); + if (configFile.error) + throw new Error( + formatDiagnosticsWithColorAndContext([configFile.error], { + getCanonicalFileName: (f) => f, + getCurrentDirectory: sys.getCurrentDirectory, + getNewLine: () => sys.newLine, + }), + ); + const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath); + if (parsed.errors.length) { + throw new Error( + formatDiagnosticsWithColorAndContext(parsed.errors, { + getCanonicalFileName: (f) => f, + getCurrentDirectory: sys.getCurrentDirectory, + getNewLine: () => sys.newLine, + }), + ); + } + return createProgram(parsed.fileNames, parsed.options); +}; + +const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => { + const expr = node.expression; + if (isCallExpression(expr)) { + if (isIdentifier(expr.expression)) return expr.expression.text === name; + if (isPropertyAccessExpression(expr.expression)) + return expr.expression.name.text === name; + } + return false; +}; + +const exprToValue = (expr: Expression): JSONValue => { + if (isStringLiteralLike(expr)) return expr.text; + if (isNumericLiteral(expr)) return Number(expr.text); + if (expr.kind === SyntaxKind.TrueKeyword) return true; + if (expr.kind === SyntaxKind.FalseKeyword) return false; + if (expr.kind === SyntaxKind.NullKeyword) return null; + + if (isArrayLiteralExpression(expr)) { + return expr.elements.map((e) => + e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e), + ); + } + + if (isObjectLiteralExpression(expr)) { + const obj: Record = {}; + for (const prop of expr.properties) { + if (isPropertyAssignment(prop)) { + const key = + isIdentifier(prop.name) || isStringLiteralLike(prop.name) + ? prop.name.text + : isComputedPropertyName(prop.name) && + isStringLiteralLike(prop.name.expression) + ? prop.name.expression.text + : undefined; + if (key) obj[key] = exprToValue(prop.initializer); + } else if (isShorthandPropertyAssignment(prop)) { + // Unsupported without a checker; skip to keep it "light". + // Could resolve via typechecker if needed. + } + // getters/setters/methods are ignored intentionally + } + return obj; + } + + // Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback. + // You can throw instead if you prefer to fail fast. + return isIdentifier(expr) + ? expr.text + : String((expr as any).getText?.() ?? ''); +}; + +const getFirstArgObject = (dec: Decorator) => { + if (!isCallExpression(dec.expression)) return undefined; + const [firstArg] = dec.expression.arguments; + return firstArg && isObjectLiteralExpression(firstArg) + ? (exprToValue(firstArg) as Record) + : undefined; +}; + +const collectObjects = (program: Program) => { + const manifest: ObjectManifest[] = []; + + for (const sf of program.getSourceFiles()) { + if (sf.isDeclarationFile) { + continue; + } + + const visit = (node: Node) => { + if (isClassDeclaration(node) && getDecorators(node)?.length) { + const decorators = getDecorators(node); + const objectDec = decorators?.find((d) => + isDecoratorNamed(d, 'ObjectMetadata'), + ); + if (objectDec) { + const cfg = getFirstArgObject(objectDec); + if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) { + manifest.push({ ...(cfg as any) } as ObjectManifest); + } + } + } + forEachChild(node, visit); + }; + + visit(sf); + } + + return manifest; +}; + +// Add if you want a small guard for "export" presence on statements +const hasExportModifier = (st: any) => + st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ?? + false; + +/** + * Finds (and validates) the new serverless file shape: + * - exactly 2 exported bindings + * - one must be `config` (typed ServerlessFunctionConfig) + * - the other must be a function (exported function declaration, or const initialized with arrow/function expression) + */ +const findHandlerAndConfig = ( + sf: SourceFile, +): { + handlerName: ServerlessFunctionManifest['handlerName']; + configObject: Pick< + ServerlessFunctionManifest, + | 'universalIdentifier' + | 'name' + | 'description' + | 'timeoutSeconds' + | 'triggers' + >; +} => { + type Exported = { + name: string; + kind: 'function' | 'const'; + init?: Expression; + declNode: Node; + }; + + const exported: Exported[] = []; + + // 1) export const X = + for (const st of sf.statements) { + if (!isVariableStatement(st) || !hasExportModifier(st)) continue; + + for (const decl of st.declarationList.declarations) { + if (!isIdentifier(decl.name)) continue; + + const name = decl.name.text; + const init = decl.initializer ?? undefined; + + exported.push({ + name, + kind: 'const', + init, + declNode: decl, + }); + } + } + + // 2) export function X() { ... } + for (const st of sf.statements) { + if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) { + const fd = st as FunctionDeclaration; + if (fd.name && isIdentifier(fd.name)) { + exported.push({ + name: fd.name.text, + kind: 'function', + init: undefined, + declNode: fd, + }); + } + } + } + + // Enforce exactly two exports + const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values()); + if (unique.length !== 2) { + throw new Error( + `Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`, + ); + } + + // Find config + const configExport = unique.find((e) => e.name === 'config'); + if (!configExport) { + throw new Error( + `Serverless file ${sf.fileName} must export a binding named "config".`, + ); + } + // Must be initialized to an object literal + if (!configExport.init || !isObjectLiteralExpression(configExport.init)) { + throw new Error( + `"config" in ${sf.fileName} must be initialized to an object literal.`, + ); + } + // (Light) type guard: ensure declared type mentions ServerlessFunctionConfig if present + const maybeVarDecl = configExport.declNode as VariableDeclaration; + if ('type' in maybeVarDecl && maybeVarDecl.type) { + const typeText = maybeVarDecl.type.getText(sf); + if (!/\bServerlessFunctionConfig\b/.test(typeText)) { + throw new Error( + `"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (got: ${typeText}).`, + ); + } + } + + const configObject = exprToValue(configExport.init) as Pick< + ServerlessFunctionManifest, + | 'universalIdentifier' + | 'name' + | 'description' + | 'timeoutSeconds' + | 'triggers' + >; + + // Identify the handler: the other export + const handlerExport = unique.find((e) => e.name !== 'config'); + if (!handlerExport) { + throw new Error(`Could not find the handler export in ${sf.fileName}.`); + } + + // If it's a const, make sure it’s a function-ish initializer + if (handlerExport.kind === 'const') { + const init = handlerExport.init; + const isFuncLike = + !!init && (isArrowFunction(init) || isFunctionExpression(init)); + if (!isFuncLike) { + throw new Error( + `Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`, + ); + } + } + + return { + handlerName: handlerExport.name, + configObject, + }; +}; + +const posixRelativeFromCwd = (absPath: string) => { + const rel = relative(process.cwd(), absPath); + // normalize to posix separators for portability / manifest stability + return rel.split(sep).join(posix.sep); +}; + +const collectServerlessFunctions = (program: Program) => { + const serverlessFunctions: ServerlessFunctionManifest[] = []; + + for (const sf of program.getSourceFiles()) { + if (sf.isDeclarationFile) continue; + + try { + const { handlerName, configObject } = findHandlerAndConfig(sf); + + const handlerPath = posixRelativeFromCwd(sf.fileName); + + serverlessFunctions.push({ + ...configObject, + handlerPath, + handlerName, + }); + } catch { + // Not a serverless file under the new format — ignore and continue scanning. + continue; + } + } + + return serverlessFunctions; +}; + +const validateProgram = (program: Program) => { + const diagnostics = [ + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ...program.getGlobalDiagnostics(), + ]; + + if (diagnostics.length > 0) { + const formatted = formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (f) => f, + getCurrentDirectory: sys.getCurrentDirectory, + getNewLine: () => sys.newLine, + }); + throw new Error(`TypeScript validation failed:\n${formatted}`); + } +}; + +const setNested = (root: Sources, parts: string[], value: string) => { + let cur: Sources = root; + for (let i = 0; i < parts.length; i++) { + const key = parts[i]; + if (i === parts.length - 1) { + cur[key] = value; + } else { + cur[key] = (cur[key] ?? {}) as Sources; + cur = cur[key] as Sources; + } + } +}; + +const loadFolderContentIntoJson = async ( + sourcePath = '.', + tsconfigPath = 'tsconfig.json', +): Promise => { + const sources: Sources = {}; + const baseAbs = resolve(sourcePath); + + // Build the program from tsconfig (uses your getProgramFromTsconfig) + const program: Program = getProgramFromTsconfig(baseAbs, tsconfigPath); + + // Iterate only files the TS program knows about. + for (const sf of program.getSourceFiles()) { + const abs = sf.fileName; + + // Skip .d.ts and anything outside sourcePath + if (sf.isDeclarationFile) continue; + if (!abs.startsWith(baseAbs + sep) && abs !== baseAbs) continue; + + // Keep only TS/TSX files + if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue; + + // Optional extra guard (usually unnecessary if tsconfig excludes node_modules) + if (abs.includes(`${sep}node_modules${sep}`)) continue; + + const relFromRoot = relative(baseAbs, abs); + const parts = relFromRoot.split(sep); + + const content = await fs.readFile(abs, 'utf8'); + setNested(sources, parts, content); + } + + return sources; +}; + +export const extractTwentyAppConfig = (program: Program): Application => { + for (const sf of program.getSourceFiles()) { + if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts')) + continue; + + let found: Application | undefined; + + const visit = (node: any): void => { + // Look for "export default twentyAppConfig" + if (isExportAssignment(node) && isIdentifier(node.expression)) { + const varName = node.expression.text; + + // find the corresponding variable declaration + for (const stmt of sf.statements) { + if (isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + if (isIdentifier(decl.name) && decl.name.text === varName) { + if ( + decl.initializer && + isObjectLiteralExpression(decl.initializer) + ) { + found = exprToValue(decl.initializer) as Application; + } + } + } + } + } + } + if (!found) forEachChild(node, visit); + }; + + visit(sf); + + if (found) return found; + } + + throw new Error('Could not find default exported ApplicationConfig'); +}; + +export const loadManifest = async ( + path?: string, +): Promise<{ + packageJson: PackageJson; + yarnLock: string; + manifest: AppManifest; +}> => { + const appPath = path ?? process.cwd(); + + const packageJson = await parseJsoncFile( + await findPathFile(appPath, 'package.json'), + ); + + const yarnLock = await parseTextFile( + await findPathFile(appPath, 'yarn.lock'), + ); + + const program = getProgramFromTsconfig(appPath, 'tsconfig.json'); + + validateProgram(program); + + const [objects, serverlessFunctions, application, sources] = + await Promise.all([ + Promise.resolve(collectObjects(program)), + Promise.resolve(collectServerlessFunctions(program)), + Promise.resolve(extractTwentyAppConfig(program)), + loadFolderContentIntoJson(appPath), + ]); + + return { + packageJson, + yarnLock, + manifest: { + application, + objects, + serverlessFunctions, + sources, + }, + }; +}; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 8f2e721d0dd..138d61ce1c1 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2131,7 +2131,7 @@ export type MutationDeactivateWorkflowVersionArgs = { export type MutationDeleteApplicationArgs = { - packageJson: Scalars['JSON']; + universalIdentifier: Scalars['String']; }; @@ -3786,6 +3786,8 @@ export type ServerlessFunction = { cronTriggers?: Maybe>; databaseEventTriggers?: Maybe>; description?: Maybe; + handlerName: Scalars['String']; + handlerPath: Scalars['String']; id: Scalars['UUID']; latestVersion?: Maybe; name: Scalars['String']; @@ -4222,6 +4224,8 @@ export type UpdateServerlessFunctionInput = { export type UpdateServerlessFunctionInputUpdates = { code: Scalars['JSON']; description?: InputMaybe; + handlerName?: InputMaybe; + handlerPath?: InputMaybe; name: Scalars['String']; timeoutSeconds?: InputMaybe; }; @@ -4901,7 +4905,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{ export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean }; -export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; +export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>; @@ -4913,7 +4917,7 @@ export type FindOneApplicationQueryVariables = Exact<{ }>; -export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } }; +export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -5739,21 +5743,21 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; -export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }; +export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }; export type CreateOneServerlessFunctionItemMutationVariables = Exact<{ input: CreateServerlessFunctionInput; }>; -export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; +export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; export type DeleteOneServerlessFunctionMutationVariables = Exact<{ input: ServerlessFunctionIdInput; }>; -export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; +export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; export type ExecuteOneServerlessFunctionMutationVariables = Exact<{ input: ExecuteServerlessFunctionInput; @@ -5767,14 +5771,14 @@ export type PublishOneServerlessFunctionMutationVariables = Exact<{ }>; -export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; +export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; export type UpdateOneServerlessFunctionMutationVariables = Exact<{ input: UpdateServerlessFunctionInput; }>; -export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; +export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; export type FindManyAvailablePackagesQueryVariables = Exact<{ input: ServerlessFunctionIdInput; @@ -5786,14 +5790,14 @@ export type FindManyAvailablePackagesQuery = { __typename?: 'Query', getAvailabl export type GetManyServerlessFunctionsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; +export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; export type GetOneServerlessFunctionQueryVariables = Exact<{ input: ServerlessFunctionIdInput; }>; -export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; +export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } }; export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{ input: GetServerlessFunctionSourceCodeInput; @@ -6414,6 +6418,8 @@ export const ServerlessFunctionFieldsFragmentDoc = gql` timeoutSeconds latestVersion publishedVersions + handlerPath + handlerName cronTriggers { id settings diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index c7d3219d668..1164c0ba39a 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -2067,7 +2067,7 @@ export type MutationDeactivateWorkflowVersionArgs = { export type MutationDeleteApplicationArgs = { - packageJson: Scalars['JSON']; + universalIdentifier: Scalars['String']; }; @@ -3632,6 +3632,8 @@ export type ServerlessFunction = { cronTriggers?: Maybe>; databaseEventTriggers?: Maybe>; description?: Maybe; + handlerName: Scalars['String']; + handlerPath: Scalars['String']; id: Scalars['UUID']; latestVersion?: Maybe; name: Scalars['String']; @@ -4060,6 +4062,8 @@ export type UpdateServerlessFunctionInput = { export type UpdateServerlessFunctionInputUpdates = { code: Scalars['JSON']; description?: InputMaybe; + handlerName?: InputMaybe; + handlerPath?: InputMaybe; name: Scalars['String']; timeoutSeconds?: InputMaybe; }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts index ebf77dc1a52..de6354440cd 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts @@ -9,6 +9,8 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql` timeoutSeconds latestVersion publishedVersions + handlerPath + handlerName cronTriggers { id settings 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 aafc76d47cf..06ed4fe6969 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,3 @@ -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,9 +5,10 @@ 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'; import { type ServerlessFunction } from '~/generated/graphql'; import { type Sources } from '@/serverless-functions/types/sources.type'; +import { flattenSources } from '@/serverless-functions/utils/flattenSources'; +import { isDefined } from 'twenty-shared/utils'; export type ServerlessFunctionNewFormValues = { name: string; @@ -68,19 +68,23 @@ export const useServerlessFunctionUpdateFormState = ({ })); if (serverlessFunctionTestData.shouldInitInput) { - const sourceCode = - data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[ - INDEX_FILE_NAME - ]; + const flattenedCode = flattenSources(code); - const functionInput = - await getFunctionInputFromSourceCode(sourceCode); + const sourceCode = flattenedCode.find( + (flatCode) => flatCode.path === serverlessFunction?.handlerPath, + ); - setServerlessFunctionTestData((prev) => ({ - ...prev, - input: functionInput, - shouldInitInput: false, - })); + if (isDefined(sourceCode)) { + const functionInput = await getFunctionInputFromSourceCode( + sourceCode.content, + ); + + setServerlessFunctionTestData((prev) => ({ + ...prev, + input: functionInput, + shouldInitInput: false, + })); + } } }, }); diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx index 7330c9368db..b0007226419 100644 --- a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx +++ b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx @@ -105,11 +105,19 @@ export const SettingsServerlessFunctionDetail = () => { const flattenedCode = flattenSources(formValues.code); - const files = flattenedCode.map((file) => ({ - path: file.path, - language: 'typescript', - content: file.content, - })); + const files = flattenedCode + .map((file) => ({ + path: file.path, + language: 'typescript', + content: file.content, + })) + .sort((a, b) => + a.path === serverlessFunction?.handlerPath + ? -1 + : b.path === serverlessFunction?.handlerPath + ? 1 + : 0, + ); const renderActiveTabContent = () => { switch (activeTabId) { diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 2b662dc5f82..d41c59cc244 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,12 +1,13 @@ { "name": "twenty-sdk", - "version": "0.0.2", + "version": "0.0.3", "license": "AGPL-3.0", "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", "files": [ - "dist" + "dist", + "application" ], "scripts": { "build": "vite build" @@ -15,5 +16,24 @@ "typescript": "5.9.2", "vite": "^7.0.0", "vite-plugin-dts": "3.8.1" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./application": { + "types": "./dist/application/index.d.ts", + "import": "./dist/application.mjs", + "require": "./dist/application.cjs" + } + }, + "typesVersions": { + "*": { + "application": [ + "dist/application/index.d.ts" + ] + } } } diff --git a/packages/twenty-sdk/project.json b/packages/twenty-sdk/project.json index ffc746bbaf8..2d03ec2497a 100644 --- a/packages/twenty-sdk/project.json +++ b/packages/twenty-sdk/project.json @@ -3,10 +3,36 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/twenty-sdk/src", "projectType": "library", - "tags": ["scope:sdk"], + "tags": [ + "scope:sdk" + ], "targets": { "build": { - "outputs": ["{projectRoot}/dist"] + "dependsOn": [ + "generateBarrels", + "^build" + ], + "outputs": [ + "{projectRoot}/dist", + "{projectRoot}/application/package.json", + "{projectRoot}/application/dist" + ] + }, + "generateBarrels": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "production", + "{projectRoot}/scripts/generateBarrels.ts" + ], + "outputs": [ + "{projectRoot}/src/index.ts", + "{projectRoot}/src/*/index.ts", + "{projectRoot}/package.json" + ], + "options": { + "command": "tsx {projectRoot}/scripts/generateBarrels.ts" + } }, "lint": { "configurations": { diff --git a/packages/twenty-sdk/scripts/generateBarrels.ts b/packages/twenty-sdk/scripts/generateBarrels.ts new file mode 100644 index 00000000000..ad29a835eec --- /dev/null +++ b/packages/twenty-sdk/scripts/generateBarrels.ts @@ -0,0 +1,498 @@ +// @ts-ignore +import prettier from '@prettier/sync'; +import * as fs from 'fs'; +import { globSync } from 'glob'; +// @ts-ignore +import path from 'path'; +import { type Options } from 'prettier'; +import slash from 'slash'; +// @ts-ignore +import ts from 'typescript'; + +// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI + +const INDEX_FILENAME = 'index'; +const PACKAGE_JSON_FILENAME = 'package.json'; +const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json'; +const PACKAGE_PATH = path.resolve('packages/twenty-sdk'); +const SRC_PATH = path.resolve(`${PACKAGE_PATH}/src`); +const PACKAGE_JSON_PATH = path.join(PACKAGE_PATH, PACKAGE_JSON_FILENAME); +const NX_PROJECT_CONFIGURATION_PATH = path.join( + PACKAGE_PATH, + NX_PROJECT_CONFIGURATION_FILENAME, +); + +const prettierConfigFile = prettier.resolveConfigFile(); +if (prettierConfigFile == null) { + throw new Error('Prettier config file not found'); +} +const prettierConfiguration = prettier.resolveConfig(prettierConfigFile); +const prettierFormat = (str: string, parser: Options['parser']) => + prettier.format(str, { + ...prettierConfiguration, + parser, + }); +type createTypeScriptFileArgs = { + path: string; + content: string; + filename: string; +}; +const createTypeScriptFile = ({ + content, + path: filePath, + filename, +}: createTypeScriptFileArgs) => { + const header = ` +/* + * _____ _ + *|_ _|_ _____ _ __ | |_ _ _ + * | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file + * | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden + * |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | + * |___/ + */ +`; + const formattedContent = prettierFormat( + `${header}\n${content}\n`, + 'typescript', + ); + fs.writeFileSync( + path.join(filePath, `${filename}.ts`), + formattedContent, + 'utf-8', + ); +}; + +const getLastPathFolder = (pathStr: string) => path.basename(pathStr); + +const getSubDirectoryPaths = (directoryPath: string): string[] => { + const pattern = slash(path.join(directoryPath, '*/')); + return globSync(pattern, { + ignore: [...EXCLUDED_DIRECTORIES], + cwd: SRC_PATH, + nodir: false, + maxDepth: 1, + }).sort((a, b) => a.localeCompare(b)); +}; + +const partitionFileExportsByType = (declarations: DeclarationOccurrence[]) => { + return declarations.reduce<{ + typeAndInterfaceDeclarations: DeclarationOccurrence[]; + otherDeclarations: DeclarationOccurrence[]; + }>( + (acc, { kind, name }) => { + if (kind === 'type' || kind === 'interface') { + return { + ...acc, + typeAndInterfaceDeclarations: [ + ...acc.typeAndInterfaceDeclarations, + { kind, name }, + ], + }; + } + + return { + ...acc, + otherDeclarations: [...acc.otherDeclarations, { kind, name }], + }; + }, + { + typeAndInterfaceDeclarations: [], + otherDeclarations: [], + }, + ); +}; + +const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => { + return exportByBarrel.map( + ({ barrel: { moduleDirectory }, allFileExports }) => { + const content = allFileExports + .sort((a, b) => a.file.localeCompare(b.file)) + .map(({ exports, file }) => { + const { otherDeclarations, typeAndInterfaceDeclarations } = + partitionFileExportsByType(exports); + + const fileWithoutExtension = path.parse(file).name; + const pathToImport = slash( + path.relative( + moduleDirectory, + path.join(path.dirname(file), fileWithoutExtension), + ), + ); + const mapDeclarationNameAndJoin = ( + declarations: DeclarationOccurrence[], + ) => declarations.map(({ name }) => name).join(', '); + + const typeExport = + typeAndInterfaceDeclarations.length > 0 + ? `export type { ${mapDeclarationNameAndJoin(typeAndInterfaceDeclarations)} } from "./${pathToImport}"` + : ''; + const othersExport = + otherDeclarations.length > 0 + ? `export { ${mapDeclarationNameAndJoin(otherDeclarations)} } from "./${pathToImport}"` + : ''; + + return [typeExport, othersExport] + .filter((el) => el !== '') + .join('\n'); + }) + .join('\n'); + + return { + content, + path: moduleDirectory, + filename: INDEX_FILENAME, + }; + }, + ); +}; + +type JsonUpdate = Record; +type WriteInJsonFileArgs = { + content: JsonUpdate; + file: string; +}; +const updateJsonFile = ({ content, file }: WriteInJsonFileArgs) => { + const updatedJsonFile = JSON.stringify(content); + const formattedContent = prettierFormat(updatedJsonFile, 'json-stringify'); + fs.writeFileSync(file, formattedContent, 'utf-8'); +}; + +const writeInPackageJson = (update: JsonUpdate) => { + const rawJsonFile = fs.readFileSync(PACKAGE_JSON_PATH, 'utf-8'); + const initialJsonFile = JSON.parse(rawJsonFile); + + updateJsonFile({ + file: PACKAGE_JSON_PATH, + content: { + ...initialJsonFile, + ...update, + }, + }); +}; + +const updateNxProjectConfigurationBuildOutputs = (outputs: JsonUpdate) => { + const rawJsonFile = fs.readFileSync(NX_PROJECT_CONFIGURATION_PATH, 'utf-8'); + const initialJsonFile = JSON.parse(rawJsonFile); + + updateJsonFile({ + file: NX_PROJECT_CONFIGURATION_PATH, + content: { + ...initialJsonFile, + targets: { + ...initialJsonFile.targets, + build: { + ...initialJsonFile.targets.build, + outputs, + }, + }, + }, + }); +}; + +type ExportOccurrence = { + types: string; + import: string; + require: string; +}; +type ExportsConfig = Record; + +const generateModulePackageExports = (moduleDirectories: string[]) => { + return moduleDirectories.reduce((acc, moduleDirectory) => { + const moduleName = getLastPathFolder(moduleDirectory); + if (moduleName === undefined) { + throw new Error( + `Should never occur, moduleName is undefined ${moduleDirectory}`, + ); + } + + return { + ...acc, + [`./${moduleName}`]: { + types: `./dist/${moduleName}/index.d.ts`, + import: `./dist/${moduleName}.mjs`, + require: `./dist/${moduleName}.cjs`, + }, + }; + }, {}); +}; + +const computePackageJsonFilesAndExportsConfig = ( + moduleDirectories: string[], +) => { + const entrypoints = moduleDirectories.map(getLastPathFolder); + const exports = { + '.': { + types: './dist/index.d.ts', + import: './dist/index.mjs', + require: './dist/index.cjs', + }, + ...generateModulePackageExports(moduleDirectories), + } satisfies ExportsConfig; + + const typesVersionsEntries = entrypoints.reduce>( + (acc, moduleName) => ({ + ...acc, + [`${moduleName}`]: [`dist/${moduleName}/index.d.ts`], + }), + {}, + ); + + return { + exports, + typesVersions: { '*': typesVersionsEntries }, + files: ['dist', ...entrypoints], + }; +}; + +const computeProjectNxBuildOutputsPath = (moduleDirectories: string[]) => { + const dynamicOutputsPath = moduleDirectories + .map(getLastPathFolder) + .flatMap((barrelName) => + ['package.json', 'dist'].map( + (subPath) => `{projectRoot}/${barrelName}/${subPath}`, + ), + ); + + return ['{projectRoot}/dist', ...dynamicOutputsPath]; +}; + +const EXCLUDED_EXTENSIONS = [ + '**/*.test.ts', + '**/*.test.tsx', + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.stories.ts', + '**/*.stories.tsx', +] as const; +const EXCLUDED_DIRECTORIES = [ + '**/__tests__/**', + '**/__mocks__/**', + '**/__stories__/**', + '**/internal/**', +] as const; +function getTypeScriptFiles( + directoryPath: string, + includeIndex: boolean = false, +): string[] { + const pattern = slash(path.join(directoryPath, '**', '*.{ts,tsx}')); + const files = globSync(pattern, { + cwd: SRC_PATH, + nodir: true, + ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES], + }); + + return files.filter( + (file) => + !file.endsWith('.d.ts') && + (includeIndex ? true : !file.endsWith('index.ts')), + ); +} + +const getKind = ( + node: ts.VariableStatement, +): Extract => { + const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0; + if (isConst) { + return 'const'; + } + + const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0; + if (isLet) { + return 'let'; + } + + return 'var'; +}; + +function extractExportsFromSourceFile(sourceFile: ts.SourceFile) { + const exports: DeclarationOccurrence[] = []; + + function visit(node: ts.Node) { + if (!ts.canHaveModifiers(node)) { + return ts.forEachChild(node, visit); + } + const modifiers = ts.getModifiers(node); + const isExport = modifiers?.some( + (mod) => mod.kind === ts.SyntaxKind.ExportKeyword, + ); + + if (!isExport && !ts.isExportDeclaration(node)) { + return ts.forEachChild(node, visit); + } + + switch (true) { + case ts.isTypeAliasDeclaration(node): + exports.push({ + kind: 'type', + name: node.name.text, + }); + break; + + case ts.isInterfaceDeclaration(node): + exports.push({ + kind: 'interface', + name: node.name.text, + }); + break; + + case ts.isEnumDeclaration(node): + exports.push({ + kind: 'enum', + name: node.name.text, + }); + break; + + case ts.isFunctionDeclaration(node) && node.name !== undefined: + exports.push({ + kind: 'function', + name: node.name.text, + }); + break; + + case ts.isVariableStatement(node): + node.declarationList.declarations.forEach((decl) => { + const kind = getKind(node); + + if (ts.isIdentifier(decl.name)) { + exports.push({ + kind, + name: decl.name.text, + }); + } else if (ts.isObjectBindingPattern(decl.name)) { + decl.name.elements.forEach((element) => { + if ( + !ts.isBindingElement(element) || + !ts.isIdentifier(element.name) + ) { + return; + } + + exports.push({ + kind, + name: element.name.text, + }); + }); + } + }); + break; + + case ts.isClassDeclaration(node) && node.name !== undefined: + exports.push({ + kind: 'class', + name: node.name.text, + }); + break; + case ts.isExportDeclaration(node): + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + node.exportClause.elements.forEach((element) => { + const exportName = element.name.text; + + // Check both the declaration and the individual specifier for type-only exports + const isTypeExport = + node.isTypeOnly || ts.isTypeOnlyExportDeclaration(node); + if (isTypeExport) { + // should handle kind + exports.push({ + kind: 'type', + name: exportName, + }); + return; + } + + exports.push({ + kind: 'const', + name: exportName, + }); + }); + } + break; + } + return ts.forEachChild(node, visit); + } + + visit(sourceFile); + return exports; +} + +type ExportKind = + | 'type' + | 'interface' + | 'enum' + | 'function' + | 'const' + | 'let' + | 'var' + | 'class'; +type DeclarationOccurrence = { kind: ExportKind; name: string }; +type FileExports = Array<{ + file: string; + exports: DeclarationOccurrence[]; +}>; + +function findAllExports(directoryPath: string): FileExports { + const results: FileExports = []; + + const files = getTypeScriptFiles(directoryPath); + + for (const file of files) { + const sourceFile = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + + const exports = extractExportsFromSourceFile(sourceFile); + if (exports.length > 0) { + results.push({ + file, + exports, + }); + } + } + + return results; +} + +type ExportByBarrel = { + barrel: { + moduleName: string; + moduleDirectory: string; + }; + allFileExports: FileExports; +}; +const retrieveExportsByBarrel = (barrelDirectories: string[]) => { + return barrelDirectories.map((moduleDirectory) => { + const moduleExportsPerFile = findAllExports(moduleDirectory); + const moduleName = getLastPathFolder(moduleDirectory); + if (!moduleName) { + throw new Error( + `Should never occur moduleName not found ${moduleDirectory}`, + ); + } + + return { + barrel: { + moduleName, + moduleDirectory, + }, + allFileExports: moduleExportsPerFile, + }; + }); +}; + +const main = () => { + const moduleDirectories = getSubDirectoryPaths(SRC_PATH); + const exportsByBarrel = retrieveExportsByBarrel(moduleDirectories); + const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel); + const packageJsonConfig = + computePackageJsonFilesAndExportsConfig(moduleDirectories); + const nxBuildOutputsPath = + computeProjectNxBuildOutputsPath(moduleDirectories); + + updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath); + writeInPackageJson(packageJsonConfig); + moduleIndexFiles.forEach(createTypeScriptFile); +}; +main(); diff --git a/packages/twenty-sdk/src/decorators/object-metadata.decorator.ts b/packages/twenty-sdk/src/application/decorators/object-metadata.decorator.ts similarity index 61% rename from packages/twenty-sdk/src/decorators/object-metadata.decorator.ts rename to packages/twenty-sdk/src/application/decorators/object-metadata.decorator.ts index e5b0e26b5a2..6b00c1ce869 100644 --- a/packages/twenty-sdk/src/decorators/object-metadata.decorator.ts +++ b/packages/twenty-sdk/src/application/decorators/object-metadata.decorator.ts @@ -1,5 +1,6 @@ -type ObjectMetadataOptions = { - universalIdentifier: string; +import { type SyncableEntityOptions } from '@/application/types/syncable-entity-options.type'; + +type ObjectMetadataOptions = SyncableEntityOptions & { nameSingular: string; namePlural: string; labelSingular: string; diff --git a/packages/twenty-sdk/src/application/index.ts b/packages/twenty-sdk/src/application/index.ts new file mode 100644 index 00000000000..2e91c59a22a --- /dev/null +++ b/packages/twenty-sdk/src/application/index.ts @@ -0,0 +1,13 @@ +/* + * _____ _ + *|_ _|_ _____ _ __ | |_ _ _ + * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file + * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden + * |_| \_/\_/ \___|_| |_|\__|\__, | + * |___/ + */ + +export { ObjectMetadata } from './decorators/object-metadata.decorator'; +export type { ApplicationConfig } from './types/application-config'; +export type { ServerlessFunctionConfig } from './types/serverless-function-config'; +export type { SyncableEntityOptions } from './types/syncable-entity-options.type'; diff --git a/packages/twenty-sdk/src/application/types/application-config.ts b/packages/twenty-sdk/src/application/types/application-config.ts new file mode 100644 index 00000000000..098ca4e9288 --- /dev/null +++ b/packages/twenty-sdk/src/application/types/application-config.ts @@ -0,0 +1,14 @@ +import { type SyncableEntityOptions } from '@/application/types/syncable-entity-options.type'; + +type ApplicationVariable = SyncableEntityOptions & { + value?: string; + description?: string; + isSecret?: boolean; +}; + +export type ApplicationConfig = SyncableEntityOptions & { + displayName?: string; + description?: string; + icon?: string; + applicationVariables?: Record; +}; diff --git a/packages/twenty-sdk/src/application/types/serverless-function-config.ts b/packages/twenty-sdk/src/application/types/serverless-function-config.ts new file mode 100644 index 00000000000..067eeb4a38d --- /dev/null +++ b/packages/twenty-sdk/src/application/types/serverless-function-config.ts @@ -0,0 +1,28 @@ +import { type SyncableEntityOptions } from '@/application/types/syncable-entity-options.type'; + +type RouteTrigger = { + type: 'route'; + path: string; + httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + isAuthRequired: boolean; +}; + +type CronTrigger = { + type: 'cron'; + pattern: string; +}; + +type DatabaseEventTrigger = { + type: 'databaseEvent'; + eventName: string; +}; + +type ServerlessFunctionTrigger = SyncableEntityOptions & + (RouteTrigger | CronTrigger | DatabaseEventTrigger); + +export type ServerlessFunctionConfig = SyncableEntityOptions & { + name?: string; + description?: string; + timeoutSeconds?: number; + triggers?: ServerlessFunctionTrigger[]; +}; diff --git a/packages/twenty-sdk/src/application/types/syncable-entity-options.type.ts b/packages/twenty-sdk/src/application/types/syncable-entity-options.type.ts new file mode 100644 index 00000000000..466e3420344 --- /dev/null +++ b/packages/twenty-sdk/src/application/types/syncable-entity-options.type.ts @@ -0,0 +1 @@ +export type SyncableEntityOptions = { universalIdentifier: string }; diff --git a/packages/twenty-sdk/src/decorators/index.ts b/packages/twenty-sdk/src/decorators/index.ts deleted file mode 100644 index 5b38cac5528..00000000000 --- a/packages/twenty-sdk/src/decorators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ObjectMetadata } from './object-metadata.decorator'; diff --git a/packages/twenty-sdk/src/index.ts b/packages/twenty-sdk/src/index.ts index b7a1cf14d0a..ff8b4c56321 100644 --- a/packages/twenty-sdk/src/index.ts +++ b/packages/twenty-sdk/src/index.ts @@ -1 +1 @@ -export * from './decorators'; +export default {}; diff --git a/packages/twenty-sdk/tsconfig.json b/packages/twenty-sdk/tsconfig.json index f8fddc5c6ff..2650d042726 100644 --- a/packages/twenty-sdk/tsconfig.json +++ b/packages/twenty-sdk/tsconfig.json @@ -15,7 +15,10 @@ "lib": ["es2020", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src/**/*.ts"] } diff --git a/packages/twenty-sdk/vite.config.ts b/packages/twenty-sdk/vite.config.ts index 18176d0bbe9..282d19a13a9 100644 --- a/packages/twenty-sdk/vite.config.ts +++ b/packages/twenty-sdk/vite.config.ts @@ -1,7 +1,14 @@ +// @ts-ignore +import path from 'path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, build: { lib: { entry: 'src/index.ts', @@ -10,5 +17,9 @@ export default defineConfig({ fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`, }, }, - plugins: [dts()], + plugins: [ + dts({ + entryRoot: 'src', + }), + ], }); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1761210191095-addHandlerToServerlessFunction.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1761210191095-addHandlerToServerlessFunction.ts new file mode 100644 index 00000000000..d6669823d2c --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1761210191095-addHandlerToServerlessFunction.ts @@ -0,0 +1,25 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddHandlerToServerlessFunction1761210191095 + implements MigrationInterface +{ + name = 'AddHandlerToServerlessFunction1761210191095'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."serverlessFunction" ADD "handlerPath" character varying NOT NULL DEFAULT 'src/index.ts'`, + ); + await queryRunner.query( + `ALTER TABLE "core"."serverlessFunction" ADD "handlerName" character varying NOT NULL DEFAULT 'main'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."serverlessFunction" DROP COLUMN "handlerName"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."serverlessFunction" DROP COLUMN "handlerPath"`, + ); + } +} 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 5edd4fabc4d..e10ed2c3a82 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 @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; +import { parse } from 'path'; + import { isDefined } from 'twenty-shared/utils'; import { ALL_METADATA_NAME, AllMetadataName } from 'twenty-shared/metadata'; @@ -11,13 +13,11 @@ import { import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input'; import { - AgentManifest, ObjectManifest, ServerlessFunctionManifest, ServerlessFunctionTriggerManifest, } from 'src/engine/core-modules/application/types/application.types'; import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service'; -import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service'; import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -34,6 +34,7 @@ import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serv import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service'; import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; +import { Sources } from 'src/engine/core-modules/file-storage/types/source.type'; @Injectable() export class ApplicationSyncService { @@ -47,7 +48,6 @@ export class ApplicationSyncService { private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly dataSourceService: DataSourceService, - private readonly agentService: AgentService, private readonly databaseEventTriggerV2Service: DatabaseEventTriggerV2Service, private readonly cronTriggerV2Service: CronTriggerV2Service, private readonly routeTriggerV2Service: RouteTriggerV2Service, @@ -69,12 +69,6 @@ export class ApplicationSyncService { yarnLock, }); - await this.syncAgents({ - agentsToSync: manifest.agents, - workspaceId, - applicationId: application.id, - }); - await this.syncObjects({ objectsToSync: manifest.objects, workspaceId, @@ -83,6 +77,7 @@ export class ApplicationSyncService { await this.syncServerlessFunctions({ serverlessFunctionsToSync: manifest.serverlessFunctions, + code: manifest.sources, workspaceId, applicationId: application.id, serverlessFunctionLayerId: application.serverlessFunctionLayerId, @@ -100,10 +95,12 @@ export class ApplicationSyncService { workspaceId: string; }): Promise { const application = await this.applicationService.findByUniversalIdentifier( - manifest.universalIdentifier, + manifest.application.universalIdentifier, workspaceId, ); + const name = manifest.application.displayName ?? packageJson.name; + if (!isDefined(application)) { const serverlessFunctionLayer = await this.serverlessFunctionLayerService.create( @@ -115,18 +112,18 @@ export class ApplicationSyncService { ); const application = await this.applicationService.create({ - universalIdentifier: manifest.universalIdentifier, - name: manifest.name, - description: manifest.description, - version: manifest.version, + universalIdentifier: manifest.application.universalIdentifier, + name, + description: manifest.application.description, + version: packageJson.version, sourcePath: 'cli-sync', // Placeholder for CLI-synced apps serverlessFunctionLayerId: serverlessFunctionLayer.id, workspaceId, }); - await this.applicationVariableService.upsertManyApplicationVariableEntitys( + await this.applicationVariableService.upsertManyApplicationVariableEntities( { - env: manifest.env, + applicationVariables: manifest.application.applicationVariables, applicationId: application.id, }, ); @@ -143,62 +140,21 @@ export class ApplicationSyncService { ); await this.applicationService.update(application.id, { - name: manifest.name, - description: manifest.description, - version: manifest.version, + name, + description: manifest.application.description, + version: packageJson.version, }); - await this.applicationVariableService.upsertManyApplicationVariableEntitys({ - env: manifest.env, - applicationId: application.id, - }); + await this.applicationVariableService.upsertManyApplicationVariableEntities( + { + applicationVariables: manifest.application.applicationVariables, + applicationId: application.id, + }, + ); return application; } - private async syncAgents({ - agentsToSync, - workspaceId, - applicationId, - }: { - agentsToSync: AgentManifest[]; - workspaceId: string; - applicationId: string; - }) { - for (const agentToSync of agentsToSync) { - const existingAgent = - await this.agentService.findOneByApplicationAndStandardId({ - workspaceId, - applicationId, - standardId: agentToSync.standardId, - }); - - if (isDefined(existingAgent)) { - await this.agentService.updateOneAgent( - { id: existingAgent.id, ...agentToSync }, - workspaceId, - ); - - return; - } - - await this.agentService.createOneAgent( - { - name: agentToSync.name, - label: agentToSync.label, - description: agentToSync.description, - icon: agentToSync.icon, - prompt: agentToSync.prompt, - modelId: agentToSync.modelId, - standardId: agentToSync.standardId, - isCustom: true, - applicationId, - }, - workspaceId, - ); - } - } - private async syncObjects({ objectsToSync, workspaceId, @@ -224,27 +180,31 @@ export class ApplicationSyncService { // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as any[]; - const objectsToSyncStandardIds = objectsToSync.map((obj) => obj.standardId); + const objectsToSyncUniversalIds = objectsToSync.map( + (obj) => obj.universalIdentifier, + ); const applicationObjectsStandardIds = applicationObjects.map( - (obj) => obj.standardId, + (obj) => obj.universalIdentifier, ); const objectsToDelete = applicationObjects.filter( (obj) => - isDefined(obj.standardId) && - !objectsToSyncStandardIds.includes(obj.standardId), + isDefined(obj.universalIdentifier) && + !objectsToSyncUniversalIds.includes(obj.universalIdentifier), ); const objectsToUpdate = applicationObjects.filter( (obj) => - isDefined(obj.standardId) && - objectsToSyncStandardIds.includes(obj.standardId), + isDefined(obj.universalIdentifier) && + objectsToSyncUniversalIds.includes(obj.universalIdentifier), ); const objectsToCreate = objectsToSync.filter( (objectToSync) => - !applicationObjectsStandardIds.includes(objectToSync.standardId), + !applicationObjectsStandardIds.includes( + objectToSync.universalIdentifier, + ), ); for (const objectToDelete of objectsToDelete) { @@ -257,12 +217,12 @@ export class ApplicationSyncService { for (const objectToUpdate of objectsToUpdate) { const objectToSync = objectsToSync.find( - (obj) => obj.standardId === objectToUpdate.standardId, + (obj) => obj.universalIdentifier === objectToUpdate.universalIdentifier, ); if (!objectToSync) { throw new ApplicationException( - `Failed to find object to sync with standardId ${objectToUpdate.standardId}`, + `Failed to find object to sync with universalIdentifier ${objectToUpdate.universalIdentifier}`, ApplicationExceptionCode.OBJECT_NOT_FOUND, ); } @@ -298,7 +258,8 @@ export class ApplicationSyncService { labelPlural: objectToCreate.labelPlural, icon: objectToCreate.icon || undefined, description: objectToCreate.description || undefined, - standardId: objectToCreate.standardId || undefined, + standardId: objectToCreate.universalIdentifier, + universalIdentifier: objectToCreate.universalIdentifier, dataSourceId: dataSourceMetadata.id, applicationId, }; @@ -312,12 +273,14 @@ export class ApplicationSyncService { private async syncServerlessFunctions({ serverlessFunctionsToSync, + code, workspaceId, applicationId, serverlessFunctionLayerId, }: { serverlessFunctionsToSync: ServerlessFunctionManifest[]; workspaceId: string; + code: Sources; applicationId: string; serverlessFunctionLayerId: string; }) { @@ -392,12 +355,18 @@ export class ApplicationSyncService { ); } + const name = + serverlessFunctionToSync.name ?? + parse(serverlessFunctionToSync.handlerName).name; + const updateServerlessFunctionInput = { id: serverlessFunctionToUpdate.id, update: { - name: serverlessFunctionToSync.name, + name, + code, timeoutSeconds: serverlessFunctionToSync.timeoutSeconds, - code: serverlessFunctionToSync.code, + handlerPath: serverlessFunctionToSync.handlerPath, + handlerName: serverlessFunctionToSync.handlerName, }, }; @@ -426,11 +395,17 @@ export class ApplicationSyncService { } for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) { + const name = + serverlessFunctionToCreate.name ?? + parse(serverlessFunctionToCreate.handlerName).name; + const createServerlessFunctionInput = { - name: serverlessFunctionToCreate.name, - code: serverlessFunctionToCreate.code, + name, + code, universalIdentifier: serverlessFunctionToCreate.universalIdentifier, timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds, + handlerPath: serverlessFunctionToCreate.handlerPath, + handlerName: serverlessFunctionToCreate.handlerName, applicationId, serverlessFunctionLayerId, }; @@ -659,7 +634,7 @@ export class ApplicationSyncService { id: triggerToUpdate.id, update: { settings: { - pattern: triggerToSync.schedule, + pattern: triggerToSync.pattern, }, }, }; @@ -677,7 +652,7 @@ export class ApplicationSyncService { const createCronTriggerInput = { settings: { - pattern: triggerToCreate.schedule, + pattern: triggerToCreate.pattern, }, universalIdentifier: triggerToCreate.universalIdentifier, serverlessFunctionId, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.module.ts b/packages/twenty-server/src/engine/core-modules/application/application.module.ts index 1f6f5ee7076..b3fba691fdb 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.module.ts @@ -8,7 +8,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; -import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module'; @@ -25,7 +24,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa WorkspaceManyOrAllFlatEntityMapsCacheModule, ObjectMetadataModule, DataSourceModule, - AgentModule, ApplicationVariableEntityModule, ServerlessFunctionLayerModule, ServerlessFunctionModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts index d2f37b238a0..ab048e64f5c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.resolver.ts @@ -57,11 +57,11 @@ export class ApplicationResolver { @Mutation(() => Boolean) async deleteApplication( - @Args() { packageJson }: DeleteApplicationInput, + @Args() { universalIdentifier }: DeleteApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { await this.applicationSyncService.deleteApplication({ - applicationUniversalIdentifier: packageJson.universalIdentifier, + applicationUniversalIdentifier: universalIdentifier, workspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts index 1863c03f6ae..75a274fc4af 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/deleteApplication.input.ts @@ -1,11 +1,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; -import GraphQLJSON from 'graphql-type-json'; - -import { PackageJson } from 'src/engine/core-modules/application/types/application.types'; - @ArgsType() export class DeleteApplicationInput { - @Field(() => GraphQLJSON, { nullable: false }) - packageJson: PackageJson; + @Field(() => String) + universalIdentifier: string; } diff --git a/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts b/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts index 3354a2ecac4..c535bed3a1b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts +++ b/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts @@ -1,51 +1,56 @@ import { type HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity'; -import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type'; export type PackageJson = { - $schema?: string; - universalIdentifier: string; name: string; - description?: string; + license: string; engines: { node: string; npm: string; yarn: string; }; - env?: EnvManifest; - icon?: string; + packageManager: string; version: string; dependencies?: object; devDependencies?: object; }; -export type EnvManifest = Record; - -export type EnvVariableManifest = { +type ApplicationVariable = { + universalIdentifier: string; value?: string; description?: string; - isSecret: boolean; + isSecret?: boolean; }; -export type AppManifest = PackageJson & { - agents: AgentManifest[]; +type Sources = { [key: string]: string | Sources }; + +type Application = { + universalIdentifier: string; + displayName?: string; + description?: string; + icon?: string; + applicationVariables?: Record; +}; +export type AppManifest = { + application: Application; objects: ObjectManifest[]; serverlessFunctions: ServerlessFunctionManifest[]; + sources: Sources; }; export type ServerlessFunctionManifest = { - $schema?: string; universalIdentifier: string; - name: string; + name?: string; description?: string; timeoutSeconds?: number; + handlerPath: string; + handlerName: string; triggers: ServerlessFunctionTriggerManifest[]; - code: ServerlessFunctionCode; }; export type ServerlessFunctionTriggerManifest = ( | { type: 'cron'; - schedule: string; + pattern: string; } | { type: 'databaseEvent'; @@ -62,8 +67,7 @@ export type ServerlessFunctionTriggerManifest = ( }; export type ObjectManifest = { - $schema?: string; - standardId: string; + universalIdentifier: string; nameSingular: string; namePlural: string; labelSingular: string; diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts index b7d69dc10e0..d5070a41180 100644 --- a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts @@ -4,7 +4,7 @@ import { isDefined } from 'twenty-shared/utils'; import { In, Not, Repository } from 'typeorm'; import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity'; -import { EnvManifest } from 'src/engine/core-modules/application/types/application.types'; +import { AppManifest } from 'src/engine/core-modules/application/types/application.types'; export class ApplicationVariableEntityService { constructor( @@ -27,33 +27,52 @@ export class ApplicationVariableEntityService { ); } - async upsertManyApplicationVariableEntitys({ - env, + async upsertManyApplicationVariableEntities({ + applicationVariables, applicationId, }: { - env?: EnvManifest; + applicationVariables?: AppManifest['application']['applicationVariables']; applicationId: string; }) { - if (!isDefined(env)) { + if (!isDefined(applicationVariables)) { return; } - for (const [key, { value, description, isSecret }] of Object.entries(env)) { - await this.applicationVariableRepository.upsert( - { + for (const [key, { value, description, isSecret }] of Object.entries( + applicationVariables, + )) { + if ( + await this.applicationVariableRepository.findOne({ + where: { + key, + applicationId, + }, + }) + ) { + await this.applicationVariableRepository.update( + { + key, + applicationId, + }, + { + description, + isSecret, + }, + ); + } else { + await this.applicationVariableRepository.save({ key, value, description, isSecret, applicationId, - }, - { conflictPaths: ['key', 'applicationId'] }, - ); + }); + } } await this.applicationVariableRepository.delete({ applicationId, - key: Not(In(Object.keys(env))), + key: Not(In(Object.keys(applicationVariables))), }); } } diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs b/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs index 0291b67c155..29023fe0c4a 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs @@ -6,20 +6,23 @@ export const handler = async (event) => { const mainPath = `/tmp/${randomId}.mjs`; + // eslint-disable-next-line no-undef const oldProcessEnv = { ...process.env }; try { - const { code, params, env } = event; + const { code, params, env, handlerName } = event; await fs.writeFile(mainPath, code, 'utf8'); + // eslint-disable-next-line no-undef process.env = { ...process.env, ...(env ?? {}) }; const mainFile = await import(mainPath); - return await mainFile.main(params); + return await mainFile[handlerName](params); } finally { await fs.rm(mainPath, { force: true }); + // eslint-disable-next-line no-undef process.env = oldProcessEnv; } }; diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts index 173b0bf0e1e..c6ddd31eb3c 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts @@ -51,6 +51,13 @@ import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/bu const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60; const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h +type LambdaDriverExecutorPayload = { + code: string; + params: object; + env: Record; + handlerName: string; +}; + export interface LambdaDriverOptions extends LambdaClientConfig { fileStorageService: FileStorageService; region: string; @@ -326,8 +333,10 @@ export class LambdaDriver implements ServerlessDriver { let builtBundleFilePath = ''; try { - builtBundleFilePath = - await buildServerlessFunctionInMemory(sourceTemporaryDir); + builtBundleFilePath = await buildServerlessFunctionInMemory({ + sourceTemporaryDir, + handlerPath: serverlessFunction.handlerPath, + }); } catch (error) { return formatBuildError(error, startTime); } @@ -336,10 +345,11 @@ export class LambdaDriver implements ServerlessDriver { 'utf-8', ); - const executorPayload = { + const executorPayload: LambdaDriverExecutorPayload = { params: payload, code: compiledCode, env: buildEnvVar(serverlessFunction), + handlerName: serverlessFunction.handlerName, }; const params: InvokeCommandInput = { diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts index 82dfd75a358..bcd4bd82e1a 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts @@ -61,27 +61,6 @@ export class LocalDriver implements ServerlessDriver { await this.createLayerIfNotExists(serverlessFunction); } - private async executeWithTimeout( - fn: () => Promise, - timeoutMs: number, - ): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Task timed out after ${timeoutMs / 1_000} seconds`)); - }, timeoutMs); - - fn() - .then((result) => { - clearTimeout(timer); - resolve(result); - }) - .catch((err) => { - clearTimeout(timer); - reject(err); - }); - }); - } - async execute( serverlessFunction: ServerlessFunctionEntity, payload: object, @@ -109,8 +88,10 @@ export class LocalDriver implements ServerlessDriver { let builtBundleFilePath = ''; try { - builtBundleFilePath = - await buildServerlessFunctionInMemory(sourceTemporaryDir); + builtBundleFilePath = await buildServerlessFunctionInMemory({ + sourceTemporaryDir, + handlerPath: serverlessFunction.handlerPath, + }); } catch (error) { return formatBuildError(error, startTime); } @@ -164,10 +145,11 @@ export class LocalDriver implements ServerlessDriver { }); try { - const runnerPath = await this.writeBootstrapRunner( - sourceTemporaryDir, - builtBundleFilePath, - ); + const runnerPath = await this.writeBootstrapRunner({ + dir: sourceTemporaryDir, + builtFileAbsPath: builtBundleFilePath, + handlerName: serverlessFunction.handlerName, + }); const { ok, result, error, stack, stdout, stderr } = await this.runChildWithEnv({ @@ -222,7 +204,15 @@ export class LocalDriver implements ServerlessDriver { } } - async writeBootstrapRunner(dir: string, builtFileAbsPath: string) { + async writeBootstrapRunner({ + dir, + builtFileAbsPath, + handlerName, + }: { + dir: string; + builtFileAbsPath: string; + handlerName: string; + }) { const runnerPath = join(dir, '__runner.cjs'); const code = ` // Auto-generated. Do not edit. @@ -232,8 +222,8 @@ export class LocalDriver implements ServerlessDriver { try { const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)}); const mod = await import(builtUrl.href); - if (typeof mod.main !== 'function') { - throw new Error('Export "main" not found in serverless bundle'); + if (typeof mod.${handlerName} !== 'function') { + throw new Error('Export "${handlerName}" not found in serverless bundle'); } let payload = undefined; @@ -241,7 +231,7 @@ export class LocalDriver implements ServerlessDriver { process.on('message', async (msg) => { if (!msg || msg.type !== 'run') return; try { - const out = await mod.main(msg.payload); + const out = await mod.${handlerName}(msg.payload); process.send && process.send({ ok: true, result: out }); process.exit(0); } catch (err) { @@ -253,7 +243,7 @@ export class LocalDriver implements ServerlessDriver { // Fallback: read payload from argv[2] (JSON) and print to stdout const json = process.argv[2]; payload = json ? JSON.parse(json) : undefined; - const out = await mod.main(payload); + const out = await mod.${handlerName}(payload); console.log(JSON.stringify({ ok: true, result: out })); process.exit(0); } diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory.ts index 4adb0d2037d..741b745a574 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory.ts @@ -2,10 +2,14 @@ import { join } from 'path'; import { build } from 'esbuild'; -export const buildServerlessFunctionInMemory = async ( - sourceTemporaryDir: string, -) => { - const entryFilePath = join(sourceTemporaryDir, 'src', 'index.ts'); +export const buildServerlessFunctionInMemory = async ({ + sourceTemporaryDir, + handlerPath, +}: { + sourceTemporaryDir: string; + handlerPath: string; +}) => { + const entryFilePath = join(sourceTemporaryDir, handlerPath); const builtBundleFilePath = join(sourceTemporaryDir, 'dist', 'main.mjs'); diff --git a/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/jobs/call-database-event-trigger-jobs.job.ts b/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/jobs/call-database-event-trigger-jobs.job.ts index cf08d80b714..d2c825ab490 100644 --- a/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/jobs/call-database-event-trigger-jobs.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/jobs/call-database-event-trigger-jobs.job.ts @@ -45,13 +45,15 @@ export class CallDatabaseEventTriggerJobsJob { continue; } - for (const eventData of workspaceEventBatch.events) { + const { events, ...batchEventInfo } = workspaceEventBatch; + + for (const event of events) { await this.messageQueueService.add( ServerlessFunctionTriggerJob.name, { serverlessFunctionId: databaseEventListener.serverlessFunction.id, workspaceId: databaseEventListener.workspaceId, - payload: eventData, + payload: { ...batchEventInfo, ...event }, }, { retryLimit: 3 }, ); diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/constants/flat-serverless-function-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/constants/flat-serverless-function-editable-properties.constant.ts index 8e8512efb35..78dcaf59488 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/constants/flat-serverless-function-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/constants/flat-serverless-function-editable-properties.constant.ts @@ -6,4 +6,6 @@ export const FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES = [ 'timeoutSeconds', 'checksum', 'code', + 'handlerPath', + 'handlerName', ] as const satisfies (keyof FlatServerlessFunction)[]; 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 52a2885c929..b7897633fc1 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 @@ -11,7 +11,7 @@ import { } from 'class-validator'; import graphqlTypeJson from 'graphql-type-json'; -import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type'; +import { Sources } from 'src/engine/core-modules/file-storage/types/source.type'; @InputType() export class CreateServerlessFunctionInput { @@ -44,5 +44,5 @@ export class CreateServerlessFunctionInput { @Field(() => graphqlTypeJson, { nullable: true }) @IsObject() @IsOptional() - code?: ServerlessFunctionCode; + code?: Sources; } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts index e27d77bf37f..a10a5f05afa 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts @@ -57,6 +57,14 @@ export class ServerlessFunctionDTO { @Field({ nullable: true }) latestVersion?: string; + @IsString() + @Field() + handlerPath: string; + + @IsString() + @Field() + handlerName: string; + @IsArray() @Field(() => [String], { nullable: false }) publishedVersions: string[]; 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 1c87e18e990..a1fae851d71 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 @@ -15,13 +15,14 @@ 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'; +import type { Sources } from 'src/engine/core-modules/file-storage/types/source.type'; @InputType() class UpdateServerlessFunctionInputUpdates { @IsString() @Field() - name: string; + @IsOptional() + name?: string; @IsString() @Field({ nullable: true }) @@ -37,7 +38,17 @@ class UpdateServerlessFunctionInputUpdates { @Field(() => graphqlTypeJson) @IsObject() - code: ServerlessFunctionCode; + code: Sources; + + @IsString() + @Field({ nullable: true }) + @IsOptional() + handlerName?: string; + + @IsString() + @Field({ nullable: true }) + @IsOptional() + handlerPath?: string; } @InputType() diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts index 3dbb60b5a09..3848f2d1d2b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts @@ -28,6 +28,9 @@ export enum ServerlessFunctionRuntime { NODE22 = 'nodejs22.x', } +export const DEFAULT_HANDLER_PATH = 'src/index.ts'; +export const DEFAULT_HANDLER_NAME = 'main'; + export const SERVERLESS_FUNCTION_ENTITY_RELATION_PROPERTIES = [ 'cronTriggers', 'databaseEventTriggers', @@ -46,6 +49,12 @@ export class ServerlessFunctionEntity @Column({ nullable: false }) name: string; + @Column({ nullable: false, default: DEFAULT_HANDLER_PATH }) + handlerPath: string; + + @Column({ nullable: false, default: DEFAULT_HANDLER_NAME }) + handlerName: string; + @Column({ nullable: true, type: 'varchar' }) description: string | null; 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 e672c0203ac..4a4a9fa20c0 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 @@ -5,8 +5,8 @@ import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/typ import { type RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity'; import { type ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity'; import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; -import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type'; import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type'; +import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type'; export type ServerlessFunctionEntityRelationProperties = ExtractRecordTypeOrmRelationProperties< @@ -22,5 +22,5 @@ export type FlatServerlessFunction = FlatEntityFrom< ServerlessFunctionEntity, ServerlessFunctionEntityRelationProperties > & { - code?: ServerlessFunctionCode; + code?: Sources; }; 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 deleted file mode 100644 index e74972ef216..00000000000 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type'; - -export type ServerlessFunctionCode = { - src: { - 'index.ts': string; - } & Sources; -}; 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 0abbcf4c309..a892edef031 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 @@ -1,7 +1,11 @@ import { v4 } from 'uuid'; 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 { + DEFAULT_HANDLER_NAME, + DEFAULT_HANDLER_PATH, + 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'; @@ -23,6 +27,8 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({ id, name: rawCreateServerlessFunctionInput.name, description: rawCreateServerlessFunctionInput.description ?? null, + handlerPath: DEFAULT_HANDLER_PATH, + handlerName: DEFAULT_HANDLER_NAME, universalIdentifier: rawCreateServerlessFunctionInput.universalIdentifier ?? v4(), createdAt: currentDate, 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 deleted file mode 100644 index c5685754e5a..00000000000 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -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: ServerlessFunctionCode, -): string => { - if (!isDefined(code) || typeof code !== 'object') { - return serverlessFunctionCreateHash(''); - } - - const codeObj = code as unknown as Record; - const sortedKeys = Object.keys(codeObj).sort(); - const concatenatedContent = sortedKeys - .map((key) => `${key}:${codeObj[key]}`) - .join('|'); - - return serverlessFunctionCreateHash(concatenatedContent); -}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type.ts index 152166cf750..fa964803210 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type.ts @@ -1,6 +1,6 @@ import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type'; import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type'; -import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type'; +import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type'; export type CreateServerlessFunctionAction = { type: 'create_serverless_function'; @@ -10,7 +10,7 @@ export type CreateServerlessFunctionAction = { export type UpdateServerlessFunctionAction = { type: 'update_serverless_function'; serverlessFunctionId: string; - code?: ServerlessFunctionCode; + code?: Sources; updates: FlatEntityPropertiesUpdates<'serverlessFunction'>; }; 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 9a8e8ff6b20..1ae592cc471 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 @@ -4,13 +4,13 @@ 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 { type Sources } from 'src/engine/core-modules/file-storage/types/source.type'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service'; import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type'; -import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type'; import { UpdateServerlessFunctionAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/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 { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; @@ -77,7 +77,7 @@ export class UpdateServerlessFunctionActionHandlerService extends WorkspaceMigra code, }: { serverlessFunction: FlatServerlessFunction; - code: ServerlessFunctionCode; + code: Sources; }) { const fileFolder = getServerlessFolder({ serverlessFunction, diff --git a/packages/twenty-shared/scripts/generateBarrels.ts b/packages/twenty-shared/scripts/generateBarrels.ts index 3bca367d8e0..27cb299701d 100644 --- a/packages/twenty-shared/scripts/generateBarrels.ts +++ b/packages/twenty-shared/scripts/generateBarrels.ts @@ -1,9 +1,12 @@ +// @ts-ignore import prettier from '@prettier/sync'; import * as fs from 'fs'; import { globSync } from 'glob'; +// @ts-ignore import path from 'path'; import { Options } from 'prettier'; import slash from 'slash'; +// @ts-ignore import ts from 'typescript'; // TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI diff --git a/packages/twenty-shared/vite.config.ts b/packages/twenty-shared/vite.config.ts index 8be82b25acd..949577bec3c 100644 --- a/packages/twenty-shared/vite.config.ts +++ b/packages/twenty-shared/vite.config.ts @@ -1,11 +1,15 @@ +// @ts-ignore import path from 'path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; import tsconfigPaths from 'vite-tsconfig-paths'; +// @ts-ignore import packageJson from './package.json'; const moduleEntries = Object.keys((packageJson as any).exports || {}) - .filter((key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/')) + .filter( + (key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'), + ) .map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`); const entries = ['src/index.ts', ...moduleEntries]; @@ -37,7 +41,10 @@ export default defineConfig(() => { return { root: __dirname, cacheDir: '../../node_modules/.vite/packages/twenty-shared', - plugins: [tsconfigPaths(), dts({ entryRoot: 'src', tsconfigPath: tsConfigPath })], + plugins: [ + tsconfigPaths(), + dts({ entryRoot: 'src', tsconfigPath: tsConfigPath }), + ], build: { outDir: 'dist', lib: { entry: entries, name: 'twenty-shared' }, @@ -58,8 +65,6 @@ export default defineConfig(() => { ], }, }, - logLevel: 'warn' + logLevel: 'warn', }; }); - - diff --git a/yarn.lock b/yarn.lock index b4d3b77faa9..d6b32fdbfc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52510,7 +52510,9 @@ __metadata: lodash.capitalize: "npm:^4.2.1" lodash.kebabcase: "npm:^4.1.1" tsx: "npm:^4.7.0" + twenty-sdk: "workspace:*" typescript: "npm:^5.9.2" + uuid: "npm:^13.0.0" wait-on: "npm:^7.2.0" bin: twenty: dist/cli.js @@ -52663,7 +52665,7 @@ __metadata: languageName: unknown linkType: soft -"twenty-sdk@workspace:packages/twenty-sdk": +"twenty-sdk@workspace:*, twenty-sdk@workspace:packages/twenty-sdk": version: 0.0.0-use.local resolution: "twenty-sdk@workspace:packages/twenty-sdk" dependencies: @@ -54392,6 +54394,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^13.0.0": + version: 13.0.0 + resolution: "uuid@npm:13.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67 + languageName: node + linkType: hard + "uuid@npm:^3.3.2": version: 3.4.0 resolution: "uuid@npm:3.4.0"