Validate range version app dev sync (#22625)

# Introduction
Also now validating the workspace version when running a sync manifest

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22625?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-07-08 18:37:42 +02:00
committed by GitHub
parent b5a73ad86a
commit 163c96c2e5
16 changed files with 253 additions and 49 deletions
@@ -15,6 +15,7 @@ export const EXPECTED_MANIFEST: Manifest = {
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
packageJsonChecksum: '[checksum]',
yarnLockChecksum: '[checksum]',
requiredServerVersionRange: null,
},
permissionFlags: [],
skills: [],
@@ -233,6 +233,7 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
requiredServerVersionRange: null,
},
frontComponents: [
{
@@ -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] }
: {}),
@@ -4,6 +4,7 @@ export type ApplicationConfig = Omit<
ApplicationManifest,
| 'packageJsonChecksum'
| 'yarnLockChecksum'
| 'requiredServerVersionRange'
| 'postInstallLogicFunction'
| 'preInstallLogicFunction'
| 'defaultRoleUniversalIdentifier'
@@ -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,
@@ -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({
@@ -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<ApplicationRegistrationEntity>,
@@ -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],
);
}
@@ -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,
};
@@ -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<ApplicationRegistrationEntity>,
@@ -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
],
);
@@ -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),
);
};
@@ -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 >=<version> but this workspace has only completed the upgrade to <version>.",
"name": "UserInputError",
}
`;
@@ -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 >=<version> but this workspace has only completed the upgrade to <version>.",
"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",
}
`;
@@ -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,
});
});
});
@@ -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<void> => {
// 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<void> => {
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,
});
});
});
@@ -0,0 +1,2 @@
export const scrubSemverVersions = (value: string): string =>
value.replace(/\d+\.\d+\.\d+/g, '<version>');
@@ -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;
};