diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts index 6ee606dadb..9a9cfab5c3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts @@ -34,7 +34,9 @@ export class ApplicationExceptionFilter implements ExceptionFilter { case ApplicationExceptionCode.APP_ALREADY_INSTALLED: case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION: case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE: + case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE: case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT: + case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION: throw new UserInputError(exception); case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED: case ApplicationExceptionCode.POST_INSTALL_ERROR: 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 9ac2109645..5a601430e6 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 @@ -50,7 +50,11 @@ export class ApplicationInstallService { INVALID_REQUIRED_VERSION: ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT, INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION, - INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE, + INVALID_WORKSPACE_VERSION: + ApplicationExceptionCode.INVALID_WORKSPACE_VERSION, + INSTANCE_INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE, + WORKSPACE_INCOMPATIBLE: + ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE, }; constructor( @@ -139,8 +143,11 @@ export class ApplicationInstallService { resolvedPackage.packageJson.engines?.['twenty']; const versionValidation = - await this.applicationVersionValidationService.validateServerCompatibility( - requiredServerVersion, + await this.applicationVersionValidationService.validateWorkspaceCompatibility( + { + requiredServerVersion, + workspaceId: params.workspaceId, + }, ); if (!versionValidation.compatible) { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts new file mode 100644 index 0000000000..57537a0eca --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts @@ -0,0 +1,151 @@ +import { Test } from '@nestjs/testing'; + +import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; +import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; + +describe('ApplicationVersionValidationService', () => { + let service: ApplicationVersionValidationService; + let getInferredVersion: jest.Mock; + let getWorkspaceCompletedVersion: jest.Mock; + + beforeEach(async () => { + getInferredVersion = jest.fn(); + getWorkspaceCompletedVersion = jest.fn(); + + const module = await Test.createTestingModule({ + providers: [ + ApplicationVersionValidationService, + { + provide: UpgradeMigrationService, + useValue: { getInferredVersion }, + }, + { + provide: UpgradeStatusService, + useValue: { getWorkspaceCompletedVersion }, + }, + ], + }).compile(); + + service = module.get(ApplicationVersionValidationService); + }); + + describe('validateServerCompatibility', () => { + it('should be compatible when no required version is declared', async () => { + await expect( + service.validateServerCompatibility(undefined), + ).resolves.toEqual({ compatible: true }); + }); + + it('should reject an invalid semver range', async () => { + const result = await service.validateServerCompatibility('not-semver'); + + expect(result).toMatchObject({ + compatible: false, + reason: 'INVALID_REQUIRED_VERSION', + }); + }); + + it('should be compatible when the inferred instance version satisfies the range', async () => { + getInferredVersion.mockResolvedValue('2.19.0'); + + await expect( + service.validateServerCompatibility('>=2.19.0'), + ).resolves.toEqual({ compatible: true }); + }); + + it('should be incompatible when the inferred instance version does not satisfy the range', async () => { + getInferredVersion.mockResolvedValue('2.18.0'); + + const result = await service.validateServerCompatibility('>=2.19.0'); + + expect(result).toMatchObject({ + compatible: false, + reason: 'INSTANCE_INCOMPATIBLE', + }); + }); + + it('should fail when the inferred instance version is not valid semver', async () => { + getInferredVersion.mockResolvedValue(null); + + const result = await service.validateServerCompatibility('>=2.19.0'); + + expect(result).toMatchObject({ + compatible: false, + reason: 'INVALID_SERVER_VERSION', + }); + }); + }); + + describe('validateWorkspaceCompatibility', () => { + it('should be compatible when no required version is declared', async () => { + await expect( + service.validateWorkspaceCompatibility({ + requiredServerVersion: undefined, + workspaceId: 'ws-1', + }), + ).resolves.toEqual({ compatible: true }); + + expect(getWorkspaceCompletedVersion).not.toHaveBeenCalled(); + }); + + it('should reject an invalid semver range', async () => { + const result = await service.validateWorkspaceCompatibility({ + requiredServerVersion: 'not-semver', + workspaceId: 'ws-1', + }); + + expect(result).toMatchObject({ + compatible: false, + reason: 'INVALID_REQUIRED_VERSION', + }); + }); + + it('should be compatible when the workspace completed version satisfies the range', async () => { + getWorkspaceCompletedVersion.mockResolvedValue('2.19.0'); + + await expect( + service.validateWorkspaceCompatibility({ + requiredServerVersion: '>=2.19.0', + workspaceId: 'ws-1', + }), + ).resolves.toEqual({ compatible: true }); + + expect(getWorkspaceCompletedVersion).toHaveBeenCalledWith('ws-1'); + expect(getInferredVersion).not.toHaveBeenCalled(); + }); + + it('should be incompatible when the workspace has only completed an earlier version', async () => { + getWorkspaceCompletedVersion.mockResolvedValue('2.18.0'); + + const result = await service.validateWorkspaceCompatibility({ + requiredServerVersion: '>=2.19.0', + workspaceId: 'ws-1', + }); + + expect(result).toMatchObject({ + compatible: false, + reason: 'WORKSPACE_INCOMPATIBLE', + message: + 'App requires Twenty server >=2.19.0 but this workspace has only completed the upgrade to 2.18.0.', + }); + }); + + it('should be incompatible when the workspace has no interpretable upgrade cursor', async () => { + getWorkspaceCompletedVersion.mockResolvedValue(null); + + const result = await service.validateWorkspaceCompatibility({ + requiredServerVersion: '>=2.19.0', + workspaceId: 'ws-1', + }); + + expect(result).toMatchObject({ + compatible: false, + reason: 'INVALID_WORKSPACE_VERSION', + message: + 'Cannot determine the completed upgrade version for workspace ws-1: no interpretable upgrade cursor found.', + }); + expect(getInferredVersion).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts index ec17be8512..addff3870d 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts @@ -1,13 +1,16 @@ import { Injectable } from '@nestjs/common'; import semver from 'semver'; -import { isDefined } from 'twenty-shared/utils'; import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; +import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; +import { isDefined } from 'twenty-shared/utils'; export type VersionValidationFailureReason = | 'INVALID_REQUIRED_VERSION' | 'INVALID_SERVER_VERSION' - | 'INCOMPATIBLE'; + | 'INVALID_WORKSPACE_VERSION' + | 'INSTANCE_INCOMPATIBLE' + | 'WORKSPACE_INCOMPATIBLE'; export type VersionValidationResult = | { compatible: true } @@ -21,6 +24,7 @@ export type VersionValidationResult = export class ApplicationVersionValidationService { constructor( private readonly upgradeMigrationService: UpgradeMigrationService, + private readonly upgradeStatusService: UpgradeStatusService, ) {} async validateServerCompatibility( @@ -41,25 +45,87 @@ export class ApplicationVersionValidationService { const inferredServerVersion = await this.upgradeMigrationService.getInferredVersion(); - if ( - !isDefined(inferredServerVersion) || - !isDefined(semver.valid(inferredServerVersion)) - ) { + return this.validateVersionAgainstRange({ + version: inferredServerVersion, + requiredVersionRange: requiredServerVersion, + scope: 'instance', + }); + } + + async validateWorkspaceCompatibility({ + requiredServerVersion, + workspaceId, + }: { + requiredServerVersion: string | undefined; + workspaceId: string; + }): Promise { + if (!isDefined(requiredServerVersion)) { + return { compatible: true }; + } + + if (!isDefined(semver.validRange(requiredServerVersion))) { return { compatible: false, - reason: 'INVALID_SERVER_VERSION', - message: `Cannot verify server compatibility: inferred server version "${inferredServerVersion ?? 'undefined'}" is not a valid semver version.`, + reason: 'INVALID_REQUIRED_VERSION', + message: `App manifest declares invalid engines.twenty value "${requiredServerVersion}". Must be a valid semver range.`, }; } - if (!semver.satisfies(inferredServerVersion, requiredServerVersion)) { + const workspaceCompletedVersion = + await this.upgradeStatusService.getWorkspaceCompletedVersion(workspaceId); + + if (!isDefined(workspaceCompletedVersion)) { return { compatible: false, - reason: 'INCOMPATIBLE', - message: `App requires Twenty server ${requiredServerVersion} but this server is ${inferredServerVersion}.`, + reason: 'INVALID_WORKSPACE_VERSION', + message: `Cannot determine the completed upgrade version for workspace ${workspaceId}: no interpretable upgrade cursor found.`, }; } + return this.validateVersionAgainstRange({ + version: workspaceCompletedVersion, + requiredVersionRange: requiredServerVersion, + scope: 'workspace', + }); + } + + private validateVersionAgainstRange({ + version, + requiredVersionRange, + scope, + }: { + version: string | null; + requiredVersionRange: string; + scope: 'instance' | 'workspace'; + }): VersionValidationResult { + if (!isDefined(version) || !isDefined(semver.valid(version))) { + return scope === 'workspace' + ? { + compatible: false, + reason: 'INVALID_WORKSPACE_VERSION', + message: `Cannot verify workspace compatibility: workspace completed version "${version ?? 'undefined'}" is not a valid semver version.`, + } + : { + compatible: false, + reason: 'INVALID_SERVER_VERSION', + message: `Cannot verify server compatibility: inferred server version "${version ?? 'undefined'}" is not a valid semver version.`, + }; + } + + if (!semver.satisfies(version, requiredVersionRange)) { + return scope === 'workspace' + ? { + compatible: false, + reason: 'WORKSPACE_INCOMPATIBLE', + message: `App requires Twenty server ${requiredVersionRange} but this workspace has only completed the upgrade to ${version}.`, + } + : { + compatible: false, + reason: 'INSTANCE_INCOMPATIBLE', + message: `App requires Twenty server ${requiredVersionRange} but this server is ${version}.`, + }; + } + return { compatible: true }; } } 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 b12fc717a6..9b80f23a5d 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,6 +12,14 @@ 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 { 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'; +import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationException, @@ -19,17 +27,9 @@ import { } from 'src/engine/core-modules/application/application-registration/application-registration.exception'; import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; -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'; -import { - ApplicationVersionValidationService, - type VersionValidationFailureReason, -} from 'src/engine/core-modules/application/application-package/application-version-validation.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import type { ApplicationManifest } from 'twenty-shared/application'; -import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; @Injectable() export class ApplicationTarballService { @@ -43,7 +43,11 @@ export class ApplicationTarballService { ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT, INVALID_SERVER_VERSION: ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION, - INCOMPATIBLE: + 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-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception.filter.ts index 4f04c8dc01..4fb627d78c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception.filter.ts @@ -34,7 +34,9 @@ const applicationExceptionCodeToHttpStatus = ( case ApplicationExceptionCode.APP_ALREADY_INSTALLED: case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION: case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE: + case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE: case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT: + case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION: return 400; case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED: case ApplicationExceptionCode.POST_INSTALL_ERROR: diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index 7bdd2a5460..23c0ce4a3b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -24,8 +24,10 @@ export enum ApplicationExceptionCode { APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED', CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION', SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE', + WORKSPACE_VERSION_INCOMPATIBLE = 'WORKSPACE_VERSION_INCOMPATIBLE', INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT', INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION', + INVALID_WORKSPACE_VERSION = 'INVALID_WORKSPACE_VERSION', APPLICATION_INSTALLATION_FAILED = 'APPLICATION_INSTALLATION_FAILED', } @@ -69,10 +71,14 @@ const getApplicationExceptionUserFriendlyMessage = ( return msg`A higher version of this application is already installed. Downgrading is not allowed.`; case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE: return msg`This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.`; + case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE: + return msg`This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.`; case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT: return msg`The app manifest declares an invalid server version requirement.`; case ApplicationExceptionCode.INVALID_SERVER_VERSION: return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`; + case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION: + return msg`This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.`; case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED: return msg`We couldn't install this application because some of its metadata could not be applied to your workspace.`; default: diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts index 0bf28a9fbb..c3767fea78 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-status.service.spec.ts @@ -11,14 +11,26 @@ import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/servi import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -const LAST_INSTANCE_COMMAND = '1.23.0_LastInstanceCommand_1780000002000'; -const LAST_WORKSPACE_COMMAND = '1.23.0_LastWorkspaceCommand_1780000003000'; -const EARLIER_COMMAND = '1.22.0_EarlierCommand_1776000001000'; +const V1_21_INSTANCE_COMMAND = '1.21.0_InstanceCommand_1772000001000'; +const V1_21_SLOW_INSTANCE_COMMAND = '1.21.0_SlowInstanceCommand_1772000002000'; +const V1_21_FIRST_WORKSPACE_COMMAND = + '1.21.0_FirstWorkspaceCommand_1772000003000'; +const V1_21_SECOND_WORKSPACE_COMMAND = + '1.21.0_SecondWorkspaceCommand_1772000004000'; +const V1_22_INSTANCE_COMMAND = '1.22.0_InstanceCommand_1776000001000'; +const V1_23_INSTANCE_COMMAND = '1.23.0_InstanceCommand_1780000002000'; +const V1_23_WORKSPACE_COMMAND = '1.23.0_WorkspaceCommand_1780000003000'; +// Three version segments: 1.21.0 has multiple workspace commands, 1.22.0 is +// instance-only, 1.23.0 ends the sequence with a workspace command. const MOCK_SEQUENCE = [ - { kind: 'fast-instance', name: EARLIER_COMMAND }, - { kind: 'fast-instance', name: LAST_INSTANCE_COMMAND }, - { kind: 'workspace', name: LAST_WORKSPACE_COMMAND }, + { kind: 'fast-instance', name: V1_21_INSTANCE_COMMAND }, + { kind: 'slow-instance', name: V1_21_SLOW_INSTANCE_COMMAND }, + { kind: 'workspace', name: V1_21_FIRST_WORKSPACE_COMMAND }, + { kind: 'workspace', name: V1_21_SECOND_WORKSPACE_COMMAND }, + { kind: 'fast-instance', name: V1_22_INSTANCE_COMMAND }, + { kind: 'fast-instance', name: V1_23_INSTANCE_COMMAND }, + { kind: 'workspace', name: V1_23_WORKSPACE_COMMAND }, ]; type WorkspaceRecord = { @@ -131,7 +143,7 @@ describe('UpgradeStatusService', () => { describe('getInstanceStatus', () => { it('should return up-to-date when cursor is at last instance command', async () => { getLastAttemptedInstanceCommand.mockResolvedValue({ - name: LAST_INSTANCE_COMMAND, + name: V1_23_INSTANCE_COMMAND, status: 'completed', executedByVersion: '1.23.0', errorMessage: null, @@ -146,7 +158,7 @@ describe('UpgradeStatusService', () => { it('should return behind when cursor is before last instance command', async () => { getLastAttemptedInstanceCommand.mockResolvedValue({ - name: EARLIER_COMMAND, + name: V1_22_INSTANCE_COMMAND, status: 'completed', executedByVersion: '1.22.0', errorMessage: null, @@ -161,7 +173,7 @@ describe('UpgradeStatusService', () => { it('should return failed when latest instance command failed', async () => { getLastAttemptedInstanceCommand.mockResolvedValue({ - name: LAST_INSTANCE_COMMAND, + name: V1_23_INSTANCE_COMMAND, status: 'failed', executedByVersion: '1.23.0', errorMessage: 'column does not exist', @@ -185,6 +197,131 @@ describe('UpgradeStatusService', () => { }); }); + describe('getWorkspaceCompletedVersion', () => { + const mockWorkspaceCursor = ( + cursor: { name: string; status: 'completed' | 'failed' } | null, + ) => { + getWorkspaceLastAttemptedCommandName.mockResolvedValue( + cursor === null + ? new Map() + : new Map([ + [ + 'ws-1', + { + workspaceId: 'ws-1', + ...cursor, + executedByVersion: '1.23.0', + errorMessage: null, + createdAt: new Date('2025-06-01T00:00:00Z'), + isInitial: false, + }, + ], + ]), + ); + }; + + it('should return the cursor version when at the last step of its segment with completed status', async () => { + mockWorkspaceCursor({ + name: V1_23_WORKSPACE_COMMAND, + status: 'completed', + }); + + await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( + '1.23.0', + ); + }); + + it('should return the immediately previous version when the cursor is mid-segment', async () => { + mockWorkspaceCursor({ + name: V1_23_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( + '1.22.0', + ); + }); + + it('should return the previous version when the last step of the segment failed', async () => { + mockWorkspaceCursor({ name: V1_23_WORKSPACE_COMMAND, status: 'failed' }); + + await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( + '1.22.0', + ); + }); + + it('should return the cursor version when completed at the end of an instance-only segment', async () => { + mockWorkspaceCursor({ + name: V1_22_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( + '1.22.0', + ); + }); + + it('should return the previous version when an instance-only segment failed', async () => { + mockWorkspaceCursor({ name: V1_22_INSTANCE_COMMAND, status: 'failed' }); + + await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( + '1.21.0', + ); + }); + + it('should return the cursor version when completed at the last of several workspace commands', async () => { + mockWorkspaceCursor({ + name: V1_21_SECOND_WORKSPACE_COMMAND, + status: 'completed', + }); + + await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( + '1.21.0', + ); + }); + + it('should not consider a segment completed while earlier workspace commands of the same segment remain', async () => { + mockWorkspaceCursor({ + name: V1_21_FIRST_WORKSPACE_COMMAND, + status: 'completed', + }); + + await expect( + service.getWorkspaceCompletedVersion('ws-1'), + ).resolves.toBeNull(); + }); + + it('should return null when the first segment failed with no previous segment', async () => { + mockWorkspaceCursor({ + name: V1_21_SECOND_WORKSPACE_COMMAND, + status: 'failed', + }); + + await expect( + service.getWorkspaceCompletedVersion('ws-1'), + ).resolves.toBeNull(); + }); + + it('should return null when the workspace has no cursor', async () => { + mockWorkspaceCursor(null); + + await expect( + service.getWorkspaceCompletedVersion('ws-1'), + ).resolves.toBeNull(); + }); + + it('should return null when the cursor is outside the supported sequence', async () => { + mockWorkspaceCursor({ + name: '1.10.0_OutOfSequenceCommand_1700000000000', + status: 'completed', + }); + + await expect( + service.getWorkspaceCompletedVersion('ws-1'), + ).resolves.toBeNull(); + }); + }); + describe('getWorkspaceStatuses', () => { it('should return up-to-date for workspace at last command', async () => { mockActiveWorkspaces([{ id: 'ws-1', displayName: 'Apple' }]); @@ -195,7 +332,7 @@ describe('UpgradeStatusService', () => { 'ws-1', { workspaceId: 'ws-1', - name: LAST_WORKSPACE_COMMAND, + name: V1_23_WORKSPACE_COMMAND, status: 'completed', executedByVersion: '1.23.0', errorMessage: null, @@ -223,7 +360,7 @@ describe('UpgradeStatusService', () => { 'ws-1', { workspaceId: 'ws-1', - name: LAST_WORKSPACE_COMMAND, + name: V1_23_WORKSPACE_COMMAND, status: 'completed', executedByVersion: '1.23.0', errorMessage: null, @@ -234,7 +371,7 @@ describe('UpgradeStatusService', () => { 'ws-2', { workspaceId: 'ws-2', - name: EARLIER_COMMAND, + name: V1_22_INSTANCE_COMMAND, status: 'completed', executedByVersion: '1.22.0', errorMessage: null, @@ -282,7 +419,7 @@ describe('UpgradeStatusService', () => { cacheGetFailedWorkspaceIds.mockResolvedValue(['ws-3']); cacheGetUpToDateWorkspaceCount.mockResolvedValue(5); getLastAttemptedInstanceCommand.mockResolvedValue({ - name: LAST_INSTANCE_COMMAND, + name: V1_23_INSTANCE_COMMAND, status: 'completed', executedByVersion: '1.23.0', errorMessage: null, @@ -354,7 +491,7 @@ describe('UpgradeStatusService', () => { 'ws-1', { workspaceId: 'ws-1', - name: LAST_WORKSPACE_COMMAND, + name: V1_23_WORKSPACE_COMMAND, status: 'completed', executedByVersion: '1.23.0', errorMessage: null, @@ -365,7 +502,7 @@ describe('UpgradeStatusService', () => { 'ws-2', { workspaceId: 'ws-2', - name: EARLIER_COMMAND, + name: V1_22_INSTANCE_COMMAND, status: 'completed', executedByVersion: '1.22.0', errorMessage: null, @@ -376,7 +513,7 @@ describe('UpgradeStatusService', () => { 'ws-3', { workspaceId: 'ws-3', - name: LAST_WORKSPACE_COMMAND, + name: V1_23_WORKSPACE_COMMAND, status: 'failed', executedByVersion: '1.23.0', errorMessage: 'boom', diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts index 77fe473262..2aaface676 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts @@ -10,6 +10,7 @@ import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/service import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/services/upgrade-status-cache.service'; import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; +import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { In, Repository } from 'typeorm'; @@ -138,6 +139,54 @@ export class UpgradeStatusService { ); } + async getWorkspaceCompletedVersion( + workspaceId: string, + ): Promise { + const cursors = + await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandName([ + workspaceId, + ]); + const cursor = cursors.get(workspaceId); + + if (!isDefined(cursor)) { + return null; + } + + const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); + const cursorIndex = sequence.findIndex((step) => step.name === cursor.name); + + if (cursorIndex === -1) { + return null; + } + + const cursorVersion = extractVersionFromCommandName(cursor.name); + + if (!isDefined(cursorVersion)) { + return null; + } + + const isLastStepOfItsVersion = + cursorIndex === sequence.length - 1 || + extractVersionFromCommandName(sequence[cursorIndex + 1].name) !== + cursorVersion; + + if (cursor.status === 'completed' && isLastStepOfItsVersion) { + return cursorVersion; + } + + for (let stepIndex = cursorIndex - 1; stepIndex >= 0; stepIndex--) { + const stepVersion = extractVersionFromCommandName( + sequence[stepIndex].name, + ); + + if (stepVersion !== cursorVersion) { + return stepVersion; + } + } + + return null; + } + async getInstanceAndAllWorkspacesStatus(): Promise { const computedAt = await this.upgradeStatusCacheService.getComputedAt(); 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 new file mode 100644 index 0000000000..050fe53bde --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Install application is gated by the workspace completed upgrade version rejects installation 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 >=2.19.0 but this workspace has only completed the upgrade to 2.18.0.", + "name": "UserInputError", +} +`; + +exports[`Install application is gated by the workspace completed upgrade version rejects installation 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/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap new file mode 100644 index 0000000000..f2220e1833 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Publish application is gated by the instance server version rejects publishing when the app requires a server version the instance does not satisfy 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "SERVER_VERSION_INCOMPATIBLE", + "userFriendlyMessage": "This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.", + }, + "message": "App requires Twenty server >=999.0.0 but this server is 2.19.0.", + "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 new file mode 100644 index 0000000000..e246e9db1b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-app-installation-workspace-version.integration-spec.ts @@ -0,0 +1,161 @@ +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 { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +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 { 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 full install flow runs cache-lock retries with real delays, so fake +// timers would hang it — mirror the other install suites. +jest.setTimeout(120000); + +const INJECTED_CURSOR_MARKER = 'integration-test-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 uploadTarballApp = async ({ + universalIdentifier, + roleId, + requiredServerVersion, +}: { + universalIdentifier: string; + roleId: string; + requiredServerVersion: string; +}): Promise => { + const tarball = await createAppTarball({ + 'manifest.json': JSON.stringify( + buildBaseManifest({ appId: universalIdentifier, roleId }), + ), + 'package.json': JSON.stringify({ + name: `test-workspace-version-gate-${universalIdentifier}`, + version: '1.0.0', + engines: { twenty: requiredServerVersion }, + }), + }); + + const uploadResult = await uploadAppTarball({ + tarballBuffer: tarball, + universalIdentifier, + }); + + expect(uploadResult.errors).toBeUndefined(); +}; + +describe('Install application is gated by the workspace completed upgrade version', () => { + let currentVersionCommandName: string; + const createdApplicationUniversalIdentifiers: 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(async () => { + for (const universalIdentifier of createdApplicationUniversalIdentifiers) { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: universalIdentifier, + }); + } + + jest.useFakeTimers(); + }); + + it('rejects installation when the workspace has not completed the required upgrade version', async () => { + const universalIdentifier = uuidv4(); + const roleId = uuidv4(); + + await uploadTarballApp({ + universalIdentifier, + roleId, + requiredServerVersion: `>=${TWENTY_CURRENT_VERSION}`, + }); + + createdApplicationUniversalIdentifiers.push(universalIdentifier); + + // 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 installApplication({ + input: { universalIdentifier }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); + + it('rejects installation when the workspace upgrade cursor cannot be interpreted', async () => { + const universalIdentifier = uuidv4(); + const roleId = uuidv4(); + + await uploadTarballApp({ + universalIdentifier, + roleId, + requiredServerVersion: '>=1.0.0', + }); + + createdApplicationUniversalIdentifiers.push(universalIdentifier); + + // 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 installApplication({ + input: { universalIdentifier }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-server-version.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-server-version.integration-spec.ts new file mode 100644 index 0000000000..ee494c8e50 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-server-version.integration-spec.ts @@ -0,0 +1,56 @@ +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 { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util'; +import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util'; +import { v4 as uuidv4 } from 'uuid'; + +// The upload flow runs cache-lock retries with real delays, so fake timers +// would hang it — mirror the other application suites. +jest.setTimeout(120000); + +describe('Publish application is gated by the instance server version', () => { + const createdApplicationUniversalIdentifiers: string[] = []; + + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(async () => { + for (const universalIdentifier of createdApplicationUniversalIdentifiers) { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: universalIdentifier, + }); + } + + jest.useFakeTimers(); + }); + + it('rejects publishing when the app requires a server version the instance does not satisfy', async () => { + const universalIdentifier = uuidv4(); + const roleId = uuidv4(); + + const tarball = await createAppTarball({ + 'manifest.json': JSON.stringify( + buildBaseManifest({ appId: universalIdentifier, roleId }), + ), + 'package.json': JSON.stringify({ + name: `test-server-version-gate-${universalIdentifier}`, + version: '1.0.0', + // Require a server version far above any real instance version so the + // instance-level compatibility check always rejects it. + engines: { twenty: '>=999.0.0' }, + }), + }); + + createdApplicationUniversalIdentifiers.push(universalIdentifier); + + const { errors } = await uploadAppTarball({ + tarballBuffer: tarball, + universalIdentifier, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/create-app-tarball.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/create-app-tarball.util.ts new file mode 100644 index 0000000000..d3ad7c07d7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/create-app-tarball.util.ts @@ -0,0 +1,42 @@ +import crypto from 'crypto'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import * as tar from 'tar'; + +export const createAppTarball = async ( + files: Record, +): Promise => { + const tempId = crypto.randomUUID(); + const sourceDir = join(tmpdir(), `test-app-tarball-src-${tempId}`); + const tarballPath = join(tmpdir(), `test-app-tarball-${tempId}.tar.gz`); + + await fs.mkdir(sourceDir, { recursive: true }); + + for (const [name, content] of Object.entries(files)) { + const filePath = join(sourceDir, name); + const dir = filePath.substring(0, filePath.lastIndexOf('/')); + + if (dir !== sourceDir) { + await fs.mkdir(dir, { recursive: true }); + } + await fs.writeFile(filePath, content); + } + + await tar.create( + { + file: tarballPath, + gzip: true, + cwd: sourceDir, + }, + Object.keys(files), + ); + + const buffer = await fs.readFile(tarballPath); + + await fs.rm(sourceDir, { recursive: true, force: true }); + await fs.rm(tarballPath, { force: true }); + + return buffer; +}; diff --git a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/failing-sequence-runner.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/failing-sequence-runner.integration-spec.ts index 5403f710b0..7fde35ef54 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/failing-sequence-runner.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/failing-sequence-runner.integration-spec.ts @@ -12,9 +12,11 @@ import { makeWorkspace, migrationRecordToKey, resetSeedSequenceCounter, + restoreUpgradeMigrations, seedInstanceMigration, seedWorkspaceMigration, setMockActiveWorkspaceIds, + snapshotUpgradeMigrations, testGetExecutedMigrationsInOrder, WS_1, WS_2, @@ -62,13 +64,19 @@ const makeWorkspaceFailingForIds = ( describe('UpgradeSequenceRunnerService — failing sequence (integration)', () => { let context: IntegrationTestContext; + let savedUpgradeMigrations: Awaited< + ReturnType + >; beforeAll(async () => { context = await createUpgradeSequenceRunnerIntegrationTestModule(); + savedUpgradeMigrations = await snapshotUpgradeMigrations( + context.dataSource, + ); }, 30000); afterAll(async () => { - await context.dataSource.query('DELETE FROM core."upgradeMigration"'); + await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations); await context.module?.close(); await context.dataSource?.destroy(); }, 15000); diff --git a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/start-from-workspace-id.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/start-from-workspace-id.integration-spec.ts index 6aa15c61a3..518930836f 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/start-from-workspace-id.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/start-from-workspace-id.integration-spec.ts @@ -6,8 +6,10 @@ import { makeWorkspace, migrationRecordToKey, resetSeedSequenceCounter, + restoreUpgradeMigrations, seedInstanceMigration, setMockActiveWorkspaceIds, + snapshotUpgradeMigrations, testGetExecutedMigrationsInOrder, WS_1, WS_2, @@ -19,13 +21,19 @@ import { describe('UpgradeSequenceRunnerService — startFromWorkspaceId (integration)', () => { let context: IntegrationTestContext; + let savedUpgradeMigrations: Awaited< + ReturnType + >; beforeAll(async () => { context = await createUpgradeSequenceRunnerIntegrationTestModule(); + savedUpgradeMigrations = await snapshotUpgradeMigrations( + context.dataSource, + ); }, 30000); afterAll(async () => { - await context.dataSource.query('DELETE FROM core."upgradeMigration"'); + await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations); await context.module?.close(); await context.dataSource?.destroy(); }, 15000); diff --git a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/successful-sequence-runner.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/successful-sequence-runner.integration-spec.ts index 2d0669cbee..d7070574e2 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/successful-sequence-runner.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/successful-sequence-runner.integration-spec.ts @@ -9,9 +9,11 @@ import { makeWorkspace, migrationRecordToKey, resetSeedSequenceCounter, + restoreUpgradeMigrations, seedInstanceMigration, seedWorkspaceMigration, setMockActiveWorkspaceIds, + snapshotUpgradeMigrations, testGetExecutedMigrationsInOrder, WS_1, WS_2, @@ -19,13 +21,19 @@ import { describe('UpgradeSequenceRunnerService — execution (integration)', () => { let context: IntegrationTestContext; + let savedUpgradeMigrations: Awaited< + ReturnType + >; beforeAll(async () => { context = await createUpgradeSequenceRunnerIntegrationTestModule(); + savedUpgradeMigrations = await snapshotUpgradeMigrations( + context.dataSource, + ); }, 30000); afterAll(async () => { - await context.dataSource.query('DELETE FROM core."upgradeMigration"'); + await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations); await context.module?.close(); await context.dataSource?.destroy(); }, 15000); diff --git a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/workspace-segment-alignment.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/workspace-segment-alignment.integration-spec.ts index 5a204009fc..0eef4f5ee0 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/workspace-segment-alignment.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/workspace-segment-alignment.integration-spec.ts @@ -7,9 +7,11 @@ import { makeWorkspace, migrationRecordToKey, resetSeedSequenceCounter, + restoreUpgradeMigrations, seedInstanceMigration, seedWorkspaceMigration, setMockActiveWorkspaceIds, + snapshotUpgradeMigrations, testGetExecutedMigrationsInOrder, WS_1, WS_2, @@ -18,13 +20,19 @@ import { describe('UpgradeSequenceRunnerService — workspace segment alignment (integration)', () => { let context: IntegrationTestContext; + let savedUpgradeMigrations: Awaited< + ReturnType + >; beforeAll(async () => { context = await createUpgradeSequenceRunnerIntegrationTestModule(); + savedUpgradeMigrations = await snapshotUpgradeMigrations( + context.dataSource, + ); }, 30000); afterAll(async () => { - await context.dataSource.query('DELETE FROM core."upgradeMigration"'); + await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations); await context.module?.close(); await context.dataSource?.destroy(); }, 15000); diff --git a/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts b/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts index 10cbd16ab1..e14b1be2aa 100644 --- a/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts +++ b/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts @@ -2,8 +2,8 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; import { config } from 'dotenv'; -import { DataSource, type Repository } from 'typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { DataSource, type Repository } from 'typeorm'; import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; @@ -16,10 +16,10 @@ import { type WorkspaceUpgradeStep, } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; import { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service'; -import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service'; import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; +import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; import { SEED_APPLE_WORKSPACE_ID, SEED_EMPTY_WORKSPACE_3_ID, @@ -398,6 +398,57 @@ export const seedWorkspaceMigration = async ( } }; +export const snapshotUpgradeMigrations = async ( + dataSource: DataSource, +): Promise => + dataSource.query( + `SELECT id, name, status, attempt, "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt" + FROM core."upgradeMigration"`, + ); + +export const restoreUpgradeMigrations = async ( + dataSource: DataSource, + rows: UpgradeMigrationEntity[], +): Promise => { + await dataSource.query('DELETE FROM core."upgradeMigration"'); + + if (rows.length === 0) { + return; + } + + const columnsPerRow = 9; + const valueGroups: string[] = []; + const args: unknown[] = []; + let paramIndex = 1; + + for (const row of rows) { + const placeholders = Array.from( + { length: columnsPerRow }, + () => `$${paramIndex++}`, + ); + + valueGroups.push(`(${placeholders.join(', ')})`); + args.push( + row.id, + row.name, + row.status, + row.attempt, + row.executedByVersion, + row.errorMessage, + row.isInitial, + row.workspaceId, + row.createdAt, + ); + } + + await dataSource.query( + `INSERT INTO core."upgradeMigration" + (id, name, status, attempt, "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt") + VALUES ${valueGroups.join(', ')}`, + args, + ); +}; + export type ExecutedMigrationRecord = { name: string; status: string;