diff --git a/packages/twenty-docker/twenty/Dockerfile b/packages/twenty-docker/twenty/Dockerfile index d17168392..93c1d6d33 100644 --- a/packages/twenty-docker/twenty/Dockerfile +++ b/packages/twenty-docker/twenty/Dockerfile @@ -66,6 +66,8 @@ ENV REACT_APP_SERVER_BASE_URL $REACT_APP_SERVER_BASE_URL ARG SENTRY_RELEASE ENV SENTRY_RELEASE $SENTRY_RELEASE +ARG APP_VERSION + # Copy built applications from previous stages COPY --chown=1000 --from=twenty-server-build /app /app COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-server /app/packages/twenty-server diff --git a/packages/twenty-docker/twenty/entrypoint.sh b/packages/twenty-docker/twenty/entrypoint.sh index 2b6985a30..2520a29c1 100755 --- a/packages/twenty-docker/twenty/entrypoint.sh +++ b/packages/twenty-docker/twenty/entrypoint.sh @@ -1,6 +1,11 @@ #!/bin/sh set -e +# Set APP_VERSION only if it has a value +if [ ! -z "${APP_VERSION}" ]; then + export APP_VERSION="${APP_VERSION}" +fi + # Check if the initialization has already been done and that we enabled automatic migration if [ "${DISABLE_DB_MIGRATIONS}" != "true" ] && [ ! -f /app/docker-data/db_status ]; then echo "Running database setup and migrations..." diff --git a/packages/twenty-server/src/database/commands/command-runners/__tests__/__snapshots__/upgrade.command-runner.spec.ts.snap b/packages/twenty-server/src/database/commands/command-runners/__tests__/__snapshots__/upgrade.command-runner.spec.ts.snap new file mode 100644 index 000000000..0eb2ea903 --- /dev/null +++ b/packages/twenty-server/src/database/commands/command-runners/__tests__/__snapshots__/upgrade.command-runner.spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UpgradeCommandRunner Workspace upgrade should fail when APP_VERSION is not defined 1`] = `[Error: Cannot run upgrade command when APP_VERSION is not defined]`; + +exports[`UpgradeCommandRunner Workspace upgrade should fail when workspace version is not defined 1`] = `[Error: WORKSPACE_VERSION_NOT_DEFINED to=2.0.0]`; + +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=2.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 to=2.0.0]`; diff --git a/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts b/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts new file mode 100644 index 000000000..73f4d53ef --- /dev/null +++ b/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts @@ -0,0 +1,417 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { SemVer } from 'semver'; +import { EachTestingContext } from 'twenty-shared'; +import { Repository } from 'typeorm'; + +import { UpgradeCommandRunner } from 'src/database/commands/command-runners/upgrade.command-runner'; +import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; + +class TestUpgradeCommandRunnerV1 extends UpgradeCommandRunner { + fromWorkspaceVersion = new SemVer('1.0.0'); + VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG = true as const; + + public override async runBeforeSyncMetadata(): Promise { + return; + } + + public override async runAfterSyncMetadata(): Promise { + return; + } +} + +class InvalidVersionUpgradeCommandRunner extends UpgradeCommandRunner { + fromWorkspaceVersion = new SemVer('invalid'); + VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG = true as const; + + protected async runBeforeSyncMetadata(): Promise { + return; + } + + protected async runAfterSyncMetadata(): Promise { + return; + } +} + +class TestUpgradeCommandRunnerV2 extends UpgradeCommandRunner { + fromWorkspaceVersion = new SemVer('2.0.0'); + VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG = true as const; + + protected async runBeforeSyncMetadata(): Promise { + return; + } + + protected async runAfterSyncMetadata(): Promise { + return; + } +} + +type CommandRunnerValues = + | typeof TestUpgradeCommandRunnerV1 + | typeof TestUpgradeCommandRunnerV2 + | typeof InvalidVersionUpgradeCommandRunner; + +const generateMockWorkspace = (overrides?: Partial) => + ({ + id: 'workspace-id', + version: '1.0.0', + 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 Workspace; + +type BuildUpgradeCommandModuleArgs = { + workspaces: Workspace[]; + appVersion: string | null; + commandRunner: CommandRunnerValues; +}; +const buildUpgradeCommandModule = async ({ + workspaces, + appVersion, + commandRunner, +}: BuildUpgradeCommandModuleArgs) => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + commandRunner, + { + provide: getRepositoryToken(Workspace, 'core'), + useValue: { + findOneByOrFail: jest + .fn() + .mockImplementation((args) => + workspaces.find((el) => el.id === args.id), + ), + update: jest.fn(), + find: jest.fn().mockResolvedValue(workspaces), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest + .fn() + .mockImplementation((key: keyof EnvironmentVariables) => { + switch (key) { + case 'APP_VERSION': { + return appVersion; + } + default: { + return; + } + } + }), + }, + }, + { + provide: TwentyORMGlobalManager, + useValue: { + connect: jest.fn(), + destroyDataSourceForWorkspace: jest.fn(), + getDataSourceForWorkspace: jest.fn(), + }, + }, + { + provide: SyncWorkspaceMetadataCommand, + useValue: { + runOnWorkspace: jest.fn(), + }, + }, + ], + }).compile(); + + return module; +}; + +describe('UpgradeCommandRunner', () => { + let upgradeCommandRunner: TestUpgradeCommandRunnerV1; + let workspaceRepository: Repository; + let syncWorkspaceMetadataCommand: jest.Mocked; + let runAfterSyncMetadataSpy: jest.SpyInstance; + let runBeforeSyncMetadataSpy: jest.SpyInstance; + let twentyORMGlobalManagerSpy: TwentyORMGlobalManager; + + type BuildModuleAndSetupSpiesArgs = { + numberOfWorkspace?: number; + workspaceOverride?: Partial; + workspaces?: Workspace[]; + appVersion?: string | null; + commandRunner?: CommandRunnerValues; + }; + const buildModuleAndSetupSpies = async ({ + numberOfWorkspace = 1, + workspaceOverride, + workspaces, + commandRunner = TestUpgradeCommandRunnerV1, + appVersion = '2.0.0', + }: BuildModuleAndSetupSpiesArgs) => { + const generatedWorkspaces = Array.from( + { length: numberOfWorkspace }, + (_v, index) => + generateMockWorkspace({ + id: `workspace_${index}`, + ...workspaceOverride, + }), + ); + const module = await buildUpgradeCommandModule({ + commandRunner, + appVersion, + workspaces: [...generatedWorkspaces, ...(workspaces ?? [])], + }); + + upgradeCommandRunner = module.get(commandRunner); + runBeforeSyncMetadataSpy = jest.spyOn( + upgradeCommandRunner, + 'runBeforeSyncMetadata', + ); + runAfterSyncMetadataSpy = jest.spyOn( + upgradeCommandRunner, + 'runAfterSyncMetadata', + ); + jest.spyOn(upgradeCommandRunner, 'runOnWorkspace'); + + workspaceRepository = module.get>( + getRepositoryToken(Workspace, 'core'), + ); + syncWorkspaceMetadataCommand = module.get(SyncWorkspaceMetadataCommand); + twentyORMGlobalManagerSpy = module.get( + TwentyORMGlobalManager, + ); + }; + + it('should ignore and list as succesfull upgrade on workspace with higher version', async () => { + const higherVersionWorkspace = generateMockWorkspace({ + id: 'higher_version_workspace', + version: '42.42.42', + }); + const appVersion = '2.0.0'; + + await buildModuleAndSetupSpies({ + numberOfWorkspace: 0, + workspaces: [higherVersionWorkspace], + appVersion, + }); + const passedParams = []; + const options = {}; + + await upgradeCommandRunner.run(passedParams, options); + + const { fail: failReport, success: successReport } = + upgradeCommandRunner.migrationReport; + + expect(successReport.length).toBe(1); + expect(failReport.length).toBe(0); + + [ + twentyORMGlobalManagerSpy.destroyDataSourceForWorkspace, + upgradeCommandRunner.runOnWorkspace, + ].forEach((fn) => expect(fn).toHaveBeenCalledTimes(1)); + + [ + upgradeCommandRunner.runBeforeSyncMetadata, + syncWorkspaceMetadataCommand.runOnWorkspace, + upgradeCommandRunner.runAfterSyncMetadata, + workspaceRepository.update, + ].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 = '2.0.0'; + + await buildModuleAndSetupSpies({ + numberOfWorkspace: numberOfValidWorkspace, + workspaces: failingWorkspaces, + appVersion, + }); + const passedParams = []; + const options = {}; + + 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: appVersion }, + ); + + // 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'; + + await buildModuleAndSetupSpies({ + numberOfWorkspace, + appVersion, + }); + const passedParams = []; + const options = {}; + + await upgradeCommandRunner.run(passedParams, options); + + [ + upgradeCommandRunner.runOnWorkspace, + upgradeCommandRunner.runBeforeSyncMetadata, + upgradeCommandRunner.runAfterSyncMetadata, + syncWorkspaceMetadataCommand.runOnWorkspace, + twentyORMGlobalManagerSpy.destroyDataSourceForWorkspace, + ].forEach((fn) => expect(fn).toHaveBeenCalledTimes(numberOfWorkspace)); + expect(workspaceRepository.update).toHaveBeenNthCalledWith( + numberOfWorkspace, + { id: expect.any(String) }, + { version: appVersion }, + ); + expect(upgradeCommandRunner.migrationReport.success.length).toBe(42); + expect(upgradeCommandRunner.migrationReport.fail.length).toBe(0); + }); + + it('should run syncMetadataCommand betweensuccessful beforeSyncMetadataUpgradeCommandsToRun and afterSyncMetadataUpgradeCommandsToRun', async () => { + await buildModuleAndSetupSpies({}); + const passedParams = []; + const options = {}; + + await upgradeCommandRunner.run(passedParams, options); + + [ + upgradeCommandRunner.runOnWorkspace, + upgradeCommandRunner.runBeforeSyncMetadata, + upgradeCommandRunner.runAfterSyncMetadata, + syncWorkspaceMetadataCommand.runOnWorkspace, + twentyORMGlobalManagerSpy.destroyDataSourceForWorkspace, + ].forEach((fn) => expect(fn).toHaveBeenCalledTimes(1)); + + // Verify order of execution + const beforeSyncCall = runBeforeSyncMetadataSpy.mock.invocationCallOrder[0]; + const afterSyncCall = runAfterSyncMetadataSpy.mock.invocationCallOrder[0]; + const syncMetadataCall = + syncWorkspaceMetadataCommand.runOnWorkspace.mock.invocationCallOrder[0]; + + expect(beforeSyncCall).toBeLessThan(syncMetadataCall); + expect(syncMetadataCall).toBeLessThan(afterSyncCall); + expect(upgradeCommandRunner.migrationReport.success.length).toBe(1); + expect(upgradeCommandRunner.migrationReport.fail.length).toBe(0); + }); + + describe('Workspace upgrade should fail', () => { + const failingTestUseCases: EachTestingContext<{ + input: Omit; + }>[] = [ + { + title: 'when workspace version is not equal to fromVersion', + context: { + input: { + appVersion: '3.0.0', + commandRunner: TestUpgradeCommandRunnerV2, + workspaceOverride: { + version: '0.1.0', + }, + }, + }, + }, + { + title: 'when workspace version is not defined', + context: { + input: { + workspaceOverride: { + version: null, + }, + }, + }, + }, + { + title: 'when APP_VERSION is not defined', + context: { + input: { + appVersion: null, + }, + }, + }, + ]; + + it.each(failingTestUseCases)('$title', async ({ context: { input } }) => { + await buildModuleAndSetupSpies(input); + + const passedParams = []; + const options = {}; + + await upgradeCommandRunner.run(passedParams, options); + + const { fail: failReport, success: successReport } = + upgradeCommandRunner.migrationReport; + + expect(successReport.length).toBe(0); + expect(failReport.length).toBe(1); + const { workspaceId, error } = failReport[0]; + + expect(workspaceId).toBe('workspace_0'); + expect(error).toMatchSnapshot(); + }); + }); + + it('should throw if upgrade command version is invalid', async () => { + await expect( + buildModuleAndSetupSpies({ + commandRunner: InvalidVersionUpgradeCommandRunner, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Invalid Version: invalid"`); + }); +}); diff --git a/packages/twenty-server/src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner.ts b/packages/twenty-server/src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner.ts index a5697c0a4..6e81d49e6 100644 --- a/packages/twenty-server/src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner.ts +++ b/packages/twenty-server/src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner.ts @@ -24,6 +24,16 @@ export type RunOnWorkspaceArgs = { total: number; }; +export type WorkspaceMigrationReport = { + fail: { + workspaceId: string; + error: Error; + }[]; + success: { + workspaceId: string; + }[]; +}; + export abstract class ActiveOrSuspendedWorkspacesMigrationCommandRunner< Options extends ActiveOrSuspendedWorkspacesMigrationCommandOptions = ActiveOrSuspendedWorkspacesMigrationCommandOptions, @@ -31,6 +41,10 @@ export abstract class ActiveOrSuspendedWorkspacesMigrationCommandRunner< private workspaceIds: string[] = []; private startFromWorkspaceId: string | undefined; private workspaceCountLimit: number | undefined; + public migrationReport: WorkspaceMigrationReport = { + fail: [], + success: [], + }; constructor( protected readonly workspaceRepository: Repository, @@ -107,7 +121,7 @@ export abstract class ActiveOrSuspendedWorkspacesMigrationCommandRunner< override async runMigrationCommand( _passedParams: string[], options: Options, - ): Promise { + ) { const activeWorkspaceIds = this.workspaceIds.length > 0 ? this.workspaceIds @@ -117,39 +131,50 @@ export abstract class ActiveOrSuspendedWorkspacesMigrationCommandRunner< this.logger.log(chalk.yellow('Dry run mode: No changes will be applied')); } - try { - for (const [index, workspaceId] of activeWorkspaceIds.entries()) { - this.logger.log( - `Running command on workspace ${workspaceId} ${index + 1}/${activeWorkspaceIds.length}`, - ); + for (const [index, workspaceId] of activeWorkspaceIds.entries()) { + this.logger.log( + `Running command on workspace ${workspaceId} ${index + 1}/${activeWorkspaceIds.length}`, + ); - try { - const dataSource = - await this.twentyORMGlobalManager.getDataSourceForWorkspace( - workspaceId, - false, - ); - - await this.runOnWorkspace({ - options, + try { + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( workspaceId, - dataSource, - index: index, - total: activeWorkspaceIds.length, - }); - } catch (error) { - this.logger.warn( - chalk.red(`Error in workspace ${workspaceId}: ${error.message}`), + false, ); - } + await this.runOnWorkspace({ + options, + workspaceId, + dataSource, + index: index, + total: activeWorkspaceIds.length, + }); + this.migrationReport.success.push({ + workspaceId, + }); + } catch (error) { + this.migrationReport.fail.push({ + error, + workspaceId, + }); + this.logger.warn( + chalk.red(`Error in workspace ${workspaceId}: ${error.message}`), + ); + } + + try { await this.twentyORMGlobalManager.destroyDataSourceForWorkspace( workspaceId, ); + } catch (error) { + this.logger.error(error); } - } catch (error) { - this.logger.error(error); } + + this.migrationReport.fail.forEach(({ error, workspaceId }) => + this.logger.error(`Error in workspace ${workspaceId}: ${error.message}`), + ); } protected abstract runOnWorkspace(args: RunOnWorkspaceArgs): Promise; diff --git a/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts b/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts new file mode 100644 index 000000000..7d213b342 --- /dev/null +++ b/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts @@ -0,0 +1,132 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import chalk from 'chalk'; +import { SemVer } from 'semver'; +import { isDefined } from 'twenty-shared'; +import { Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; +import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major'; + +type ValidateWorkspaceVersionEqualsWorkspaceFromVersionOrThrowArgs = { + workspaceId: string; + appVersion: string | undefined; +}; + +export abstract class UpgradeCommandRunner extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + abstract readonly fromWorkspaceVersion: SemVer; + public readonly VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG?: true; + + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + protected readonly environmentService: EnvironmentService, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + protected readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace(args: RunOnWorkspaceArgs): Promise { + const { workspaceId, index, total, options } = args; + const appVersion = this.environmentService.get('APP_VERSION'); + + this.logger.log( + chalk.blue( + `${options.dryRun ? '(dry run)' : ''} Upgrading workspace ${workspaceId} ${index + 1}/${total}`, + ), + ); + + const workspaceVersionCompareResult = + await this.retrieveWorkspaceVersionAndCompareToWorkspaceFromVersion({ + appVersion, + workspaceId, + }); + + switch (workspaceVersionCompareResult) { + case 'lower': { + throw new Error( + `WORKSPACE_VERSION_MISSMATCH Upgrade for workspace ${workspaceId} failed as its version is beneath fromWorkspaceVersion=${this.fromWorkspaceVersion.version}`, + ); + } + case 'equal': { + await this.runBeforeSyncMetadata(args); + await this.syncWorkspaceMetadataCommand.runOnWorkspace(args); + await this.runAfterSyncMetadata(args); + + await this.workspaceRepository.update( + { id: workspaceId }, + { version: appVersion }, + ); + this.logger.log( + chalk.blue(`Upgrade for workspace ${workspaceId} completed.`), + ); + + return; + } + case 'higher': { + this.logger.log( + chalk.blue( + `Upgrade for workspace ${workspaceId} ignored as is already at a higher version.`, + ), + ); + + return; + } + default: { + throw new Error( + `Should never occur, encountered unexpected value from retrieveWorkspaceVersionAndCompareToWorkspaceFromVersion ${workspaceVersionCompareResult}`, + ); + } + } + } + + private async retrieveWorkspaceVersionAndCompareToWorkspaceFromVersion({ + appVersion, + workspaceId, + }: ValidateWorkspaceVersionEqualsWorkspaceFromVersionOrThrowArgs) { + if (!isDefined(appVersion)) { + throw new Error( + 'Cannot run upgrade command when APP_VERSION is not defined', + ); + } + + // TODO remove after first release has been done using workspace_version + if (!isDefined(this.VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG)) { + this.logger.warn( + 'VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG set to true ignoring workspace versions validation step', + ); + const equalVersions = 0; + + return equalVersions; + } + + const workspace = await this.workspaceRepository.findOneByOrFail({ + id: workspaceId, + }); + const currentWorkspaceVersion = workspace.version; + + if (!isDefined(currentWorkspaceVersion)) { + throw new Error(`WORKSPACE_VERSION_NOT_DEFINED to=${appVersion}`); + } + + return compareVersionMajorAndMinor( + currentWorkspaceVersion, + this.fromWorkspaceVersion.version, + ); + } + + protected abstract runBeforeSyncMetadata( + args: RunOnWorkspaceArgs, + ): Promise; + protected abstract runAfterSyncMetadata( + args: RunOnWorkspaceArgs, + ): Promise; +} diff --git a/packages/twenty-server/src/database/commands/data-seed-demo-workspace/services/data-seed-demo-workspace.service.ts b/packages/twenty-server/src/database/commands/data-seed-demo-workspace/services/data-seed-demo-workspace.service.ts index 2ba62c005..7b1d30a7d 100644 --- a/packages/twenty-server/src/database/commands/data-seed-demo-workspace/services/data-seed-demo-workspace.service.ts +++ b/packages/twenty-server/src/database/commands/data-seed-demo-workspace/services/data-seed-demo-workspace.service.ts @@ -11,14 +11,12 @@ import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; @Injectable() export class DataSeedDemoWorkspaceService { constructor( - private readonly environmentService: EnvironmentService, private readonly workspaceManagerService: WorkspaceManagerService, @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/0-43/0-43-add-tasks-assigned-to-me-view.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/0-43/0-43-add-tasks-assigned-to-me-view.command.ts index ed7696ed2..bce4367b3 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/0-43/0-43-add-tasks-assigned-to-me-view.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/0-43/0-43-add-tasks-assigned-to-me-view.command.ts @@ -13,7 +13,6 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataDefaultOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { tasksAssignedToMeView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-assigned-to-me'; import { TASK_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; @@ -36,7 +35,6 @@ export class AddTasksAssignedToMeViewCommand extends ActiveOrSuspendedWorkspaces @InjectRepository(FieldMetadataEntity, 'metadata') private readonly fieldMetadataRepository: Repository, protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, ) { super(workspaceRepository, twentyORMGlobalManager); } 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 c2779a740..3a6ece934 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 @@ -1,18 +1,17 @@ import { InjectRepository } from '@nestjs/typeorm'; -import chalk from 'chalk'; import { Command } from 'nest-commander'; +import { SemVer } from 'semver'; import { Repository } from 'typeorm'; -import { - ActiveOrSuspendedWorkspacesMigrationCommandRunner, - RunOnWorkspaceArgs, -} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { UpgradeCommandRunner } from 'src/database/commands/command-runners/upgrade.command-runner'; import { AddTasksAssignedToMeViewCommand } from 'src/database/commands/upgrade-version-command/0-43/0-43-add-tasks-assigned-to-me-view.command'; import { MigrateIsSearchableForCustomObjectMetadataCommand } from 'src/database/commands/upgrade-version-command/0-43/0-43-migrate-is-searchable-for-custom-object-metadata.command'; import { MigrateRichTextContentPatchCommand } from 'src/database/commands/upgrade-version-command/0-43/0-43-migrate-rich-text-content-patch.command'; import { MigrateSearchVectorOnNoteAndTaskEntitiesCommand } from 'src/database/commands/upgrade-version-command/0-43/0-43-migrate-search-vector-on-note-and-task-entities.command'; import { UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand } from 'src/database/commands/upgrade-version-command/0-43/0-43-update-default-view-record-opening-on-workflow-objects.command'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; @@ -21,28 +20,31 @@ import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/works name: 'upgrade', description: 'Upgrade workspaces to the latest version', }) -export class UpgradeCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { +export class UpgradeCommand extends UpgradeCommandRunner { + fromWorkspaceVersion = new SemVer('0.43.0'); + constructor( @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, + protected readonly environmentService: EnvironmentService, protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + protected readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, + protected readonly migrateRichTextContentPatchCommand: MigrateRichTextContentPatchCommand, protected readonly addTasksAssignedToMeViewCommand: AddTasksAssignedToMeViewCommand, protected readonly migrateIsSearchableForCustomObjectMetadataCommand: MigrateIsSearchableForCustomObjectMetadataCommand, protected readonly updateDefaultViewRecordOpeningOnWorkflowObjectsCommand: UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand, protected readonly migrateSearchVectorOnNoteAndTaskEntitiesCommand: MigrateSearchVectorOnNoteAndTaskEntitiesCommand, - protected readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, ) { - super(workspaceRepository, twentyORMGlobalManager); + super( + workspaceRepository, + environmentService, + twentyORMGlobalManager, + syncWorkspaceMetadataCommand, + ); } - override async runOnWorkspace(args: RunOnWorkspaceArgs): Promise { - this.logger.log( - chalk.blue( - `${args.options.dryRun ? '(dry run)' : ''} Upgrading workspace ${args.workspaceId} ${args.index + 1}/${args.total}`, - ), - ); - + override async runBeforeSyncMetadata(args: RunOnWorkspaceArgs) { await this.migrateRichTextContentPatchCommand.runOnWorkspace(args); await this.migrateIsSearchableForCustomObjectMetadataCommand.runOnWorkspace( @@ -56,17 +58,13 @@ export class UpgradeCommand extends ActiveOrSuspendedWorkspacesMigrationCommandR await this.migrateIsSearchableForCustomObjectMetadataCommand.runOnWorkspace( args, ); + } - await this.syncWorkspaceMetadataCommand.runOnWorkspace(args); - + override async runAfterSyncMetadata(args: RunOnWorkspaceArgs) { await this.updateDefaultViewRecordOpeningOnWorkflowObjectsCommand.runOnWorkspace( args, ); await this.addTasksAssignedToMeViewCommand.runOnWorkspace(args); - - this.logger.log( - chalk.blue(`Upgrade for workspace ${args.workspaceId} completed.`), - ); } } diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index fa0d2391c..4dd6cc255 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -7,6 +7,7 @@ import { IsEnum, IsNumber, IsOptional, + IsSemVer, IsString, IsUrl, ValidateIf, @@ -978,6 +979,14 @@ export class EnvironmentVariables { @IsOptional() @IsBoolean() IS_ATTACHMENT_PREVIEW_ENABLED = true; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Twenty server version', + }) + @IsOptional() + @IsSemVer() + APP_VERSION?: string; } export const validate = ( diff --git a/packages/twenty-server/src/utils/version/__tests__/__snapshots__/compare-version-minor-and-major.spec.ts.snap b/packages/twenty-server/src/utils/version/__tests__/__snapshots__/compare-version-minor-and-major.spec.ts.snap new file mode 100644 index 000000000..14c19802c --- /dev/null +++ b/packages/twenty-server/src/utils/version/__tests__/__snapshots__/compare-version-minor-and-major.spec.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`is-same-major-and-minor-version incomplete version1 1`] = `"Received invalid version: 1.0 1.1.0"`; + +exports[`is-same-major-and-minor-version invalid version1 1`] = `"Received invalid version: invalid 1.1.0"`; + +exports[`is-same-major-and-minor-version invalid version2 1`] = `"Received invalid version: 1.0.0 invalid"`; diff --git a/packages/twenty-server/src/utils/version/__tests__/compare-version-minor-and-major.spec.ts b/packages/twenty-server/src/utils/version/__tests__/compare-version-minor-and-major.spec.ts new file mode 100644 index 000000000..2bf5b919c --- /dev/null +++ b/packages/twenty-server/src/utils/version/__tests__/compare-version-minor-and-major.spec.ts @@ -0,0 +1,192 @@ +import { EachTestingContext } from 'twenty-shared'; + +import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major'; + +type IsSameVersionTestCase = EachTestingContext<{ + version1: string; + version2: string; + expected?: ReturnType; + expectToThrow?: boolean; +}>; +describe('is-same-major-and-minor-version', () => { + const beneathVersionTestCases: IsSameVersionTestCase[] = [ + { + context: { + version1: '1.0.0', + version2: '1.1.0', + expected: 'lower', + }, + title: 'different minor version', + }, + { + context: { + version1: '2.3.0', + version2: '2.4.0', + expected: 'lower', + }, + title: 'different minor version', + }, + { + context: { + version1: '0.1.0', + version2: '0.2.0', + expected: 'lower', + }, + title: 'different minor version with zero major', + }, + { + context: { + version1: '2.3.5', + version2: '2.4.1', + expected: 'lower', + }, + title: 'different minor and patch versions', + }, + { + context: { + version1: '1.0.0-alpha', + version2: '1.1.0-beta', + expected: 'lower', + }, + title: 'different minor version with different pre-release tags', + }, + { + context: { + version1: 'v1.0.0', + version2: 'v1.1.0', + expected: 'lower', + }, + title: 'different minor version with v prefix', + }, + { + context: { + version1: '2.0.0', + version2: '42.42.42', + expected: 'lower', + }, + title: 'above version2', + }, + { + context: { + version1: '2.0.0', + version2: 'v42.42.42', + expected: 'lower', + }, + title: 'above version2 with v-prefix', + }, + ]; + + const sameVersionTestCases: IsSameVersionTestCase[] = [ + { + context: { + version1: '1.1.0', + version2: '1.1.0', + expected: 'equal', + }, + title: 'exact same version', + }, + { + context: { + version1: '1.1.0', + version2: '1.1.42', + expected: 'equal', + }, + title: 'exact same major and minor but different patch version', + }, + { + context: { + version1: 'v1.1.0', + version2: 'v1.1.0', + expected: 'equal', + }, + title: 'exact same version with v prefix', + }, + { + context: { + version1: '1.1.0-alpha', + version2: '1.1.0-alpha', + expected: 'equal', + }, + title: 'exact same version with same pre-release tag', + }, + { + context: { + version1: '0.0.1', + version2: '0.0.1', + expected: 'equal', + }, + title: 'exact same version with all zeros', + }, + { + context: { + version1: 'v1.1.0', + version2: '1.1.0', + expected: 'equal', + }, + title: 'same version with different v-prefix', + }, + ]; + + const aboveVersionTestCases: IsSameVersionTestCase[] = [ + { + context: { + version1: 'v42.1.0', + version2: '2.0.0', + expected: 'higher', + }, + title: 'above version', + }, + { + context: { + version1: '42.42.42', + version2: '2.0.0', + expected: 'higher', + }, + title: 'above version with prefix', + }, + ]; + const invalidTestCases: IsSameVersionTestCase[] = [ + { + context: { + version1: 'invalid', + version2: '1.1.0', + expectToThrow: true, + }, + title: 'invalid version1', + }, + { + context: { + version1: '1.0.0', + version2: 'invalid', + expectToThrow: true, + }, + title: 'invalid version2', + }, + { + context: { + version1: '1.0', + version2: '1.1.0', + expectToThrow: true, + }, + title: 'incomplete version1', + }, + ]; + + test.each([ + ...sameVersionTestCases, + ...invalidTestCases, + ...beneathVersionTestCases, + ...aboveVersionTestCases, + ])( + '$title', + ({ context: { version1, version2, expected, expectToThrow = false } }) => { + if (expectToThrow) { + expect(() => + compareVersionMajorAndMinor(version1, version2), + ).toThrowErrorMatchingSnapshot(); + } else { + expect(compareVersionMajorAndMinor(version1, version2)).toBe(expected); + } + }, + ); +}); diff --git a/packages/twenty-server/src/utils/version/compare-version-minor-and-major.ts b/packages/twenty-server/src/utils/version/compare-version-minor-and-major.ts new file mode 100644 index 000000000..796d9ac2e --- /dev/null +++ b/packages/twenty-server/src/utils/version/compare-version-minor-and-major.ts @@ -0,0 +1,37 @@ +import * as semver from 'semver'; + +type CompareVersionMajorAndMinorReturnType = 'lower' | 'equal' | 'higher'; +export function compareVersionMajorAndMinor( + rawVersion1: string, + rawVersion2: string, +): CompareVersionMajorAndMinorReturnType { + const [version1, version2] = [rawVersion1, rawVersion2].map((version) => + semver.parse(version), + ); + + if (version1 === null || version2 === null) { + throw new Error(`Received invalid version: ${rawVersion1} ${rawVersion2}`); + } + + const v1WithoutPatch = `${version1.major}.${version1.minor}.0`; + const v2WithoutPatch = `${version2.major}.${version2.minor}.0`; + + const compareResult = semver.compare(v1WithoutPatch, v2WithoutPatch); + + switch (compareResult) { + case -1: { + return 'lower'; + } + case 0: { + return 'equal'; + } + case 1: { + return 'higher'; + } + default: { + throw new Error( + `Should never occur, encountered an unexpected value from semver.compare ${compareResult}`, + ); + } + } +} diff --git a/packages/twenty-website/src/content/developers/self-hosting/setup.mdx b/packages/twenty-website/src/content/developers/self-hosting/setup.mdx index 86ad2ccbd..232b6ad68 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/setup.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/setup.mdx @@ -161,7 +161,7 @@ yarn command:prod cron:calendar:ongoing-stale ['SERVER_URL', 'http://localhost:3000', 'Url to the hosted server'], ['FRONTEND_URL', '$SERVER_URL', 'Url to the frontend server. Same as SERVER_URL by default'], ['PORT', '3000', 'Port of the backend server'], - ['CACHE_STORAGE_TTL', '3600 * 24 * 7', 'Cache TTL in seconds'] + ['CACHE_STORAGE_TTL', '3600 * 24 * 7', 'Cache TTL in seconds'], ]}> ### Security