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:
Charles Bochet
2025-09-05 15:58:17 +02:00
committed by GitHub
parent d5ef4cbbff
commit f802294c84
11 changed files with 264 additions and 246 deletions
@@ -41,7 +41,7 @@ export type AddPositionsToWorkflowVersionsAndWorkflowRunsOptions =
name: 'upgrade:1-5:add-positions-to-workflow-versions-and-workflow-runs',
description: 'Add positions to workflow versions and workflow runs',
})
export class AddPositionsToWorkflowVersionsAndWorkflowRuns extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
export class AddPositionsToWorkflowVersionsAndWorkflowRunsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
@@ -0,0 +1,668 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { type ViewFilterOperand as SharedViewFilterOperand } from 'twenty-shared/types';
import { DataSource, In, Repository, type QueryRunner } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import { ViewFilterGroupEntity } from 'src/engine/core-modules/view/entities/view-filter-group.entity';
import { ViewFilterEntity } from 'src/engine/core-modules/view/entities/view-filter.entity';
import { ViewGroupEntity } from 'src/engine/core-modules/view/entities/view-group.entity';
import { ViewSortEntity } from 'src/engine/core-modules/view/entities/view-sort.entity';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { type ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator';
import { ViewKey } from 'src/engine/core-modules/view/enums/view-key.enum';
import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in';
import { type ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction';
import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
import { type ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity';
import { type ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { type ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { type ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { type ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
import { convertViewFilterOperandToCoreOperand } from 'src/modules/view/utils/convert-view-filter-operand-to-core-operand.util';
import { convertViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/convert-view-filter-workspace-value-to-core-value';
@Command({
name: 'upgrade:1-5:migrate-views-to-core',
description: 'Migrate views from workspace schemas to core schema',
})
export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
index,
total,
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Migrating views to core schema for workspace ${workspaceId} ${index + 1}/${total}`,
);
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
try {
await queryRunner.startTransaction();
try {
await this.migrateViews(
workspaceId,
options.dryRun ?? false,
queryRunner,
);
if (options.dryRun) {
this.logger.log(
`DRY RUN: Would migrate views to core schema for workspace ${workspaceId}`,
);
} else {
await queryRunner.commitTransaction();
this.logger.log(
`Successfully migrated views to core schema for workspace ${workspaceId}`,
);
}
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Transaction rolled back for workspace ${workspaceId} due to error: ${error.message}`,
);
}
throw error;
}
} catch (error) {
this.logger.error(
`Failed to migrate views to core schema for workspace ${workspaceId}: ${error.message}`,
);
throw error;
} finally {
await queryRunner.release();
}
}
private async migrateViews(
workspaceId: string,
dryRun: boolean,
queryRunner: QueryRunner,
): Promise<void> {
const workspaceViewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
{ shouldBypassPermissionChecks: true },
);
const workspaceViews = await workspaceViewRepository.find({
relations: [
'viewFields',
'viewFilters',
'viewSorts',
'viewGroups',
'viewFilterGroups',
],
withDeleted: true,
});
const {
corruptedViewIds,
corruptedViewFieldIds,
corruptedViewFilterIds,
corruptedViewSortIds,
corruptedViewGroupIds,
} = await this.fetchAndDeleteCorruptedViewResources(
workspaceId,
workspaceViews,
queryRunner,
dryRun,
);
const workspaceViewWithoutOrphansResources = workspaceViews
.filter((view) => !corruptedViewIds.includes(view.id))
.map((view) => ({
...view,
viewFields: view.viewFields.filter(
(viewField) => !corruptedViewFieldIds.includes(viewField.id),
),
viewFilters: view.viewFilters.filter(
(viewFilter) => !corruptedViewFilterIds.includes(viewFilter.id),
),
viewSorts: view.viewSorts.filter(
(viewSort) => !corruptedViewSortIds.includes(viewSort.id),
),
viewGroups: view.viewGroups.filter(
(viewGroup) => !corruptedViewGroupIds.includes(viewGroup.id),
),
}));
if (workspaceViewWithoutOrphansResources.length === 0) {
this.logger.log(`No views to migrate for workspace ${workspaceId}`);
return;
}
this.logger.log(
`${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceViews.length} views to migrate for workspace ${workspaceId}`,
);
if (dryRun) {
for (const view of workspaceViewWithoutOrphansResources) {
const deletedStatus = view.deletedAt ? ' (DELETED)' : '';
this.logger.log(
`DRY RUN: Would migrate view ${view.id} (${view.name})${deletedStatus} with ${view.viewFields.length} fields, ${view.viewFilters.length} filters, ${view.viewSorts.length} sorts, ${view.viewGroups.length} groups, ${view.viewFilterGroups.length} filter groups`,
);
}
return;
}
await this.deleteExistingCoreViewObjects(workspaceId, queryRunner, dryRun);
for (const workspaceView of workspaceViewWithoutOrphansResources) {
await this.migrateViewEntity(workspaceView, workspaceId, queryRunner);
if (workspaceView.viewFields?.length > 0) {
await this.migrateViewFields(
workspaceView.viewFields,
workspaceId,
queryRunner,
);
}
if (workspaceView.viewFilterGroups?.length > 0) {
await this.migrateViewFilterGroups(
workspaceView.viewFilterGroups,
workspaceId,
queryRunner,
);
}
if (workspaceView.viewFilters?.length > 0) {
await this.migrateViewFilters(
workspaceView.viewFilters,
workspaceId,
queryRunner,
);
}
if (workspaceView.viewSorts?.length > 0) {
await this.migrateViewSorts(
workspaceView.viewSorts,
workspaceId,
queryRunner,
);
}
if (workspaceView.viewGroups?.length > 0) {
await this.migrateViewGroups(
workspaceView.viewGroups,
workspaceId,
queryRunner,
);
}
const deletedStatus = workspaceView.deletedAt ? ' (DELETED)' : '';
this.logger.log(
`Migrated view ${workspaceView.id} (${workspaceView.name})${deletedStatus} to core schema`,
);
}
}
private async fetchAndDeleteCorruptedViewResources(
workspaceId: string,
views: ViewWorkspaceEntity[],
queryRunner: QueryRunner,
dryRun: boolean,
): Promise<{
corruptedViewIds: string[];
corruptedViewFieldIds: string[];
corruptedViewFilterIds: string[];
corruptedViewSortIds: string[];
corruptedViewGroupIds: string[];
}> {
const fieldMetadataIdsUsedInViewFields = views
.flatMap((view) => view.viewFields)
.map((viewField) => viewField.fieldMetadataId);
const fieldMetadataIdsUsedInViewFilters = views
.flatMap((view) => view.viewFilters)
.map((viewFilter) => viewFilter.fieldMetadataId);
const fieldMetadataIdsUsedInViewSorts = views
.flatMap((view) => view.viewSorts)
.map((viewSort) => viewSort.fieldMetadataId);
const fieldMetadataIdsUsedInViewGroups = views
.flatMap((view) => view.viewGroups)
.map((viewGroup) => viewGroup.fieldMetadataId);
const fieldMetadataIds = [
...fieldMetadataIdsUsedInViewFields,
...fieldMetadataIdsUsedInViewFilters,
...fieldMetadataIdsUsedInViewSorts,
...fieldMetadataIdsUsedInViewGroups,
];
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const fieldMetadataItems = await fieldMetadataRepository.find({
where: {
id: In(fieldMetadataIds),
},
});
const existingFieldMetadataIds = fieldMetadataItems.map(
(fieldMetadata) => fieldMetadata.id,
);
const corruptedViewFieldIds = views
.flatMap((view) => view.viewFields)
.filter(
(viewField) =>
!existingFieldMetadataIds.includes(viewField.fieldMetadataId) ||
viewField.position === null,
)
.map((viewField) => viewField.id);
const corruptedViewFilterIds = views
.flatMap((view) => view.viewFilters)
.filter(
(viewFilter) =>
!existingFieldMetadataIds.includes(viewFilter.fieldMetadataId),
)
.map((viewFilter) => viewFilter.id);
const corruptedViewSortIds = views
.flatMap((view) => view.viewSorts)
.filter(
(viewSort) =>
!existingFieldMetadataIds.includes(viewSort.fieldMetadataId),
)
.map((viewSort) => viewSort.id);
const corruptedViewGroupIds = views
.flatMap((view) => view.viewGroups)
.filter(
(viewGroup) =>
!existingFieldMetadataIds.includes(viewGroup.fieldMetadataId),
)
.map((viewGroup) => viewGroup.id);
const workspaceViewFieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewFieldWorkspaceEntity>(
workspaceId,
'viewField',
{ shouldBypassPermissionChecks: true },
);
if (!dryRun) {
await workspaceViewFieldRepository.delete({
id: In(corruptedViewFieldIds),
});
}
this.logger.log(
`Deleted ${corruptedViewFieldIds.length} out of ${views.flatMap((view) => view.viewFields).length} view fields that have no corresponding field metadata for workspace ${workspaceId}`,
);
const workspaceViewFilterRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewFilterWorkspaceEntity>(
workspaceId,
'viewFilter',
{ shouldBypassPermissionChecks: true },
);
if (!dryRun) {
await workspaceViewFilterRepository.delete({
id: In(corruptedViewFilterIds),
});
}
this.logger.log(
`Deleted ${corruptedViewFilterIds.length} out of ${views.flatMap((view) => view.viewFilters).length} view filters that have no corresponding field metadata for workspace ${workspaceId}`,
);
const workspaceViewSortRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewSortWorkspaceEntity>(
workspaceId,
'viewSort',
{ shouldBypassPermissionChecks: true },
);
if (!dryRun) {
await workspaceViewSortRepository.delete({
id: In(corruptedViewSortIds),
});
}
this.logger.log(
`Deleted ${corruptedViewSortIds.length} out of ${views.flatMap((view) => view.viewSorts).length} view sorts that have no corresponding field metadata for workspace ${workspaceId}`,
);
const workspaceViewGroupRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewGroupWorkspaceEntity>(
workspaceId,
'viewGroup',
{ shouldBypassPermissionChecks: true },
);
if (!dryRun) {
await workspaceViewGroupRepository.delete({
id: In(corruptedViewGroupIds),
});
}
this.logger.log(
`Deleted ${corruptedViewGroupIds.length} out of ${views.flatMap((view) => view.viewGroups).length} view groups that have no corresponding field metadata for workspace ${workspaceId}`,
);
const objectMetadataIdsUsedInViews = views.map(
(view) => view.objectMetadataId,
);
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const objectMetadataItems = await objectMetadataRepository.find({
where: {
id: In(objectMetadataIdsUsedInViews),
},
});
const existingObjectMetadataIds = objectMetadataItems.map(
(objectMetadata) => objectMetadata.id,
);
const corruptedViewIds = views
.filter(
(view) => !existingObjectMetadataIds.includes(view.objectMetadataId),
)
.map((view) => view.id);
const workspaceViewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
{ shouldBypassPermissionChecks: true },
);
if (!dryRun) {
await workspaceViewRepository.delete({
id: In(corruptedViewIds),
});
}
this.logger.log(
`Deleted ${corruptedViewIds.length} out of ${views.length} views that have no corresponding object metadata for workspace ${workspaceId}`,
);
return {
corruptedViewIds,
corruptedViewFieldIds,
corruptedViewFilterIds,
corruptedViewSortIds,
corruptedViewGroupIds,
};
}
private async deleteExistingCoreViewObjects(
workspaceId: string,
queryRunner: QueryRunner,
dryRun: boolean,
): Promise<void> {
const viewRepository = queryRunner.manager.getRepository(ViewEntity);
const existingViews = await viewRepository.find({
where: { workspaceId },
select: ['id'],
withDeleted: true,
});
if (existingViews.length === 0) {
this.logger.log(
`No existing core view objects found for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${dryRun ? 'DRY RUN: ' : ''}Deleting ${existingViews.length} existing core view objects for workspace ${workspaceId}`,
);
if (dryRun) {
this.logger.log(
`DRY RUN: Would delete all existing core view objects for workspace ${workspaceId}`,
);
return;
}
await viewRepository.delete({ workspaceId });
this.logger.log(
`Deleted all existing core view objects for workspace ${workspaceId}`,
);
}
private async migrateViewEntity(
workspaceView: ViewWorkspaceEntity,
workspaceId: string,
queryRunner: QueryRunner,
): Promise<void> {
let viewName = workspaceView.name;
if (workspaceView.key === 'INDEX' && !viewName.includes('{')) {
viewName = 'All {objectLabelPlural}';
}
const coreView: Partial<ViewEntity> = {
id: workspaceView.id,
name: viewName,
objectMetadataId: workspaceView.objectMetadataId,
type: workspaceView.type === 'table' ? ViewType.TABLE : ViewType.KANBAN,
key:
workspaceView.key === 'INDEX' || workspaceView.key === ViewKey.INDEX
? ViewKey.INDEX
: null,
icon: workspaceView.icon,
position: workspaceView.position,
isCompact: workspaceView.isCompact,
isCustom: workspaceView.key !== 'INDEX',
openRecordIn:
workspaceView.openRecordIn === 'SIDE_PANEL'
? ViewOpenRecordIn.SIDE_PANEL
: ViewOpenRecordIn.RECORD_PAGE,
kanbanAggregateOperation: workspaceView.kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId:
workspaceView.kanbanAggregateOperationFieldMetadataId,
workspaceId,
createdAt: new Date(workspaceView.createdAt),
updatedAt: new Date(workspaceView.updatedAt),
deletedAt: workspaceView.deletedAt
? new Date(workspaceView.deletedAt)
: null,
anyFieldFilterValue: workspaceView.anyFieldFilterValue,
};
const repository = queryRunner.manager.getRepository(ViewEntity);
await repository.insert(coreView);
}
private async migrateViewFields(
workspaceViewFields: ViewFieldWorkspaceEntity[],
workspaceId: string,
queryRunner: QueryRunner,
): Promise<void> {
for (const field of workspaceViewFields) {
const coreViewField: Partial<ViewFieldEntity> = {
id: field.id,
fieldMetadataId: field.fieldMetadataId,
viewId: field.viewId,
position: field.position,
isVisible: field.isVisible,
size: field.size,
workspaceId,
createdAt: new Date(field.createdAt),
updatedAt: new Date(field.updatedAt),
deletedAt: field.deletedAt ? new Date(field.deletedAt) : null,
};
const repository = queryRunner.manager.getRepository(ViewFieldEntity);
await repository.insert(coreViewField);
}
}
private async migrateViewFilters(
workspaceViewFilters: ViewFilterWorkspaceEntity[],
workspaceId: string,
queryRunner: QueryRunner,
): Promise<void> {
for (const filter of workspaceViewFilters) {
if (!filter.viewId) {
this.logger.warn(
`Skipping view filter ${filter.id} with null viewId for workspace ${workspaceId}`,
);
continue;
}
const coreViewFilter: Partial<ViewFilterEntity> = {
id: filter.id,
fieldMetadataId: filter.fieldMetadataId,
viewId: filter.viewId,
operand: convertViewFilterOperandToCoreOperand(
filter.operand as SharedViewFilterOperand,
),
value: convertViewFilterWorkspaceValueToCoreValue(filter.value),
viewFilterGroupId: filter.viewFilterGroupId,
workspaceId,
createdAt: new Date(filter.createdAt),
updatedAt: new Date(filter.updatedAt),
deletedAt: filter.deletedAt ? new Date(filter.deletedAt) : null,
};
const repository = queryRunner.manager.getRepository(ViewFilterEntity);
await repository.insert(coreViewFilter);
}
}
private async migrateViewSorts(
workspaceViewSorts: ViewSortWorkspaceEntity[],
workspaceId: string,
queryRunner: QueryRunner,
): Promise<void> {
for (const sort of workspaceViewSorts) {
if (!sort.viewId) {
this.logger.warn(
`Skipping view sort ${sort.id} with null viewId for workspace ${workspaceId}`,
);
continue;
}
const direction = sort.direction.toUpperCase() as ViewSortDirection;
const coreViewSort: Partial<ViewSortEntity> = {
id: sort.id,
fieldMetadataId: sort.fieldMetadataId,
viewId: sort.viewId,
direction: direction,
workspaceId,
createdAt: new Date(sort.createdAt),
updatedAt: new Date(sort.updatedAt),
deletedAt: sort.deletedAt ? new Date(sort.deletedAt) : null,
};
const repository = queryRunner.manager.getRepository(ViewSortEntity);
await repository.insert(coreViewSort);
}
}
private async migrateViewGroups(
workspaceViewGroups: ViewGroupWorkspaceEntity[],
workspaceId: string,
queryRunner: QueryRunner,
): Promise<void> {
for (const group of workspaceViewGroups) {
if (!group.viewId) {
this.logger.warn(
`Skipping view group ${group.id} with null viewId for workspace ${workspaceId}`,
);
continue;
}
const coreViewGroup: Partial<ViewGroupEntity> = {
id: group.id,
fieldMetadataId: group.fieldMetadataId,
viewId: group.viewId,
fieldValue: group.fieldValue,
isVisible: group.isVisible,
position: group.position,
workspaceId,
createdAt: new Date(group.createdAt),
updatedAt: new Date(group.updatedAt),
deletedAt: group.deletedAt ? new Date(group.deletedAt) : null,
};
const repository = queryRunner.manager.getRepository(ViewGroupEntity);
await repository.insert(coreViewGroup);
}
}
private async migrateViewFilterGroups(
workspaceViewFilterGroups: ViewFilterGroupWorkspaceEntity[],
workspaceId: string,
queryRunner: QueryRunner,
): Promise<void> {
for (const filterGroup of workspaceViewFilterGroups.sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)) {
const coreViewFilterGroup: Partial<ViewFilterGroupEntity> = {
id: filterGroup.id,
viewId: filterGroup.viewId,
logicalOperator:
filterGroup.logicalOperator as ViewFilterGroupLogicalOperator,
parentViewFilterGroupId: filterGroup.parentViewFilterGroupId,
positionInViewFilterGroup: filterGroup.positionInViewFilterGroup,
workspaceId,
createdAt: new Date(filterGroup.createdAt),
updatedAt: new Date(filterGroup.updatedAt),
deletedAt: filterGroup.deletedAt
? new Date(filterGroup.deletedAt)
: null,
};
const repository = queryRunner.manager.getRepository(
ViewFilterGroupEntity,
);
await repository.insert(coreViewFilterGroup);
}
}
}
@@ -22,10 +22,10 @@ import {
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
@Command({
name: 'migrate:1-5:remove-favorite-view-relation',
name: 'upgrade:1-5:remove-favorite-view-relation',
description: 'Remove favorite view relation.',
})
export class RemoveFavoriteViewRelation extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
export class RemoveFavoriteViewRelationCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddPositionsToWorkflowVersionsAndWorkflowRuns } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
import { RemoveFavoriteViewRelation } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { AddPositionsToWorkflowVersionsAndWorkflowRunsCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
import { MigrateViewsToCoreCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-migrate-views-to-core.command';
import { RemoveFavoriteViewRelationCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -22,12 +23,14 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
WorkspaceMetadataVersionModule,
],
providers: [
RemoveFavoriteViewRelation,
AddPositionsToWorkflowVersionsAndWorkflowRuns,
RemoveFavoriteViewRelationCommand,
AddPositionsToWorkflowVersionsAndWorkflowRunsCommand,
MigrateViewsToCoreCommand,
],
exports: [
RemoveFavoriteViewRelation,
AddPositionsToWorkflowVersionsAndWorkflowRuns,
RemoveFavoriteViewRelationCommand,
AddPositionsToWorkflowVersionsAndWorkflowRunsCommand,
MigrateViewsToCoreCommand,
],
})
export class V1_5_UpgradeVersionCommandModule {}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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 { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@Module({
imports: [
TypeOrmModule.forFeature([
Workspace,
FieldMetadataEntity,
ObjectMetadataEntity,
]),
WorkspaceDataSourceModule,
WorkspaceSchemaManagerModule,
WorkspaceMetadataVersionModule,
],
providers: [],
exports: [],
})
export class V1_6_UpgradeVersionCommandModule {}
@@ -7,10 +7,8 @@ import { V1_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V1_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-2/1-2-upgrade-version-command.module';
import { V1_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module';
import { V1_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-5/1-5-upgrade-version-command.module';
import {
DatabaseMigrationService,
UpgradeCommand,
} from 'src/database/commands/upgrade-version-command/upgrade.command';
import { V1_6_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
@@ -23,8 +21,9 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
V1_2_UpgradeVersionCommandModule,
V1_3_UpgradeVersionCommandModule,
V1_5_UpgradeVersionCommandModule,
V1_6_UpgradeVersionCommandModule,
WorkspaceSyncMetadataModule,
],
providers: [DatabaseMigrationService, UpgradeCommand],
providers: [UpgradeCommand],
})
export class UpgradeVersionCommandModule {}
@@ -1,13 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { exec } from 'child_process';
import { promisify } from 'util';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { type ActiveOrSuspendedWorkspacesMigrationCommandOptions } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import {
@@ -31,96 +25,13 @@ import { RemoveWorkflowRunsWithoutState } from 'src/database/commands/upgrade-ve
import { AddNextStepIdsToWorkflowRunsTrigger } from 'src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command';
import { AssignRolesToExistingApiKeysCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-assign-roles-to-existing-api-keys.command';
import { UpdateTimestampColumnTypeInWorkspaceSchemaCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-update-timestamp-column-type-in-workspace-schema.command';
import { AddPositionsToWorkflowVersionsAndWorkflowRuns } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
import { RemoveFavoriteViewRelation } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { AddPositionsToWorkflowVersionsAndWorkflowRunsCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
import { MigrateViewsToCoreCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-migrate-views-to-core.command';
import { RemoveFavoriteViewRelationCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.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';
const execPromise = promisify(exec);
@Injectable()
export class DatabaseMigrationService {
private logger = new Logger(DatabaseMigrationService.name);
constructor(
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
) {}
// TODO centralize with ActiveOrSuspendedRunner method
private async loadActiveOrSuspendedWorkspace() {
return await this.workspaceRepository.find({
select: ['id', 'version'],
where: {
activationStatus: In([
WorkspaceActivationStatus.ACTIVE,
WorkspaceActivationStatus.SUSPENDED,
]),
},
order: {
id: 'ASC',
},
});
}
async shouldSkipUpgradeIfFreshInstallation(): Promise<boolean> {
const activeWorkspaceOrSuspendedWorkspaceCount =
await this.loadActiveOrSuspendedWorkspace();
return activeWorkspaceOrSuspendedWorkspaceCount.length === 0;
}
async runMigrations(): 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;
}
}
public async areAllWorkspacesAboveVersion0_53(): Promise<boolean> {
try {
const allActiveOrSuspendedWorkspaces =
await this.loadActiveOrSuspendedWorkspace();
if (allActiveOrSuspendedWorkspaces.length === 0) {
this.logger.log(
'No workspaces found. Running migrations for fresh installation.',
);
return true;
}
const workspacesBelowVersion = allActiveOrSuspendedWorkspaces.filter(
({ version }) =>
version === null ||
compareVersionMajorAndMinor(version, '0.53.0') === 'lower',
);
this.logger.log(
`Found ${workspacesBelowVersion.length} active or suspended workspaces that are below version 0.53.0 \n${workspacesBelowVersion.map((el) => el.id).join('\n')}`,
);
return workspacesBelowVersion.length === 0;
} catch (error) {
this.logger.error('Error checking workspaces below version:', error);
throw error;
}
}
}
@Command({
name: 'upgrade',
@@ -136,8 +47,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
protected readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly databaseMigrationService: DatabaseMigrationService,
// 0.54 Commands
protected readonly fixStandardSelectFieldsPositionCommand: FixStandardSelectFieldsPositionCommand,
protected readonly fixCreatedByDefaultValueCommand: FixCreatedByDefaultValueCommand,
@@ -165,8 +74,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly updateTimestampColumnTypeInWorkspaceSchemaCommand: UpdateTimestampColumnTypeInWorkspaceSchemaCommand,
// 1.5 Commands
protected readonly removeFavoriteViewRelation: RemoveFavoriteViewRelation,
protected readonly addPositionsToWorkflowVersionsAndWorkflowRuns: AddPositionsToWorkflowVersionsAndWorkflowRuns,
protected readonly removeFavoriteViewRelationCommand: RemoveFavoriteViewRelationCommand,
protected readonly addPositionsToWorkflowVersionsAndWorkflowRunsCommand: AddPositionsToWorkflowVersionsAndWorkflowRunsCommand,
protected readonly migrateViewsToCoreCommand: MigrateViewsToCoreCommand,
) {
super(
workspaceRepository,
@@ -242,12 +152,18 @@ export class UpgradeCommand extends UpgradeCommandRunner {
const commands_150: VersionCommands = {
beforeSyncMetadata: [
this.removeFavoriteViewRelation,
this.addPositionsToWorkflowVersionsAndWorkflowRuns,
this.migrateViewsToCoreCommand,
this.removeFavoriteViewRelationCommand,
this.addPositionsToWorkflowVersionsAndWorkflowRunsCommand,
],
afterSyncMetadata: [],
};
const commands_160: VersionCommands = {
beforeSyncMetadata: [],
afterSyncMetadata: [],
};
this.allCommands = {
'0.53.0': commands_053,
'0.54.0': commands_054,
@@ -259,6 +175,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
'1.3.0': commands_130,
'1.4.0': commands_140,
'1.5.0': commands_150,
'1.6.0': commands_160,
};
}
@@ -266,31 +183,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
passedParams: string[],
options: ActiveOrSuspendedWorkspacesMigrationCommandOptions,
): Promise<void> {
const shouldSkipUpgradeIfFreshInstallation =
await this.databaseMigrationService.shouldSkipUpgradeIfFreshInstallation();
if (shouldSkipUpgradeIfFreshInstallation) {
this.logger.log(
chalk.blue('Fresh installation detected, skipping migration'),
);
return;
}
const shouldPreventFromUpgradingIfWorkspaceIsBelowVersion0_53 =
!(await this.databaseMigrationService.areAllWorkspacesAboveVersion0_53());
if (shouldPreventFromUpgradingIfWorkspaceIsBelowVersion0_53) {
this.logger.log(
chalk.red(
'Not able to run migrate command, aborting the whole migrate-upgrade operation',
),
);
throw new Error('Could not run migration aborting');
}
await this.databaseMigrationService.runMigrations();
await super.runMigrationCommand(passedParams, options);
return await super.runMigrationCommand(passedParams, options);
}
}