Workspace command decorators (#19397)
# Introduction Migrating the workspace commands to the decorator version + timestamp listing as for the instance commands We've now been able to remove the upgrade command abstraction where we needed to import all modules and order them Now they're dynamically retrieved at upgrade runtime, sorted by timestamp ## Instance and workspace commands name The name is computed from the command metadata `version` `className` and `timestamp` we have a duplicate validation at module init from the unified registry
This commit is contained in:
-584
@@ -1,584 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
eachTestingContextFilter,
|
||||
type EachTestingContext,
|
||||
} from 'twenty-shared/testing';
|
||||
import {
|
||||
type DataSource,
|
||||
type MigrationInterface,
|
||||
type QueryRunner,
|
||||
} from 'typeorm';
|
||||
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
UpgradeCommandOptions,
|
||||
UpgradeCommandRunner,
|
||||
type AllCommands,
|
||||
} from 'src/database/commands/command-runners/upgrade.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
|
||||
import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service';
|
||||
import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
|
||||
import { UPGRADE_COMMAND_SUPPORTED_VERSIONS } from 'src/engine/constants/upgrade-command-supported-versions.constant';
|
||||
import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
|
||||
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
|
||||
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';
|
||||
|
||||
const CURRENT_VERSION =
|
||||
UPGRADE_COMMAND_SUPPORTED_VERSIONS[
|
||||
UPGRADE_COMMAND_SUPPORTED_VERSIONS.length - 1
|
||||
];
|
||||
const PREVIOUS_VERSION =
|
||||
UPGRADE_COMMAND_SUPPORTED_VERSIONS[
|
||||
UPGRADE_COMMAND_SUPPORTED_VERSIONS.length - 2
|
||||
];
|
||||
|
||||
class BasicUpgradeCommandRunner extends UpgradeCommandRunner {
|
||||
allCommands = Object.fromEntries(
|
||||
UPGRADE_COMMAND_SUPPORTED_VERSIONS.map((version) => [version, []]),
|
||||
) as unknown as AllCommands;
|
||||
}
|
||||
|
||||
type CommandRunnerValues = typeof BasicUpgradeCommandRunner;
|
||||
|
||||
const generateMockWorkspace = (overrides?: Partial<WorkspaceEntity>) =>
|
||||
({
|
||||
id: 'workspace-id',
|
||||
version: PREVIOUS_VERSION,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
allowImpersonation: false,
|
||||
isPublicInviteLinkEnabled: false,
|
||||
displayName: 'Test Workspace',
|
||||
domainName: 'test',
|
||||
inviteHash: 'hash',
|
||||
logo: null,
|
||||
deletedAt: null,
|
||||
activationStatus: 'active',
|
||||
workspaceMembersCount: 1,
|
||||
...overrides,
|
||||
}) as WorkspaceEntity;
|
||||
|
||||
type BuildUpgradeCommandModuleArgs = {
|
||||
workspaces: WorkspaceEntity[];
|
||||
appVersion: string | null;
|
||||
commandRunner: CommandRunnerValues;
|
||||
migrations?: MigrationInterface[];
|
||||
};
|
||||
const buildUpgradeCommandModule = async ({
|
||||
workspaces,
|
||||
appVersion,
|
||||
commandRunner,
|
||||
migrations,
|
||||
}: BuildUpgradeCommandModuleArgs) => {
|
||||
const registryProvider = migrations
|
||||
? {
|
||||
provide: RegisteredInstanceMigrationService,
|
||||
useFactory: () => {
|
||||
const fakeDiscoveryService = {
|
||||
getProviders: () =>
|
||||
migrations.map((migration) => ({
|
||||
instance: migration,
|
||||
metatype: migration.constructor,
|
||||
})),
|
||||
} as unknown as import('@nestjs/core').DiscoveryService;
|
||||
const registry = new RegisteredInstanceMigrationService(
|
||||
fakeDiscoveryService,
|
||||
);
|
||||
|
||||
registry.onModuleInit();
|
||||
|
||||
return registry;
|
||||
},
|
||||
}
|
||||
: {
|
||||
provide: RegisteredInstanceMigrationService,
|
||||
useValue: {
|
||||
getInstanceCommandsForVersion: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useValue: {
|
||||
runMigrations: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: commandRunner,
|
||||
useFactory: (
|
||||
coreEngineVersionService: CoreEngineVersionService,
|
||||
workspaceVersionService: WorkspaceVersionService,
|
||||
registeredInstanceMigrationService: RegisteredInstanceMigrationService,
|
||||
instanceUpgradeService: InstanceUpgradeService,
|
||||
workspaceIteratorService: WorkspaceIteratorService,
|
||||
workspaceUpgradeService: WorkspaceUpgradeService,
|
||||
dataSource: DataSource,
|
||||
) => {
|
||||
return new commandRunner(
|
||||
coreEngineVersionService,
|
||||
workspaceVersionService,
|
||||
registeredInstanceMigrationService,
|
||||
instanceUpgradeService,
|
||||
workspaceIteratorService,
|
||||
workspaceUpgradeService,
|
||||
dataSource,
|
||||
);
|
||||
},
|
||||
inject: [
|
||||
CoreEngineVersionService,
|
||||
WorkspaceVersionService,
|
||||
RegisteredInstanceMigrationService,
|
||||
InstanceUpgradeService,
|
||||
WorkspaceIteratorService,
|
||||
WorkspaceUpgradeService,
|
||||
getDataSourceToken(),
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn().mockImplementation((key: keyof ConfigVariables) => {
|
||||
switch (key) {
|
||||
case 'APP_VERSION': {
|
||||
return appVersion;
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
CoreEngineVersionService,
|
||||
{
|
||||
provide: WorkspaceVersionService,
|
||||
useValue: {
|
||||
hasActiveOrSuspendedWorkspaces: jest
|
||||
.fn()
|
||||
.mockResolvedValue(workspaces.length > 0),
|
||||
getWorkspacesBelowVersion: jest
|
||||
.fn()
|
||||
.mockImplementation((version: string) => {
|
||||
return workspaces.filter((workspace) => {
|
||||
if (
|
||||
workspace.version === null ||
|
||||
workspace.version === undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
compareVersionMajorAndMinor(workspace.version, version) ===
|
||||
'lower'
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: InstanceUpgradeService,
|
||||
useValue: {
|
||||
runSingleMigration: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'success' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceUpgradeService,
|
||||
useValue: {
|
||||
upgradeWorkspace: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
registryProvider,
|
||||
{
|
||||
provide: WorkspaceIteratorService,
|
||||
useValue: {
|
||||
iterate: jest.fn().mockImplementation(async (args: any) => {
|
||||
const { callback, ...options } = args;
|
||||
const workspaceIds =
|
||||
options.workspaceIds ??
|
||||
workspaces.map((workspace) => workspace.id);
|
||||
|
||||
const report = {
|
||||
fail: [] as any[],
|
||||
success: [] as any[],
|
||||
};
|
||||
|
||||
for (const [index, workspaceId] of workspaceIds.entries()) {
|
||||
try {
|
||||
await callback({
|
||||
workspaceId,
|
||||
index,
|
||||
total: workspaceIds.length,
|
||||
});
|
||||
report.success.push({ workspaceId });
|
||||
} catch (error) {
|
||||
report.fail.push({ error, workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
describe('UpgradeCommandRunner', () => {
|
||||
let upgradeCommandRunner: BasicUpgradeCommandRunner;
|
||||
|
||||
type BuildModuleAndSetupSpiesArgs = {
|
||||
numberOfWorkspace?: number;
|
||||
workspaceOverride?: Partial<WorkspaceEntity>;
|
||||
workspaces?: WorkspaceEntity[];
|
||||
appVersion?: string | null;
|
||||
commandRunner?: CommandRunnerValues;
|
||||
migrations?: MigrationInterface[];
|
||||
};
|
||||
const buildModuleAndSetupSpies = async ({
|
||||
numberOfWorkspace = 1,
|
||||
workspaceOverride,
|
||||
workspaces,
|
||||
commandRunner = BasicUpgradeCommandRunner,
|
||||
appVersion = CURRENT_VERSION,
|
||||
migrations,
|
||||
}: BuildModuleAndSetupSpiesArgs) => {
|
||||
const generatedWorkspaces = Array.from(
|
||||
{ length: numberOfWorkspace },
|
||||
(_v, index) =>
|
||||
generateMockWorkspace({
|
||||
id: `workspace_${index}`,
|
||||
...workspaceOverride,
|
||||
}),
|
||||
);
|
||||
const module = await buildUpgradeCommandModule({
|
||||
commandRunner,
|
||||
appVersion,
|
||||
workspaces: [...generatedWorkspaces, ...(workspaces ?? [])],
|
||||
migrations,
|
||||
});
|
||||
|
||||
upgradeCommandRunner = module.get(commandRunner);
|
||||
|
||||
jest.spyOn(upgradeCommandRunner['logger'], 'log').mockImplementation();
|
||||
jest.spyOn(upgradeCommandRunner['logger'], 'error').mockImplementation();
|
||||
jest.spyOn(upgradeCommandRunner['logger'], 'warn').mockImplementation();
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
it('should delegate workspace upgrade to WorkspaceUpgradeService', async () => {
|
||||
const module = await buildModuleAndSetupSpies({});
|
||||
|
||||
const workspaceUpgradeService = module.get(WorkspaceUpgradeService);
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
expect(workspaceUpgradeService.upgradeWorkspace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call upgradeWorkspace for each workspace', async () => {
|
||||
const numberOfWorkspace = 42;
|
||||
|
||||
const module = await buildModuleAndSetupSpies({
|
||||
numberOfWorkspace,
|
||||
});
|
||||
|
||||
const workspaceUpgradeService = module.get(WorkspaceUpgradeService);
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
expect(workspaceUpgradeService.upgradeWorkspace).toHaveBeenCalledTimes(
|
||||
numberOfWorkspace,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Workspace upgrade should succeed ', () => {
|
||||
const successfulTestUseCases: EachTestingContext<{
|
||||
input: Omit<BuildModuleAndSetupSpiesArgs, 'numberOfWorkspace'>;
|
||||
}>[] = [
|
||||
{
|
||||
title: 'even if workspace version and app version differ in patch',
|
||||
context: {
|
||||
input: {
|
||||
appVersion: `v${CURRENT_VERSION}`,
|
||||
workspaceOverride: {
|
||||
version: `v${PREVIOUS_VERSION.replace('.0', '.12')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'even if workspace version and app version differ in patch and semantic',
|
||||
context: {
|
||||
input: {
|
||||
appVersion: `v${CURRENT_VERSION}`,
|
||||
workspaceOverride: {
|
||||
version: PREVIOUS_VERSION.replace('.0', '.12'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'even if app version contains a patch value',
|
||||
context: {
|
||||
input: {
|
||||
appVersion: CURRENT_VERSION.replace('.0', '.24'),
|
||||
workspaceOverride: {
|
||||
version: PREVIOUS_VERSION.replace('.0', '.12'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(eachTestingContextFilter(successfulTestUseCases))(
|
||||
'$title',
|
||||
async ({ context: { input } }) => {
|
||||
const module = await buildModuleAndSetupSpies(input);
|
||||
|
||||
const workspaceUpgradeService = module.get(WorkspaceUpgradeService);
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
expect(workspaceUpgradeService.upgradeWorkspace).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call runSingleMigration for each current-version instance command', async () => {
|
||||
@RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
|
||||
class AddIndexToUsers1770000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
@RegisteredInstanceMigration(CURRENT_VERSION, 1771000000000)
|
||||
class AddColumnToAccounts1771000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
@RegisteredInstanceMigration(PREVIOUS_VERSION, 1769000000000)
|
||||
class DropLegacyTable1769000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
class UndecoratedMigration1768000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
const addIndex = new AddIndexToUsers1770000000000();
|
||||
const addColumn = new AddColumnToAccounts1771000000000();
|
||||
const dropLegacy = new DropLegacyTable1769000000000();
|
||||
const undecorated = new UndecoratedMigration1768000000000();
|
||||
|
||||
const module = await buildModuleAndSetupSpies({
|
||||
migrations: [undecorated, dropLegacy, addIndex, addColumn],
|
||||
});
|
||||
|
||||
const instanceUpgradeService = module.get(InstanceUpgradeService);
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
expect(instanceUpgradeService.runSingleMigration).toHaveBeenCalledTimes(2);
|
||||
expect(instanceUpgradeService.runSingleMigration).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
addIndex,
|
||||
);
|
||||
expect(instanceUpgradeService.runSingleMigration).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
addColumn,
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip already-executed instance commands', async () => {
|
||||
@RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
|
||||
class AlreadyRunMigration1770000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
const alreadyRun = new AlreadyRunMigration1770000000000();
|
||||
|
||||
const module = await buildModuleAndSetupSpies({
|
||||
migrations: [alreadyRun],
|
||||
});
|
||||
|
||||
const instanceUpgradeService = module.get(InstanceUpgradeService);
|
||||
|
||||
(instanceUpgradeService.runSingleMigration as jest.Mock).mockResolvedValue({
|
||||
status: 'already-executed',
|
||||
});
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
expect(upgradeCommandRunner['logger'].warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('already executed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when a migration fails', async () => {
|
||||
@RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
|
||||
class FailingMigration1770000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
const failing = new FailingMigration1770000000000();
|
||||
|
||||
const module = await buildModuleAndSetupSpies({
|
||||
migrations: [failing],
|
||||
});
|
||||
|
||||
const instanceUpgradeService = module.get(InstanceUpgradeService);
|
||||
|
||||
(instanceUpgradeService.runSingleMigration as jest.Mock).mockResolvedValue({
|
||||
status: 'failed',
|
||||
error: new Error('SQL error'),
|
||||
});
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await expect(
|
||||
upgradeCommandRunner.run(passedParams, options),
|
||||
).rejects.toThrow('Core migration FailingMigration1770000000000 failed');
|
||||
});
|
||||
|
||||
it('should log success when a migration succeeds', async () => {
|
||||
@RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
|
||||
class SuccessMigration1770000000000 implements MigrationInterface {
|
||||
async up(_queryRunner: QueryRunner) {}
|
||||
async down(_queryRunner: QueryRunner) {}
|
||||
}
|
||||
|
||||
const success = new SuccessMigration1770000000000();
|
||||
|
||||
const module = await buildModuleAndSetupSpies({
|
||||
migrations: [success],
|
||||
});
|
||||
|
||||
const instanceUpgradeService = module.get(InstanceUpgradeService);
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options: UpgradeCommandOptions = {};
|
||||
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
expect(instanceUpgradeService.runSingleMigration).toHaveBeenCalledWith(
|
||||
success,
|
||||
);
|
||||
expect(upgradeCommandRunner['logger'].log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('executed successfully'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('Workspace upgrade should fail', () => {
|
||||
const failingTestUseCases: EachTestingContext<{
|
||||
input: Omit<BuildModuleAndSetupSpiesArgs, 'numberOfWorkspace'>;
|
||||
expectedErrorMessage: string;
|
||||
}>[] = [
|
||||
{
|
||||
title: 'when workspace version is not equal to fromVersion',
|
||||
context: {
|
||||
input: {
|
||||
workspaceOverride: {
|
||||
version: '0.1.0',
|
||||
},
|
||||
},
|
||||
expectedErrorMessage:
|
||||
'Unable to run the upgrade command. Aborting the upgrade process.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when workspace version is not defined',
|
||||
context: {
|
||||
input: {
|
||||
workspaceOverride: {
|
||||
version: null,
|
||||
},
|
||||
},
|
||||
expectedErrorMessage:
|
||||
'Unable to run the upgrade command. Aborting the upgrade process.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when APP_VERSION is not defined',
|
||||
context: {
|
||||
input: {
|
||||
appVersion: null,
|
||||
},
|
||||
expectedErrorMessage:
|
||||
'APP_VERSION is not defined, please double check your env variables',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when current version commands are not found',
|
||||
context: {
|
||||
input: {
|
||||
appVersion: '42.0.0',
|
||||
},
|
||||
expectedErrorMessage:
|
||||
'No command found for version 42.0.0. Please check the commands record.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when previous version is not found',
|
||||
context: {
|
||||
input: {
|
||||
appVersion: UPGRADE_COMMAND_SUPPORTED_VERSIONS[0],
|
||||
},
|
||||
expectedErrorMessage: `No previous version found for version ${UPGRADE_COMMAND_SUPPORTED_VERSIONS[0]}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(eachTestingContextFilter(failingTestUseCases))(
|
||||
'$title',
|
||||
async ({ context: { input, expectedErrorMessage } }) => {
|
||||
await buildModuleAndSetupSpies(input);
|
||||
|
||||
const passedParams: string[] = [];
|
||||
const options = {};
|
||||
|
||||
await expect(
|
||||
upgradeCommandRunner.run(passedParams, options),
|
||||
).rejects.toThrow(expectedErrorMessage);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
-314
@@ -1,314 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { CommandRunner, Option } from 'nest-commander';
|
||||
import { SemVer } from 'semver';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource, MigrationInterface } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { WorkspaceCommandRunner } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { CommandLogger } from 'src/database/commands/logger';
|
||||
import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant';
|
||||
import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
|
||||
import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
|
||||
import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
|
||||
import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service';
|
||||
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
|
||||
|
||||
export type VersionCommands = (
|
||||
| WorkspaceCommandRunner
|
||||
| ActiveOrSuspendedWorkspaceCommandRunner
|
||||
)[];
|
||||
export type AllCommands = Record<UpgradeCommandVersion, VersionCommands>;
|
||||
|
||||
export type UpgradeCommandOptions = {
|
||||
workspaceId?: Set<string>;
|
||||
startFromWorkspaceId?: string;
|
||||
workspaceCountLimit?: number;
|
||||
dryRun?: boolean;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type VersionContext = {
|
||||
fromWorkspaceVersion: SemVer;
|
||||
currentAppVersion: SemVer;
|
||||
currentVersionMajorMinor: UpgradeCommandVersion;
|
||||
instanceCommands: MigrationInterface[];
|
||||
workspaceCommands: VersionCommands;
|
||||
};
|
||||
|
||||
export abstract class UpgradeCommandRunner extends CommandRunner {
|
||||
protected logger: CommandLogger;
|
||||
|
||||
public abstract allCommands: AllCommands;
|
||||
|
||||
constructor(
|
||||
protected readonly coreEngineVersionService: CoreEngineVersionService,
|
||||
protected readonly workspaceVersionService: WorkspaceVersionService,
|
||||
protected readonly registeredInstanceMigrationService: RegisteredInstanceMigrationService,
|
||||
protected readonly instanceUpgradeService: InstanceUpgradeService,
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
protected readonly workspaceUpgradeService: WorkspaceUpgradeService,
|
||||
protected readonly dataSource: DataSource,
|
||||
) {
|
||||
super();
|
||||
this.logger = new CommandLogger({
|
||||
verbose: false,
|
||||
constructorName: this.constructor.name,
|
||||
});
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run',
|
||||
description: 'Simulate the command without making actual changes',
|
||||
required: false,
|
||||
})
|
||||
parseDryRun(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-v, --verbose',
|
||||
description: 'Verbose output',
|
||||
required: false,
|
||||
})
|
||||
parseVerbose(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description:
|
||||
'workspace id. Command runs on all active/suspended workspaces if not provided.',
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceId(val: string, previous?: Set<string>): Set<string> {
|
||||
const accumulator = previous ?? new Set<string>();
|
||||
|
||||
accumulator.add(val);
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--start-from-workspace-id [workspace_id]',
|
||||
description:
|
||||
'Start from a specific workspace id. Workspaces are processed in ascending order of id.',
|
||||
required: false,
|
||||
})
|
||||
parseStartFromWorkspaceId(val: string): string {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--workspace-count-limit [count]',
|
||||
description:
|
||||
'Limit the number of workspaces to process. Workspaces are processed in ascending order of id.',
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceCountLimit(val: string): number {
|
||||
const limit = parseInt(val);
|
||||
|
||||
if (isNaN(limit)) {
|
||||
throw new Error('Workspace count limit must be a number');
|
||||
}
|
||||
|
||||
if (limit <= 0) {
|
||||
throw new Error('Workspace count limit must be greater than 0');
|
||||
}
|
||||
|
||||
return limit;
|
||||
}
|
||||
|
||||
override async run(
|
||||
_passedParams: string[],
|
||||
options: UpgradeCommandOptions,
|
||||
): Promise<void> {
|
||||
if (options.verbose) {
|
||||
this.logger = new CommandLogger({
|
||||
verbose: true,
|
||||
constructorName: this.constructor.name,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const versionContext = this.resolveVersionContext();
|
||||
this.logger.log(
|
||||
chalk.blue(
|
||||
[
|
||||
'Initialized upgrade context with:',
|
||||
`- currentVersion (migrating to): ${versionContext.currentAppVersion}`,
|
||||
`- fromWorkspaceVersion: ${versionContext.fromWorkspaceVersion}`,
|
||||
`- ${versionContext.instanceCommands.length} instance commands (from registry)`,
|
||||
`- ${versionContext.workspaceCommands.length} workspace commands`,
|
||||
].join('\n '),
|
||||
),
|
||||
);
|
||||
|
||||
const workspacesBelowMinimumVersion =
|
||||
await this.workspaceVersionService.getWorkspacesBelowVersion(
|
||||
versionContext.fromWorkspaceVersion.version,
|
||||
);
|
||||
|
||||
if (workspacesBelowMinimumVersion.length > 0) {
|
||||
const ineligibleIds = workspacesBelowMinimumVersion
|
||||
.map((workspace) => workspace.id)
|
||||
.join(', ');
|
||||
|
||||
throw new Error(
|
||||
`Unable to run the upgrade command. Aborting the upgrade process.
|
||||
Workspaces below minimum version (${versionContext.fromWorkspaceVersion.version}): ${ineligibleIds}.
|
||||
Please roll back to that version and run the upgrade command again.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.runLegacyPendingTypeOrmMigrations();
|
||||
await this.runInstanceCommandsOrThrow(versionContext);
|
||||
|
||||
const hasWorkspaces =
|
||||
await this.workspaceVersionService.hasActiveOrSuspendedWorkspaces();
|
||||
|
||||
if (!hasWorkspaces) {
|
||||
this.logger.log(
|
||||
chalk.blue(
|
||||
'Fresh installation detected, skipping workspace commands',
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const iteratorReport = await this.runWorkspaceCommands(
|
||||
options,
|
||||
versionContext,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
chalk.blue(
|
||||
`Upgrade summary: ${iteratorReport.success.length} succeeded, ${iteratorReport.fail.length} failed`,
|
||||
),
|
||||
);
|
||||
|
||||
if (iteratorReport.fail.length > 0) {
|
||||
throw new Error(
|
||||
`Upgrade completed with ${iteratorReport.fail.length} workspace failure(s)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(chalk.red(`Upgrade failed: ${error.message}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runLegacyPendingTypeOrmMigrations(): Promise<void> {
|
||||
this.logger.log('Running legacy TypeORM migrations...');
|
||||
|
||||
const migrations = await this.dataSource.runMigrations({
|
||||
transaction: 'each',
|
||||
});
|
||||
|
||||
if (migrations.length === 0) {
|
||||
this.logger.log('No pending legacy migrations');
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Executed ${migrations.length} legacy migration(s): ${migrations.map((migration) => migration.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runInstanceCommandsOrThrow(
|
||||
versionContext: VersionContext,
|
||||
): Promise<void> {
|
||||
for (const instanceCommand of versionContext.instanceCommands) {
|
||||
const migrationName = instanceCommand.constructor.name;
|
||||
const result =
|
||||
await this.instanceUpgradeService.runSingleMigration(instanceCommand);
|
||||
|
||||
switch (result.status) {
|
||||
case 'already-executed': {
|
||||
this.logger.warn(
|
||||
`Core migration ${migrationName} already executed, skipping`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'failed': {
|
||||
this.logger.error(`Core migration ${migrationName} failed`);
|
||||
|
||||
if (isDefined(result.error)) {
|
||||
this.logger.error(
|
||||
result.error instanceof Error
|
||||
? (result.error.stack ?? result.error.message)
|
||||
: String(result.error),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Core migration ${migrationName} failed`);
|
||||
}
|
||||
case 'success': {
|
||||
this.logger.log(
|
||||
`Core migration ${migrationName} executed successfully`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveVersionContext(): VersionContext {
|
||||
const currentAppVersion = this.coreEngineVersionService.getCurrentVersion();
|
||||
const currentVersionMajorMinor =
|
||||
`${currentAppVersion.major}.${currentAppVersion.minor}.0` as UpgradeCommandVersion;
|
||||
const workspaceCommands = this.allCommands[currentVersionMajorMinor];
|
||||
|
||||
if (!isDefined(workspaceCommands)) {
|
||||
throw new Error(
|
||||
`No command found for version ${currentAppVersion}. Please check the commands record.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fromWorkspaceVersion =
|
||||
this.coreEngineVersionService.getPreviousVersion();
|
||||
|
||||
const instanceCommands =
|
||||
this.registeredInstanceMigrationService.getInstanceCommandsForVersion(
|
||||
currentVersionMajorMinor,
|
||||
);
|
||||
|
||||
return {
|
||||
fromWorkspaceVersion,
|
||||
currentAppVersion,
|
||||
currentVersionMajorMinor,
|
||||
workspaceCommands,
|
||||
instanceCommands,
|
||||
};
|
||||
}
|
||||
|
||||
private async runWorkspaceCommands(
|
||||
options: UpgradeCommandOptions,
|
||||
versionContext: VersionContext,
|
||||
) {
|
||||
return await this.workspaceIteratorService.iterate({
|
||||
workspaceIds:
|
||||
options.workspaceId && options.workspaceId.size > 0
|
||||
? Array.from(options.workspaceId)
|
||||
: undefined,
|
||||
startFromWorkspaceId: options.startFromWorkspaceId,
|
||||
workspaceCountLimit: options.workspaceCountLimit,
|
||||
dryRun: options.dryRun,
|
||||
callback: async (context) => {
|
||||
await this.workspaceUpgradeService.upgradeWorkspace({
|
||||
iteratorContext: context,
|
||||
options,
|
||||
fromWorkspaceVersion: versionContext.fromWorkspaceVersion,
|
||||
currentAppVersion: versionContext.currentAppVersion,
|
||||
workspaceCommands: versionContext.workspaceCommands,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user