From 163c96c2e504d2906c24c8e48c0ae6a958a5d7a6 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:37:42 +0200 Subject: [PATCH] Validate range version app dev sync (#22625) # Introduction Also now validating the workspace version when running a sync manifest Review in cubic --- .../app-dev/expected-manifest.ts | 1 + .../app-dev/expected-manifest.ts | 1 + .../build/manifest/manifest-build.ts | 2 + .../define/application/application-config.ts | 1 + .../application-development.module.ts | 2 + .../application-development.resolver.ts | 19 +++ .../application-install.service.ts | 24 +-- ...rsion-reason-to-exception-code.constant.ts | 34 +++++ .../application-tarball.service.ts | 24 +-- ...not-internal-server-error-snapshot.util.ts | 12 +- ...workspace-version.integration-spec.ts.snap | 2 +- ...workspace-version.integration-spec.ts.snap | 25 ++++ ...tion-workspace-version.integration-spec.ts | 11 +- ...tion-workspace-version.integration-spec.ts | 137 ++++++++++++++++++ .../test/utils/scrub-semver-versions.util.ts | 2 + .../src/application/applicationType.ts | 5 +- 16 files changed, 253 insertions(+), 49 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-workspace-version.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-workspace-version.integration-spec.ts create mode 100644 packages/twenty-server/test/utils/scrub-semver-versions.util.ts diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts index 4352c8f69d..05223b12d5 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts @@ -15,6 +15,7 @@ export const EXPECTED_MANIFEST: Manifest = { defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002', packageJsonChecksum: '[checksum]', yarnLockChecksum: '[checksum]', + requiredServerVersionRange: null, }, permissionFlags: [], skills: [], 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 61c8286e1f..3f0af021c2 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 @@ -233,6 +233,7 @@ export const EXPECTED_MANIFEST: Manifest = { universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e', packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde', + requiredServerVersionRange: null, }, frontComponents: [ { 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 61a39aedc4..14b93ebdfc 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 @@ -11,6 +11,7 @@ import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/uti import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields'; import { validateConditionalAvailabilityUsage } from '@/cli/utilities/build/manifest/utils/validate-conditional-availability-usage'; import { validateViewFilterOperands } from '@/cli/utilities/build/manifest/utils/validate-view-filter-operands'; +import { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range'; import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define'; import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config'; import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config'; @@ -577,6 +578,7 @@ export const buildManifest = async ( aboutDescription: readmeContent, yarnLockChecksum: null, packageJsonChecksum: null, + requiredServerVersionRange: getEngineVersionRange(appPath), ...(postInstallLogicFunctions.length >= 1 ? { postInstallLogicFunction: postInstallLogicFunctions[0] } : {}), diff --git a/packages/twenty-sdk/src/sdk/define/application/application-config.ts b/packages/twenty-sdk/src/sdk/define/application/application-config.ts index 1ed5ab8fd5..1ea4747979 100644 --- a/packages/twenty-sdk/src/sdk/define/application/application-config.ts +++ b/packages/twenty-sdk/src/sdk/define/application/application-config.ts @@ -4,6 +4,7 @@ export type ApplicationConfig = Omit< ApplicationManifest, | 'packageJsonChecksum' | 'yarnLockChecksum' + | 'requiredServerVersionRange' | 'postInstallLogicFunction' | 'preInstallLogicFunction' | 'defaultRoleUniversalIdentifier' diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.module.ts index a76456ecc3..b562ac09df 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { ApplicationManifestModule } from 'src/engine/core-modules/application/application-manifest/application-manifest.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module'; import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver'; import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -16,6 +17,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor imports: [ ApplicationModule, ApplicationManifestModule, + ApplicationPackageModule, ApplicationRegistrationModule, CacheLockModule, FeatureFlagModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts index 81c23a1fa4..2aef49686a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts @@ -22,6 +22,8 @@ import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/appli import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util'; +import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; @@ -64,6 +66,7 @@ export class ApplicationDevelopmentResolver { private readonly applicationSyncService: ApplicationSyncService, private readonly applicationRegistrationService: ApplicationRegistrationService, private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, + private readonly applicationVersionValidationService: ApplicationVersionValidationService, private readonly fileStorageService: FileStorageService, private readonly sdkClientGenerationService: SdkClientGenerationService, private readonly twentyConfigService: TwentyConfigService, @@ -118,6 +121,22 @@ export class ApplicationDevelopmentResolver { workspaceId, ); + const versionValidation = + await this.applicationVersionValidationService.validateWorkspaceCompatibility( + { + requiredServerVersion: + manifest.application.requiredServerVersionRange ?? undefined, + workspaceId, + }, + ); + + if (!versionValidation.compatible) { + throw new ApplicationException( + versionValidation.message, + VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason], + ); + } + if (dryRun === true) { const { workspaceMigration } = await this.applicationSyncService.synchronizeFromManifest({ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts index 700e3b8cda..8e64131b41 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts @@ -21,10 +21,8 @@ import { ManifestAssetUrlResolverService } from 'src/engine/core-modules/applica import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service'; -import { - ApplicationVersionValidationService, - type VersionValidationFailureReason, -} from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; @@ -43,20 +41,6 @@ import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-func export class ApplicationInstallService { private readonly logger = new Logger(ApplicationInstallService.name); - private static readonly VERSION_REASON_TO_EXCEPTION_CODE: Record< - VersionValidationFailureReason, - ApplicationExceptionCode - > = { - INVALID_REQUIRED_VERSION: - ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT, - INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION, - INVALID_WORKSPACE_VERSION: - ApplicationExceptionCode.INVALID_WORKSPACE_VERSION, - INSTANCE_INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE, - WORKSPACE_INCOMPATIBLE: - ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE, - }; - constructor( @InjectRepository(ApplicationRegistrationEntity) private readonly appRegistrationRepository: Repository, @@ -157,9 +141,7 @@ export class ApplicationInstallService { throw new ApplicationException( versionValidation.message, - ApplicationInstallService.VERSION_REASON_TO_EXCEPTION_CODE[ - versionValidation.reason - ], + VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason], ); } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts new file mode 100644 index 0000000000..222935e89a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts @@ -0,0 +1,34 @@ +import { type VersionValidationFailureReason } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { ApplicationRegistrationExceptionCode } from 'src/engine/core-modules/application/application-registration/application-registration.exception'; +import { ApplicationExceptionCode } from 'src/engine/core-modules/application/application.exception'; + +export const VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE: Record< + VersionValidationFailureReason, + ApplicationExceptionCode +> = { + INVALID_REQUIRED_VERSION: + ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT, + INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION, + INVALID_WORKSPACE_VERSION: ApplicationExceptionCode.INVALID_WORKSPACE_VERSION, + INSTANCE_INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE, + WORKSPACE_INCOMPATIBLE: + ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE, +}; + +// The registration flow (tarball upload) has no per-workspace context, so +// workspace-scoped reasons collapse onto the server-scoped registration codes. +export const VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE: Record< + VersionValidationFailureReason, + ApplicationRegistrationExceptionCode +> = { + INVALID_REQUIRED_VERSION: + ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT, + INVALID_SERVER_VERSION: + ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION, + INVALID_WORKSPACE_VERSION: + ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION, + INSTANCE_INCOMPATIBLE: + ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE, + WORKSPACE_INCOMPATIBLE: + ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE, +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts index 88181ed1e0..dd0d6d0c21 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts @@ -12,10 +12,8 @@ import { Repository } from 'typeorm'; import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { v4 } from 'uuid'; -import { - ApplicationVersionValidationService, - type VersionValidationFailureReason, -} from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util'; import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util'; import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils'; @@ -35,22 +33,6 @@ import type { ApplicationManifest } from 'twenty-shared/application'; export class ApplicationTarballService { private readonly logger = new Logger(ApplicationTarballService.name); - private static readonly VERSION_REASON_TO_EXCEPTION_CODE: Record< - VersionValidationFailureReason, - ApplicationRegistrationExceptionCode - > = { - INVALID_REQUIRED_VERSION: - ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT, - INVALID_SERVER_VERSION: - ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION, - INVALID_WORKSPACE_VERSION: - ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION, - INSTANCE_INCOMPATIBLE: - ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE, - WORKSPACE_INCOMPATIBLE: - ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE, - }; - constructor( @InjectRepository(ApplicationRegistrationEntity) private readonly appRegistrationRepository: Repository, @@ -107,7 +89,7 @@ export class ApplicationTarballService { if (!versionValidation.compatible) { throw new ApplicationRegistrationException( versionValidation.message, - ApplicationTarballService.VERSION_REASON_TO_EXCEPTION_CODE[ + VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE[ versionValidation.reason ], ); diff --git a/packages/twenty-server/test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util.ts b/packages/twenty-server/test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util.ts index 7379a364cc..2d546c7c62 100644 --- a/packages/twenty-server/test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util.ts @@ -1,17 +1,25 @@ import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; +import { isDefined } from 'twenty-shared/utils'; import { type BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; export const expectOneNotInternalServerErrorSnapshot = ({ errors, + normalizeMessage, }: { errors: BaseGraphQLError[]; + normalizeMessage?: (message: string) => string; }) => { expect(errors.length).toBe(1); const [firstError] = errors; expect(firstError.extensions.code).not.toBe('INTERNAL_SERVER_ERROR'); - expect(firstError).toMatchSnapshot( - extractRecordIdsAndDatesAsExpectAny(firstError), + + const errorForSnapshot = isDefined(normalizeMessage) + ? { ...firstError, message: normalizeMessage(firstError.message) } + : firstError; + + expect(errorForSnapshot).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny(errorForSnapshot), ); }; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap index d3e6ebc282..1627504ca2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap @@ -7,7 +7,7 @@ exports[`Install application is gated by the workspace completed upgrade version "subCode": "WORKSPACE_VERSION_INCOMPATIBLE", "userFriendlyMessage": "This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.", }, - "message": "App requires Twenty server >=2.20.0 but this workspace has only completed the upgrade to 2.19.0.", + "message": "App requires Twenty server >= but this workspace has only completed the upgrade to .", "name": "UserInputError", } `; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-workspace-version.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-workspace-version.integration-spec.ts.snap new file mode 100644 index 0000000000..50fc977bad --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-workspace-version.integration-spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Sync application is gated by the workspace completed upgrade version rejects sync when the workspace has not completed the required upgrade version 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "WORKSPACE_VERSION_INCOMPATIBLE", + "userFriendlyMessage": "This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.", + }, + "message": "App requires Twenty server >= but this workspace has only completed the upgrade to .", + "name": "UserInputError", +} +`; + +exports[`Sync application is gated by the workspace completed upgrade version rejects sync when the workspace upgrade cursor cannot be interpreted 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_WORKSPACE_VERSION", + "userFriendlyMessage": "This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.", + }, + "message": "Cannot determine the completed upgrade version for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419: no interpretable upgrade cursor found.", + "name": "UserInputError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-app-installation-workspace-version.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-app-installation-workspace-version.integration-spec.ts index e246e9db1b..30eecf8509 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/failing-app-installation-workspace-version.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-app-installation-workspace-version.integration-spec.ts @@ -4,6 +4,7 @@ import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/ import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util'; import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util'; import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util'; +import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util'; import { isDefined } from 'twenty-shared/utils'; import { v4 as uuidv4 } from 'uuid'; @@ -129,7 +130,10 @@ describe('Install application is gated by the workspace completed upgrade versio expectToFail: true, }); - expectOneNotInternalServerErrorSnapshot({ errors }); + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); }); it('rejects installation when the workspace upgrade cursor cannot be interpreted', async () => { @@ -156,6 +160,9 @@ describe('Install application is gated by the workspace completed upgrade versio expectToFail: true, }); - expectOneNotInternalServerErrorSnapshot({ errors }); + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-workspace-version.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-workspace-version.integration-spec.ts new file mode 100644 index 0000000000..18e3240db7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-workspace-version.integration-spec.ts @@ -0,0 +1,137 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util'; +import { type Manifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +// The sync flow can run cache-lock retries with real delays, so fake timers +// would hang it — mirror the other application suites. +jest.setTimeout(120000); + +const INJECTED_CURSOR_MARKER = 'integration-test-sync-workspace-version-gate'; + +const injectWorkspaceCursor = async ( + name: string, + status: 'completed' | 'failed', +): Promise => { + // Inject as attempt 2 with a fresh createdAt (now): this stays unique against + // the seeded completed attempt-1 cursor (which may share this name) and + // outranks it, so getWorkspaceCompletedVersion reads this attempt. + await global.testDataSource.query( + `INSERT INTO core."upgradeMigration" + (name, status, attempt, "executedByVersion", "workspaceId", "isInitial") + VALUES ($1, $2, 2, $3, $4, false)`, + [name, status, INJECTED_CURSOR_MARKER, SEED_APPLE_WORKSPACE_ID], + ); +}; + +const clearInjectedWorkspaceCursors = async (): Promise => { + await global.testDataSource.query( + `DELETE FROM core."upgradeMigration" + WHERE "workspaceId" = $1 AND "executedByVersion" = $2`, + [SEED_APPLE_WORKSPACE_ID, INJECTED_CURSOR_MARKER], + ); +}; + +const buildManifestWithRequiredServerVersionRange = ({ + appId, + roleId, + requiredServerVersionRange, +}: { + appId: string; + roleId: string; + requiredServerVersionRange: string; +}): Manifest => { + const manifest = buildBaseManifest({ appId, roleId }); + + manifest.application.requiredServerVersionRange = requiredServerVersionRange; + + return manifest; +}; + +describe('Sync application is gated by the workspace completed upgrade version', () => { + let currentVersionCommandName: string; + + beforeAll(async () => { + jest.useRealTimers(); + + // The seeded workspace's cursor is the last step of the current version. + // Re-injecting it as a failed attempt makes the workspace resolve to the + // previous completed version — i.e. behind the instance. + const [workspaceCursor] = await global.testDataSource.query( + `SELECT name FROM core."upgradeMigration" + WHERE "workspaceId" = $1 + ORDER BY "createdAt" DESC, attempt DESC + LIMIT 1`, + [SEED_APPLE_WORKSPACE_ID], + ); + + if (!isDefined(workspaceCursor)) { + throw new Error( + `Expected a seeded upgrade cursor for workspace ${SEED_APPLE_WORKSPACE_ID}`, + ); + } + + currentVersionCommandName = workspaceCursor.name; + }); + + afterEach(async () => { + await clearInjectedWorkspaceCursors(); + }); + + afterAll(() => { + jest.useFakeTimers(); + }); + + it('rejects sync when the workspace has not completed the required upgrade version', async () => { + const manifest = buildManifestWithRequiredServerVersionRange({ + appId: uuidv4(), + roleId: uuidv4(), + requiredServerVersionRange: `>=${TWENTY_CURRENT_VERSION}`, + }); + + // The workspace failed mid-way through the current version's upgrade + // segment, so its last completed version is the previous one. + await injectWorkspaceCursor(currentVersionCommandName, 'failed'); + + const { errors } = await syncApplication({ + manifest, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); + }); + + it('rejects sync when the workspace upgrade cursor cannot be interpreted', async () => { + const manifest = buildManifestWithRequiredServerVersionRange({ + appId: uuidv4(), + roleId: uuidv4(), + requiredServerVersionRange: '>=1.0.0', + }); + + // A cursor pointing at a command outside the supported upgrade sequence + // cannot be mapped to a completed version. + await injectWorkspaceCursor( + '1.0.0_UnknownLegacyCommand_1700000000000', + 'completed', + ); + + const { errors } = await syncApplication({ + manifest, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); + }); +}); diff --git a/packages/twenty-server/test/utils/scrub-semver-versions.util.ts b/packages/twenty-server/test/utils/scrub-semver-versions.util.ts new file mode 100644 index 0000000000..8726798a48 --- /dev/null +++ b/packages/twenty-server/test/utils/scrub-semver-versions.util.ts @@ -0,0 +1,2 @@ +export const scrubSemverVersions = (value: string): string => + value.replace(/\d+\.\d+\.\d+/g, ''); diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index 7817a58b8a..40a9c83e80 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -1,9 +1,9 @@ +import { type PostInstallLogicFunctionApplicationManifest } from '@/application/postInstallLogicFunctionApplicationType'; +import { type PreInstallLogicFunctionApplicationManifest } from '@/application/preInstallLogicFunctionApplicationType'; import { type ApplicationCategory } from './applicationCategoryType'; import { type ApplicationVariables } from './applicationVariablesType'; import { type ServerVariables } from './server-variables.type'; import { type SyncableEntityOptions } from './syncableEntityOptionsType'; -import { type PostInstallLogicFunctionApplicationManifest } from '@/application/postInstallLogicFunctionApplicationType'; -import { type PreInstallLogicFunctionApplicationManifest } from '@/application/preInstallLogicFunctionApplicationType'; export type ApplicationManifest = SyncableEntityOptions & { defaultRoleUniversalIdentifier: string; @@ -30,4 +30,5 @@ export type ApplicationManifest = SyncableEntityOptions & { settingsCustomTabFrontComponentUniversalIdentifier?: string; packageJsonChecksum: string | null; yarnLockChecksum: string | null; + requiredServerVersionRange?: string | null; };