Let the instance upgrade version reach releases without instance commands (#23552)
Fixes the CI failure on #23520: An upgrade version sequence has to at least contain one instance or one workspace command Workspaces commands do not run for the instance level and aren't triggered automatically Explaining this PR need <img width="1396" height="954" alt="image" src="https://github.com/user-attachments/assets/5455d05b-b286-482a-8914-808f55f3b0bf" /> ``` Upload failed: App requires Twenty server >=2.26.0 but this server is 2.25.0. ``` The server really is 2.26 (`TWENTY_CURRENT_VERSION = '2.26.0'`), but `validateServerCompatibility` resolves the instance version through `UpgradeMigrationService.getInferredVersion()`, which reads the last row in `core.upgradeMigration` with `workspaceId IS NULL AND isInitial = false` and takes the version prefix off its name. Instance commands are the only ones that write a `workspaceId`-null row, and `2-26/` ships none (only three workspace commands), so the highest instance command in the tree is still `2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message`. A fully migrated 2.26 server infers 2.25.0, and any app declaring `engines.twenty: ">=2.26.0"` is unpublishable. Two defects, both of which `getWorkspaceCompletedVersion` already avoids: - **Not sequence-aware.** The workspace path walks the registered sequence and only credits a version once the cursor sits on that version's last step. The instance path just reads the cursor's prefix, so a version contributing zero instance commands is unreachable. - **Not status-aware.** `getLastAttemptedInstanceCommand` filters on `attempt = MAX(attempt)` but not on status, so a *failed* 2.25 command still made the server report 2.25.0. ## What changed `UpgradeStatusService` gains `getInstanceCompletedVersion()`, the instance-scope mirror of `getWorkspaceCompletedVersion`. It walks the sequence filtered to instance steps, requires the cursor to sit on the last instance step of its version *and* be `completed`, then advances through any later supported version that declares no instance command at all. The version-skipping rule is the part that unblocks 2.26: a release with no instance-level work has nothing for the cursor to land on, so it is reached as soon as the last version that does have instance commands is done. A version whose instance command exists but has not run still holds the cursor back. - `validateServerCompatibility` calls the new method; `UpgradeMigrationService` is no longer a dependency of `ApplicationVersionValidationService`. - `getInstanceStatus` reports it as `inferredVersion`, so the upgrade gauge metric and `upgrade:status` CLI stop showing 2.25.0 on a 2.26 server. - `getInferredVersion` is deleted. Its one remaining caller passed a command name, which is just `extractVersionFromCommandName`. - Cursor resolution is extracted to `resolve-completed-version-from-cursor.util`, now shared by both scopes; the skip rule lives in `advance-through-versions-without-instance-commands.util`. The asymmetry between the two scopes is intentional and stays: instance commands record a row per workspace as well, so workspace cursors land on both command kinds and never had this gap. ## Testing - `npx nx typecheck twenty-server` clean, `npx nx lint:diff-with-main twenty-server` clean. - 294 unit tests pass across the upgrade and application modules, including 7 new ones for `getInstanceCompletedVersion`. Two pin the boundary: a trailing workspace-only version is reached, a trailing version whose instance command has not run is not. - The fixture in `upgrade-status.service.spec.ts` used `1.21.0`/`1.22.0`/`1.23.0`, which are real entries in `TWENTY_PREVIOUS_VERSIONS`. With the skip rule in place that sequence read as "every version from 2.0 onward has no instance commands" and walked to the end, so the fixture is renumbered to `0.2x.0` to keep those tests on cursor resolution alone. - `failing-app-installation-workspace-version.integration-spec.ts` already carried a comment describing this bug as a hazard it worked around. The workaround still holds, but integration tests were not run here (no DB in this session) — the stale comment is updated. #23520 stays at `>=2.26.0` and unblocks once this lands. --- _Generated by [Claude Code](https://claude.ai/code/session_012SvBG1BB3jTaZs6LA2Wi9R)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23552?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+14
-16
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
-8
@@ -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',
|
||||
});
|
||||
|
||||
+25
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+190
-64
@@ -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<string, true>) =>
|
||||
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'),
|
||||
},
|
||||
|
||||
-13
@@ -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<UpgradeMigrationEntity>,
|
||||
) {}
|
||||
|
||||
async getInferredVersion(commandName?: string): Promise<string | null> {
|
||||
if (isDefined(commandName)) {
|
||||
return extractVersionFromCommandName(commandName);
|
||||
}
|
||||
|
||||
const migration = await this.getLastAttemptedInstanceCommand();
|
||||
|
||||
return isDefined(migration)
|
||||
? extractVersionFromCommandName(migration.name)
|
||||
: null;
|
||||
}
|
||||
|
||||
async isLastAttemptCompleted({
|
||||
name,
|
||||
workspaceId,
|
||||
|
||||
+10
@@ -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<Record<UpgradeStepKind, true>>,
|
||||
): string[] {
|
||||
return this.getUpgradeSequence()
|
||||
.filter((step) => !isDefined(kinds) || kinds[step.kind] === true)
|
||||
.map((step) => step.name);
|
||||
}
|
||||
|
||||
locateStepInSequenceOrThrow({
|
||||
sequence,
|
||||
stepName,
|
||||
|
||||
+83
-81
@@ -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<InstanceUpgradeStatus> {
|
||||
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<string | null> {
|
||||
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<InstanceAndAllWorkspacesUpgradeStatus> {
|
||||
@@ -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<InstanceUpgradeStatus> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+29
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
-25
@@ -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();
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
export const advanceThroughVersionsWithoutInstanceCommands = ({
|
||||
completedVersion,
|
||||
supportedVersions,
|
||||
versionsWithInstanceCommands,
|
||||
}: {
|
||||
completedVersion: string;
|
||||
supportedVersions: readonly string[];
|
||||
versionsWithInstanceCommands: Set<string>;
|
||||
}): 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;
|
||||
};
|
||||
+11
@@ -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);
|
||||
};
|
||||
-9
@@ -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);
|
||||
};
|
||||
+46
@@ -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;
|
||||
};
|
||||
-1
@@ -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';
|
||||
|
||||
|
||||
+5
-17
@@ -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 () => {
|
||||
|
||||
+187
@@ -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<typeof snapshotUpgradeMigrations>
|
||||
>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+38
-11
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user