From 87f5c0083fe51c3ef79b9c62b01a2688d3b0ed9d Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:53:12 +0200 Subject: [PATCH] Prevent cross version upgrade mismatch in `1.22` (#19627) ## Introduction As the new upgrade sequence engine is released in `1.22` it requires all workspaces to be in `1.21.0` which mean they will have a cursor on the sequence As if if someone upgrades from `1.20` to `1.22` no `upgradeMigration` will exist and throw a pretty basic `Could not find any cursor, database might not been initialized correctly` Here we allow a meaningful error --- ...ce-command-generation.service.spec.ts.snap | 32 +++--- ...nstance-command-generation.service.spec.ts | 31 ++++-- .../upgrade.command.ts | 50 ++++++++- .../twenty-previous-versions.constant.ts | 2 +- ...g-sequence-runner.integration-spec.ts.snap | 3 + ...ailing-sequence-runner.integration-spec.ts | 2 +- .../upgrade-command-registry.service.spec.ts | 103 ++++++++++-------- .../upgrade-sequence-reader.service.ts | 9 +- 8 files changed, 153 insertions(+), 79 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/__snapshots__/failing-sequence-runner.integration-spec.ts.snap diff --git a/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap b/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap index b24073cffc..c5f4780350 100644 --- a/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap +++ b/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap @@ -3,13 +3,13 @@ exports[`InstanceCommandGenerationService should encode version correctly in file and class names 1`] = ` { "className": "TestFastInstanceCommand", - "fileName": "1-20-instance-command-fast-1775000000000-test.ts", + "fileName": "1-21-instance-command-fast-1775000000000-test.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.20.0', 1775000000000) +@RegisteredInstanceCommand('1.21.0', 1775000000000) export class TestFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('SELECT 1'); @@ -26,13 +26,13 @@ export class TestFastInstanceCommand implements FastInstanceCommand { exports[`InstanceCommandGenerationService should escape backslashes in SQL queries 1`] = ` { "className": "UpdatePathFastInstanceCommand", - "fileName": "1-21-instance-command-fast-1775000000000-update-path.ts", + "fileName": "1-22-instance-command-fast-1775000000000-update-path.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000) +@RegisteredInstanceCommand('1.22.0', 1775000000000) export class UpdatePathFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('UPDATE "core"."config" SET "value" = E\\'path\\\\\\\\to\\\\\\\\file\\''); @@ -49,13 +49,13 @@ export class UpdatePathFastInstanceCommand implements FastInstanceCommand { exports[`InstanceCommandGenerationService should escape single quotes in SQL queries 1`] = ` { "className": "UpdateConfigFastInstanceCommand", - "fileName": "1-21-instance-command-fast-1775000000000-update-config.ts", + "fileName": "1-22-instance-command-fast-1775000000000-update-config.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000) +@RegisteredInstanceCommand('1.22.0', 1775000000000) export class UpdateConfigFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('UPDATE "core"."config" SET "value" = \\'it\\'\\'s done\\''); @@ -72,13 +72,13 @@ export class UpdateConfigFastInstanceCommand implements FastInstanceCommand { exports[`InstanceCommandGenerationService should generate a migration with a single up/down query 1`] = ` { "className": "AddFooColumnFastInstanceCommand", - "fileName": "1-21-instance-command-fast-1775000000000-add-foo-column.ts", + "fileName": "1-22-instance-command-fast-1775000000000-add-foo-column.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000) +@RegisteredInstanceCommand('1.22.0', 1775000000000) export class AddFooColumnFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "core"."user" ADD "foo" varchar'); @@ -95,13 +95,13 @@ export class AddFooColumnFastInstanceCommand implements FastInstanceCommand { exports[`InstanceCommandGenerationService should generate a migration with multiple queries 1`] = ` { "className": "CreateTaskTableFastInstanceCommand", - "fileName": "1-21-instance-command-fast-1775000000000-create-task-table.ts", + "fileName": "1-22-instance-command-fast-1775000000000-create-task-table.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000) +@RegisteredInstanceCommand('1.22.0', 1775000000000) export class CreateTaskTableFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('CREATE TABLE "core"."task" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" varchar NOT NULL)'); @@ -120,13 +120,13 @@ export class CreateTaskTableFastInstanceCommand implements FastInstanceCommand { exports[`InstanceCommandGenerationService should generate a migration with query parameters 1`] = ` { "className": "SeedSettingFastInstanceCommand", - "fileName": "1-21-instance-command-fast-1775000000000-seed-setting.ts", + "fileName": "1-22-instance-command-fast-1775000000000-seed-setting.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000) +@RegisteredInstanceCommand('1.22.0', 1775000000000) export class SeedSettingFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('INSERT INTO "core"."setting" ("key", "value") VALUES ($1, $2)', ["theme","dark"]); @@ -143,13 +143,13 @@ export class SeedSettingFastInstanceCommand implements FastInstanceCommand { exports[`InstanceCommandGenerationService should generate a slow instance command with populated up/down 1`] = ` { "className": "MakeColumnNotNullableSlowInstanceCommand", - "fileName": "1-21-instance-command-slow-1775000000000-make-column-not-nullable.ts", + "fileName": "1-22-instance-command-slow-1775000000000-make-column-not-nullable.ts", "fileTemplate": "import { DataSource, QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000, { type: 'slow' }) +@RegisteredInstanceCommand('1.22.0', 1775000000000, { type: 'slow' }) export class MakeColumnNotNullableSlowInstanceCommand implements SlowInstanceCommand { async runDataMigration(dataSource: DataSource): Promise { // TODO: implement data backfill before the DDL migration @@ -170,13 +170,13 @@ export class MakeColumnNotNullableSlowInstanceCommand implements SlowInstanceCom exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = ` { "className": "AutoGeneratedFastInstanceCommand", - "fileName": "1-21-instance-command-fast-1775000000000-auto-generated.ts", + "fileName": "1-22-instance-command-fast-1775000000000-auto-generated.ts", "fileTemplate": "import { QueryRunner } from 'typeorm'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; -@RegisteredInstanceCommand('1.21.0', 1775000000000) +@RegisteredInstanceCommand('1.22.0', 1775000000000) export class AutoGeneratedFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "core"."user" ADD "bar" integer'); diff --git a/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts b/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts index c9e0fe54f0..31e96b0f64 100644 --- a/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts +++ b/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts @@ -2,6 +2,11 @@ import { Test } from '@nestjs/testing'; import { getDataSourceToken } from '@nestjs/typeorm'; import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service'; +import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant'; +import { TWENTY_PREVIOUS_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant'; + +const VERSION_A = TWENTY_CURRENT_VERSION; +const VERSION_B = TWENTY_PREVIOUS_VERSIONS[0]; const FIXED_TIMESTAMP = 1775000000000; @@ -39,7 +44,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'no-changes', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -54,7 +59,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'add-foo-column', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -81,7 +86,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'create-task-table', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -107,7 +112,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'seed-setting', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -122,7 +127,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'update-config', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -141,7 +146,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'update-path', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -156,7 +161,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'auto-generated', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, }); @@ -171,7 +176,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'test', - version: '1.20.0', + version: VERSION_B, timestamp: FIXED_TIMESTAMP, }); @@ -183,7 +188,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'no-changes', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, type: 'slow', }); @@ -207,7 +212,7 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'make-column-not-nullable', - version: '1.21.0', + version: VERSION_A, timestamp: FIXED_TIMESTAMP, type: 'slow', }); @@ -223,13 +228,15 @@ describe('InstanceCommandGenerationService', () => { const result = await service.generateInstanceCommand({ migrationName: 'backfill-data', - version: '1.20.0', + version: VERSION_B, timestamp: FIXED_TIMESTAMP, type: 'slow', }); + const versionSlug = VERSION_B.split('.').slice(0, 2).join('-'); + expect(result?.fileName).toBe( - '1-20-instance-command-slow-1775000000000-backfill-data.ts', + `${versionSlug}-instance-command-slow-${FIXED_TIMESTAMP}-backfill-data.ts`, ); expect(result?.className).toBe('BackfillDataSlowInstanceCommand'); }); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index d84b023b90..210ed91385 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -2,7 +2,8 @@ import { InjectDataSource } from '@nestjs/typeorm'; import chalk from 'chalk'; import { Command, CommandRunner, Option } from 'nest-commander'; -import { DataSource } from 'typeorm'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { DataSource, In } from 'typeorm'; import { CommandLogger } from 'src/database/commands/logger'; import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service'; @@ -10,7 +11,9 @@ import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/service import { UpgradeSequenceReaderService } 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 { RemovedSinceVersion } from 'src/engine/core-modules/upgrade/types/removed-since-version.type'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service'; +import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major'; import { isDefined } from 'twenty-shared/utils'; type RawUpgradeCommandOptions = { @@ -127,6 +130,7 @@ export class UpgradeCommand extends CommandRunner { try { await this.runBootstrapMigrations(); + await this.guardAllActiveOrSuspendedWorkspacesAreIn1_21_0(); await this.backfillWorkspaceCreatedIn1_21_0Cursors(); const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); @@ -172,6 +176,50 @@ export class UpgradeCommand extends CommandRunner { } } + private async guardAllActiveOrSuspendedWorkspacesAreIn1_21_0(): RemovedSinceVersion< + '1.23.0', + Promise + > { + const MINIMUM_VERSION = '1.21.0'; + + const activeOrSuspendedWorkspaces = await this.dataSource + .getRepository(WorkspaceEntity) + .find({ + select: { + version: true, + id: true, + }, + where: { + activationStatus: In([ + WorkspaceActivationStatus.ACTIVE, + WorkspaceActivationStatus.SUSPENDED, + ]), + }, + }); + + const workspacesBelowMinimum = activeOrSuspendedWorkspaces.filter( + (workspace) => + !isDefined(workspace.version) || + compareVersionMajorAndMinor(workspace.version, MINIMUM_VERSION) === + 'lower', + ); + + if (workspacesBelowMinimum.length > 0) { + const listing = workspacesBelowMinimum + .map( + (workspace) => + ` - ${workspace.id} (version: ${workspace.version ?? 'null'})`, + ) + .join('\n'); + + throw new Error( + `Cannot upgrade: ${workspacesBelowMinimum.length} workspace(s) have a version below ${MINIMUM_VERSION}.\n` + + `All workspaces must be upgraded to at least ${MINIMUM_VERSION} before running the 1.22.0 upgrade.\n` + + `Affected workspaces:\n${listing}`, + ); + } + } + // Workspaces created during 1.21 were activated before the cursor-based // upgrade system existed. They have no upgradeMigration record yet. // Stamp them with the last 1.21 workspace command as their initial cursor. diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts index fe3a7c75ef..2cb16e9eb3 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts @@ -1 +1 @@ -export const TWENTY_PREVIOUS_VERSIONS = ['1.20.0', '1.21.0'] as const; +export const TWENTY_PREVIOUS_VERSIONS = ['1.21.0'] as const; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/__snapshots__/failing-sequence-runner.integration-spec.ts.snap b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/__snapshots__/failing-sequence-runner.integration-spec.ts.snap new file mode 100644 index 0000000000..4eab39e60a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/__snapshots__/failing-sequence-runner.integration-spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`UpgradeSequenceRunnerService — failing sequence (integration) should throw when cursor command is not found in the sequence 1`] = `"Step "RemovedCommand" not found in upgrade sequence. The sequence only covers versions [1.21.0, 1.22.0]. Please upgrade to 1.21.0 first."`; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/failing-sequence-runner.integration-spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/failing-sequence-runner.integration-spec.ts index f2cb5baec2..e211cc076a 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/failing-sequence-runner.integration-spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/failing-sequence-runner.integration-spec.ts @@ -104,7 +104,7 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () = sequence, options: DEFAULT_OPTIONS, }), - ).rejects.toThrow('Step "RemovedCommand" not found in upgrade sequence'); + ).rejects.toThrowErrorMatchingSnapshot(); }); it('should throw when workspace cursors are outside the current slice', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts index 063f60996e..eef3b2bfb7 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts @@ -11,8 +11,13 @@ import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/s import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; +import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant'; +import { TWENTY_PREVIOUS_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant'; -@RegisteredInstanceCommand('1.21.0', 1770000000000) +const VERSION_A = TWENTY_CURRENT_VERSION; +const VERSION_B = TWENTY_PREVIOUS_VERSIONS[0]; + +@RegisteredInstanceCommand(VERSION_A, 1770000000000) class MigrationA1770000000000 implements FastInstanceCommand { name = 'MigrationA1770000000000'; @@ -20,7 +25,7 @@ class MigrationA1770000000000 implements FastInstanceCommand { async down(): Promise {} } -@RegisteredInstanceCommand('1.21.0', 1771000000000) +@RegisteredInstanceCommand(VERSION_A, 1771000000000) class MigrationB1771000000000 implements FastInstanceCommand { name = 'MigrationB1771000000000'; @@ -28,7 +33,7 @@ class MigrationB1771000000000 implements FastInstanceCommand { async down(): Promise {} } -@RegisteredInstanceCommand('1.21.0', 1772000000000) +@RegisteredInstanceCommand(VERSION_A, 1772000000000) class MigrationC1772000000000 implements FastInstanceCommand { name = 'MigrationC1772000000000'; @@ -36,7 +41,7 @@ class MigrationC1772000000000 implements FastInstanceCommand { async down(): Promise {} } -@RegisteredInstanceCommand('1.20.0', 1769000000000) +@RegisteredInstanceCommand(VERSION_B, 1769000000000) class MigrationD1769000000000 implements FastInstanceCommand { name = 'MigrationD1769000000000'; @@ -51,12 +56,12 @@ class UndecoratedMigration1768000000000 implements FastInstanceCommand { async down(): Promise {} } -@RegisteredWorkspaceCommand('1.21.0', 1773000000000) +@RegisteredWorkspaceCommand(VERSION_A, 1773000000000) class WorkspaceCommandA { async runOnWorkspace(): Promise {} } -@RegisteredWorkspaceCommand('1.21.0', 1774000000000) +@RegisteredWorkspaceCommand(VERSION_A, 1774000000000) class WorkspaceCommandB { async runOnWorkspace(): Promise {} } @@ -98,15 +103,19 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const v120 = service.getBundleForVersion('1.20.0'); - const v121 = service.getBundleForVersion('1.21.0'); + const bundleB = service.getBundleForVersion(VERSION_B); + const bundleA = service.getBundleForVersion(VERSION_A); expect( - v120.fastInstanceCommands.map((entry) => entry.command.constructor.name), + bundleB.fastInstanceCommands.map( + (entry) => entry.command.constructor.name, + ), ).toStrictEqual(['MigrationD1769000000000']); expect( - v121.fastInstanceCommands.map((entry) => entry.command.constructor.name), + bundleA.fastInstanceCommands.map( + (entry) => entry.command.constructor.name, + ), ).toStrictEqual([ 'MigrationA1770000000000', 'MigrationB1771000000000', @@ -123,7 +132,7 @@ describe('UpgradeCommandRegistryService', () => { ]); const names = service - .getBundleForVersion('1.21.0') + .getBundleForVersion(VERSION_A) .fastInstanceCommands.map((entry) => entry.command.constructor.name); expect(names).toStrictEqual([ @@ -140,10 +149,10 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const v121 = service.getBundleForVersion('1.21.0'); + const bundleA = service.getBundleForVersion(VERSION_A); - expect(v121.fastInstanceCommands).toHaveLength(1); - expect(v121.fastInstanceCommands[0].command.constructor.name).toBe( + expect(bundleA.fastInstanceCommands).toHaveLength(1); + expect(bundleA.fastInstanceCommands[0].command.constructor.name).toBe( 'MigrationA1770000000000', ); }); @@ -151,13 +160,13 @@ describe('UpgradeCommandRegistryService', () => { it('should return empty array for version with no commands', async () => { const service = await buildRegistryService([]); - const v120 = service.getBundleForVersion('1.20.0'); - const v121 = service.getBundleForVersion('1.21.0'); + const bundleB = service.getBundleForVersion(VERSION_B); + const bundleA = service.getBundleForVersion(VERSION_A); - expect(v120.fastInstanceCommands).toStrictEqual([]); - expect(v121.fastInstanceCommands).toStrictEqual([]); - expect(v120.workspaceCommands).toStrictEqual([]); - expect(v121.workspaceCommands).toStrictEqual([]); + expect(bundleB.fastInstanceCommands).toStrictEqual([]); + expect(bundleA.fastInstanceCommands).toStrictEqual([]); + expect(bundleB.workspaceCommands).toStrictEqual([]); + expect(bundleA.workspaceCommands).toStrictEqual([]); }); it('should not throw when no commands are discovered (empty bundle)', async () => { @@ -168,7 +177,7 @@ describe('UpgradeCommandRegistryService', () => { const service = await buildRegistryService([]); expect( - service.getBundleForVersion('99.0.0' as unknown as '1.21.0') + service.getBundleForVersion('99.0.0' as typeof VERSION_A) .fastInstanceCommands, ).toStrictEqual([]); }); @@ -179,7 +188,7 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const { workspaceCommands } = service.getBundleForVersion('1.21.0'); + const { workspaceCommands } = service.getBundleForVersion(VERSION_A); expect( workspaceCommands.map((entry) => entry.command.constructor.name), @@ -194,14 +203,14 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandB(), ]); - const bucket = service.getBundleForVersion('1.21.0'); + const bucket = service.getBundleForVersion(VERSION_A); expect(bucket.fastInstanceCommands).toHaveLength(2); expect(bucket.workspaceCommands).toHaveLength(2); }); it('should allow same timestamp across different kinds', async () => { - @RegisteredWorkspaceCommand('1.21.0', 1770000000000) + @RegisteredWorkspaceCommand(VERSION_A, 1770000000000) class WorkspaceCommandSameTimestamp { async runOnWorkspace(): Promise {} } @@ -211,14 +220,14 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandSameTimestamp(), ]); - const bucket = service.getBundleForVersion('1.21.0'); + const bucket = service.getBundleForVersion(VERSION_A); expect(bucket.fastInstanceCommands).toHaveLength(1); expect(bucket.workspaceCommands).toHaveLength(1); }); it('should throw on duplicate timestamps within the same kind', async () => { - @RegisteredInstanceCommand('1.21.0', 1770000000000) + @RegisteredInstanceCommand(VERSION_A, 1770000000000) class DuplicateInstanceTimestamp implements FastInstanceCommand { name = 'DuplicateInstanceTimestamp'; @@ -237,7 +246,7 @@ describe('UpgradeCommandRegistryService', () => { }); it('should throw on duplicate computed names across kinds', async () => { - @RegisteredWorkspaceCommand('1.21.0', 1770000000000) + @RegisteredWorkspaceCommand(VERSION_A, 1770000000000) class MigrationA1770000000000_WS { async runOnWorkspace(): Promise {} } @@ -252,7 +261,7 @@ describe('UpgradeCommandRegistryService', () => { new MigrationA1770000000000_WS(), ]), ).rejects.toThrow( - 'Duplicate upgrade command name "1.21.0_MigrationA1770000000000_1770000000000"', + `Duplicate upgrade command name "${VERSION_A}_MigrationA1770000000000_1770000000000"`, ); }); @@ -268,10 +277,10 @@ describe('UpgradeCommandRegistryService', () => { const allCommands = service.getCrossUpgradeSupportedFastInstanceCommands(); expect(allCommands.map((entry) => entry.name)).toStrictEqual([ - '1.20.0_MigrationD1769000000000_1769000000000', - '1.21.0_MigrationA1770000000000_1770000000000', - '1.21.0_MigrationB1771000000000_1771000000000', - '1.21.0_MigrationC1772000000000_1772000000000', + `${VERSION_B}_MigrationD1769000000000_1769000000000`, + `${VERSION_A}_MigrationA1770000000000_1770000000000`, + `${VERSION_A}_MigrationB1771000000000_1771000000000`, + `${VERSION_A}_MigrationC1772000000000_1772000000000`, ]); }); @@ -284,7 +293,7 @@ describe('UpgradeCommandRegistryService', () => { }); it('should allow same class name with different timestamps across kinds', async () => { - @RegisteredWorkspaceCommand('1.21.0', 1790000000000) + @RegisteredWorkspaceCommand(VERSION_A, 1790000000000) class MigrationA1770000000000_WS { async runOnWorkspace(): Promise {} } @@ -298,14 +307,14 @@ describe('UpgradeCommandRegistryService', () => { new MigrationA1770000000000_WS(), ]); - const bucket = service.getBundleForVersion('1.21.0'); + const bucket = service.getBundleForVersion(VERSION_A); expect(bucket.fastInstanceCommands).toHaveLength(1); expect(bucket.workspaceCommands).toHaveLength(1); }); it('should discover slow instance commands and sort by timestamp', async () => { - @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1780000000000, { type: 'slow' }) class SlowMigrationB1780000000000 implements SlowInstanceCommand { name = 'SlowMigrationB1780000000000'; @@ -314,7 +323,7 @@ describe('UpgradeCommandRegistryService', () => { async down(): Promise {} } - @RegisteredInstanceCommand('1.21.0', 1779000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1779000000000, { type: 'slow' }) class SlowMigrationA1779000000000 implements SlowInstanceCommand { name = 'SlowMigrationA1779000000000'; @@ -329,7 +338,7 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const { slowInstanceCommands } = service.getBundleForVersion('1.21.0'); + const { slowInstanceCommands } = service.getBundleForVersion(VERSION_A); expect( slowInstanceCommands.map((entry) => entry.command.constructor.name), @@ -340,7 +349,7 @@ describe('UpgradeCommandRegistryService', () => { }); it('should separate fast and slow instance commands in the same version', async () => { - @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1780000000000, { type: 'slow' }) class SlowMigration1780000000000 implements SlowInstanceCommand { name = 'SlowMigration1780000000000'; @@ -355,14 +364,14 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const bucket = service.getBundleForVersion('1.21.0'); + const bucket = service.getBundleForVersion(VERSION_A); expect(bucket.fastInstanceCommands).toHaveLength(1); expect(bucket.slowInstanceCommands).toHaveLength(1); }); it('should throw on duplicate timestamps within slow instance commands', async () => { - @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1780000000000, { type: 'slow' }) class SlowMigrationA1780000000000 implements SlowInstanceCommand { name = 'SlowMigrationA1780000000000'; @@ -371,7 +380,7 @@ describe('UpgradeCommandRegistryService', () => { async down(): Promise {} } - @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1780000000000, { type: 'slow' }) class SlowMigrationB1780000000000 implements SlowInstanceCommand { name = 'SlowMigrationB1780000000000'; @@ -391,7 +400,7 @@ describe('UpgradeCommandRegistryService', () => { }); it('should allow same timestamp across fast and slow instance commands', async () => { - @RegisteredInstanceCommand('1.21.0', 1770000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1770000000000, { type: 'slow' }) class SlowMigrationSameTimestamp implements SlowInstanceCommand { name = 'SlowMigrationSameTimestamp'; @@ -406,14 +415,14 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const bucket = service.getBundleForVersion('1.21.0'); + const bucket = service.getBundleForVersion(VERSION_A); expect(bucket.fastInstanceCommands).toHaveLength(1); expect(bucket.slowInstanceCommands).toHaveLength(1); }); it('should return all slow instance commands across versions', async () => { - @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_A, 1780000000000, { type: 'slow' }) class SlowMigration1780000000000 implements SlowInstanceCommand { name = 'SlowMigration1780000000000'; @@ -422,7 +431,7 @@ describe('UpgradeCommandRegistryService', () => { async down(): Promise {} } - @RegisteredInstanceCommand('1.20.0', 1768000000000, { type: 'slow' }) + @RegisteredInstanceCommand(VERSION_B, 1768000000000, { type: 'slow' }) class SlowMigration1768000000000 implements SlowInstanceCommand { name = 'SlowMigration1768000000000'; @@ -441,8 +450,8 @@ describe('UpgradeCommandRegistryService', () => { service.getCrossUpgradeSupportedSlowInstanceCommands(); expect(allSlowCommands.map((entry) => entry.name)).toStrictEqual([ - '1.20.0_SlowMigration1768000000000_1768000000000', - '1.21.0_SlowMigration1780000000000_1780000000000', + `${VERSION_B}_SlowMigration1768000000000_1768000000000`, + `${VERSION_A}_SlowMigration1780000000000_1780000000000`, ]); }); }); 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 c88bb7db9f..56075e9563 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 @@ -65,7 +65,14 @@ export class UpgradeSequenceReaderService { const cursor = sequence.findIndex((step) => step.name === stepName); if (cursor === -1) { - throw new Error(`Step "${stepName}" not found in upgrade sequence`); + const supportedVersions = + TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS.join(', '); + + throw new Error( + `Step "${stepName}" not found in upgrade sequence. ` + + `The sequence only covers versions [${supportedVersions}]. ` + + `Please upgrade to ${TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS[0]} first.`, + ); } return cursor;