From dbd2eac69c129c2cfa973d498b9bdb4d3371b729 Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:32:38 +0200
Subject: [PATCH] Let the instance upgrade version reach releases without
instance commands (#23552)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
```
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)_
---
...ication-version-validation.service.spec.ts | 30 +--
.../application-version-validation.service.ts | 12 +-
.../upgrade-sequence-reader.service.spec.ts | 25 ++
.../__tests__/upgrade-status.service.spec.ts | 254 +++++++++++++-----
.../services/upgrade-migration.service.ts | 13 -
.../upgrade-sequence-reader.service.ts | 10 +
.../services/upgrade-status.service.ts | 164 +++++------
...on-from-command-name-or-throw.util.spec.ts | 29 ++
...act-version-from-command-name.util.spec.ts | 25 --
...versions-without-instance-commands.util.ts | 29 ++
...version-from-command-name-or-throw.util.ts | 11 +
.../extract-version-from-command-name.util.ts | 9 -
...olve-completed-version-from-cursor.util.ts | 46 ++++
.../object-permission.service.spec.ts | 1 -
...tion-workspace-version.integration-spec.ts | 22 +-
...ance-completed-version.integration-spec.ts | 187 +++++++++++++
...e-sequence-runner-integration-test.util.ts | 49 +++-
17 files changed, 671 insertions(+), 245 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name-or-throw.util.spec.ts
delete mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/extract-version-from-command-name.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/advance-through-versions-without-instance-commands.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util.ts
delete mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-completed-version-from-cursor.util.ts
create mode 100644 packages/twenty-server/test/integration/upgrade/suites/sequence-runner/instance-completed-version.integration-spec.ts
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),
};
};