TimelineActivity migration to morph (#15652)

# TimelineActivity migration to morph


- Creates `timelineActivities2` relations on Company, Dashboard, Note,
Opportunity, Person, Task, Workflow, WorkflowRun, and WorkflowVersion
entities with proper metadata and cascade delete behavior.

It was required to create standard fields as well since the
mapObjectMetadataByUniqueIdentifier needs it. otherwise the fields won't
be considered

- Feature Flag `IS_TIMELINE_ACTIVITY_MIGRATED` necessary to have the two
states in parallel. It is used as a stamp once the migration has been
run

- Migration is done using the coreDataSource. Why ?
even though is unsafe to use, the first implementation of the migration
took forever on each workspace. See [this
commit](https://github.com/twentyhq/twenty/pull/15652/commits/477011e8d7d4c580f79ba7ec4a8fb002a3ec86b2)

The plan for this complex migration is as follows :

![plan](https://github.com/user-attachments/assets/51c63ea6-fb0d-40b4-b99d-3e0b35a204e6)
Note: we will need to rename fields in the release 1.12 (there is no
easy way to do all this in one release)
This commit is contained in:
Guillim
2025-12-11 14:28:26 +01:00
committed by GitHub
parent c13ef0c4a4
commit b83dc3aaff
65 changed files with 1299 additions and 467 deletions
@@ -0,0 +1,267 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { capitalize } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
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 { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { TIMELINE_ACTIVITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { buildTimelineActivityRelatedMorphFieldMetadataName } from 'src/modules/timeline/utils/timeline-activity-related-morph-field-metadata-name-builder.util';
@Command({
name: 'upgrade:1-13:migrate-timeline-activity-to-morph-relations',
description:
'Migrate timeline activity relations to morph relation fields and set feature flag',
})
export class MigrateTimelineActivityToMorphRelationsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_TIMELINE_ACTIVITY_MIGRATED,
workspaceId,
);
this.logger.log(`Migrating timelineActivity for workspace ${workspaceId}`);
if (isMigrated) {
this.logger.log(
`Timeline activity migration already completed. Skipping...`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have migrated timeline activities for workspace ${workspaceId}. Skipping...`,
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const schemaName = getWorkspaceSchemaName(workspaceId);
const tableName = 'timelineActivity';
const customObjectMetadata = await this.objectMetadataRepository.find({
where: {
workspaceId,
isCustom: true,
},
});
const customObjectMetadataNames = customObjectMetadata.map(
(objectMetadata) => objectMetadata.nameSingular,
);
const fieldNameMigrations = [
{ old: 'company', new: 'targetCompany' },
{ old: 'person', new: 'targetPerson' },
{ old: 'opportunity', new: 'targetOpportunity' },
{ old: 'note', new: 'targetNote' },
{ old: 'task', new: 'targetTask' },
{ old: 'workflow', new: 'targetWorkflow' },
{ old: 'workflowVersion', new: 'targetWorkflowVersion' },
{ old: 'workflowRun', new: 'targetWorkflowRun' },
{ old: 'dashboard', new: 'targetDashboard' },
...customObjectMetadataNames.map((customObjectName) => ({
old: `${customObjectName}`,
new: `target${capitalize(customObjectName)}`,
})),
];
const fieldMigrations = fieldNameMigrations.map(
({ old: oldFieldName, new: newFieldName }) => ({
old: `${oldFieldName}Id`,
new: `${newFieldName}Id`,
}),
);
const timelineActivityObjectMetadata =
await this.objectMetadataRepository.findOne({
where: {
workspaceId,
nameSingular: 'timelineActivity',
},
relations: ['fields'],
});
if (!timelineActivityObjectMetadata) {
this.logger.error(
`🟥 Timeline activity object metadata not found for workspace ${workspaceId}`,
);
return;
}
for (const { new: newField, old: oldField } of fieldMigrations) {
try {
await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}"
RENAME COLUMN "${oldField}" TO "${newField}"`,
);
this.logger.log(
`Renamed column "${oldField}" to "${newField}" for "${tableName}"`,
);
} catch (error) {
this.logger.error(
`Error renaming column "${oldField}" to "${newField}" for "${tableName}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated timeline activity records`);
const objectNamesToMigrate = fieldNameMigrations.map(
({ old: oldFieldName }) => oldFieldName,
);
const relatedObjectMetadata = await this.objectMetadataRepository.find({
where: {
workspaceId,
},
});
const relatedObjectMetadataMap = new Map(
relatedObjectMetadata.map((obj) => [obj.nameSingular, obj]),
);
const morphId = TIMELINE_ACTIVITY_STANDARD_FIELD_IDS.targetMorphId;
for (const objectName of objectNamesToMigrate) {
const relatedObject = relatedObjectMetadataMap.get(objectName);
if (!relatedObject) {
this.logger.warn(
`Related object metadata not found for ${objectName} in workspace ${workspaceId}`,
);
continue;
}
const fieldToMigrate = timelineActivityObjectMetadata.fields.find(
(field) =>
field.type === 'RELATION' &&
field.relationTargetObjectMetadataId === relatedObject.id,
);
if (!fieldToMigrate) {
this.logger.log(
`No RELATION field found for ${objectName} in timelineActivity`,
);
continue;
}
const newFieldName = buildTimelineActivityRelatedMorphFieldMetadataName(
fieldToMigrate.name,
);
const settings = {
...fieldToMigrate.settings,
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
name: newFieldName,
}),
};
try {
const result = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET name = $1, type = 'MORPH_RELATION', "morphId" = $3, settings = $4
WHERE id = $2`,
[newFieldName, fieldToMigrate.id, morphId, settings],
);
const rowsUpdated = result[1] || 0;
if (rowsUpdated > 0) {
this.logger.log(
`Updated fieldMetadata: ${fieldToMigrate.name}${newFieldName} (type: MORPH_RELATION)`,
);
}
} catch (error) {
this.logger.error(
`Error updating fieldMetadata for ${objectName} in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(
`✅ Successfully migrated timeline activity fieldmetadata`,
);
await queryRunner.commitTransaction();
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_TIMELINE_ACTIVITY_MIGRATED],
workspaceId,
);
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
'featureFlagsMap',
]);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(`Cache flushed`);
this.logger.log(
`Set IS_TIMELINE_ACTIVITY_MIGRATED feature flag for workspace ${workspaceId}`,
);
this.logger.log(`Flush cache for workspace ${workspaceId}`);
await this.workspaceCacheStorageService.flush(workspaceId);
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Error migrating timeline activity to morph relations (rolled transaction back on ${workspaceId})`,
error,
);
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -6,23 +6,30 @@ import { BackfillViewMainGroupByFieldMetadataIdCommand } from 'src/database/comm
import { CleanEmptyStringNullInTextFieldsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-clean-empty-string-null-in-text-fields.command';
import { DeduplicateRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-deduplicate-role-targets.command';
import { MigrateStandardInvalidEntitiesCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-migrate-standard-invalid-entities.command';
import { MigrateTimelineActivityToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-migrate-timeline-activity-to-morph-relations.command';
import { RenameIndexNameCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-rename-unique-index.command';
import { UpdateRoleTargetsUniqueConstraintMigrationCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-update-role-targets-unique-constraint-migration.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.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 { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
@Module({
imports: [
@@ -38,11 +45,17 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
PageLayoutEntity,
PageLayoutWidgetEntity,
PageLayoutTabEntity,
TimelineActivityWorkspaceEntity,
]),
DataSourceModule,
ApplicationModule,
WorkspaceSchemaManagerModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
FeatureFlagModule,
WorkspaceCacheModule,
FieldMetadataModule,
ObjectMetadataModule,
ApplicationModule,
],
providers: [
CleanEmptyStringNullInTextFieldsCommand,
@@ -52,6 +65,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
RenameIndexNameCommand,
MigrateStandardInvalidEntitiesCommand,
UpdateRoleTargetsUniqueConstraintMigrationCommand,
MigrateTimelineActivityToMorphRelationsCommand,
],
exports: [
CleanEmptyStringNullInTextFieldsCommand,
@@ -61,6 +75,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
RenameIndexNameCommand,
MigrateStandardInvalidEntitiesCommand,
UpdateRoleTargetsUniqueConstraintMigrationCommand,
MigrateTimelineActivityToMorphRelationsCommand,
],
})
export class V1_13_UpgradeVersionCommandModule {}
@@ -30,6 +30,7 @@ import { WorkspaceCustomApplicationIdNonNullableCommand } from 'src/database/com
import { BackfillPageLayoutUniversalIdentifiersCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-backfill-page-layout-universal-identifiers.command';
import { DeduplicateRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-deduplicate-role-targets.command';
import { MigrateStandardInvalidEntitiesCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-migrate-standard-invalid-entities.command';
import { MigrateTimelineActivityToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-migrate-timeline-activity-to-morph-relations.command';
import { UpdateRoleTargetsUniqueConstraintMigrationCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-update-role-targets-unique-constraint-migration.command';
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
@@ -98,6 +99,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly cleanNullEquivalentValuesCommand: CleanNullEquivalentValuesCommand,
// 1.13 Commands
protected readonly migrateTimelineActivityToMorphRelationsCommand: MigrateTimelineActivityToMorphRelationsCommand,
protected readonly deduplicateRoleTargetsCommand: DeduplicateRoleTargetsCommand,
protected readonly updateRoleTargetsUniqueConstraintMigrationCommand: UpdateRoleTargetsUniqueConstraintMigrationCommand,
protected readonly backfillPageLayoutUniversalIdentifiersCommand: BackfillPageLayoutUniversalIdentifiersCommand,
@@ -173,6 +175,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
beforeSyncMetadata: [
this.deduplicateRoleTargetsCommand,
this.updateRoleTargetsUniqueConstraintMigrationCommand,
this.migrateTimelineActivityToMorphRelationsCommand,
this.backfillPageLayoutUniversalIdentifiersCommand,
this.migrateStandardInvalidEntitiesCommand,
],