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
---
.../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;
};