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 index 3709826eb1..672246d42a 100644 --- 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 @@ -1,28 +1,26 @@ 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 getInstanceCompletedVersion: jest.Mock; let getWorkspaceCompletedVersion: jest.Mock; beforeEach(async () => { - getInferredVersion = jest.fn(); + getInstanceCompletedVersion = jest.fn(); getWorkspaceCompletedVersion = jest.fn(); const module = await Test.createTestingModule({ providers: [ ApplicationVersionValidationService, - { - provide: UpgradeMigrationService, - useValue: { getInferredVersion }, - }, { provide: UpgradeStatusService, - useValue: { getWorkspaceCompletedVersion }, + useValue: { + getInstanceCompletedVersion, + getWorkspaceCompletedVersion, + }, }, ], }).compile(); @@ -46,16 +44,16 @@ describe('ApplicationVersionValidationService', () => { }); }); - it('should be compatible when the inferred instance version satisfies the range', async () => { - getInferredVersion.mockResolvedValue('2.19.0'); + it('should be compatible when the instance completed version satisfies the range', async () => { + getInstanceCompletedVersion.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'); + it('should be incompatible when the instance completed version does not satisfy the range', async () => { + getInstanceCompletedVersion.mockResolvedValue('2.18.0'); const result = await service.validateServerCompatibility('>=2.19.0'); @@ -65,8 +63,8 @@ describe('ApplicationVersionValidationService', () => { }); }); - it('should fail when the inferred instance version is not valid semver', async () => { - getInferredVersion.mockResolvedValue(null); + it('should fail when the instance completed version is not valid semver', async () => { + getInstanceCompletedVersion.mockResolvedValue(null); const result = await service.validateServerCompatibility('>=2.19.0'); @@ -112,7 +110,7 @@ describe('ApplicationVersionValidationService', () => { ).resolves.toEqual({ compatible: true }); expect(getWorkspaceCompletedVersion).toHaveBeenCalledWith('ws-1'); - expect(getInferredVersion).not.toHaveBeenCalled(); + expect(getInstanceCompletedVersion).not.toHaveBeenCalled(); }); it('should be incompatible when the workspace has only completed an earlier version', async () => { @@ -145,7 +143,7 @@ describe('ApplicationVersionValidationService', () => { message: 'Cannot determine the completed upgrade version for workspace ws-1: no interpretable upgrade cursor found.', }); - expect(getInferredVersion).not.toHaveBeenCalled(); + expect(getInstanceCompletedVersion).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 24cc9f39cd..4a066e5dd0 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,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import semver from 'semver'; -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'; @@ -35,10 +34,7 @@ export type VersionProgressionResult = @Injectable() export class ApplicationVersionValidationService { - constructor( - private readonly upgradeMigrationService: UpgradeMigrationService, - private readonly upgradeStatusService: UpgradeStatusService, - ) {} + constructor(private readonly upgradeStatusService: UpgradeStatusService) {} async validateServerCompatibility( requiredServerVersion: string | undefined, @@ -55,11 +51,11 @@ export class ApplicationVersionValidationService { }; } - const inferredServerVersion = - await this.upgradeMigrationService.getInferredVersion(); + const instanceCompletedVersion = + await this.upgradeStatusService.getInstanceCompletedVersion(); return this.validateVersionAgainstRange({ - version: inferredServerVersion, + version: instanceCompletedVersion, requiredVersionRange: requiredServerVersion, scope: 'instance', }); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-sequence-reader.service.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-sequence-reader.service.spec.ts index 08ab58770b..8a0c992678 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-sequence-reader.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-sequence-reader.service.spec.ts @@ -296,4 +296,29 @@ describe('UpgradeSequenceReaderService', () => { expect(result).toEqual({ name: 'Ic1', status: 'failed' }); }); }); + + describe('getUpgradeStepNames', () => { + const sequence = [ + makeFastInstance('Ic0'), + makeStep('slow-instance', 'Sic0'), + makeWorkspace('Wc0'), + ]; + + it('should return every step name when no kind is requested', async () => { + const service = await buildServiceWithMockedSequence(sequence); + + expect(service.getUpgradeStepNames()).toEqual(['Ic0', 'Sic0', 'Wc0']); + }); + + it('should keep only the requested kinds', async () => { + const service = await buildServiceWithMockedSequence(sequence); + + expect( + service.getUpgradeStepNames({ + 'fast-instance': true, + 'slow-instance': true, + }), + ).toEqual(['Ic0', 'Sic0']); + }); + }); }); 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 c3767fea78..17930b0300 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 @@ -2,35 +2,49 @@ import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { UpgradeHealthEnum } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-cross-upgrade-supported-version.constant'; import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.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 { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -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'; +// The three most recent supported versions: the upgrade sequence only ever +// covers supported versions, so fixtures built out of anything else describe a +// state the server cannot reach. +const [OLDEST_VERSION, MIDDLE_VERSION, NEWEST_VERSION] = + TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS.slice(-3); -// 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 OLDEST_INSTANCE_COMMAND = `${OLDEST_VERSION}_InstanceCommand_1772000001000`; +const OLDEST_SLOW_INSTANCE_COMMAND = `${OLDEST_VERSION}_SlowInstanceCommand_1772000002000`; +const OLDEST_FIRST_WORKSPACE_COMMAND = `${OLDEST_VERSION}_FirstWorkspaceCommand_1772000003000`; +const OLDEST_SECOND_WORKSPACE_COMMAND = `${OLDEST_VERSION}_SecondWorkspaceCommand_1772000004000`; +const MIDDLE_INSTANCE_COMMAND = `${MIDDLE_VERSION}_InstanceCommand_1776000001000`; +const NEWEST_INSTANCE_COMMAND = `${NEWEST_VERSION}_InstanceCommand_1780000002000`; +const NEWEST_WORKSPACE_COMMAND = `${NEWEST_VERSION}_WorkspaceCommand_1780000003000`; + +// Three version segments: the oldest has multiple workspace commands, the +// middle one is instance-only, the newest ends the sequence with a workspace +// command. const MOCK_SEQUENCE = [ - { 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 }, + { kind: 'fast-instance', name: OLDEST_INSTANCE_COMMAND }, + { kind: 'slow-instance', name: OLDEST_SLOW_INSTANCE_COMMAND }, + { kind: 'workspace', name: OLDEST_FIRST_WORKSPACE_COMMAND }, + { kind: 'workspace', name: OLDEST_SECOND_WORKSPACE_COMMAND }, + { kind: 'fast-instance', name: MIDDLE_INSTANCE_COMMAND }, + { kind: 'fast-instance', name: NEWEST_INSTANCE_COMMAND }, + { kind: 'workspace', name: NEWEST_WORKSPACE_COMMAND }, +]; + +// The newest supported version ships workspace commands only: nothing for the +// instance cursor to land on. +const MOCK_SEQUENCE_WITHOUT_TRAILING_INSTANCE_COMMAND = [ + { kind: 'fast-instance', name: MIDDLE_INSTANCE_COMMAND }, + { kind: 'workspace', name: NEWEST_WORKSPACE_COMMAND }, ]; type WorkspaceRecord = { @@ -61,8 +75,8 @@ const buildWorkspaceCacheGetMock = ( describe('UpgradeStatusService', () => { let service: UpgradeStatusService; + let sequence: { kind: string; name: string }[]; let getLastAttemptedInstanceCommand: jest.Mock; - let getInferredVersion: jest.Mock; let getWorkspaceLastAttemptedCommandName: jest.Mock; let workspaceFind: jest.Mock; let coreEntityCacheGet: jest.Mock; @@ -81,13 +95,8 @@ describe('UpgradeStatusService', () => { }; beforeEach(async () => { + sequence = MOCK_SEQUENCE; getLastAttemptedInstanceCommand = jest.fn(); - getInferredVersion = jest.fn(async (name?: string) => { - if (!name) return null; - const idx = name.indexOf('_'); - - return idx === -1 ? null : name.substring(0, idx); - }); getWorkspaceLastAttemptedCommandName = jest.fn(); workspaceFind = jest.fn().mockResolvedValue([]); coreEntityCacheGet = jest.fn().mockResolvedValue(null); @@ -105,14 +114,19 @@ describe('UpgradeStatusService', () => { provide: UpgradeMigrationService, useValue: { getLastAttemptedInstanceCommand, - getInferredVersion, getWorkspaceLastAttemptedCommandName, }, }, { provide: UpgradeSequenceReaderService, useValue: { - getUpgradeSequence: () => MOCK_SEQUENCE, + getUpgradeSequence: () => sequence, + getUpgradeStepNames: (kinds?: Record) => + sequence + .filter( + (step) => !isDefined(kinds) || kinds[step.kind] === true, + ) + .map((step) => step.name), }, }, { @@ -143,9 +157,9 @@ describe('UpgradeStatusService', () => { describe('getInstanceStatus', () => { it('should return up-to-date when cursor is at last instance command', async () => { getLastAttemptedInstanceCommand.mockResolvedValue({ - name: V1_23_INSTANCE_COMMAND, + name: NEWEST_INSTANCE_COMMAND, status: 'completed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), }); @@ -153,14 +167,14 @@ describe('UpgradeStatusService', () => { const result = await service.getInstanceStatus(); expect(result.health).toBe(UpgradeHealthEnum.UP_TO_DATE); - expect(result.inferredVersion).toBe('1.23.0'); + expect(result.inferredVersion).toBe(NEWEST_VERSION); }); it('should return behind when cursor is before last instance command', async () => { getLastAttemptedInstanceCommand.mockResolvedValue({ - name: V1_22_INSTANCE_COMMAND, + name: MIDDLE_INSTANCE_COMMAND, status: 'completed', - executedByVersion: '1.22.0', + executedByVersion: MIDDLE_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), }); @@ -168,14 +182,14 @@ describe('UpgradeStatusService', () => { const result = await service.getInstanceStatus(); expect(result.health).toBe(UpgradeHealthEnum.BEHIND); - expect(result.inferredVersion).toBe('1.22.0'); + expect(result.inferredVersion).toBe(MIDDLE_VERSION); }); it('should return failed when latest instance command failed', async () => { getLastAttemptedInstanceCommand.mockResolvedValue({ - name: V1_23_INSTANCE_COMMAND, + name: NEWEST_INSTANCE_COMMAND, status: 'failed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: 'column does not exist', createdAt: new Date('2025-06-01T01:00:00Z'), }); @@ -197,6 +211,118 @@ describe('UpgradeStatusService', () => { }); }); + describe('getInstanceCompletedVersion', () => { + const mockInstanceCursor = ( + cursor: { name: string; status: 'completed' | 'failed' } | null, + ) => { + getLastAttemptedInstanceCommand.mockResolvedValue( + cursor === null + ? null + : { + ...cursor, + executedByVersion: NEWEST_VERSION, + errorMessage: null, + createdAt: new Date('2025-06-01T00:00:00Z'), + }, + ); + }; + + it('should return null when no instance command has run', async () => { + mockInstanceCursor(null); + + await expect(service.getInstanceCompletedVersion()).resolves.toBeNull(); + }); + + it('should return the cursor version when completed at the last instance step of its segment', async () => { + mockInstanceCursor({ + name: NEWEST_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getInstanceCompletedVersion()).resolves.toBe( + NEWEST_VERSION, + ); + }); + + it('should ignore workspace steps of the same segment', async () => { + mockInstanceCursor({ + name: OLDEST_SLOW_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getInstanceCompletedVersion()).resolves.toBe( + OLDEST_VERSION, + ); + }); + + it('should return the previous version when the last instance step of the segment failed', async () => { + mockInstanceCursor({ name: NEWEST_INSTANCE_COMMAND, status: 'failed' }); + + await expect(service.getInstanceCompletedVersion()).resolves.toBe( + MIDDLE_VERSION, + ); + }); + + it('should return null when earlier instance steps of the same segment remain', async () => { + mockInstanceCursor({ + name: OLDEST_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getInstanceCompletedVersion()).resolves.toBeNull(); + }); + + it('should reach a trailing version that declares no instance command', async () => { + sequence = MOCK_SEQUENCE_WITHOUT_TRAILING_INSTANCE_COMMAND; + mockInstanceCursor({ + name: MIDDLE_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getInstanceCompletedVersion()).resolves.toBe( + NEWEST_VERSION, + ); + }); + + it('should not reach a trailing version whose instance command has not run', async () => { + sequence = [ + { kind: 'fast-instance', name: MIDDLE_INSTANCE_COMMAND }, + { kind: 'fast-instance', name: NEWEST_INSTANCE_COMMAND }, + ]; + mockInstanceCursor({ + name: MIDDLE_INSTANCE_COMMAND, + status: 'completed', + }); + + await expect(service.getInstanceCompletedVersion()).resolves.toBe( + MIDDLE_VERSION, + ); + }); + + it('should throw when a step name carries no version prefix', async () => { + sequence = [{ kind: 'fast-instance', name: 'NoVersionPrefixCommand' }]; + mockInstanceCursor({ + name: 'NoVersionPrefixCommand', + status: 'completed', + }); + + await expect(service.getInstanceCompletedVersion()).rejects.toThrow( + 'does not carry a version prefix', + ); + }); + + it('should throw when a step belongs to an unsupported version', async () => { + const unsupportedCommand = '0.1.0_InstanceCommand_1700000000000'; + + sequence = [{ kind: 'fast-instance', name: unsupportedCommand }]; + mockInstanceCursor({ name: unsupportedCommand, status: 'completed' }); + + await expect(service.getInstanceCompletedVersion()).rejects.toThrow( + 'is not one of the supported versions', + ); + }); + }); + describe('getWorkspaceCompletedVersion', () => { const mockWorkspaceCursor = ( cursor: { name: string; status: 'completed' | 'failed' } | null, @@ -210,7 +336,7 @@ describe('UpgradeStatusService', () => { { workspaceId: 'ws-1', ...cursor, - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), isInitial: false, @@ -222,67 +348,67 @@ describe('UpgradeStatusService', () => { it('should return the cursor version when at the last step of its segment with completed status', async () => { mockWorkspaceCursor({ - name: V1_23_WORKSPACE_COMMAND, + name: NEWEST_WORKSPACE_COMMAND, status: 'completed', }); await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( - '1.23.0', + NEWEST_VERSION, ); }); it('should return the immediately previous version when the cursor is mid-segment', async () => { mockWorkspaceCursor({ - name: V1_23_INSTANCE_COMMAND, + name: NEWEST_INSTANCE_COMMAND, status: 'completed', }); await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( - '1.22.0', + MIDDLE_VERSION, ); }); it('should return the previous version when the last step of the segment failed', async () => { - mockWorkspaceCursor({ name: V1_23_WORKSPACE_COMMAND, status: 'failed' }); + mockWorkspaceCursor({ name: NEWEST_WORKSPACE_COMMAND, status: 'failed' }); await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( - '1.22.0', + MIDDLE_VERSION, ); }); it('should return the cursor version when completed at the end of an instance-only segment', async () => { mockWorkspaceCursor({ - name: V1_22_INSTANCE_COMMAND, + name: MIDDLE_INSTANCE_COMMAND, status: 'completed', }); await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( - '1.22.0', + MIDDLE_VERSION, ); }); it('should return the previous version when an instance-only segment failed', async () => { - mockWorkspaceCursor({ name: V1_22_INSTANCE_COMMAND, status: 'failed' }); + mockWorkspaceCursor({ name: MIDDLE_INSTANCE_COMMAND, status: 'failed' }); await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( - '1.21.0', + OLDEST_VERSION, ); }); it('should return the cursor version when completed at the last of several workspace commands', async () => { mockWorkspaceCursor({ - name: V1_21_SECOND_WORKSPACE_COMMAND, + name: OLDEST_SECOND_WORKSPACE_COMMAND, status: 'completed', }); await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe( - '1.21.0', + OLDEST_VERSION, ); }); it('should not consider a segment completed while earlier workspace commands of the same segment remain', async () => { mockWorkspaceCursor({ - name: V1_21_FIRST_WORKSPACE_COMMAND, + name: OLDEST_FIRST_WORKSPACE_COMMAND, status: 'completed', }); @@ -293,7 +419,7 @@ describe('UpgradeStatusService', () => { it('should return null when the first segment failed with no previous segment', async () => { mockWorkspaceCursor({ - name: V1_21_SECOND_WORKSPACE_COMMAND, + name: OLDEST_SECOND_WORKSPACE_COMMAND, status: 'failed', }); @@ -332,9 +458,9 @@ describe('UpgradeStatusService', () => { 'ws-1', { workspaceId: 'ws-1', - name: V1_23_WORKSPACE_COMMAND, + name: NEWEST_WORKSPACE_COMMAND, status: 'completed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), }, @@ -360,9 +486,9 @@ describe('UpgradeStatusService', () => { 'ws-1', { workspaceId: 'ws-1', - name: V1_23_WORKSPACE_COMMAND, + name: NEWEST_WORKSPACE_COMMAND, status: 'completed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), }, @@ -371,9 +497,9 @@ describe('UpgradeStatusService', () => { 'ws-2', { workspaceId: 'ws-2', - name: V1_22_INSTANCE_COMMAND, + name: MIDDLE_INSTANCE_COMMAND, status: 'completed', - executedByVersion: '1.22.0', + executedByVersion: MIDDLE_VERSION, errorMessage: null, createdAt: new Date('2025-05-01T00:00:00Z'), }, @@ -419,9 +545,9 @@ describe('UpgradeStatusService', () => { cacheGetFailedWorkspaceIds.mockResolvedValue(['ws-3']); cacheGetUpToDateWorkspaceCount.mockResolvedValue(5); getLastAttemptedInstanceCommand.mockResolvedValue({ - name: V1_23_INSTANCE_COMMAND, + name: NEWEST_INSTANCE_COMMAND, status: 'completed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), }); @@ -491,9 +617,9 @@ describe('UpgradeStatusService', () => { 'ws-1', { workspaceId: 'ws-1', - name: V1_23_WORKSPACE_COMMAND, + name: NEWEST_WORKSPACE_COMMAND, status: 'completed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: null, createdAt: new Date('2025-06-01T00:00:00Z'), }, @@ -502,9 +628,9 @@ describe('UpgradeStatusService', () => { 'ws-2', { workspaceId: 'ws-2', - name: V1_22_INSTANCE_COMMAND, + name: MIDDLE_INSTANCE_COMMAND, status: 'completed', - executedByVersion: '1.22.0', + executedByVersion: MIDDLE_VERSION, errorMessage: null, createdAt: new Date('2025-05-01T00:00:00Z'), }, @@ -513,9 +639,9 @@ describe('UpgradeStatusService', () => { 'ws-3', { workspaceId: 'ws-3', - name: V1_23_WORKSPACE_COMMAND, + name: NEWEST_WORKSPACE_COMMAND, status: 'failed', - executedByVersion: '1.23.0', + executedByVersion: NEWEST_VERSION, errorMessage: 'boom', createdAt: new Date('2025-06-01T00:00:00Z'), }, diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts index 084706b938..80a2d4bfe9 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts @@ -10,7 +10,6 @@ import { UpgradeMigrationStatus, } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; import { formatUpgradeErrorForStorage } from 'src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util'; -import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util'; export type WorkspaceLastAttemptedCommand = { workspaceId: string; @@ -31,18 +30,6 @@ export class UpgradeMigrationService { private readonly upgradeMigrationRepository: Repository, ) {} - async getInferredVersion(commandName?: string): Promise { - if (isDefined(commandName)) { - return extractVersionFromCommandName(commandName); - } - - const migration = await this.getLastAttemptedInstanceCommand(); - - return isDefined(migration) - ? extractVersionFromCommandName(migration.name) - : null; - } - async isLastAttemptCompleted({ name, workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service.ts index 846c15cf16..6813a07df2 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service.ts @@ -28,6 +28,8 @@ export type WorkspaceUpgradeStep = { export type UpgradeStep = InstanceUpgradeStep | WorkspaceUpgradeStep; +export type UpgradeStepKind = UpgradeStep['kind']; + @Injectable() export class UpgradeSequenceReaderService { constructor( @@ -57,6 +59,14 @@ export class UpgradeSequenceReaderService { return sequence; } + getUpgradeStepNames( + kinds?: Partial>, + ): string[] { + return this.getUpgradeSequence() + .filter((step) => !isDefined(kinds) || kinds[step.kind] === true) + .map((step) => step.name); + } + locateStepInSequenceOrThrow({ sequence, stepName, 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 a2c98ab8ff..a6ed9fe9f3 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 @@ -6,19 +6,22 @@ import { PROVISIONED_WORKSPACE_ACTIVATION_STATUSES } from 'twenty-shared/workspa import { InjectRepository } from '@nestjs/typeorm'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-cross-upgrade-supported-version.constant'; import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.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 { advanceThroughVersionsWithoutInstanceCommands } from 'src/engine/core-modules/upgrade/utils/advance-through-versions-without-instance-commands.util'; +import { extractVersionFromCommandNameOrThrow } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util'; +import { + resolveCompletedVersionFromCursor, + type UpgradeCursor, +} from 'src/engine/core-modules/upgrade/utils/resolve-completed-version-from-cursor.util'; import { activationStatusIn } from 'src/database/commands/command-runners/utils/activation-status-in.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { In, Repository } from 'typeorm'; -export type LatestUpgradeCommand = { - name: string; - status: UpgradeMigrationStatus; +export type LatestUpgradeCommand = UpgradeCursor & { executedByVersion: string; errorMessage: string | null; createdAt: Date; @@ -52,16 +55,16 @@ export type InstanceAndAllWorkspacesUpgradeStatus = { }; const deriveHealth = ( - migration: { name: string; status: UpgradeMigrationStatus }, + cursor: UpgradeCursor, lastExpectedCommandName: string | null, ): UpgradeHealthEnum => { - if (migration.status === 'failed') { + if (cursor.status === 'failed') { return UpgradeHealthEnum.FAILED; } if ( lastExpectedCommandName !== null && - migration.name !== lastExpectedCommandName + cursor.name !== lastExpectedCommandName ) { return UpgradeHealthEnum.BEHIND; } @@ -83,21 +86,26 @@ export class UpgradeStatusService { ) {} async getInstanceStatus(): Promise { - const migration = + const cursor = await this.upgradeMigrationService.getLastAttemptedInstanceCommand(); - const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); - const lastInstanceStep = [...sequence] - .reverse() - .find( - (step) => - step.kind === 'fast-instance' || step.kind === 'slow-instance', - ); + const stepNames = this.upgradeSequenceReaderService.getUpgradeStepNames({ + 'fast-instance': true, + 'slow-instance': true, + }); + const lastExpectedCommandName = stepNames[stepNames.length - 1] ?? null; - return await this.buildCursorStatus( - migration, - lastInstanceStep?.name ?? null, - ); + return { + ...this.buildCursorStatus(cursor, lastExpectedCommandName), + inferredVersion: this.resolveInstanceCompletedVersion(cursor), + }; + } + + async getInstanceCompletedVersion(): Promise { + const cursor = + await this.upgradeMigrationService.getLastAttemptedInstanceCommand(); + + return this.resolveInstanceCompletedVersion(cursor); } async getWorkspaceStatuses( @@ -123,20 +131,17 @@ export class UpgradeStatusService { loadedWorkspaceIds, ); - const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); - const lastStepName = - sequence.length > 0 ? sequence[sequence.length - 1].name : null; + const stepNames = this.upgradeSequenceReaderService.getUpgradeStepNames(); + const lastExpectedCommandName = stepNames[stepNames.length - 1] ?? null; - return Promise.all( - workspaces.map(async (workspace) => ({ - ...(await this.buildCursorStatus( - cursors.get(workspace.id) ?? null, - lastStepName, - )), - workspaceId: workspace.id, - displayName: workspace.displayName ?? null, - })), - ); + return workspaces.map((workspace) => ({ + ...this.buildCursorStatus( + cursors.get(workspace.id) ?? null, + lastExpectedCommandName, + ), + workspaceId: workspace.id, + displayName: workspace.displayName ?? null, + })); } async getWorkspaceCompletedVersion( @@ -152,39 +157,10 @@ export class UpgradeStatusService { 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; + return resolveCompletedVersionFromCursor({ + stepNames: this.upgradeSequenceReaderService.getUpgradeStepNames(), + cursor, + }); } async getInstanceAndAllWorkspacesStatus(): Promise { @@ -275,11 +251,41 @@ export class UpgradeStatusService { await this.upgradeStatusCacheService.invalidate(); } - private async buildCursorStatus( - migration: LatestUpgradeCommand | null, + private resolveInstanceCompletedVersion( + cursor: UpgradeCursor | null, + ): string | null { + if (!isDefined(cursor)) { + return null; + } + + const stepNames = this.upgradeSequenceReaderService.getUpgradeStepNames({ + 'fast-instance': true, + 'slow-instance': true, + }); + + const completedVersion = resolveCompletedVersionFromCursor({ + stepNames, + cursor, + }); + + if (!isDefined(completedVersion)) { + return null; + } + + return advanceThroughVersionsWithoutInstanceCommands({ + completedVersion, + supportedVersions: TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS, + versionsWithInstanceCommands: new Set( + stepNames.map(extractVersionFromCommandNameOrThrow), + ), + }); + } + + private buildCursorStatus( + cursor: LatestUpgradeCommand | null, lastExpectedCommandName: string | null, - ): Promise { - if (!migration) { + ): InstanceUpgradeStatus { + if (!isDefined(cursor)) { return { inferredVersion: null, health: UpgradeHealthEnum.BEHIND, @@ -287,19 +293,15 @@ export class UpgradeStatusService { }; } - const health = deriveHealth(migration, lastExpectedCommandName); - return { - inferredVersion: await this.upgradeMigrationService.getInferredVersion( - migration.name, - ), - health, + inferredVersion: extractVersionFromCommandNameOrThrow(cursor.name), + health: deriveHealth(cursor, lastExpectedCommandName), latestCommand: { - name: migration.name, - status: migration.status, - executedByVersion: migration.executedByVersion, - errorMessage: migration.errorMessage, - createdAt: migration.createdAt, + name: cursor.name, + status: cursor.status, + executedByVersion: cursor.executedByVersion, + errorMessage: cursor.errorMessage, + createdAt: cursor.createdAt, }, }; } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name-or-throw.util.spec.ts new file mode 100644 index 0000000000..8ac8e1b62d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name-or-throw.util.spec.ts @@ -0,0 +1,29 @@ +import { extractVersionFromCommandNameOrThrow } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util'; + +describe('extractVersionFromCommandNameOrThrow', () => { + it('should extract version from standard command name', () => { + expect( + extractVersionFromCommandNameOrThrow( + '1.21.0_BackfillDatasourceCommand_1775500003000', + ), + ).toBe('1.21.0'); + }); + + it('should extract version with different version numbers', () => { + expect( + extractVersionFromCommandNameOrThrow('1.22.0_SomeCommand_1780000001000'), + ).toBe('1.22.0'); + }); + + it('should throw for names without underscores', () => { + expect(() => extractVersionFromCommandNameOrThrow('nounderscores')).toThrow( + 'does not carry a version prefix', + ); + }); + + it('should throw for an empty string', () => { + expect(() => extractVersionFromCommandNameOrThrow('')).toThrow( + 'does not carry a version prefix', + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts deleted file mode 100644 index 153dcab121..0000000000 --- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util'; - -describe('extractVersionFromCommandName', () => { - it('should extract version from standard command name', () => { - expect( - extractVersionFromCommandName( - '1.21.0_BackfillDatasourceCommand_1775500003000', - ), - ).toBe('1.21.0'); - }); - - it('should extract version with different version numbers', () => { - expect( - extractVersionFromCommandName('1.22.0_SomeCommand_1780000001000'), - ).toBe('1.22.0'); - }); - - it('should return null for names without underscores', () => { - expect(extractVersionFromCommandName('nounderscores')).toBeNull(); - }); - - it('should handle empty string', () => { - expect(extractVersionFromCommandName('')).toBeNull(); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/advance-through-versions-without-instance-commands.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/advance-through-versions-without-instance-commands.util.ts new file mode 100644 index 0000000000..e3599b4f9e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/advance-through-versions-without-instance-commands.util.ts @@ -0,0 +1,29 @@ +export const advanceThroughVersionsWithoutInstanceCommands = ({ + completedVersion, + supportedVersions, + versionsWithInstanceCommands, +}: { + completedVersion: string; + supportedVersions: readonly string[]; + versionsWithInstanceCommands: Set; +}): string => { + const completedVersionIndex = supportedVersions.indexOf(completedVersion); + + if (completedVersionIndex === -1) { + throw new Error( + `Completed upgrade version "${completedVersion}" is not one of the supported versions [${supportedVersions.join(', ')}]`, + ); + } + + let reachedVersion = completedVersion; + + for (const version of supportedVersions.slice(completedVersionIndex + 1)) { + if (versionsWithInstanceCommands.has(version)) { + break; + } + + reachedVersion = version; + } + + return reachedVersion; +}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util.ts new file mode 100644 index 0000000000..b523fae579 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util.ts @@ -0,0 +1,11 @@ +export const extractVersionFromCommandNameOrThrow = (name: string): string => { + const firstUnderscore = name.indexOf('_'); + + if (firstUnderscore === -1) { + throw new Error( + `Upgrade command name "${name}" does not carry a version prefix`, + ); + } + + return name.substring(0, firstUnderscore); +}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts deleted file mode 100644 index ae589dc307..0000000000 --- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const extractVersionFromCommandName = (name: string): string | null => { - const firstUnderscore = name.indexOf('_'); - - if (firstUnderscore === -1) { - return null; - } - - return name.substring(0, firstUnderscore); -}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-completed-version-from-cursor.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-completed-version-from-cursor.util.ts new file mode 100644 index 0000000000..505cb25615 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-completed-version-from-cursor.util.ts @@ -0,0 +1,46 @@ +import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; +import { extractVersionFromCommandNameOrThrow } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util'; + +export type UpgradeCursor = { + name: string; + status: UpgradeMigrationStatus; +}; + +export const resolveCompletedVersionFromCursor = ({ + stepNames, + cursor, +}: { + stepNames: string[]; + cursor: UpgradeCursor; +}): string | null => { + const cursorIndex = stepNames.indexOf(cursor.name); + + if (cursorIndex === -1) { + return null; + } + + const cursorVersion = extractVersionFromCommandNameOrThrow(cursor.name); + + const nextStepName = + cursorIndex < stepNames.length - 1 ? stepNames[cursorIndex + 1] : null; + + const isCursorOnLastStepOfItsVersion = + nextStepName === null || + extractVersionFromCommandNameOrThrow(nextStepName) !== cursorVersion; + + if (cursor.status === 'completed' && isCursorOnLastStepOfItsVersion) { + return cursorVersion; + } + + for (let stepIndex = cursorIndex - 1; stepIndex >= 0; stepIndex--) { + const stepVersion = extractVersionFromCommandNameOrThrow( + stepNames[stepIndex], + ); + + if (stepVersion !== cursorVersion) { + return stepVersion; + } + } + + return null; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/__tests__/object-permission.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/__tests__/object-permission.service.spec.ts index 847bfe5af3..f5aa1ef8a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/__tests__/object-permission.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/__tests__/object-permission.service.spec.ts @@ -7,7 +7,6 @@ import { ObjectPermissionService } from 'src/engine/metadata-modules/object-perm import { PermissionsException, PermissionsExceptionCode, - PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; 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 3419236403..b1d852acd5 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 @@ -8,7 +8,7 @@ import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util'; import { isDefined } from 'twenty-shared/utils'; import { v4 as uuidv4 } from 'uuid'; -import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util'; +import { extractVersionFromCommandNameOrThrow } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util'; 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 @@ -76,13 +76,9 @@ describe('Install application is gated by the workspace completed upgrade versio beforeAll(async () => { jest.useRealTimers(); - // Derive the gate version from the last attempted instance command, which - // is exactly what the upload-time server-compat check uses - // (getInferredVersion). The seeded workspace cursor can sit a version ahead - // of the instance right after a version bump whose newest segment ends in - // workspace-scoped commands with no new instance command: requiring - // >=workspaceVersion would then fail the instance gate at upload time, - // before the workspace gate under test is reached. + // Derive the gate version from the last attempted instance command, so the + // requirement is one the instance gate at upload time always satisfies and + // the workspace gate under test is actually reached. const [instanceCommand] = await global.testDataSource.query( `SELECT migration.name AS name FROM core."upgradeMigration" migration @@ -107,17 +103,9 @@ describe('Install application is gated by the workspace completed upgrade versio // instance), so the install reaches the workspace gate. currentVersionCommandName = instanceCommand.name; - const inferredServerVersion = extractVersionFromCommandName( + currentServerVersion = extractVersionFromCommandNameOrThrow( currentVersionCommandName, ); - - if (!isDefined(inferredServerVersion)) { - throw new Error( - `Could not extract a server version from upgrade cursor "${currentVersionCommandName}"`, - ); - } - - currentServerVersion = inferredServerVersion; }); afterEach(async () => { diff --git a/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/instance-completed-version.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/instance-completed-version.integration-spec.ts new file mode 100644 index 0000000000..82f29b10cb --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/suites/sequence-runner/instance-completed-version.integration-spec.ts @@ -0,0 +1,187 @@ +import { TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-cross-upgrade-supported-version.constant'; +import { type UpgradeStep } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; + +import { + type IntegrationTestContext, + createUpgradeSequenceRunnerIntegrationTestModule, + DEFAULT_OPTIONS, + makeVersionedStep, + resetSeedSequenceCounter, + restoreUpgradeMigrations, + seedInstanceMigration, + setMockActiveWorkspaceIds, + snapshotUpgradeMigrations, + WS_1, +} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util'; + +// The sequence only ever covers supported versions, and the skip rule walks +// that same list, so the fixture has to be built from real ones. +const [OLDEST_VERSION, MIDDLE_VERSION, NEWEST_VERSION] = + TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS.slice(-3); + +const OLDEST_INSTANCE_COMMAND = makeVersionedStep('fast-instance', { + version: OLDEST_VERSION, + label: 'OldestInstanceCommand', +}); +const OLDEST_SLOW_INSTANCE_COMMAND = makeVersionedStep('slow-instance', { + version: OLDEST_VERSION, + label: 'OldestSlowInstanceCommand', +}); +const MIDDLE_INSTANCE_COMMAND = makeVersionedStep('fast-instance', { + version: MIDDLE_VERSION, + label: 'MiddleInstanceCommand', +}); +const NEWEST_INSTANCE_COMMAND = makeVersionedStep('fast-instance', { + version: NEWEST_VERSION, + label: 'NewestInstanceCommand', +}); +const NEWEST_WORKSPACE_COMMAND = makeVersionedStep('workspace', { + version: NEWEST_VERSION, + label: 'NewestWorkspaceCommand', +}); + +describe('UpgradeStatusService — instance completed version (integration)', () => { + let context: IntegrationTestContext; + let savedUpgradeMigrations: Awaited< + ReturnType + >; + + const mockSequence = (sequence: UpgradeStep[]) => { + jest + .spyOn(context.upgradeSequenceReaderService, 'getUpgradeSequence') + .mockReturnValue(sequence); + }; + + beforeAll(async () => { + context = await createUpgradeSequenceRunnerIntegrationTestModule(); + savedUpgradeMigrations = await snapshotUpgradeMigrations( + context.dataSource, + ); + }, 30000); + + afterAll(async () => { + await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations); + await context.module?.close(); + await context.dataSource?.destroy(); + }, 15000); + + beforeEach(async () => { + await context.dataSource.query('DELETE FROM core."upgradeMigration"'); + resetSeedSequenceCounter(); + setMockActiveWorkspaceIds([]); + jest.restoreAllMocks(); + }); + + it('should return null when no instance command has ever run', async () => { + mockSequence([OLDEST_INSTANCE_COMMAND, MIDDLE_INSTANCE_COMMAND]); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBeNull(); + }); + + it('should reach a trailing version that ships no instance command', async () => { + mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_WORKSPACE_COMMAND]); + + await seedInstanceMigration(context.dataSource, { + name: MIDDLE_INSTANCE_COMMAND.name, + status: 'completed', + }); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBe(NEWEST_VERSION); + }); + + it('should hold at the previous version while the trailing instance command has not run', async () => { + mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_INSTANCE_COMMAND]); + + await seedInstanceMigration(context.dataSource, { + name: MIDDLE_INSTANCE_COMMAND.name, + status: 'completed', + }); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBe(MIDDLE_VERSION); + }); + + it('should not credit a version whose last instance command failed', async () => { + mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_INSTANCE_COMMAND]); + + await seedInstanceMigration(context.dataSource, { + name: MIDDLE_INSTANCE_COMMAND.name, + status: 'completed', + }); + await seedInstanceMigration(context.dataSource, { + name: NEWEST_INSTANCE_COMMAND.name, + status: 'failed', + }); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBe(MIDDLE_VERSION); + }); + + it('should credit a version once its failed command is retried successfully', async () => { + mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_INSTANCE_COMMAND]); + + await seedInstanceMigration(context.dataSource, { + name: MIDDLE_INSTANCE_COMMAND.name, + status: 'completed', + }); + await seedInstanceMigration(context.dataSource, { + name: NEWEST_INSTANCE_COMMAND.name, + status: 'failed', + }); + await seedInstanceMigration(context.dataSource, { + name: NEWEST_INSTANCE_COMMAND.name, + status: 'completed', + attempt: 2, + }); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBe(NEWEST_VERSION); + }); + + it('should not credit a version while earlier instance commands of that version remain', async () => { + mockSequence([OLDEST_INSTANCE_COMMAND, OLDEST_SLOW_INSTANCE_COMMAND]); + + await seedInstanceMigration(context.dataSource, { + name: OLDEST_INSTANCE_COMMAND.name, + status: 'completed', + }); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBeNull(); + }); + + it('should report the version reached after the runner executes the whole sequence', async () => { + const sequence = [ + OLDEST_INSTANCE_COMMAND, + MIDDLE_INSTANCE_COMMAND, + NEWEST_WORKSPACE_COMMAND, + ]; + + mockSequence(sequence); + setMockActiveWorkspaceIds([WS_1]); + + // The runner resumes from a cursor, so the sequence needs a starting point. + await seedInstanceMigration(context.dataSource, { + name: OLDEST_INSTANCE_COMMAND.name, + status: 'completed', + workspaceIds: [WS_1], + }); + + await context.runner.run({ + sequence, + options: DEFAULT_OPTIONS, + }); + + await expect( + context.upgradeStatusService.getInstanceCompletedVersion(), + ).resolves.toBe(NEWEST_VERSION); + }); +}); 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 f9cb1d1861..7964b8130a 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 @@ -7,6 +7,7 @@ import { DataSource, type Repository } from 'typeorm'; import { CommandShutdownService } from 'src/database/commands/command-runners/command-shutdown.service'; import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service'; @@ -17,9 +18,11 @@ 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 { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/services/upgrade-status-cache.service'; 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; import { SEED_APPLE_WORKSPACE_ID, @@ -110,9 +113,12 @@ const EXECUTED_BY_VERSION = '42.42.42'; const noopAsync = async () => {}; +const DEFAULT_STEP_VERSION = '1.21.0'; + export const makeStep = ( kind: UpgradeStep['kind'], name: string, + version: string = DEFAULT_STEP_VERSION, ): UpgradeStep => { const command = kind === 'workspace' @@ -125,19 +131,26 @@ export const makeStep = ( kind, name, command, - version: '1.21.0', + version, timestamp: 0, } as unknown as UpgradeStep; }; -export const makeFastInstance = (name: string) => - makeStep('fast-instance', name); +export const makeFastInstance = (name: string, version?: string) => + makeStep('fast-instance', name, version); -export const makeSlowInstance = (name: string) => - makeStep('slow-instance', name); +export const makeSlowInstance = (name: string, version?: string) => + makeStep('slow-instance', name, version); -export const makeWorkspace = (name: string) => - makeStep('workspace', name) as WorkspaceUpgradeStep; +export const makeWorkspace = (name: string, version?: string) => + makeStep('workspace', name, version) as WorkspaceUpgradeStep; + +// Steps the status service can resolve a version from: it reads the version +// off the command name, not off the step's `version` field. +export const makeVersionedStep = ( + kind: UpgradeStep['kind'], + { version, label }: { version: string; label: string }, +): UpgradeStep => makeStep(kind, `${version}_${label}_0`, version); let mockActiveWorkspaceIds: string[] = []; @@ -214,13 +227,25 @@ export const createUpgradeSequenceRunnerIntegrationTestModule = async () => { useFactory: () => new UpgradeSequenceReaderService({} as any), }, { - provide: UpgradeStatusService, + provide: getRepositoryToken(WorkspaceEntity), + useValue: dataSource.getRepository(WorkspaceEntity), + }, + { + provide: UpgradeStatusCacheService, useValue: { - invalidateInstanceAndAllWorkspacesStatus: jest - .fn() - .mockResolvedValue(undefined), + getComputedAt: jest.fn().mockResolvedValue(null), + getBehindWorkspaceIds: jest.fn().mockResolvedValue([]), + getFailedWorkspaceIds: jest.fn().mockResolvedValue([]), + getUpToDateWorkspaceCount: jest.fn().mockResolvedValue(0), + write: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn().mockResolvedValue(undefined), }, }, + { + provide: CoreEntityCacheService, + useValue: { get: jest.fn().mockResolvedValue(null) }, + }, + UpgradeStatusService, InstanceCommandRunnerService, WorkspaceCommandRunnerService, { @@ -296,6 +321,8 @@ export const createUpgradeSequenceRunnerIntegrationTestModule = async () => { module, dataSource, runner, + upgradeStatusService: module.get(UpgradeStatusService), + upgradeSequenceReaderService: module.get(UpgradeSequenceReaderService), }; };