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
This commit is contained in:
Paul Rastoin
2026-04-13 16:53:12 +02:00
committed by GitHub
parent 7dfc556250
commit 87f5c0083f
8 changed files with 153 additions and 79 deletions
@@ -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;
@@ -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."`;
@@ -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 () => {
@@ -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<void> {}
}
@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<void> {}
}
@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<void> {}
}
@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<void> {}
}
@RegisteredWorkspaceCommand('1.21.0', 1773000000000)
@RegisteredWorkspaceCommand(VERSION_A, 1773000000000)
class WorkspaceCommandA {
async runOnWorkspace(): Promise<void> {}
}
@RegisteredWorkspaceCommand('1.21.0', 1774000000000)
@RegisteredWorkspaceCommand(VERSION_A, 1774000000000)
class WorkspaceCommandB {
async runOnWorkspace(): Promise<void> {}
}
@@ -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<void> {}
}
@@ -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<void> {}
}
@@ -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<void> {}
}
@@ -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<void> {}
}
@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<void> {}
}
@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<void> {}
}
@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`,
]);
});
});
@@ -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;