From 65678ed99f96ae1a277f013c70a33369f6f767ac Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 3 Feb 2026 12:11:10 +0100 Subject: [PATCH] Upload files instead ofsources (#17608) --- .../src/utils/app-template.ts | 4 +- .../src/generated-metadata/graphql.ts | 31 +++- .../twenty-front/src/generated/graphql.ts | 31 +++- .../manifest.integration.spec.ts | 2 +- .../app-dev/expected-manifest.ts | 77 +++------- .../app-dev/tests/entities.tests.ts | 16 +- .../app-dev/tests/manifest.tests.ts | 14 +- .../test-function-2.function.ts | 2 +- .../rich-app/src/objects/postCard.object.ts | 4 +- .../src/roles/default-function.role.ts | 10 +- .../app-dev/expected-manifest.ts | 41 +---- .../src/cli/commands/app/app-dev.ts | 29 +++- .../logic-function/logic-function-execute.ts | 1 - .../src/cli/utilities/api/api-service.ts | 117 ++++++++++++-- ...sset-watcher.ts => file-upload-watcher.ts} | 45 +++--- ...fest.spec.ts => manifest-validate.spec.ts} | 21 ++- .../build/manifest/manifest-build.ts | 44 +++--- ...cksums.ts => manifest-update-checksums.ts} | 12 +- ...idate-manifest.ts => manifest-validate.ts} | 2 +- .../build/manifest/manifest-watcher.ts | 6 +- .../utilities/dev/dev-mode-orchestrator.ts | 57 ++++++- .../cli/utilities/dev/dev-ui-state-manager.ts | 4 +- .../src/cli/utilities/file/file-jsonc.ts | 4 +- .../src/sdk/application/application-config.ts | 6 + .../src/sdk/application/define-application.ts | 10 +- .../sdk/common/types/define-entity.type.ts | 4 +- packages/twenty-sdk/src/sdk/index.ts | 1 + .../1-17/1-17-seed-workflow-v1-16.command.ts | 10 +- ...032815802-addFileEntityUniqueConstraint.ts | 19 +++ ...DependencyChecksumsToLogicFunctionLayer.ts | 31 ++++ .../application/dtos/application.input.ts | 8 +- .../dtos/create-application.input.ts | 31 ++++ .../resolvers/application.resolver.ts | 52 +++++-- .../services/application-sync.service.ts | 45 ++++-- .../services/application.service.ts | 67 +++++++- .../services/marketplace.service.ts | 18 ++- .../interfaces/storage-driver.interface.ts | 5 + .../file-storage/drivers/local.driver.ts | 13 ++ .../file-storage/drivers/s3.driver.ts | 23 ++- .../file-storage/file-storage.service.ts | 45 ++++-- .../core-modules/file/entities/file.entity.ts | 6 + .../resolvers/files-field.resolver.ts | 4 +- .../services/logic-function-build.service.ts | 66 +++++++- .../constants/seed-project/src/index.mjs | 8 + .../drivers/lambda.driver.ts | 75 +++++++-- .../drivers/local.driver.ts | 69 +++++++-- ...opy-yarn-engine-and-build-dependencies.ts} | 16 +- .../get-last-common-layer-dependencies.ts | 6 +- .../utils/get-seed-project-files.ts | 2 +- .../logic-function-executor.module.ts | 2 + .../logic-function-executor.service.ts | 52 ++++--- .../services/logic-function-layer.service.ts | 145 +++++++++++++++--- .../public-domain/public-domain.service.ts | 4 +- .../dtos/create-logic-function-layer.input.ts | 8 +- .../logic-function-layer.entity.ts | 10 +- ...ntity-to-flat-logic-function-layer.util.ts | 4 +- .../services/logic-function.service.ts | 13 +- ...gic-function-to-logic-function-dto.util.ts | 1 + ...e-logic-function-action-handler.service.ts | 118 +++++++------- ...e-logic-function-action-handler.service.ts | 28 ++-- .../src/application/applicationType.ts | 4 +- .../twenty-shared/src/application/index.ts | 1 - .../src/application/manifestType.ts | 3 - .../src/application/packageJsonType.ts | 14 -- 64 files changed, 1148 insertions(+), 473 deletions(-) rename packages/twenty-sdk/src/cli/utilities/build/common/{asset-watcher.ts => file-upload-watcher.ts} (59%) rename packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/{validate-manifest.spec.ts => manifest-validate.spec.ts} (92%) rename packages/twenty-sdk/src/cli/utilities/build/manifest/{update-manifest-checksums.ts => manifest-update-checksums.ts} (88%) rename packages/twenty-sdk/src/cli/utilities/build/manifest/{validate-manifest.ts => manifest-validate.ts} (96%) create mode 100644 packages/twenty-sdk/src/sdk/application/application-config.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1770032815802-addFileEntityUniqueConstraint.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1770038963629-addDependencyChecksumsToLogicFunctionLayer.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/dtos/create-application.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/seed-project/src/index.mjs rename packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/{copy-and-build-dependencies.ts => copy-yarn-engine-and-build-dependencies.ts} (74%) delete mode 100644 packages/twenty-shared/src/application/packageJsonType.ts diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index 7e7757fe558..22da0a677f8 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -266,8 +266,8 @@ const createPackageJson = async ({ devDependencies: { typescript: '^5.9.3', '@types/node': '^24.7.2', - '@types/react': '^19.0.2', - react: '^19.0.2', + '@types/react': '^19.0.0', + react: '^19.0.0', eslint: '^9.32.0', 'typescript-eslint': '^8.50.0', }, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 40cdc0b75b6..9d520f7d5aa 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -916,6 +916,14 @@ export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']; }; +export type CreateApplicationInput = { + description?: InputMaybe; + name: Scalars['String']; + sourcePath: Scalars['String']; + universalIdentifier: Scalars['String']; + version: Scalars['String']; +}; + export type CreateApprovedAccessDomainInput = { domain: Scalars['String']; email: Scalars['String']; @@ -2047,6 +2055,7 @@ export type Mutation = { createObjectEvent: Analytics; createOneAgent: Agent; createOneAppToken: AppToken; + createOneApplication: Application; createOneField: Field; createOneLogicFunction: LogicFunction; createOneLogicFunctionLayer: LogicFunctionLayer; @@ -2370,6 +2379,11 @@ export type MutationCreateOneAppTokenArgs = { }; +export type MutationCreateOneApplicationArgs = { + input: CreateApplicationInput; +}; + + export type MutationCreateOneFieldArgs = { input: CreateOneFieldMetadataInput; }; @@ -2381,8 +2395,9 @@ export type MutationCreateOneLogicFunctionArgs = { export type MutationCreateOneLogicFunctionLayerArgs = { - packageJson: Scalars['JSON']; - yarnLock: Scalars['String']; + applicationUniversalIdentifier: Scalars['String']; + packageJsonChecksum: Scalars['JSON']; + yarnLockChecksum: Scalars['String']; }; @@ -2843,8 +2858,6 @@ export type MutationSubmitFormStepArgs = { export type MutationSyncApplicationArgs = { manifest: Scalars['JSON']; - packageJson: Scalars['JSON']; - yarnLock: Scalars['String']; }; @@ -3546,6 +3559,7 @@ export type Query = { chatMessages: Array; chatThread: AgentChatThread; chatThreads: Array; + checkApplicationExist: Scalars['Boolean']; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValidOutput; commandMenuItem?: Maybe; @@ -3658,6 +3672,12 @@ export type QueryChatThreadArgs = { }; +export type QueryCheckApplicationExistArgs = { + id?: InputMaybe; + universalIdentifier?: InputMaybe; +}; + + export type QueryCheckUserExistsArgs = { captchaToken?: InputMaybe; email: Scalars['String']; @@ -3691,7 +3711,8 @@ export type QueryFindOneAgentArgs = { export type QueryFindOneApplicationArgs = { - id: Scalars['UUID']; + id?: InputMaybe; + universalIdentifier?: InputMaybe; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index a11921612f4..e998c9f747b 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -912,6 +912,14 @@ export type CreateApiKeyInput = { roleId: Scalars['UUID']; }; +export type CreateApplicationInput = { + description?: InputMaybe; + name: Scalars['String']; + sourcePath: Scalars['String']; + universalIdentifier: Scalars['String']; + version: Scalars['String']; +}; + export type CreateApprovedAccessDomainInput = { domain: Scalars['String']; email: Scalars['String']; @@ -2016,6 +2024,7 @@ export type Mutation = { createObjectEvent: Analytics; createOneAgent: Agent; createOneAppToken: AppToken; + createOneApplication: Application; createOneField: Field; createOneLogicFunction: LogicFunction; createOneLogicFunctionLayer: LogicFunctionLayer; @@ -2316,6 +2325,11 @@ export type MutationCreateOneAgentArgs = { }; +export type MutationCreateOneApplicationArgs = { + input: CreateApplicationInput; +}; + + export type MutationCreateOneFieldArgs = { input: CreateOneFieldMetadataInput; }; @@ -2327,8 +2341,9 @@ export type MutationCreateOneLogicFunctionArgs = { export type MutationCreateOneLogicFunctionLayerArgs = { - packageJson: Scalars['JSON']; - yarnLock: Scalars['String']; + applicationUniversalIdentifier: Scalars['String']; + packageJsonChecksum: Scalars['JSON']; + yarnLockChecksum: Scalars['String']; }; @@ -2758,8 +2773,6 @@ export type MutationSubmitFormStepArgs = { export type MutationSyncApplicationArgs = { manifest: Scalars['JSON']; - packageJson: Scalars['JSON']; - yarnLock: Scalars['String']; }; @@ -3447,6 +3460,7 @@ export type Query = { apiKeys: Array; barChartData: BarChartDataOutput; billingPortalSession: BillingSessionOutput; + checkApplicationExist: Scalars['Boolean']; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValidOutput; commandMenuItem?: Maybe; @@ -3540,6 +3554,12 @@ export type QueryBillingPortalSessionArgs = { }; +export type QueryCheckApplicationExistArgs = { + id?: InputMaybe; + universalIdentifier?: InputMaybe; +}; + + export type QueryCheckUserExistsArgs = { captchaToken?: InputMaybe; email: Scalars['String']; @@ -3562,7 +3582,8 @@ export type QueryFindOneAgentArgs = { export type QueryFindOneApplicationArgs = { - id: Scalars['UUID']; + id?: InputMaybe; + universalIdentifier?: InputMaybe; }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts index 7ba3a696a80..2908fdf1bfa 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts @@ -11,7 +11,7 @@ describe('invalid-app manifest', () => { const result = await runAppDev({ appPath: APP_PATH, timeout: 10000 }); expect(result.success).toBe(false); - expect(result.output).toContain('Duplicate universalIdentifier'); + expect(result.output).toContain('Duplicate universal identifiers'); const manifestExists = await fs.pathExists(MANIFEST_OUTPUT_PATH); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index d202d4757b2..b876f2b3bc5 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -12,7 +12,6 @@ export const EXPECTED_MANIFEST: Manifest = { fileType: 'png', }, ], - yarnLock: '', application: { applicationVariables: { DEFAULT_RECIPIENT_NAME: { @@ -27,6 +26,8 @@ export const EXPECTED_MANIFEST: Manifest = { defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', icon: 'IconWorld', universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', + yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e', + packageJsonChecksum: '42c3913415952d91ff1cf67ef6452872', }, frontComponents: [ { @@ -68,14 +69,6 @@ export const EXPECTED_MANIFEST: Manifest = { ], fields: [ - { - objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', - description: 'Priority level for the post card (1-10)', - label: 'Priority', - name: 'priority', - type: FieldType.NUMBER, - universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d', - }, { objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', description: 'Post card category', @@ -104,6 +97,14 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldType.SELECT, universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e', }, + { + objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', + description: 'Priority level for the post card (1-10)', + label: 'Priority', + name: 'priority', + type: FieldType.NUMBER, + universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d', + }, ], objects: [ { @@ -234,8 +235,8 @@ export const EXPECTED_MANIFEST: Manifest = { { canReadFieldValue: false, canUpdateFieldValue: false, - fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff', - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', + fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', }, ], label: 'Default function role', @@ -245,7 +246,7 @@ export const EXPECTED_MANIFEST: Manifest = { canReadObjectRecords: true, canSoftDeleteObjectRecords: false, canUpdateObjectRecords: true, - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', + objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', }, ], permissionFlags: [PermissionFlagType.APPLICATIONS], @@ -273,10 +274,10 @@ export const EXPECTED_MANIFEST: Manifest = { }, { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'src/functions/greeting.function.mjs', + builtHandlerPath: 'src/logic-functions/greeting.function.mjs', handlerName: 'default.handler', name: 'greeting-function', - sourceHandlerPath: 'src/functions/greeting.function.ts', + sourceHandlerPath: 'src/logic-functions/greeting.function.ts', timeoutSeconds: 5, triggers: [ { @@ -291,10 +292,10 @@ export const EXPECTED_MANIFEST: Manifest = { }, { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'src/functions/test-function-2.function.mjs', - handlerName: 'config.handler', + builtHandlerPath: 'src/logic-functions/test-function-2.function.mjs', + handlerName: 'default.handler', name: 'test-function-2', - sourceHandlerPath: 'src/functions/test-function-2.function.ts', + sourceHandlerPath: 'src/logic-functions/test-function-2.function.ts', timeoutSeconds: 2, triggers: [ { @@ -307,10 +308,10 @@ export const EXPECTED_MANIFEST: Manifest = { }, { builtHandlerChecksum: '[checksum]', - builtHandlerPath: 'src/functions/test-function.function.mjs', + builtHandlerPath: 'src/logic-functions/test-function.function.mjs', handlerName: 'default.handler', name: 'test-function', - sourceHandlerPath: 'src/functions/test-function.function.ts', + sourceHandlerPath: 'src/logic-functions/test-function.function.ts', timeoutSeconds: 2, triggers: [ { @@ -335,42 +336,4 @@ export const EXPECTED_MANIFEST: Manifest = { universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', }, ], - packageJson: { - name: 'rich-app', - version: '0.1.0', - license: 'MIT', - engines: { - node: '^24.5.0', - npm: 'please-use-yarn', - yarn: '>=4.0.2', - }, - packageManager: 'yarn@4.9.2', - scripts: { - 'auth:login': 'twenty auth:login', - 'auth:logout': 'twenty auth:logout', - 'auth:status': 'twenty auth:status', - 'auth:switch': 'twenty auth:switch', - 'auth:list': 'twenty auth:list', - 'app:dev': 'twenty app:dev', - 'entity:add': 'twenty entity:add', - 'app:generate': 'twenty app:generate', - 'function:logs': 'twenty function:logs', - 'function:execute': 'twenty function:execute', - 'app:uninstall': 'twenty app:uninstall', - help: 'twenty help', - lint: 'eslint', - 'lint:fix': 'eslint --fix', - }, - dependencies: { - 'twenty-sdk': 'latest', - }, - devDependencies: { - typescript: '^5.9.3', - '@types/node': '^24.7.2', - '@types/react': '^19.0.2', - react: '^19.0.2', - eslint: '^9.32.0', - 'typescript-eslint': '^8.50.0', - }, - }, }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts index 014e81b2177..cfece7e9966 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts @@ -11,6 +11,7 @@ export const defineEntitiesTests = (appPath: string): void => { expect(sortedFiles).toEqual([ 'manifest.json', + 'package.json', 'public', 'public/favicon.png', 'src', @@ -21,17 +22,18 @@ export const defineEntitiesTests = (appPath: string): void => { 'src/components/greeting.front-component.mjs.map', 'src/components/test.front-component.mjs', 'src/components/test.front-component.mjs.map', - 'src/functions', - 'src/functions/greeting.function.mjs', - 'src/functions/greeting.function.mjs.map', - 'src/functions/test-function-2.function.mjs', - 'src/functions/test-function-2.function.mjs.map', - 'src/functions/test-function.function.mjs', - 'src/functions/test-function.function.mjs.map', + 'src/logic-functions', + 'src/logic-functions/greeting.function.mjs', + 'src/logic-functions/greeting.function.mjs.map', + 'src/logic-functions/test-function-2.function.mjs', + 'src/logic-functions/test-function-2.function.mjs.map', + 'src/logic-functions/test-function.function.mjs', + 'src/logic-functions/test-function.function.mjs.map', 'src/root.front-component.mjs', 'src/root.front-component.mjs.map', 'src/root.function.mjs', 'src/root.function.mjs.map', + 'yarn.lock', ]); }); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts index 36b0abaea55..da6b23c076e 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts @@ -19,13 +19,13 @@ export const defineManifestTests = (appPath: string): void => { normalizeManifestForComparison(EXPECTED_MANIFEST), ); - for (const fn of manifest.entities.logicFunctions) { + for (const fn of manifest.logicFunctions) { expect(fn.builtHandlerChecksum).toBeDefined(); expect(fn.builtHandlerChecksum).not.toBeNull(); expect(typeof fn.builtHandlerChecksum).toBe('string'); } - for (const component of manifest.entities.frontComponents ?? []) { + for (const component of manifest.frontComponents ?? []) { expect(component.builtComponentChecksum).toBeDefined(); expect(component.builtComponentChecksum).not.toBeNull(); expect(typeof component.builtComponentChecksum).toBe('string'); @@ -44,11 +44,11 @@ export const defineManifestTests = (appPath: string): void => { it('should load all entity types', async () => { const manifest = await fs.readJson(manifestOutputPath); - expect(manifest?.entities.objects).toHaveLength(2); - expect(manifest?.entities.logicFunctions).toHaveLength(4); - expect(manifest?.entities.frontComponents).toHaveLength(4); - expect(manifest?.entities.roles).toHaveLength(2); - expect(manifest?.entities.objectExtensions).toHaveLength(1); + expect(manifest.objects).toHaveLength(2); + expect(manifest.logicFunctions).toHaveLength(4); + expect(manifest.frontComponents).toHaveLength(4); + expect(manifest.roles).toHaveLength(2); + expect(manifest.fields).toHaveLength(2); }); }); }; diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/logic-functions/test-function-2.function.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/logic-functions/test-function-2.function.ts index 100ec80d59f..7aab36fec1b 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/logic-functions/test-function-2.function.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/logic-functions/test-function-2.function.ts @@ -1,7 +1,7 @@ import { defineLogicFunction } from '@/sdk'; import { testFunction2 } from '../utils/test-function-2.util'; -export const config = defineLogicFunction({ +export default defineLogicFunction({ universalIdentifier: 'eb3ffc98-88ec-45d4-9b4a-56833b219ccb', name: 'test-function-2', timeoutSeconds: 2, diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/objects/postCard.object.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/objects/postCard.object.ts index ea80b3a4875..c2a7f02f3de 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/objects/postCard.object.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/objects/postCard.object.ts @@ -10,6 +10,8 @@ enum PostCardStatus { export const POST_CARD_UNIVERSAL_IDENTIFIER = '54b589ca-eeed-4950-a176-358418b85c05'; +export const CONTENT_FIELD_UNIVERSAL_IDENTIFIER = + '58a0a314-d7ea-4865-9850-7fb84e72f30b'; export default defineObject({ universalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, nameSingular: 'postCard', @@ -20,7 +22,7 @@ export default defineObject({ icon: 'IconMail', fields: [ { - universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', + universalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER, type: FieldType.TEXT, label: 'Content', description: "Postcard's content", diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/roles/default-function.role.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/roles/default-function.role.ts index 6a27de17d93..b2955db9878 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/roles/default-function.role.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/src/roles/default-function.role.ts @@ -1,4 +1,8 @@ import { PermissionFlag, defineRole } from '@/sdk'; +import { + CONTENT_FIELD_UNIVERSAL_IDENTIFIER, + POST_CARD_UNIVERSAL_IDENTIFIER, +} from '@/cli/__tests__/apps/rich-app/src/objects/postCard.object'; export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'b648f87b-1d26-4961-b974-0908fd991061'; @@ -17,7 +21,7 @@ export default defineRole({ canBeAssignedToApiKeys: false, objectPermissions: [ { - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', + objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, canReadObjectRecords: true, canUpdateObjectRecords: true, canSoftDeleteObjectRecords: false, @@ -26,8 +30,8 @@ export default defineRole({ ], fieldPermissions: [ { - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', - fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff', + objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, + fieldUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER, canReadFieldValue: false, canUpdateFieldValue: false, }, diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts index c3ba28efa8c..b48473ee858 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts @@ -3,51 +3,14 @@ import type { Manifest } from 'twenty-shared/application'; export const EXPECTED_MANIFEST: Manifest = { sources: {}, - yarnLock: '', - packageJson: { - name: 'root-app', - version: '0.1.0', - license: 'MIT', - engines: { - node: '^24.5.0', - npm: 'please-use-yarn', - yarn: '>=4.0.2', - }, - packageManager: 'yarn@4.9.2', - scripts: { - 'auth:login': 'twenty auth:login', - 'auth:logout': 'twenty auth:logout', - 'auth:status': 'twenty auth:status', - 'auth:switch': 'twenty auth:switch', - 'auth:list': 'twenty auth:list', - 'app:dev': 'twenty app:dev', - 'entity:add': 'twenty entity:add', - 'app:generate': 'twenty app:generate', - 'function:logs': 'twenty function:logs', - 'function:execute': 'twenty function:execute', - 'app:uninstall': 'twenty app:uninstall', - help: 'twenty help', - lint: 'eslint', - 'lint:fix': 'eslint --fix', - }, - dependencies: { - 'twenty-sdk': 'latest', - }, - devDependencies: { - typescript: '^5.9.3', - '@types/node': '^24.7.2', - '@types/react': '^19.0.2', - react: '^19.0.2', - eslint: '^9.32.0', - 'typescript-eslint': '^8.50.0', - }, - }, application: { universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001', displayName: 'Root App', description: 'An app with all entities at root level', icon: 'IconFolder', defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002', + packageJsonChecksum: '351460efb13a6c1bee63e27cf87f6ece', + yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e', }, publicAssets: [], fields: [], diff --git a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts index 2129d899bd4..76cd54ea227 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-dev.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-dev.ts @@ -1,10 +1,9 @@ -import { AssetWatcher } from '@/cli/utilities/build/common/asset-watcher'; import { createFrontComponentsWatcher, createLogicFunctionsWatcher, type EsbuildWatcher, } from '@/cli/utilities/build/common/esbuild-watcher'; -import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/update-manifest-checksums'; +import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-update-checksums'; import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import { DevModeOrchestrator } from '@/cli/utilities/dev/dev-mode-orchestrator'; @@ -12,7 +11,9 @@ import path from 'path'; import * as fs from 'fs-extra'; import { DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager'; import { renderDevUI } from '@/cli/utilities/dev/dev-ui'; -import { OUTPUT_DIR } from 'twenty-shared/application'; +import { ASSETS_DIR, OUTPUT_DIR } from 'twenty-shared/application'; +import { FileUploadWatcher } from '@/cli/utilities/build/common/file-upload-watcher'; +import { FileFolder } from 'twenty-shared/types'; export type AppDevOptions = { appPath?: string; @@ -24,7 +25,8 @@ export class AppDevCommand { private manifestWatcher: ManifestWatcher | null = null; private logicFunctionsWatcher: EsbuildWatcher | null = null; private frontComponentsWatcher: EsbuildWatcher | null = null; - private assetWatcher: AssetWatcher | null = null; + private assetWatcher: FileUploadWatcher | null = null; + private dependencyWatcher: FileUploadWatcher | null = null; private watchersStarted = false; private uiStateManager: DevUiStateManager | null = null; private unmountUI: (() => void) | null = null; @@ -96,6 +98,7 @@ export class AppDevCommand { this.startLogicFunctionsWatcher(logicFunctions), this.startFrontComponentsWatcher(frontComponents), this.startAssetWatcher(), + this.startDependencyWatcher(), ]); } @@ -134,8 +137,10 @@ export class AppDevCommand { } private async startAssetWatcher(): Promise { - this.assetWatcher = new AssetWatcher({ + this.assetWatcher = new FileUploadWatcher({ appPath: this.appPath, + fileFolder: FileFolder.PublicAsset, + watchPaths: [ASSETS_DIR], handleFileBuilt: this.orchestrator!.handleFileBuilt.bind( this.orchestrator, ), @@ -144,6 +149,19 @@ export class AppDevCommand { await this.assetWatcher.start(); } + private async startDependencyWatcher(): Promise { + this.dependencyWatcher = new FileUploadWatcher({ + appPath: this.appPath, + fileFolder: FileFolder.Source, + watchPaths: ['package.json', 'yarn.lock'], + handleFileBuilt: this.orchestrator!.handleFileBuilt.bind( + this.orchestrator, + ), + }); + + this.dependencyWatcher.start(); + } + private setupGracefulShutdown(): void { const shutdown = async () => { this.unmountUI?.(); @@ -153,6 +171,7 @@ export class AppDevCommand { this.logicFunctionsWatcher?.close(), this.frontComponentsWatcher?.close(), this.assetWatcher?.close(), + this.dependencyWatcher?.close(), ]); process.exit(0); diff --git a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts b/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts index eeb8c0643f1..6ebd5846fd8 100644 --- a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts +++ b/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts @@ -95,7 +95,6 @@ export class LogicFunctionExecuteCommand { const result = await this.apiService.executeLogicFunction({ functionId: targetFunction.id, payload: parsedPayload, - version: 'draft', }); if (!result.success) { diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index 030e4f23c42..593d5443636 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -9,7 +9,10 @@ import { printSchema, } from 'graphql/index'; import * as path from 'path'; -import { type Manifest } from 'twenty-shared/application'; +import { + type ApplicationManifest, + type Manifest, +} from 'twenty-shared/application'; import { type FileFolder } from 'twenty-shared/types'; import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; import { pascalCase } from 'twenty-shared/utils'; @@ -99,18 +102,69 @@ export class ApiService { } } - async syncApplication(manifest: Manifest): Promise { + async checkApplicationExist( + universalIdentifier: string, + ): Promise> { + try { + const query = ` + query CheckApplicationExist($universalIdentifier: UUID!) { + checkApplicationExist(universalIdentifier: $universalIdentifier) + } + `; + const response = await this.client.post( + '/metadata', + { + query, + variables: { universalIdentifier }, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + if (response.data.errors) { + return { + success: false, + error: response.data.errors[0], + }; + } + + return { + success: true, + data: response.data.data.checkApplicationExist, + message: `Successfully find application`, + }; + } catch (error) { + return { + success: false, + error, + }; + } + } + + async createApplication( + manifest: Manifest, + ): Promise> { try { const mutation = ` - mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) { - syncApplication(manifest: $manifest, packageJson: $packageJson, yarnLock: $yarnLock) + mutation CreateOneApplication($input: CreateApplicationInput!) { + createOneApplication(input: $input) { + id + universalIdentifier + } } `; const variables = { - manifest, - packageJson: manifest.packageJson, - yarnLock: manifest.yarnLock, + input: { + universalIdentifier: manifest.application.universalIdentifier, + name: manifest.application.displayName, + version: '0.0.1', + sourcePath: 'cli-sync', + }, }; const response: AxiosResponse = await this.client.post( @@ -134,10 +188,54 @@ export class ApiService { }; } + return { + success: true, + data: response.data.data.createOneApplication, + message: `Successfully create application: ${manifest.application.displayName}`, + }; + } catch (error) { + return { + success: false, + error, + }; + } + } + + async syncApplication(manifest: Manifest): Promise { + try { + const mutation = ` + mutation SyncApplication($manifest: JSON!) { + syncApplication(manifest: $manifest) + } + `; + + const variables = { manifest }; + + const response: AxiosResponse = await this.client.post( + '/metadata', + { + query: mutation, + variables, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + if (response.data.errors) { + return { + success: false, + error: response.data.errors[0], + }; + } + return { success: true, data: response.data.data.syncApplication, - message: `Successfully synced application: ${manifest.packageJson.name}`, + message: `Successfully synced application: ${manifest.application.displayName}`, }; } catch (error) { return { @@ -297,11 +395,9 @@ export class ApiService { async executeLogicFunction({ functionId, payload, - version = 'latest', }: { functionId: string; payload: Record; - version?: string; }): Promise< ApiResponse<{ data: unknown; @@ -332,7 +428,6 @@ export class ApiService { input: { id: functionId, payload, - version, }, }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/asset-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts similarity index 59% rename from packages/twenty-sdk/src/cli/utilities/build/common/asset-watcher.ts rename to packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts index bff4a670d2b..cae5770ccff 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/asset-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts @@ -1,12 +1,14 @@ import chokidar, { type FSWatcher } from 'chokidar'; import crypto from 'crypto'; import * as fs from 'fs-extra'; -import path from 'path'; -import { FileFolder } from 'twenty-shared/types'; -import { ASSETS_DIR, OUTPUT_DIR } from 'twenty-shared/application'; +import { dirname, join, relative } from 'path'; +import { type FileFolder } from 'twenty-shared/types'; +import { OUTPUT_DIR } from 'twenty-shared/application'; export type AssetWatcherOptions = { appPath: string; + fileFolder: FileFolder; + watchPaths: string[]; handleFileBuilt: (options: { fileFolder: FileFolder; builtPath: string; @@ -15,26 +17,33 @@ export type AssetWatcherOptions = { }) => void; }; -export class AssetWatcher { +export class FileUploadWatcher { private appPath: string; private watcher: FSWatcher | null = null; + private fileFolder: FileFolder; + private watchPaths: string[]; private handleFileBuilt: AssetWatcherOptions['handleFileBuilt']; constructor(options: AssetWatcherOptions) { this.appPath = options.appPath; + this.fileFolder = options.fileFolder; + this.watchPaths = options.watchPaths; this.handleFileBuilt = options.handleFileBuilt; } async start(): Promise { - const assetsPath = path.join(this.appPath, ASSETS_DIR); + const rootPaths = this.watchPaths.map((watchPath) => + join(this.appPath, watchPath), + ); - const exists = await fs.pathExists(assetsPath); - - if (!exists) { - return; + for (const rootPath of rootPaths) { + const exists = await fs.pathExists(rootPath); + if (!exists) { + return; + } } - this.watcher = chokidar.watch(assetsPath, { + this.watcher = chokidar.watch(rootPaths, { awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50, @@ -62,18 +71,18 @@ export class AssetWatcher { } private async copyAndNotify(absoluteFilePath: string): Promise { - const sourcePath = path.relative(this.appPath, absoluteFilePath); - const outputPath = path.join(OUTPUT_DIR, sourcePath); - const absoluteOutputPath = path.join(this.appPath, outputPath); + const sourcePath = relative(this.appPath, absoluteFilePath); + const outputPath = join(OUTPUT_DIR, sourcePath); + const absoluteOutputPath = join(this.appPath, outputPath); - await fs.ensureDir(path.dirname(absoluteOutputPath)); + await fs.ensureDir(dirname(absoluteOutputPath)); await fs.copy(absoluteFilePath, absoluteOutputPath); const content = await fs.readFile(absoluteOutputPath); const checksum = crypto.createHash('md5').update(content).digest('hex'); this.handleFileBuilt({ - fileFolder: FileFolder.PublicAsset, + fileFolder: this.fileFolder, builtPath: outputPath, sourcePath, checksum, @@ -81,9 +90,9 @@ export class AssetWatcher { } private async handleUnlink(absoluteFilePath: string): Promise { - const sourcePath = path.relative(this.appPath, absoluteFilePath); - const builtPath = path.join(OUTPUT_DIR, sourcePath); - const absoluteBuiltPath = path.join(this.appPath, builtPath); + const sourcePath = relative(this.appPath, absoluteFilePath); + const builtPath = join(OUTPUT_DIR, sourcePath); + const absoluteBuiltPath = join(this.appPath, builtPath); await fs.remove(absoluteBuiltPath); } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/validate-manifest.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts similarity index 92% rename from packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/validate-manifest.spec.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index 39a32f6db03..d14f82f66fe 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/validate-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -1,16 +1,17 @@ import { type ApplicationManifest, type Manifest, - type PackageJson, type FieldManifest, } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; -import { validateManifest } from '@/cli/utilities/build/manifest/validate-manifest'; +import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate'; const validApplication: ApplicationManifest = { universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', displayName: 'Test App', defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2', + packageJsonChecksum: '98592af7-4be9-4655-b5c4-9bef307a996c', + yarnLockChecksum: '580ee05f-15fe-4146-bac2-6c382483c94e', }; const validField: FieldManifest = { @@ -31,14 +32,12 @@ const validManifest: Manifest = { roles: [], publicAssets: [], sources: {}, - packageJson: {} as PackageJson, - yarnLock: '', }; -describe('validateManifest - objectExtensions', () => { +describe('manifestValidate', () => { describe('valid object extensions', () => { it('should pass validation with valid object extension by nameSingular', () => { - const result = validateManifest({ + const result = manifestValidate({ ...validManifest, fields: [validField], }); @@ -56,7 +55,7 @@ describe('validateManifest - objectExtensions', () => { label: 'Custom Note', }; - const result = validateManifest({ + const result = manifestValidate({ ...validManifest, fields: [extensionByUuid], }); @@ -74,7 +73,7 @@ describe('validateManifest - objectExtensions', () => { label: 'Nickname', }; - const result = validateManifest({ + const result = manifestValidate({ ...validManifest, fields: [validField, anotherExtension], }); @@ -101,7 +100,7 @@ describe('validateManifest - objectExtensions', () => { }, ], }; - const result = validateManifest({ + const result = manifestValidate({ ...validManifest, fields: [extensionWithSelect], }); @@ -132,7 +131,7 @@ describe('validateManifest - objectExtensions', () => { }, ]; - const result = validateManifest({ + const result = manifestValidate({ ...validManifest, fields: fieldsWithDuplicates, }); @@ -149,7 +148,7 @@ describe('validateManifest - objectExtensions', () => { it('should fail when extension field ID conflicts with object field ID', () => { const sharedId = '550e8400-e29b-41d4-a716-446655440001'; - const result = validateManifest({ + const result = manifestValidate({ ...validManifest, objects: [ { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 51014e64988..57899c129b9 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -5,11 +5,12 @@ import { TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING, } from '@/cli/utilities/build/manifest/manifest-extract-config'; import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file'; -import { findPathFile } from '@/cli/utilities/file/file-find'; -import { parseJsoncFile } from '@/cli/utilities/file/file-jsonc'; -import { type FrontComponentConfig, type LogicFunctionConfig } from '@/sdk'; +import { + type FrontComponentConfig, + type LogicFunctionConfig, + type ApplicationConfig, +} from '@/sdk'; import { glob } from 'fast-glob'; -import * as fs from 'fs-extra'; import { readFile } from 'fs-extra'; import { basename, extname, relative, sep } from 'path'; import { @@ -23,8 +24,9 @@ import { type ObjectManifest, type RoleManifest, } from 'twenty-shared/application'; -import { type Sources } from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; +import { type Sources } from 'twenty-shared/types'; +import * as fs from 'fs-extra'; const loadSources = async (appPath: string): Promise => { return await glob(['**/*.ts', '**/*.tsx'], { @@ -108,11 +110,16 @@ export const buildManifest = async ( switch (entity) { case ManifestEntityKey.Application: { - const extract = await extractManifestFromFile({ + const extract = await extractManifestFromFile({ appPath, filePath, }); - application = extract.config; + + application = { + ...extract.config, + yarnLockChecksum: null, + packageJsonChecksum: null, + }; errors.push(...extract.errors); applicationFilePaths.push(relativePath); break; @@ -157,11 +164,13 @@ export const buildManifest = async ( const { handler: _, ...rest } = extract.config; + const relativeFilePath = relative(appPath, filePath); + const config: LogicFunctionManifest = { ...rest, handlerName: 'default.handler', - sourceHandlerPath: filePath, - builtHandlerPath: filePath.replace(/\.tsx?$/, '.mjs'), + sourceHandlerPath: relativeFilePath, + builtHandlerPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'), builtHandlerChecksum: null, }; @@ -179,11 +188,13 @@ export const buildManifest = async ( const { component, ...rest } = extract.config; + const relativeFilePath = relative(appPath, filePath); + const config: FrontComponentManifest = { ...rest, componentName: component.name, - sourceComponentPath: filePath, - builtComponentPath: filePath.replace(/\.tsx?$/, '.mjs'), + sourceComponentPath: relativeFilePath, + builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'), builtComponentChecksum: null, }; @@ -219,15 +230,6 @@ export const buildManifest = async ( ); } - const packageJson = await parseJsoncFile( - await findPathFile(appPath, 'package.json'), - ); - - const yarnLock = await readFile( - await findPathFile(appPath, 'yarn.lock'), - 'utf8', - ); - const manifest = !application ? null : { @@ -239,8 +241,6 @@ export const buildManifest = async ( frontComponents, publicAssets, sources: await computeSources(appPath, filePaths), - packageJson, - yarnLock, }; const entityFilePaths: EntityFilePaths = { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/update-manifest-checksums.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-update-checksums.ts similarity index 88% rename from packages/twenty-sdk/src/cli/utilities/build/manifest/update-manifest-checksums.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-update-checksums.ts index f014db64205..2023f98020c 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/update-manifest-checksums.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-update-checksums.ts @@ -18,7 +18,7 @@ export type UpdateManifestChecksumParams = { >; }; -export const updateManifestChecksums = ({ +export const manifestUpdateChecksums = ({ manifest, builtFileInfos, }: UpdateManifestChecksumParams): Manifest => { @@ -77,6 +77,16 @@ export const updateManifestChecksums = ({ ), }; } + + if (fileFolder === FileFolder.Source) { + if (rootBuiltPath === 'package.json') { + result.application.packageJsonChecksum = checksum; + } + + if (rootBuiltPath === 'yarn.lock') { + result.application.yarnLockChecksum = checksum; + } + } } return result; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/validate-manifest.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts similarity index 96% rename from packages/twenty-sdk/src/cli/utilities/build/manifest/validate-manifest.ts rename to packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index 2f92a284ce5..370bca7dae1 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/validate-manifest.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -34,7 +34,7 @@ const findUniversalIdentifiers = (obj: object): string[] => { return universalIdentifiers; }; -export const validateManifest = (manifest: Manifest) => { +export const manifestValidate = (manifest: Manifest) => { const errors: string[] = []; const warnings: string[] = []; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts index 6035f035ebe..f984e512dbd 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-watcher.ts @@ -41,6 +41,10 @@ export class ManifestWatcher { const isAssetFile = relativePath.startsWith(ASSETS_DIR); + const isDependencyFile = ['package.json', 'yarn.lock'].includes( + relativePath, + ); + const isTypeScriptFile = relativePath.endsWith('.ts') || relativePath.endsWith('.tsx'); @@ -48,7 +52,7 @@ export class ManifestWatcher { const shouldIgnore = isInIgnoredDir || !isTypeScriptFile || isHiddenFile; - if (shouldIgnore && !isAssetFile) { + if (shouldIgnore && !isAssetFile && !isDependencyFile) { return; } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts index fee5f4047a1..620b67534f9 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-mode-orchestrator.ts @@ -1,7 +1,7 @@ import { type ManifestBuildResult, - updateManifestChecksums, -} from '@/cli/utilities/build/manifest/update-manifest-checksums'; + manifestUpdateChecksums, +} from '@/cli/utilities/build/manifest/manifest-update-checksums'; import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; import { ApiService } from '@/cli/utilities/api/api-service'; import { FileUploader } from '@/cli/utilities/file/file-uploader'; @@ -10,7 +10,7 @@ import type { Location } from 'esbuild'; import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager'; import { type EventName } from 'chokidar/handler.js'; import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; -import { validateManifest } from '@/cli/utilities/build/manifest/validate-manifest'; +import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate'; export type DevModeOrchestratorOptions = { appPath: string; @@ -248,7 +248,7 @@ export class DevModeOrchestrator { return; } - const validation = validateManifest(result.manifest); + const validation = manifestValidate(result.manifest); if (!validation.isValid) { for (const e of validation.errors) { @@ -289,11 +289,58 @@ export class DevModeOrchestrator { await this.handleManifestBuilt(result); if (!this.fileUploader) { + const checkApplicationExistResult = + await this.apiService.checkApplicationExist( + result.manifest.application.universalIdentifier, + ); + + if (!checkApplicationExistResult.success) { + this.uiStateManager.addEvent({ + message: `Failed to check if application ${result.manifest.application.universalIdentifier} already exists`, + status: 'error', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + error: `Failed to check if application already exists`, + }); + return; + } + + const applicationExists = checkApplicationExistResult.data; + + if (!applicationExists) { + this.uiStateManager.addEvent({ + message: 'Creating application', + status: 'info', + }); + + const createApplicationResult = + await this.apiService.createApplication(result.manifest); + + if (createApplicationResult.success) { + this.uiStateManager.addEvent({ + message: 'Application created', + status: 'success', + }); + } else { + this.uiStateManager.addEvent({ + message: `Application creation failed with error ${JSON.stringify(createApplicationResult.error, null, 2)}`, + status: 'error', + }); + this.uiStateManager.updateManifestState({ + manifestStatus: 'error', + error: `Application creation failed with error ${JSON.stringify(createApplicationResult.error, null, 2)}`, + }); + return; + } + } + this.fileUploader = new FileUploader({ appPath: this.appPath, applicationUniversalIdentifier: result.manifest.application.universalIdentifier, }); + for (const [ builtPath, { fileFolder, sourcePath }, @@ -306,7 +353,7 @@ export class DevModeOrchestrator { await Promise.all(this.activeUploads); } - const manifest = updateManifestChecksums({ + const manifest = manifestUpdateChecksums({ manifest: result.manifest, builtFileInfos: this.builtFileInfos, }); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts index bc4e72c8953..f1a0f6d480a 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/dev-ui-state-manager.ts @@ -67,7 +67,7 @@ export class DevUiStateManager { const event: UiEvent = { id: ++this.eventIdCounter, timestamp: new Date(), - message, + message: message.slice(0, 5_000), status, }; @@ -92,7 +92,7 @@ export class DevUiStateManager { ...this.state, ...(manifestStatus ? { manifestStatus } : {}), ...(appName ? { appName } : {}), - ...(error ? { error } : { error: undefined }), + ...(error ? { error: error.slice(0, 5_000) } : { error: undefined }), }; this.notify(); diff --git a/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts b/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts index 41e170271da..2f0744270c8 100644 --- a/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts +++ b/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts @@ -43,10 +43,10 @@ export const parseJsoncString = ( return result; }; -export const parseJsoncFile = async ( +export const parseJsoncFile = async ( filePath: string, options: JsoncParseOptions = {}, -): Promise => { +): Promise => { try { const content = await fs.readFile(filePath, 'utf8'); return parseJsoncString(content, options); diff --git a/packages/twenty-sdk/src/sdk/application/application-config.ts b/packages/twenty-sdk/src/sdk/application/application-config.ts new file mode 100644 index 00000000000..e7ba4c1a38f --- /dev/null +++ b/packages/twenty-sdk/src/sdk/application/application-config.ts @@ -0,0 +1,6 @@ +import { type ApplicationManifest } from 'twenty-shared/application'; + +export type ApplicationConfig = Omit< + ApplicationManifest, + 'packageJsonChecksum' | 'yarnLockChecksum' +>; diff --git a/packages/twenty-sdk/src/sdk/application/define-application.ts b/packages/twenty-sdk/src/sdk/application/define-application.ts index 252948f7a15..5e2a2ed26ad 100644 --- a/packages/twenty-sdk/src/sdk/application/define-application.ts +++ b/packages/twenty-sdk/src/sdk/application/define-application.ts @@ -1,10 +1,8 @@ -import { type ApplicationManifest } from 'twenty-shared/application'; import { createValidationResult } from '@/sdk/common/utils/create-validation-result'; import { type DefineEntity } from '@/sdk/common/types/define-entity.type'; +import { type ApplicationConfig } from '@/sdk/application/application-config'; -export const defineApplication: DefineEntity = ( - config, -) => { +export const defineApplication: DefineEntity = (config) => { const errors = []; if (!config.universalIdentifier) { @@ -15,6 +13,10 @@ export const defineApplication: DefineEntity = ( errors.push('Application must have a defaultRoleUniversalIdentifier'); } + if (!config.displayName || config.displayName.length === 0) { + errors.push('Application must have a non empty display name'); + } + return createValidationResult({ config, errors, diff --git a/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts index 754b0dba8c0..b453294c7b9 100644 --- a/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts +++ b/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts @@ -1,11 +1,11 @@ import { - type ApplicationManifest, type FieldManifest, type ObjectManifest, type RoleManifest, } from 'twenty-shared/application'; import { type FrontComponentConfig } from '@/sdk/front-components/front-component-config'; import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config'; +import { type ApplicationConfig } from '@/sdk/application/application-config'; export type ValidationResult = { success: boolean; @@ -14,7 +14,7 @@ export type ValidationResult = { }; export type DefinableEntity = - | ApplicationManifest + | ApplicationConfig | ObjectManifest | FieldManifest | FrontComponentConfig diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index 1ec1bd8769a..04ee7d50971 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -7,6 +7,7 @@ * |___/ */ +export type { ApplicationConfig } from './application/application-config'; export { defineApplication } from './application/define-application'; export type { ValidationResult, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-seed-workflow-v1-16.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-seed-workflow-v1-16.command.ts index 808e8bd845c..41e763957ce 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-seed-workflow-v1-16.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-seed-workflow-v1-16.command.ts @@ -416,15 +416,17 @@ export class SeedWorkflowV1_16Command extends ActiveOrSuspendedWorkspacesMigrati workspaceId: string, name: string = 'Seed code step (v1.16)', ): Promise { - const { id: logicFunctionLayerId } = - await this.logicFunctionLayerService.createCommonLayerIfNotExist( - workspaceId, - ); const { workspaceCustomFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { workspaceId }, ); const applicationId = workspaceCustomFlatApplication.id; + const { id: logicFunctionLayerId } = + await this.logicFunctionLayerService.createCommonLayer({ + workspaceId, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + }); const id = uuidv4(); const universalIdentifier = uuidv4(); const now = new Date(); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770032815802-addFileEntityUniqueConstraint.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770032815802-addFileEntityUniqueConstraint.ts new file mode 100644 index 00000000000..59377efea05 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770032815802-addFileEntityUniqueConstraint.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddFileEntityUniqueConstraint1770032815802 + implements MigrationInterface +{ + name = 'AddFileEntityUniqueConstraint1770032815802'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."file" ADD CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE" UNIQUE ("workspaceId", "applicationId", "path")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."file" DROP CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770038963629-addDependencyChecksumsToLogicFunctionLayer.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770038963629-addDependencyChecksumsToLogicFunctionLayer.ts new file mode 100644 index 00000000000..7eceb17fa58 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770038963629-addDependencyChecksumsToLogicFunctionLayer.ts @@ -0,0 +1,31 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddDependencyChecksumsToLogicFunctionLayer1770038963629 + implements MigrationInterface +{ + name = 'AddDependencyChecksumsToLogicFunctionLayer1770038963629'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."logicFunctionLayer" ADD "packageJsonChecksum" text`, + ); + await queryRunner.query( + `ALTER TABLE "core"."logicFunctionLayer" RENAME COLUMN "checksum" TO "yarnLockChecksum"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."logicFunctionLayer" ADD "availablePackages" jsonb NOT NULL DEFAULT '{}'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."logicFunctionLayer" DROP COLUMN "availablePackages"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."logicFunctionLayer" RENAME COLUMN "yarnLockChecksum" TO "checksum"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."logicFunctionLayer" DROP COLUMN "packageJsonChecksum"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.input.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.input.ts index 3b27eb50d77..d25ffae4de7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.input.ts @@ -1,16 +1,10 @@ import { ArgsType, Field } from '@nestjs/graphql'; import GraphQLJSON from 'graphql-type-json'; -import { Manifest, PackageJson } from 'twenty-shared/application'; +import { Manifest } from 'twenty-shared/application'; @ArgsType() export class ApplicationInput { @Field(() => GraphQLJSON, { nullable: false }) manifest: Manifest; - - @Field(() => GraphQLJSON, { nullable: false }) - packageJson: PackageJson; - - @Field(() => String, { nullable: false }) - yarnLock: string; } diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/create-application.input.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/create-application.input.ts new file mode 100644 index 00000000000..a82d28ce964 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/create-application.input.ts @@ -0,0 +1,31 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +@InputType() +export class CreateApplicationInput { + @IsString() + @IsNotEmpty() + @Field() + universalIdentifier: string; + + @IsString() + @IsNotEmpty() + @Field() + name: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + + @IsString() + @IsNotEmpty() + @Field() + version: string; + + @IsString() + @IsNotEmpty() + @Field() + sourcePath: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts index 9409dba7996..667d95cb264 100644 --- a/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts @@ -5,12 +5,10 @@ import { UsePipes, } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { PermissionFlagType } from 'twenty-shared/constants'; import { FileFolder } from 'twenty-shared/types'; -import { type Repository } from 'typeorm'; import type { FileUpload } from 'graphql-upload/processRequest.mjs'; @@ -30,7 +28,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/services import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; -import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -41,6 +38,7 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input'; @UseGuards( WorkspaceAuthGuard, @@ -57,8 +55,6 @@ export class ApplicationResolver { private readonly applicationService: ApplicationService, private readonly fileStorageService: FileStorageService, private readonly workspaceCacheService: WorkspaceCacheService, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, ) {} @Query(() => [ApplicationDTO]) @@ -69,31 +65,65 @@ export class ApplicationResolver { return this.applicationService.findManyApplications(workspaceId); } + @Query(() => Boolean) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async checkApplicationExist( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string, + @Args('universalIdentifier', { type: () => UUIDScalarType, nullable: true }) + universalIdentifier?: string, + ) { + return await this.applicationService.checkApplicationExist({ + id, + universalIdentifier, + workspaceId, + }); + } + @Query(() => ApplicationDTO) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async findOneApplication( - @Args('id', { type: () => UUIDScalarType }) id: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string, + @Args('universalIdentifier', { type: () => UUIDScalarType, nullable: true }) + universalIdentifier?: string, + ) { + return await this.applicationService.findOneApplicationOrThrow({ + id, + universalIdentifier, + workspaceId, + }); + } + + @Mutation(() => ApplicationDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async createOneApplication( + @Args('input') input: CreateApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { - return await this.applicationService.findOneApplication(id, workspaceId); + return await this.applicationService.create({ + ...input, + sourceType: 'local', + workspaceId, + }); } @Mutation(() => Boolean) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async syncApplication( - @Args() { manifest, packageJson, yarnLock }: ApplicationInput, + @Args() { manifest }: ApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { await this.applicationSyncService.synchronizeFromManifest({ workspaceId, manifest, - yarnLock, - packageJson, }); return true; } @Mutation(() => Boolean) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async installApplication( @Args() { workspaceMigration: { actions } }: InstallApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -132,6 +162,7 @@ export class ApplicationResolver { } @Mutation(() => Boolean) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async uninstallApplication( @Args() { universalIdentifier }: UninstallApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -146,6 +177,7 @@ export class ApplicationResolver { @Mutation(() => FileDTO) @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async uploadApplicationFile( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @Args({ name: 'file', type: () => GraphQLUpload }) diff --git a/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts index 603b16d5b7d..e6fc4317840 100644 --- a/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts @@ -11,8 +11,9 @@ import { RelationFieldManifest, RoleManifest, } from 'twenty-shared/application'; -import { FieldMetadataType, Sources } from 'twenty-shared/types'; +import { FieldMetadataType, FileFolder, Sources } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { PackageJson } from 'type-fest'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { @@ -39,6 +40,8 @@ import { PermissionFlagService } from 'src/engine/metadata-modules/permission-fl import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; @Injectable() export class ApplicationSyncService { @@ -58,21 +61,18 @@ export class ApplicationSyncService { private readonly objectPermissionService: ObjectPermissionService, private readonly fieldPermissionService: FieldPermissionService, private readonly permissionService: PermissionFlagService, + private readonly fileStorageService: FileStorageService, ) {} public async synchronizeFromManifest({ workspaceId, manifest, - packageJson, - yarnLock, }: ApplicationInput & { workspaceId: string; }) { const application = await this.syncApplication({ workspaceId, manifest, - packageJson, - yarnLock, }); const ownerFlatApplication: FlatApplication = application; @@ -126,12 +126,24 @@ export class ApplicationSyncService { private async syncApplication({ workspaceId, manifest, - packageJson, - yarnLock, }: ApplicationInput & { workspaceId: string; }): Promise { - const name = manifest.application.displayName ?? packageJson.name; + const name = manifest.application.displayName; + const packageJson = JSON.parse( + ( + await streamToBuffer( + await this.fileStorageService.readFile_v2({ + applicationUniversalIdentifier: + manifest.application.universalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'package.json', + workspaceId, + }), + ) + ).toString('utf-8'), + ) as PackageJson; + const application = (await this.applicationService.findByUniversalIdentifier({ universalIdentifier: manifest.application.universalIdentifier, @@ -150,13 +162,19 @@ export class ApplicationSyncService { let logicFunctionLayerId = application.logicFunctionLayerId; - if (manifest.logicFunctions.length > 0) { + if ( + manifest.logicFunctions.length > 0 && + isDefined(manifest.application.packageJsonChecksum) && + isDefined(manifest.application.yarnLockChecksum) + ) { if (!isDefined(logicFunctionLayerId)) { logicFunctionLayerId = ( await this.logicFunctionLayerService.create( { - packageJson, - yarnLock, + packageJsonChecksum: manifest.application.packageJsonChecksum, + yarnLockChecksum: manifest.application.yarnLockChecksum, + applicationUniversalIdentifier: + manifest.application.universalIdentifier, }, workspaceId, ) @@ -166,9 +184,10 @@ export class ApplicationSyncService { await this.logicFunctionLayerService.update( logicFunctionLayerId, { - packageJson, - yarnLock, + packageJsonChecksum: manifest.application.packageJsonChecksum, + yarnLockChecksum: manifest.application.yarnLockChecksum, }, + manifest.application.universalIdentifier, workspaceId, ); } diff --git a/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts index 1b5912c50fc..7e9838d5149 100644 --- a/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/services/application.service.ts @@ -123,12 +123,43 @@ export class ApplicationService { }); } - async findOneApplication( - applicationId: string, - workspaceId: string, - ): Promise { - const application = await this.applicationRepository.findOne({ - where: { workspaceId, id: applicationId }, + async checkApplicationExist({ + id, + universalIdentifier, + workspaceId, + }: { + id?: string; + universalIdentifier?: string; + workspaceId: string; + }) { + return isDefined( + await this.findOneApplication({ id, universalIdentifier, workspaceId }), + ); + } + + async findOneApplication({ + id, + universalIdentifier, + workspaceId, + }: { + id?: string; + universalIdentifier?: string; + workspaceId: string; + }): Promise { + if (!isDefined(id) && !isDefined(universalIdentifier)) { + throw new ApplicationException( + `Either id or universalIdentifier must be provided to find application.`, + ApplicationExceptionCode.APPLICATION_NOT_FOUND, + ); + } + + const where = { + workspaceId, + ...(isDefined(id) ? { id } : { universalIdentifier }), + }; + + return await this.applicationRepository.findOne({ + where, relations: [ 'logicFunctions', 'agents', @@ -136,10 +167,26 @@ export class ApplicationService { 'applicationVariables', ], }); + } + + async findOneApplicationOrThrow({ + id, + universalIdentifier, + workspaceId, + }: { + id?: string; + universalIdentifier?: string; + workspaceId: string; + }): Promise { + const application = await this.findOneApplication({ + id, + universalIdentifier, + workspaceId, + }); if (!isDefined(application)) { throw new ApplicationException( - `Application with id ${applicationId} not found`, + `Application with id ${id} or universalIdentifier ${universalIdentifier} not found`, ApplicationExceptionCode.APPLICATION_NOT_FOUND, ); } @@ -168,6 +215,12 @@ export class ApplicationService { }); } + async createOneApplication( + data: Partial & { workspaceId: string }, + ): Promise { + return this.create(data); + } + async findTwentyStandardApplicationOrThrow(workspaceId: string): Promise<{ application: ApplicationEntity; workspace: WorkspaceEntity; diff --git a/packages/twenty-server/src/engine/core-modules/application/services/marketplace.service.ts b/packages/twenty-server/src/engine/core-modules/application/services/marketplace.service.ts index aab7353b98d..fba99c22a7d 100644 --- a/packages/twenty-server/src/engine/core-modules/application/services/marketplace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/services/marketplace.service.ts @@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; // eslint-disable-next-line no-restricted-imports import { lowerCase, upperFirst } from 'lodash'; import { type Manifest } from 'twenty-shared/application'; +import { PackageJson } from 'type-fest'; import { MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto'; @@ -198,9 +199,18 @@ export class MarketplaceService { return null; } - const manifest = JSON.parse(manifestContent) as Manifest; + const packageJsonContent = await this.fetchGitHubFile( + `${appPath}/.twenty/output/package.json`, + ); - const { application, packageJson } = manifest; + if (!packageJsonContent) { + return null; + } + + const manifest = JSON.parse(manifestContent) as Manifest; + const packageJson = JSON.parse(packageJsonContent) as PackageJson; + + const { application } = manifest; const marketplaceData = application.marketplaceData; if (!marketplaceData?.author || !marketplaceData?.category) { @@ -209,10 +219,10 @@ export class MarketplaceService { return { id: application.universalIdentifier, - name: application.displayName ?? packageJson.name, + name: application.displayName, description: application.description ?? '', icon: application.icon ?? 'IconApps', - version: packageJson.version, + version: packageJson.version ?? '0.1.0', author: marketplaceData.author, category: marketplaceData.category, logo: this.resolveAssetUrl(appPath, marketplaceData.logo), diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts index 1d234ac5649..3a938dbc130 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts @@ -17,6 +17,11 @@ export interface StorageDriver { onStoragePath: string; }): Promise; + downloadFile(params: { + onStoragePath: string; + localPath: string; + }): Promise; + delete(params: { folderPath: string; filename?: string }): Promise; move(params: { from: { folderPath: string; filename?: string }; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts index 739414551f8..358f08d38df 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts @@ -72,6 +72,19 @@ export class LocalDriver implements StorageDriver { await fs.writeFile(filePath, params.sourceFile); } + async downloadFile(params: { + onStoragePath: string; + localPath: string; + }): Promise { + await this.createFolder(dirname(params.localPath)); + + const filePath = join(`${this.options.storagePath}/`, params.onStoragePath); + + const content = await fs.readFile(filePath); + + await fs.writeFile(params.localPath, content); + } + async downloadFolder(params: { onStoragePath: string; localPath: string; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts index fb9af2e2389..a45347939bd 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts @@ -1,8 +1,8 @@ import { Logger } from '@nestjs/common'; import fs from 'fs'; -import { mkdir, readdir, readFile } from 'fs/promises'; -import { join } from 'path'; +import { readdir, readFile } from 'fs/promises'; +import { dirname, join } from 'path'; import { Readable } from 'stream'; import { pipeline } from 'stream/promises'; @@ -95,6 +95,23 @@ export class S3Driver implements StorageDriver { await this.s3Client.send(command); } + private async createFolder(path: string) { + return fs.mkdirSync(path, { recursive: true }); + } + + async downloadFile(params: { + onStoragePath: string; + localPath: string; + }): Promise { + await this.createFolder(dirname(params.localPath)); + + const fileStream = await this.readFile({ + filePath: params.onStoragePath, + }); + + await pipeline(fileStream, fs.createWriteStream(params.localPath)); + } + async downloadFolder(params: { onStoragePath: string; localPath: string; @@ -124,7 +141,7 @@ export class S3Driver implements StorageDriver { ? join(params.localPath, relativePath) : params.localPath; - await mkdir(localFolderPath, { recursive: true }); + await this.createFolder(localFolderPath); const fileStream = await this.readFile({ filePath: `${fromFolderPath}/${filename}`, diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index 13206a7d961..e910f8af268 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -97,19 +97,28 @@ export class FileStorageService { sourceFile, }); - const fileEntity = await this.fileRepository.save({ - path: `${fileFolder}/${resourcePath}`, - workspaceId, - applicationId: application.id, - id: fileId, - size: - typeof sourceFile === 'string' - ? Buffer.byteLength(sourceFile) - : sourceFile.length, - settings, - }); + await this.fileRepository.upsert( + { + path: `${fileFolder}/${resourcePath}`, + workspaceId, + applicationId: application.id, + id: fileId, + size: + typeof sourceFile === 'string' + ? Buffer.byteLength(sourceFile) + : sourceFile.length, + settings, + }, + ['path', 'workspaceId', 'applicationId'], + ); - return fileEntity; + return await this.fileRepository.findOneOrFail({ + where: { + path: `${fileFolder}/${resourcePath}`, + applicationId: application.id, + workspaceId, + }, + }); } /** @@ -224,6 +233,18 @@ export class FileStorageService { }); } + downloadFile_v2( + params: ResourceIdentifier & { localPath: string }, + ): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const onStoragePath = this.buildOnStoragePath(params); + + return driver.downloadFile({ + onStoragePath, + localPath: params.localPath, + }); + } + /** * @deprecated Use delete_v2 instead */ diff --git a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts index 3d6afe1f8ff..18d7657ce42 100644 --- a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts @@ -8,6 +8,7 @@ import { ManyToOne, PrimaryGeneratedColumn, Relation, + Unique, UpdateDateColumn, } from 'typeorm'; @@ -17,6 +18,11 @@ import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/works @Entity('file') @Index('IDX_FILE_WORKSPACE_ID', ['workspaceId']) +@Unique('IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE', [ + 'workspaceId', + 'applicationId', + 'path', +]) export class FileEntity extends WorkspaceRelatedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts index 3785728dad1..35bae995398 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts @@ -34,14 +34,12 @@ export class FilesFieldResolver { const stream = createReadStream(); const buffer = await streamToBuffer(stream); - const fileEntity = await this.filesFieldService.uploadFile({ + return await this.filesFieldService.uploadFile({ file: buffer, filename, declaredMimeType: mimetype, workspaceId, applicationId: workspaceCustomApplicationId, }); - - return fileEntity; } } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service.ts index 6fe0a60a220..13970656d1c 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import fs from 'fs/promises'; import { dirname, join } from 'path'; +import crypto from 'crypto'; import { build } from 'esbuild'; import { FileFolder } from 'twenty-shared/types'; @@ -13,6 +14,7 @@ import { getLogicFunctionBaseFolderPath, getRelativePathFromBase, } from 'src/engine/core-modules/logic-function/logic-function-build/utils/get-logic-function-base-folder-path.util'; +import { FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type'; export type FunctionBuildParams = { flatLogicFunction: FlatLogicFunction; @@ -23,6 +25,64 @@ export type FunctionBuildParams = { export class LogicFunctionBuildService { constructor(private readonly fileStorageService: FileStorageService) {} + async hasLayerDependencies({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }: { + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + }): Promise { + const packageJsonExists = await this.fileStorageService.checkFileExists_v2({ + workspaceId: flatLogicFunctionLayer.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'package.json', + }); + + const yarnLockExists = await this.fileStorageService.checkFileExists_v2({ + workspaceId: flatLogicFunctionLayer.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'yarn.lock', + }); + + return packageJsonExists && yarnLockExists; + } + + async uploadDependencies({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }: { + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + }) { + await this.fileStorageService.writeFile_v2({ + workspaceId: flatLogicFunctionLayer.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'package.json', + sourceFile: JSON.stringify(flatLogicFunctionLayer.packageJson, null, 2), + mimeType: undefined, + settings: { + isTemporaryFile: false, + toDelete: false, + }, + }); + + await this.fileStorageService.writeFile_v2({ + workspaceId: flatLogicFunctionLayer.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'yarn.lock', + sourceFile: flatLogicFunctionLayer.yarnLock, + mimeType: undefined, + settings: { + isTemporaryFile: false, + toDelete: false, + }, + }); + } + async isBuilt({ flatLogicFunction, applicationUniversalIdentifier, @@ -38,7 +98,7 @@ export class LogicFunctionBuildService { async buildAndUpload({ flatLogicFunction, applicationUniversalIdentifier, - }: FunctionBuildParams): Promise { + }: FunctionBuildParams): Promise<{ checksum: string }> { const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); try { @@ -85,6 +145,10 @@ export class LogicFunctionBuildService { toDelete: false, }, }); + + return { + checksum: crypto.createHash('md5').update(builtFile).digest('hex'), + }; } finally { await lambdaBuildDirectoryManager.clean(); } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/seed-project/src/index.mjs b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/seed-project/src/index.mjs new file mode 100644 index 00000000000..bd89c19f6c4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/seed-project/src/index.mjs @@ -0,0 +1,8 @@ +var main = async (params) => { + const { a, b } = params; + const message = `Hello, input: ${a} and ${b}`; + return { message }; +}; +export { + main +}; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts index b113a713550..9071a66a480 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts @@ -29,7 +29,7 @@ import { } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-executor-driver.interface'; import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; -import { copyAndBuildDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies'; +import { copyYarnEngineAndBuildDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-yarn-engine-and-build-dependencies'; import { copyExecutor } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-executor'; import { createZipFile } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/create-zip-file'; import { @@ -139,12 +139,43 @@ export class LambdaDriver implements LogicFunctionExecutorDriver { } private getLayerName(flatLogicFunctionLayer: FlatLogicFunctionLayer) { - return flatLogicFunctionLayer.checksum; + return flatLogicFunctionLayer.yarnLockChecksum; } - private async createLayerIfNotExists( - flatLogicFunctionLayer: FlatLogicFunctionLayer, - ): Promise { + private async copyDependenciesInMemory({ + applicationUniversalIdentifier, + workspaceId, + inMemoryLayerFolderPath, + }: { + applicationUniversalIdentifier: string; + workspaceId: string; + inMemoryLayerFolderPath: string; + }) { + await Promise.all([ + this.fileStorageService.downloadFile_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'package.json', + localPath: join(inMemoryLayerFolderPath, 'package.json'), + }), + this.fileStorageService.downloadFile_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'yarn.lock', + localPath: join(inMemoryLayerFolderPath, 'yarn.lock'), + }), + ]); + } + + private async createLayerIfNotExists({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }: { + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + }): Promise { const layerName = this.getLayerName(flatLogicFunctionLayer); const listLayerParams: ListLayerVersionsCommandInput = { @@ -171,10 +202,12 @@ export class LambdaDriver implements LogicFunctionExecutorDriver { NODE_LAYER_SUBFOLDER, ); - await copyAndBuildDependencies( - nodeDependenciesFolder, - flatLogicFunctionLayer, - ); + await this.copyDependenciesInMemory({ + applicationUniversalIdentifier, + workspaceId: flatLogicFunctionLayer.workspaceId, + inMemoryLayerFolderPath: nodeDependenciesFolder, + }); + await copyYarnEngineAndBuildDependencies(nodeDependenciesFolder); await createZipFile(sourceTemporaryDir, lambdaZipPath); @@ -257,15 +290,23 @@ export class LambdaDriver implements LogicFunctionExecutorDriver { return false; } - private async build( - flatLogicFunction: FlatLogicFunction, - flatLogicFunctionLayer: FlatLogicFunctionLayer, - ) { + private async build({ + flatLogicFunction, + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }: { + flatLogicFunction: FlatLogicFunction; + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + }) { if (await this.isAlreadyBuilt(flatLogicFunction, flatLogicFunctionLayer)) { return; } - const layerArn = await this.createLayerIfNotExists(flatLogicFunctionLayer); + const layerArn = await this.createLayerIfNotExists({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }); const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); @@ -317,7 +358,11 @@ export class LambdaDriver implements LogicFunctionExecutorDriver { payload, env, }: LogicFunctionExecuteParams): Promise { - await this.build(flatLogicFunction, flatLogicFunctionLayer); + await this.build({ + flatLogicFunction, + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }); await this.waitFunctionUpdates(flatLogicFunction); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts index 1b03083a055..fa236582124 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts @@ -5,14 +5,14 @@ import { join } from 'path'; import { FileFolder } from 'twenty-shared/types'; import { - type LogicFunctionExecutorDriver, type LogicFunctionExecuteParams, type LogicFunctionExecuteResult, + type LogicFunctionExecutorDriver, } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-executor-driver.interface'; import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder'; -import { copyAndBuildDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies'; +import { copyYarnEngineAndBuildDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-yarn-engine-and-build-dependencies'; import { ConsoleListener } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/intercept-console'; import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/lambda-build-directory-manager'; import { type FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type'; @@ -38,13 +38,44 @@ export class LocalDriver implements LogicFunctionExecutorDriver { ) => { return join( LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER, - flatLogicFunctionLayer.checksum, + flatLogicFunctionLayer.yarnLockChecksum, ); }; - private async createLayerIfNotExists( - flatLogicFunctionLayer: FlatLogicFunctionLayer, - ) { + private async copyDependenciesInMemory({ + applicationUniversalIdentifier, + workspaceId, + inMemoryLayerFolderPath, + }: { + applicationUniversalIdentifier: string; + workspaceId: string; + inMemoryLayerFolderPath: string; + }) { + await Promise.all([ + this.fileStorageService.downloadFile_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'package.json', + localPath: join(inMemoryLayerFolderPath, 'package.json'), + }), + this.fileStorageService.downloadFile_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'yarn.lock', + localPath: join(inMemoryLayerFolderPath, 'yarn.lock'), + }), + ]); + } + + private async createLayerIfNotExists({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }: { + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + }) { const inMemoryLayerFolderPath = this.getInMemoryLayerFolderPath( flatLogicFunctionLayer, ); @@ -52,17 +83,28 @@ export class LocalDriver implements LogicFunctionExecutorDriver { try { await fs.access(inMemoryLayerFolderPath); } catch { - await copyAndBuildDependencies( + await this.copyDependenciesInMemory({ + applicationUniversalIdentifier, + workspaceId: flatLogicFunctionLayer.workspaceId, inMemoryLayerFolderPath, - flatLogicFunctionLayer, - ); + }); + await copyYarnEngineAndBuildDependencies(inMemoryLayerFolderPath); } } async delete() {} - private async build(flatLogicFunctionLayer: FlatLogicFunctionLayer) { - await this.createLayerIfNotExists(flatLogicFunctionLayer); + private async build({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }: { + flatLogicFunctionLayer: FlatLogicFunctionLayer; + applicationUniversalIdentifier: string; + }) { + await this.createLayerIfNotExists({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }); } async execute({ @@ -72,7 +114,10 @@ export class LocalDriver implements LogicFunctionExecutorDriver { payload, env, }: LogicFunctionExecuteParams): Promise { - await this.build(flatLogicFunctionLayer); + await this.build({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }); const startTime = Date.now(); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-yarn-engine-and-build-dependencies.ts similarity index 74% rename from packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies.ts rename to packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-yarn-engine-and-build-dependencies.ts index 3d26d474f16..bfca06d2eed 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-and-build-dependencies.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-yarn-engine-and-build-dependencies.ts @@ -4,30 +4,16 @@ import { join } from 'path'; import { promisify } from 'util'; import { getLayerDependenciesDirName } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-layer-dependencies-dir-name'; -import { type FlatLogicFunctionLayer } from 'src/engine/metadata-modules/logic-function-layer/types/flat-logic-function-layer.type'; const execFilePromise = promisify(execFile); -export const copyAndBuildDependencies = async ( +export const copyYarnEngineAndBuildDependencies = async ( buildDirectory: string, - flatLogicFunctionLayer: FlatLogicFunctionLayer, ) => { await fs.mkdir(buildDirectory, { recursive: true, }); - const packageJson = flatLogicFunctionLayer.packageJson; - - const yarnLock = flatLogicFunctionLayer.yarnLock; - - await fs.writeFile( - join(buildDirectory, 'package.json'), - JSON.stringify(packageJson, null, 2), - 'utf8', - ); - - await fs.writeFile(join(buildDirectory, 'yarn.lock'), yarnLock, 'utf8'); - await fs.cp(getLayerDependenciesDirName('engine'), buildDirectory, { recursive: true, }); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-last-common-layer-dependencies.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-last-common-layer-dependencies.ts index 94a8a2841c8..b65a548cb4d 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-last-common-layer-dependencies.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-last-common-layer-dependencies.ts @@ -1,13 +1,11 @@ import fs from 'fs/promises'; import { join } from 'path'; -import { type PackageJson } from 'twenty-shared/application'; - import { getLayerDependenciesDirName } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-layer-dependencies-dir-name'; import { LAST_LAYER_VERSION } from 'src/engine/core-modules/logic-function/logic-function-drivers/layers/last-layer-version'; export type LayerDependencies = { - packageJson: PackageJson; + packageJson: string; yarnLock: string; }; @@ -20,5 +18,5 @@ export const getLastCommonLayerDependencies = async ( fs.readFile(join(lastVersionLayerDirName, 'yarn.lock'), 'utf8'), ]); - return { packageJson: JSON.parse(packageJson), yarnLock }; + return { packageJson, yarnLock }; }; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-seed-project-files.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-seed-project-files.ts index ba5cc930280..6d95a7dc6c9 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-seed-project-files.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/utils/get-seed-project-files.ts @@ -16,7 +16,7 @@ const getAllFiles = async ( const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - files.push(...(await getAllFiles(rootDir, fullPath, files))); + await getAllFiles(rootDir, fullPath, files); } else { files.push({ path: path.relative(rootDir, dir), diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts index aa0c9faf22f..2a094cdc0f1 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts @@ -12,6 +12,7 @@ import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.mod import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { LogicFunctionLayerModule } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache SubscriptionsModule, WorkspaceCacheModule, LogicFunctionBuildModule, + LogicFunctionLayerModule, FileModule, TypeOrmModule.forFeature([LogicFunctionEntity]), ], diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service.ts index cbc4b485cf1..7d96a33f7f6 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service.ts @@ -6,7 +6,7 @@ import { DEFAULT_API_URL_NAME, } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isEmptyObject } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { @@ -35,6 +35,7 @@ import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { cleanServerUrl } from 'src/utils/clean-server-url'; +import { LogicFunctionLayerService } from 'src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service'; const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5; @@ -68,6 +69,7 @@ export class LogicFunctionExecutorService private readonly functionBuildService: LogicFunctionBuildService, private readonly subscriptionService: SubscriptionService, private readonly auditService: AuditService, + private readonly logicFunctionLayerService: LogicFunctionLayerService, private readonly fileStorageService: FileStorageService, @InjectRepository(LogicFunctionEntity) private readonly logicFunctionRepository: Repository, @@ -178,6 +180,19 @@ export class LogicFunctionExecutorService ); } + // TODO: remove when all logic functions are migrated + if ( + !(await this.functionBuildService.hasLayerDependencies({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + })) + ) { + await this.functionBuildService.uploadDependencies({ + flatLogicFunctionLayer, + applicationUniversalIdentifier, + }); + } + if ( !(await this.functionBuildService.isBuilt({ flatLogicFunction, @@ -189,6 +204,7 @@ export class LogicFunctionExecutorService applicationUniversalIdentifier, }); } + // END TODO const resultLogicFunction = await this.callWithTimeout({ callback: () => @@ -291,30 +307,26 @@ export class LogicFunctionExecutorService async getAvailablePackages(logicFunctionId: string) { const logicFunction = await this.logicFunctionRepository.findOneOrFail({ where: { id: logicFunctionId }, - relations: ['logicFunctionLayer'], + relations: ['logicFunctionLayer', 'application'], }); - const packageJson = logicFunction.logicFunctionLayer.packageJson; + if (isEmptyObject(logicFunction.logicFunctionLayer.availablePackages)) { + await this.logicFunctionLayerService.update( + logicFunction.logicFunctionLayer.id, + {}, + logicFunction.application.universalIdentifier, + logicFunction.workspaceId, + ); - const yarnLock = logicFunction.logicFunctionLayer.yarnLock; - - const packageVersionRegex = /^"([^@]+)@.*?":\n\s+version: (.+)$/gm; - - const versions: Record = {}; - - let match: RegExpExecArray | null; - - while ((match = packageVersionRegex.exec(yarnLock)) !== null) { - const packageName = match[1].split('@', 1)[0]; - const version = match[2]; - - // @ts-expect-error legacy noImplicitAny - if (packageJson.dependencies?.[packageName]) { - versions[packageName] = version; - } + return ( + await this.logicFunctionRepository.findOneOrFail({ + where: { id: logicFunctionId }, + relations: ['logicFunctionLayer'], + }) + ).logicFunctionLayer.availablePackages; } - return versions; + return logicFunction.logicFunctionLayer.availablePackages; } private async throttleExecution(workspaceId: string) { diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service.ts index f056833a2c2..7b6e296f680 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-layer/services/logic-function-layer.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { isDefined } from 'twenty-shared/utils'; +import { FileFolder } from 'twenty-shared/types'; +import { PackageJson } from 'type-fest'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; @@ -11,6 +13,8 @@ import { CreateLogicFunctionLayerInput } from 'src/engine/metadata-modules/logic import { getLastCommonLayerDependencies } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-last-common-layer-dependencies'; import { logicFunctionCreateHash } from 'src/engine/metadata-modules/logic-function/utils/logic-function-create-hash.utils'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; @Injectable() export class LogicFunctionLayerService { @@ -18,23 +22,34 @@ export class LogicFunctionLayerService { @InjectRepository(LogicFunctionLayerEntity) private readonly logicFunctionLayerRepository: Repository, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly fileStorageService: FileStorageService, ) {} async create( - { packageJson, yarnLock }: CreateLogicFunctionLayerInput, + { + packageJsonChecksum, + yarnLockChecksum, + applicationUniversalIdentifier, + }: CreateLogicFunctionLayerInput, workspaceId: string, ) { - const checksum = logicFunctionCreateHash(yarnLock); - const logicFunctionLayer = this.logicFunctionLayerRepository.create({ - packageJson, - yarnLock, - checksum, + packageJson: {}, // TODO: Delete when migration to Source files storage is done + yarnLock: '', // TODO: Delete when migration to Source files storage is done + packageJsonChecksum, + yarnLockChecksum, workspaceId, + } as Omit); + + const availablePackages = await this.getAvailablePackages({ + workspaceId, + applicationUniversalIdentifier, }); - const savedLayer = - await this.logicFunctionLayerRepository.save(logicFunctionLayer); + const savedLayer = await this.logicFunctionLayerRepository.save({ + ...logicFunctionLayer, + availablePackages, + }); await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ 'logicFunctionLayerMaps', @@ -45,19 +60,19 @@ export class LogicFunctionLayerService { async update( id: string, - data: QueryDeepPartialEntity, + data: QueryDeepPartialEntity>, + applicationUniversalIdentifier: string, workspaceId: string, ) { - const checksum = data.yarnLock - ? logicFunctionCreateHash(data.yarnLock as string) - : undefined; + const availablePackages = await this.getAvailablePackages({ + workspaceId, + applicationUniversalIdentifier, + }); - const updateData = { ...data, ...(checksum && { checksum }) }; - - const result = await this.logicFunctionLayerRepository.update( - id, - updateData, - ); + const result = await this.logicFunctionLayerRepository.update(id, { + ...data, + availablePackages, + }); await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ 'logicFunctionLayerMaps', @@ -66,12 +81,45 @@ export class LogicFunctionLayerService { return result; } - async createCommonLayerIfNotExist(workspaceId: string) { + async createCommonLayer({ + workspaceId, + applicationUniversalIdentifier, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + }) { const { packageJson, yarnLock } = await getLastCommonLayerDependencies(); - const checksum = logicFunctionCreateHash(yarnLock); + + await this.fileStorageService.writeFile_v2({ + sourceFile: packageJson, + mimeType: undefined, + fileFolder: FileFolder.Source, + applicationUniversalIdentifier, + workspaceId, + resourcePath: 'package.json', + settings: { isTemporaryFile: false, toDelete: false }, + }); + + await this.fileStorageService.writeFile_v2({ + sourceFile: yarnLock, + mimeType: undefined, + fileFolder: FileFolder.Source, + applicationUniversalIdentifier, + workspaceId, + resourcePath: 'yarn.lock', + settings: { isTemporaryFile: false, toDelete: false }, + }); + + const packageJsonChecksum = logicFunctionCreateHash( + JSON.stringify(packageJson), + ); + + const yarnLockChecksum = logicFunctionCreateHash(yarnLock); + const commonLayer = await this.logicFunctionLayerRepository.findOne({ where: { - checksum, + yarnLockChecksum, + packageJsonChecksum, workspaceId, }, }); @@ -80,6 +128,59 @@ export class LogicFunctionLayerService { return commonLayer; } - return this.create({ packageJson, yarnLock }, workspaceId); + return this.create( + { packageJsonChecksum, yarnLockChecksum, applicationUniversalIdentifier }, + workspaceId, + ); + } + + private async getAvailablePackages({ + workspaceId, + applicationUniversalIdentifier, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + }) { + const packageJson = JSON.parse( + ( + await streamToBuffer( + await this.fileStorageService.readFile_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'package.json', + }), + ) + ).toString('utf-8'), + ) as PackageJson; + + const yarnLock = ( + await streamToBuffer( + await this.fileStorageService.readFile_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: 'yarn.lock', + }), + ) + ).toString('utf-8'); + + const packageVersionRegex = + /^"(@?[^@]+(?:\/[^@]+)?)@.*?":\n\s+version:\s*(.+)$/gm; + + const versions: Record = {}; + + let match: RegExpExecArray | null; + + while ((match = packageVersionRegex.exec(yarnLock)) !== null) { + const packageName = match[1]; + const version = match[2]; + + if (packageJson.dependencies?.[packageName]) { + versions[packageName] = version; + } + } + + return versions; } } diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts index 69b7cac9a7a..aa951ab9e45 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts @@ -93,7 +93,9 @@ export class PublicDomainService { try { await this.publicDomainRepository.insert( - publicDomain as QueryDeepPartialEntity, + publicDomain as QueryDeepPartialEntity< + Omit + >, ); } catch (error) { await this.dnsManagerService.deleteHostnameSilently(formattedDomain, { diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/dtos/create-logic-function-layer.input.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/dtos/create-logic-function-layer.input.ts index cfd597dd599..71763c2c2b3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/dtos/create-logic-function-layer.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/dtos/create-logic-function-layer.input.ts @@ -2,14 +2,16 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsString } from 'class-validator'; import GraphQLJSON from 'graphql-type-json'; -import { PackageJson } from 'twenty-shared/application'; @ArgsType() export class CreateLogicFunctionLayerInput { @Field(() => GraphQLJSON, { nullable: false }) - packageJson: PackageJson; + packageJsonChecksum: string; @IsString() @Field(() => String, { nullable: false }) - yarnLock: string; + yarnLockChecksum: string; + + @Field(() => String) + applicationUniversalIdentifier: string; } diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity.ts index c1960bba74e..4843bf2e4c8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity.ts @@ -1,4 +1,4 @@ -import { PackageJson } from 'twenty-shared/application'; +import { type PackageJson } from 'type-fest'; import { Column, CreateDateColumn, @@ -23,8 +23,14 @@ export class LogicFunctionLayerEntity extends WorkspaceRelatedEntity { @Column({ type: 'text', nullable: false }) yarnLock: string; + @Column({ type: 'text', nullable: true }) + packageJsonChecksum?: string; + @Column({ type: 'text', nullable: false }) - checksum: string; + yarnLockChecksum: string; + + @Column({ type: 'jsonb', nullable: false, default: {} }) + availablePackages: Record; @OneToMany( () => LogicFunctionEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/utils/from-logic-function-layer-entity-to-flat-logic-function-layer.util.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/utils/from-logic-function-layer-entity-to-flat-logic-function-layer.util.ts index ef3a10cf9b5..3af0e1b8386 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/utils/from-logic-function-layer-entity-to-flat-logic-function-layer.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function-layer/utils/from-logic-function-layer-entity-to-flat-logic-function-layer.util.ts @@ -6,10 +6,12 @@ export const fromLogicFunctionLayerEntityToFlatLogicFunctionLayer = ( ): FlatLogicFunctionLayer => ({ id: entity.id, packageJson: entity.packageJson, + packageJsonChecksum: entity.packageJsonChecksum, yarnLock: entity.yarnLock, - checksum: entity.checksum, + yarnLockChecksum: entity.yarnLockChecksum, workspaceId: entity.workspaceId, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), logicFunctionIds: entity.logicFunctions?.map((lf) => lf.id) ?? [], + availablePackages: entity.availablePackages ?? {}, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function.service.ts index 2296343c387..0e1ca8ec9a7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function.service.ts @@ -57,11 +57,20 @@ export class LogicFunctionService { let logicFunctionToCreateLayerId = input.logicFunctionLayerId; + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + if (!isDefined(logicFunctionToCreateLayerId)) { const { id: commonLogicFunctionLayerId } = - await this.logicFunctionLayerService.createCommonLayerIfNotExist( + await this.logicFunctionLayerService.createCommonLayer({ workspaceId, - ); + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + }); logicFunctionToCreateLayerId = commonLogicFunctionLayerId; } diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-flat-logic-function-to-logic-function-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-flat-logic-function-to-logic-function-dto.util.ts index 8ac19d94fcc..5e912940180 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-flat-logic-function-to-logic-function-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-flat-logic-function-to-logic-function-dto.util.ts @@ -8,6 +8,7 @@ export const fromFlatLogicFunctionToLogicFunctionDto = ({ }): LogicFunctionDTO => { return { id: flatLogicFunction.id, + universalIdentifier: flatLogicFunction.universalIdentifier, name: flatLogicFunction.name, description: flatLogicFunction.description ?? undefined, runtime: flatLogicFunction.runtime, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts index 3d16c20a2d3..8d03c512fb8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/create-logic-function-action-handler.service.ts @@ -1,12 +1,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { promises as fs } from 'fs'; -import { dirname, join } from 'path'; +import crypto from 'crypto'; import { Repository } from 'typeorm'; -import { isObject } from '@sniptt/guards'; -import { FileFolder, type Sources } from 'twenty-shared/types'; +import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; @@ -14,20 +12,18 @@ import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-mana import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { getSeedProjectFiles } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-seed-project-files'; -import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/lambda-build-directory-manager'; import { getLogicFunctionBaseFolderPath } from 'src/engine/core-modules/logic-function/logic-function-build/utils/get-logic-function-base-folder-path.util'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; import { LogicFunctionException, LogicFunctionExceptionCode, } from 'src/engine/metadata-modules/logic-function/logic-function.exception'; -import { FlatCreateLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; +import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; import { WorkspaceMigrationActionRunnerArgs, WorkspaceMigrationActionRunnerContext, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; -import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; -import { LogicFunctionBuildService } from 'src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service'; +import { FlatCreateLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; @Injectable() export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( @@ -36,7 +32,6 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR ) { constructor( private readonly fileStorageService: FileStorageService, - private readonly functionBuildService: LogicFunctionBuildService, @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, ) { @@ -58,10 +53,14 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR const applicationUniversalIdentifier = await this.getApplicationUniversalIdentifier(logicFunction.applicationId); - await this.buildAndSaveLogicFunction( - logicFunction, - applicationUniversalIdentifier, - ); + let seedChecksum: string | undefined; + + if (!isDefined(logicFunction.checksum)) { + seedChecksum = await this.seedLogicFunctionFiles( + logicFunction, + applicationUniversalIdentifier, + ); + } const logicFunctionRepository = queryRunner.manager.getRepository( @@ -71,6 +70,7 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR await logicFunctionRepository.insert({ ...logicFunction, workspaceId, + checksum: seedChecksum ?? logicFunction.checksum, }); } @@ -92,67 +92,57 @@ export class CreateLogicFunctionActionHandlerService extends WorkspaceMigrationR return application.universalIdentifier; } - private async writeSourcesToLocalFolder( - sources: Sources, - localPath: string, - ): Promise { - for (const key of Object.keys(sources)) { - const filePath = join(localPath, key); - const value = sources[key]; - - if (isObject(value)) { - await this.writeSourcesToLocalFolder(value as Sources, filePath); - continue; - } - await fs.mkdir(dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, value); - } - } - - private async buildAndSaveLogicFunction( + private async seedLogicFunctionFiles( logicFunction: FlatLogicFunction, applicationUniversalIdentifier: string, - ) { - const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); + ): Promise { + const seedProjectFiles = await getSeedProjectFiles; - try { - const { sourceTemporaryDir } = await lambdaBuildDirectoryManager.init(); + const sourceFiles = seedProjectFiles.filter((file) => + file.name.endsWith('index.ts'), + ); - if (isDefined(logicFunction?.code)) { - await this.writeSourcesToLocalFolder( - logicFunction.code, - sourceTemporaryDir, - ); - } else { - for (const file of await getSeedProjectFiles) { - const filePath = join(sourceTemporaryDir, file.path, file.name); + const builtFiles = seedProjectFiles.filter((file) => + file.name.endsWith('.mjs'), + ); - await fs.mkdir(join(sourceTemporaryDir, file.path), { - recursive: true, - }); - await fs.writeFile(filePath, file.content); - } - } - - const baseFolderPath = getLogicFunctionBaseFolderPath( - logicFunction.sourceHandlerPath, + if (sourceFiles.length !== 1 || builtFiles.length !== 1) { + throw new LogicFunctionException( + 'Seed project should have one index.ts file and one index.mjs file', + LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED, ); - - await this.fileStorageService.uploadFolder_v2({ - workspaceId: logicFunction.workspaceId, - applicationUniversalIdentifier, - fileFolder: FileFolder.Source, - resourcePath: baseFolderPath, - localPath: sourceTemporaryDir, - }); - } finally { - await lambdaBuildDirectoryManager.clean(); } - await this.functionBuildService.buildAndUpload({ - flatLogicFunction: logicFunction, + const sourceFile = sourceFiles[0]; + const builtFile = builtFiles[0]; + + await this.fileStorageService.writeFile_v2({ + workspaceId: logicFunction.workspaceId, applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + resourcePath: logicFunction.sourceHandlerPath, + sourceFile: sourceFile.content, + mimeType: 'application/typescript', + settings: { + isTemporaryFile: false, + toDelete: false, + }, }); + + await this.fileStorageService.writeFile_v2({ + workspaceId: logicFunction.workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltLogicFunction, + resourcePath: logicFunction.builtHandlerPath, + sourceFile: builtFile.content, + mimeType: 'application/javascript', + settings: { + isTemporaryFile: false, + toDelete: false, + }, + }); + + return crypto.createHash('md5').update(builtFile.content).digest('hex'); } async rollbackForMetadata( diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts index 547f0afb4d9..056e7011afb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts @@ -4,32 +4,32 @@ import { InjectRepository } from '@nestjs/typeorm'; import { promises as fs } from 'fs'; import { dirname, join } from 'path'; -import { isObject } from '@sniptt/guards'; -import { FileFolder, type Sources } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; +import { FileFolder, Sources } from 'twenty-shared/types'; +import { isObject } from '@sniptt/guards'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; -import { LogicFunctionBuildService } from 'src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service'; -import { getLogicFunctionBaseFolderPath } from 'src/engine/core-modules/logic-function/logic-function-build/utils/get-logic-function-base-folder-path.util'; -import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/lambda-build-directory-manager'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/services/logic-function-executor.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; -import { - LogicFunctionException, - LogicFunctionExceptionCode, -} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; -import { FlatUpdateLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; import { WorkspaceMigrationActionRunnerArgs, WorkspaceMigrationActionRunnerContext, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { FlatUpdateLogicFunctionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/types/workspace-migration-logic-function-action.type'; import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { LogicFunctionBuildService } from 'src/engine/core-modules/logic-function/logic-function-build/services/logic-function-build.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { getLogicFunctionBaseFolderPath } from 'src/engine/core-modules/logic-function/logic-function-build/utils/get-logic-function-base-folder-path.util'; +import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/lambda-build-directory-manager'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; @Injectable() export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( @@ -37,11 +37,11 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR 'logicFunction', ) { constructor( - private readonly fileStorageService: FileStorageService, private readonly logicFunctionExecutorService: LogicFunctionExecutorService, private readonly functionBuildService: LogicFunctionBuildService, @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, + private readonly fileStorageService: FileStorageService, ) { super(); } @@ -93,7 +93,7 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR }: { flatLogicFunction: FlatLogicFunction; }) { - this.logicFunctionExecutorService.delete(flatLogicFunction); + await this.logicFunctionExecutorService.delete(flatLogicFunction); } private async getApplicationUniversalIdentifier( diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index f10f9513f6f..42497b7b305 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -14,9 +14,11 @@ export type ApplicationMarketplaceData = { export type ApplicationManifest = SyncableEntityOptions & { defaultRoleUniversalIdentifier: string; - displayName?: string; + displayName: string; description?: string; icon?: string; applicationVariables?: ApplicationVariables; marketplaceData?: ApplicationMarketplaceData; + packageJsonChecksum: string | null; + yarnLockChecksum: string | null; }; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 9316f550f43..4358e344e83 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -36,6 +36,5 @@ export type { export type { Manifest } from './manifestType'; export type { ObjectFieldManifest } from './objectFieldManifest.type'; export type { ObjectManifest } from './objectManifestType'; -export type { PackageJson } from './packageJsonType'; export type { RoleManifest } from './roleManifestType'; export type { SyncableEntityOptions } from './syncableEntityOptionsType'; diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts index 304e6c17bd7..220533d0a2c 100644 --- a/packages/twenty-shared/src/application/manifestType.ts +++ b/packages/twenty-shared/src/application/manifestType.ts @@ -5,7 +5,6 @@ import { type FieldManifest } from './fieldManifestType'; import { type FrontComponentManifest } from './frontComponentManifestType'; import { type LogicFunctionManifest } from './logicFunctionManifestType'; import { type ObjectManifest } from './objectManifestType'; -import { type PackageJson } from './packageJsonType'; import { type RoleManifest } from './roleManifestType'; export type Manifest = { @@ -17,6 +16,4 @@ export type Manifest = { roles: RoleManifest[]; publicAssets: AssetManifest[]; sources: Sources; - packageJson: PackageJson; - yarnLock: string; }; diff --git a/packages/twenty-shared/src/application/packageJsonType.ts b/packages/twenty-shared/src/application/packageJsonType.ts deleted file mode 100644 index bf84ff19e0b..00000000000 --- a/packages/twenty-shared/src/application/packageJsonType.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type PackageJson = { - name: string; - license: string; - engines: { - node: string; - npm: string; - yarn: string; - }; - packageManager: string; - version: string; - dependencies?: object; - devDependencies?: object; - scripts?: Record; -};