Improve upgrade command and prepare 1.5 release (#14325)
In this PR: - refactor the upgrade command / upgrade command runner to keep upgrade command as light as possible (all wrapping logic should go to upgrade command runner) - prevent any upgrade if there is at least one workspace.version < previsousVersion ==> this leads to corrupted state where only core migrations are run if the self-hoster is skipping a version
This commit is contained in:
+10
-8
@@ -8,12 +8,14 @@ exports[`UpgradeCommandRunner Workspace upgrade should fail when current version
|
||||
|
||||
exports[`UpgradeCommandRunner Workspace upgrade should fail when previous version is not found 1`] = `[Error: No previous version found for version 1.0.0. Please review the "allCommands" record. Available versions are: 1.0.0, 2.0.0]`;
|
||||
|
||||
exports[`UpgradeCommandRunner Workspace upgrade should fail when workspace version is not defined 1`] = `[Error: WORKSPACE_VERSION_NOT_DEFINED workspace=workspace_0]`;
|
||||
exports[`UpgradeCommandRunner Workspace upgrade should fail when workspace version is not defined 1`] = `
|
||||
[Error: Unable to run the upgrade command. Aborting the upgrade process.
|
||||
Please ensure that all workspaces are on at least the previous minor version (1.0.0).
|
||||
If any workspaces are not on the previous minor version, roll back to that version and run the upgrade command again.]
|
||||
`;
|
||||
|
||||
exports[`UpgradeCommandRunner Workspace upgrade should fail when workspace version is not equal to fromVersion 1`] = `[Error: WORKSPACE_VERSION_MISSMATCH Upgrade for workspace workspace_0 failed as its version is beneath fromWorkspaceVersion=1.0.0]`;
|
||||
|
||||
exports[`UpgradeCommandRunner should run upgrade command with failing and successful workspaces 1`] = `[Error: WORKSPACE_VERSION_MISSMATCH Upgrade for workspace outated_version_workspace failed as its version is beneath fromWorkspaceVersion=1.0.0]`;
|
||||
|
||||
exports[`UpgradeCommandRunner should run upgrade command with failing and successful workspaces 2`] = `[Error: Received invalid version: invalid 1.0.0]`;
|
||||
|
||||
exports[`UpgradeCommandRunner should run upgrade command with failing and successful workspaces 3`] = `[Error: WORKSPACE_VERSION_NOT_DEFINED workspace=null_version_workspace]`;
|
||||
exports[`UpgradeCommandRunner Workspace upgrade should fail when workspace version is not equal to fromVersion 1`] = `
|
||||
[Error: Unable to run the upgrade command. Aborting the upgrade process.
|
||||
Please ensure that all workspaces are on at least the previous minor version (1.0.0).
|
||||
If any workspaces are not on the previous minor version, roll back to that version and run the upgrade command again.]
|
||||
`;
|
||||
|
||||
+43
-89
@@ -1,7 +1,10 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type EachTestingContext } from 'twenty-shared/testing';
|
||||
import {
|
||||
eachTestingContextFilter,
|
||||
type EachTestingContext,
|
||||
} from 'twenty-shared/testing';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { UpgradeCommandRunner } from 'src/database/commands/command-runners/upgrade.command-runner';
|
||||
@@ -125,6 +128,7 @@ describe('UpgradeCommandRunner', () => {
|
||||
let syncWorkspaceMetadataCommand: jest.Mocked<SyncWorkspaceMetadataCommand>;
|
||||
let runAfterSyncMetadataSpy: jest.SpyInstance;
|
||||
let runBeforeSyncMetadataSpy: jest.SpyInstance;
|
||||
let runCoreMigrationsSpy: jest.SpyInstance;
|
||||
let twentyORMGlobalManagerSpy: TwentyORMGlobalManager;
|
||||
|
||||
type BuildModuleAndSetupSpiesArgs = {
|
||||
@@ -165,6 +169,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
'runAfterSyncMetadata',
|
||||
);
|
||||
jest.spyOn(upgradeCommandRunner, 'runOnWorkspace');
|
||||
runCoreMigrationsSpy = jest
|
||||
.spyOn(upgradeCommandRunner, 'runCoreMigrations')
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
workspaceRepository = module.get<Repository<Workspace>>(
|
||||
getRepositoryToken(Workspace),
|
||||
@@ -213,76 +220,6 @@ describe('UpgradeCommandRunner', () => {
|
||||
].forEach((fn) => expect(fn).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should run upgrade command with failing and successful workspaces', async () => {
|
||||
const outdatedVersionWorkspaces = generateMockWorkspace({
|
||||
id: 'outated_version_workspace',
|
||||
version: '0.42.42',
|
||||
});
|
||||
const invalidVersionWorkspace = generateMockWorkspace({
|
||||
id: 'invalid_version_workspace',
|
||||
version: 'invalid',
|
||||
});
|
||||
const nullVersionWorkspace = generateMockWorkspace({
|
||||
id: 'null_version_workspace',
|
||||
version: null,
|
||||
});
|
||||
const numberOfValidWorkspace = 4;
|
||||
const failingWorkspaces = [
|
||||
outdatedVersionWorkspaces,
|
||||
invalidVersionWorkspace,
|
||||
nullVersionWorkspace,
|
||||
];
|
||||
const totalWorkspace = numberOfValidWorkspace + failingWorkspaces.length;
|
||||
const appVersion = 'v2.0.0';
|
||||
const expectedToVersion = '2.0.0';
|
||||
|
||||
await buildModuleAndSetupSpies({
|
||||
numberOfWorkspace: numberOfValidWorkspace,
|
||||
workspaces: failingWorkspaces,
|
||||
appVersion,
|
||||
});
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
const passedParams = [];
|
||||
const options = {};
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
// Common assertions
|
||||
const { fail: failReport, success: successReport } =
|
||||
upgradeCommandRunner.migrationReport;
|
||||
|
||||
[
|
||||
twentyORMGlobalManagerSpy.destroyDataSourceForWorkspace,
|
||||
upgradeCommandRunner.runOnWorkspace,
|
||||
].forEach((fn) => expect(fn).toHaveBeenCalledTimes(totalWorkspace));
|
||||
expect(failReport.length + successReport.length).toBe(totalWorkspace);
|
||||
|
||||
// Success assertions
|
||||
[
|
||||
upgradeCommandRunner.runBeforeSyncMetadata,
|
||||
syncWorkspaceMetadataCommand.runOnWorkspace,
|
||||
upgradeCommandRunner.runAfterSyncMetadata,
|
||||
].forEach((fn) => expect(fn).toHaveBeenCalledTimes(numberOfValidWorkspace));
|
||||
expect(successReport.length).toBe(numberOfValidWorkspace);
|
||||
expect(workspaceRepository.update).toHaveBeenNthCalledWith(
|
||||
numberOfValidWorkspace,
|
||||
{ id: expect.any(String) },
|
||||
{ version: expectedToVersion },
|
||||
);
|
||||
|
||||
// Failing assertions
|
||||
expect(failReport.length).toBe(failingWorkspaces.length);
|
||||
failReport.forEach((report) => {
|
||||
expect(
|
||||
failingWorkspaces.some(
|
||||
(workspace) => workspace.id === report.workspaceId,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(report.error).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('should run upgrade over several workspaces', async () => {
|
||||
const numberOfWorkspace = 42;
|
||||
const appVersion = '2.0.0';
|
||||
@@ -383,7 +320,7 @@ describe('UpgradeCommandRunner', () => {
|
||||
},
|
||||
];
|
||||
|
||||
it.each(successfulTestUseCases)(
|
||||
it.each(eachTestingContextFilter(successfulTestUseCases))(
|
||||
'$title',
|
||||
async ({ context: { input } }) => {
|
||||
await buildModuleAndSetupSpies(input);
|
||||
@@ -400,8 +337,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
|
||||
expect(failReport.length).toBe(0);
|
||||
expect(successReport.length).toBe(1);
|
||||
expect(runAfterSyncMetadataSpy).toBeCalledTimes(1);
|
||||
expect(runBeforeSyncMetadataSpy).toBeCalledTimes(1);
|
||||
expect(runCoreMigrationsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(runAfterSyncMetadataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(runBeforeSyncMetadataSpy).toHaveBeenCalledTimes(1);
|
||||
const { workspaceId } = successReport[0];
|
||||
|
||||
expect(workspaceId).toBe('workspace_0');
|
||||
@@ -412,6 +350,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
describe('Workspace upgrade should fail', () => {
|
||||
const failingTestUseCases: EachTestingContext<{
|
||||
input: Omit<BuildModuleAndSetupSpiesArgs, 'numberOfWorkspace'>;
|
||||
output?: {
|
||||
failReportWorkspaceId: string;
|
||||
};
|
||||
}>[] = [
|
||||
{
|
||||
title: 'when workspace version is not equal to fromVersion',
|
||||
@@ -422,6 +363,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
version: '0.1.0',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
failReportWorkspaceId: 'workspace_0',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -432,6 +376,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
version: null,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
failReportWorkspaceId: 'workspace_0',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -440,6 +387,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
input: {
|
||||
appVersion: null,
|
||||
},
|
||||
output: {
|
||||
failReportWorkspaceId: 'global',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -448,6 +398,9 @@ describe('UpgradeCommandRunner', () => {
|
||||
input: {
|
||||
appVersion: '42.0.0',
|
||||
},
|
||||
output: {
|
||||
failReportWorkspaceId: 'global',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -468,25 +421,26 @@ describe('UpgradeCommandRunner', () => {
|
||||
},
|
||||
];
|
||||
|
||||
it.each(failingTestUseCases)('$title', async ({ context: { input } }) => {
|
||||
await buildModuleAndSetupSpies(input);
|
||||
it.each(eachTestingContextFilter(failingTestUseCases))(
|
||||
'$title',
|
||||
async ({ context: { input, output } }) => {
|
||||
await buildModuleAndSetupSpies(input);
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
const passedParams = [];
|
||||
const options = {};
|
||||
const passedParams: string[] = [];
|
||||
const options = {};
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
await upgradeCommandRunner.run(passedParams, options);
|
||||
|
||||
const { fail: failReport, success: successReport } =
|
||||
upgradeCommandRunner.migrationReport;
|
||||
const { fail: failReport, success: successReport } =
|
||||
upgradeCommandRunner.migrationReport;
|
||||
|
||||
expect(successReport.length).toBe(0);
|
||||
expect(failReport.length).toBe(1);
|
||||
const { workspaceId, error } = failReport[0];
|
||||
expect(successReport.length).toBe(0);
|
||||
expect(failReport.length).toBe(1);
|
||||
const { workspaceId, error } = failReport[0];
|
||||
|
||||
expect(workspaceId).toBe('workspace_0');
|
||||
expect(error).toMatchSnapshot();
|
||||
});
|
||||
expect(workspaceId).toBe(output?.failReportWorkspaceId ?? 'global');
|
||||
expect(error).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+151
-3
@@ -1,11 +1,16 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { SemVer } from 'semver';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandOptions,
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
type RunOnWorkspaceArgs,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
@@ -24,6 +29,8 @@ export type VersionCommands = {
|
||||
afterSyncMetadata: ActiveOrSuspendedWorkspacesMigrationCommandRunner[];
|
||||
};
|
||||
export type AllCommands = Record<string, VersionCommands>;
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export abstract class UpgradeCommandRunner extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
private fromWorkspaceVersion: SemVer;
|
||||
private currentAppVersion: SemVer;
|
||||
@@ -41,14 +48,99 @@ export abstract class UpgradeCommandRunner extends ActiveOrSuspendedWorkspacesMi
|
||||
super(workspaceRepository, twentyORMGlobalManager);
|
||||
}
|
||||
|
||||
private async loadActiveOrSuspendedWorkspace() {
|
||||
return await this.workspaceRepository.find({
|
||||
select: ['id', 'version', 'displayName'],
|
||||
where: {
|
||||
activationStatus: In([
|
||||
WorkspaceActivationStatus.ACTIVE,
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
]),
|
||||
},
|
||||
order: {
|
||||
id: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async shouldSkipUpgradeIfFreshInstallation(): Promise<boolean> {
|
||||
const activeWorkspaceOrSuspendedWorkspaceCount =
|
||||
await this.loadActiveOrSuspendedWorkspace();
|
||||
|
||||
return activeWorkspaceOrSuspendedWorkspaceCount.length === 0;
|
||||
}
|
||||
|
||||
async runCoreMigrations(): Promise<void> {
|
||||
this.logger.log('Running global database migrations');
|
||||
|
||||
try {
|
||||
this.logger.log('Running core datasource migrations...');
|
||||
const coreResult = await execPromise(
|
||||
'npx -y typeorm migration:run -d dist/src/database/typeorm/core/core.datasource',
|
||||
);
|
||||
|
||||
this.logger.log(coreResult.stdout);
|
||||
|
||||
this.logger.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Error running database migrations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async workspacesThatAreBelowFromWorkspaceVersion(
|
||||
fromWorkspaceVersion: SemVer,
|
||||
): Promise<Pick<Workspace, 'id' | 'displayName' | 'version'>[]> {
|
||||
try {
|
||||
const allActiveOrSuspendedWorkspaces =
|
||||
await this.loadActiveOrSuspendedWorkspace();
|
||||
|
||||
if (allActiveOrSuspendedWorkspaces.length === 0) {
|
||||
this.logger.log(
|
||||
'No workspaces found. Running migrations for fresh installation.',
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspacesThatAreBelowFromWorkspaceVersion =
|
||||
allActiveOrSuspendedWorkspaces.filter((workspace) => {
|
||||
if (!isDefined(workspace.version)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const versionCompareResult = compareVersionMajorAndMinor(
|
||||
workspace.version,
|
||||
fromWorkspaceVersion.version,
|
||||
);
|
||||
|
||||
return versionCompareResult === 'lower';
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error checking workspace ${workspace.id} version: ${error.message}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return workspacesThatAreBelowFromWorkspaceVersion;
|
||||
} catch (error) {
|
||||
this.logger.error('Error checking workspaces below version:', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setUpgradeContextVersionsAndCommandsForCurrentAppVersion() {
|
||||
const ugpradeContextIsAlreadyDefined = [
|
||||
const upgradeContextIsAlreadyDefined = [
|
||||
this.currentAppVersion,
|
||||
this.commands,
|
||||
this.fromWorkspaceVersion,
|
||||
].every(isDefined);
|
||||
|
||||
if (ugpradeContextIsAlreadyDefined) {
|
||||
if (upgradeContextIsAlreadyDefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -87,6 +179,62 @@ export abstract class UpgradeCommandRunner extends ActiveOrSuspendedWorkspacesMi
|
||||
this.logger.log(chalk.blue(message.join('\n ')));
|
||||
}
|
||||
|
||||
override async runMigrationCommand(
|
||||
passedParams: string[],
|
||||
options: ActiveOrSuspendedWorkspacesMigrationCommandOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.setUpgradeContextVersionsAndCommandsForCurrentAppVersion();
|
||||
|
||||
const shouldSkipUpgradeIfFreshInstallation =
|
||||
await this.shouldSkipUpgradeIfFreshInstallation();
|
||||
|
||||
if (shouldSkipUpgradeIfFreshInstallation) {
|
||||
this.logger.log(
|
||||
chalk.blue('Fresh installation detected, skipping migration'),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const workspacesThatAreBelowFromWorkspaceVersion =
|
||||
await this.workspacesThatAreBelowFromWorkspaceVersion(
|
||||
this.fromWorkspaceVersion,
|
||||
);
|
||||
|
||||
if (workspacesThatAreBelowFromWorkspaceVersion.length > 0) {
|
||||
this.migrationReport.fail.push(
|
||||
...workspacesThatAreBelowFromWorkspaceVersion.map((workspace) => ({
|
||||
error: new Error(
|
||||
`Unable to run the upgrade command. Aborting the upgrade process.
|
||||
Please ensure that all workspaces are on at least the previous minor version (${this.fromWorkspaceVersion.version}).
|
||||
If any workspaces are not on the previous minor version, roll back to that version and run the upgrade command again.`,
|
||||
),
|
||||
workspaceId: workspace.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.migrationReport.fail.push({
|
||||
error,
|
||||
workspaceId: 'global',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.migrationReport.fail.length > 0) {
|
||||
this.migrationReport.fail.forEach(({ error, workspaceId }) =>
|
||||
this.logger.error(
|
||||
`Error in workspace ${workspaceId}: ${error.message}`,
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runCoreMigrations();
|
||||
await super.runMigrationCommand(passedParams, options);
|
||||
}
|
||||
|
||||
override async runOnWorkspace(args: RunOnWorkspaceArgs): Promise<void> {
|
||||
this.setUpgradeContextVersionsAndCommandsForCurrentAppVersion();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user