[FEAT] New APP_VERSION env var inferred from tag & refactor upgrade-command to integrate versioning (#10751)

# Introduction
This PR contains a big test file and few snapshots
Related to https://github.com/twentyhq/core-team-issues/issues/487

## New env var `APP_VERSION`
Now will be injected directly in a built docker image the twenty's built
version. Inferred from the build git tag name.
Which mean on main or other `not a tag version` built APP_VERSION will
be `null`

## New upgrade-commander-runner
Refactored the upgrade command to be more strict regarding:
 - Version management
 - Sync metadata command always run
 - Added failing workspaces aggregator + logs on cleanup
 
From now on the `upgrade` command will compare the `WORKSPACE_VERSION`
to the `APP_VERSION` in order to bypass any workspace version != than
the upgrade version `fromVersion`
## Existing commands
Note that the version validation will be done only when passing by the
`upgrade` command.
Which means that running the following command
`upgrade:x.y-some-specific-command` won't result in workspace version
mutation

This is to enforce that all an upgrade commands + sync-metadata has been
run on a workspace



## Will do in other PR but related
### New workspace
New workspace will now be inserted with version equal to the APP_VERSION
they've been created by

### Old workspace
Will create a command that should be ran outside of any `upgrade-runner`
extending command, the command will have to be ran on every workspace
before making the next release upgrade
This command iterates over any active and suspended workspace that has
`version` to `NULL` in order to update it `APP_VERSION` -1 minor

### SENTRY_RELEASE
- Either deprecate SENTRY_RELEASE in favor of `APP_VERSION` => What
about main with null version ? or create a new env var that would be
`APP_COMMIT_SHA` instead of SENTRY third party ref

### Update CD to inject APP_VERSION from branch name

### Update docs and release logs
Adding documentation for `APP_VERSION`

## Related PRs:
https://github.com/twentyhq/twenty-infra/pull/181
This commit is contained in:
Paul Rastoin
2025-03-13 15:46:27 +01:00
committed by GitHub
parent 15019d2c66
commit bd5d211590
14 changed files with 884 additions and 51 deletions

View File

@ -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

View File

@ -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..."

View File

@ -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]`;

View File

@ -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<void> {
return;
}
public override async runAfterSyncMetadata(): Promise<void> {
return;
}
}
class InvalidVersionUpgradeCommandRunner extends UpgradeCommandRunner {
fromWorkspaceVersion = new SemVer('invalid');
VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG = true as const;
protected async runBeforeSyncMetadata(): Promise<void> {
return;
}
protected async runAfterSyncMetadata(): Promise<void> {
return;
}
}
class TestUpgradeCommandRunnerV2 extends UpgradeCommandRunner {
fromWorkspaceVersion = new SemVer('2.0.0');
VALIDATE_WORKSPACE_VERSION_FEATURE_FLAG = true as const;
protected async runBeforeSyncMetadata(): Promise<void> {
return;
}
protected async runAfterSyncMetadata(): Promise<void> {
return;
}
}
type CommandRunnerValues =
| typeof TestUpgradeCommandRunnerV1
| typeof TestUpgradeCommandRunnerV2
| typeof InvalidVersionUpgradeCommandRunner;
const generateMockWorkspace = (overrides?: Partial<Workspace>) =>
({
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<Workspace>;
let syncWorkspaceMetadataCommand: jest.Mocked<SyncWorkspaceMetadataCommand>;
let runAfterSyncMetadataSpy: jest.SpyInstance;
let runBeforeSyncMetadataSpy: jest.SpyInstance;
let twentyORMGlobalManagerSpy: TwentyORMGlobalManager;
type BuildModuleAndSetupSpiesArgs = {
numberOfWorkspace?: number;
workspaceOverride?: Partial<Workspace>;
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<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
syncWorkspaceMetadataCommand = module.get(SyncWorkspaceMetadataCommand);
twentyORMGlobalManagerSpy = module.get<TwentyORMGlobalManager>(
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<BuildModuleAndSetupSpiesArgs, 'numberOfWorkspace'>;
}>[] = [
{
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"`);
});
});

View File

@ -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<Workspace>,
@ -107,7 +121,7 @@ export abstract class ActiveOrSuspendedWorkspacesMigrationCommandRunner<
override async runMigrationCommand(
_passedParams: string[],
options: Options,
): Promise<void> {
) {
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<void>;

View File

@ -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<Workspace>,
protected readonly environmentService: EnvironmentService,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
protected readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace(args: RunOnWorkspaceArgs): Promise<void> {
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<void>;
protected abstract runAfterSyncMetadata(
args: RunOnWorkspaceArgs,
): Promise<void>;
}

View File

@ -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<Workspace>,

View File

@ -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<FieldMetadataEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager);
}

View File

@ -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<Workspace>,
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<void> {
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.`),
);
}
}

View File

@ -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 = (

View File

@ -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"`;

View File

@ -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<typeof compareVersionMajorAndMinor>;
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);
}
},
);
});

View File

@ -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}`,
);
}
}
}

View File

@ -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'],
]}></ArticleTable>
### Security