Migrate attachments to morph relations + fix morph join column filtering (#17381)
Closes [1744](https://github.com/twentyhq/core-team-issues/issues/1744). This PR migrates attachments to morph relations behind a feature flag, following the TimelineActivity pattern. it introduces the `IS_ATTACHMENT_MIGRATED` flag, updates standard field metadata and indexes to use morph relations, adds a workspace migration that renames `attachment.*Id` columns to `target*Id` and converts the corresponding field metadata to `MORPH_RELATION` with a shared `morphId`. On the frontend, attachment read/write paths now switch to `target*Id` when the flag is enabled. It also fixes optimistic filtering for morph join columns. The metadata API deduplicates morph fields, so attachments now expose a single target field of type `MORPH_RELATION` plus a `morphRelations` array listing each target object. Because only one `settings.joinColumnName` is returned (e.g. `targetRocketId`), filters like `targetCompanyId` don’t map to any field and the optimistic cache code throws. `doesMorphRelationJoinColumnMatch` resolves this by computing all valid join column names from `morphRelations` using `computeMorphRelationFieldName` and comparing them to the filter key. That makes filters like `targetCompanyId` resolvable even with a single target field, so attachment uploads and list matching no longer crash. <img width="477" height="474" alt="image" src="https://github.com/user-attachments/assets/50e19418-3438-4d1e-9f1f-1bc1a03174a9" /> <br /> <br /> Today the metadata API returns one morph field called `target` and a list of possible targets (`morphRelations`), but it does not tell us the join column for each target. That’s why the Frontend had to compute join column names. If we want to fix this at the API level, there are two options: - Add join column names to each target in `morphRelations` (e.g. company → `targetCompanyId`). This is additive and low‑risk. - Return each target as its own field (`targetCompany`, `targetPerson`, etc.) instead of a single target. This is a larger change because it changes the shape of metadata and would require more UI updates. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces morph relations for attachments behind `IS_ATTACHMENT_MIGRATED`, aligning server schema/metadata and frontend behavior. > > - Adds `IS_ATTACHMENT_MIGRATED` flag (frontend/server) and seeds/defaults; updates generated GraphQL enums > - New workspace upgrade `1.17` command migrates data: renames `attachment.*Id` → `target*Id` and converts related fields to `MORPH_RELATION` with shared `morphId` > - Updates standard field metadata and indexes to `target*` (attachment + related objects), dev seeds, snapshots, and workspace entity types > - Frontend: switches attachment read/write filters via `getActivityTargetObjectFieldIdName` using the feature flag; updates hooks/components (`useAttachments`, `useUploadAttachmentFile`, editors); expands `Attachment` type > - Fixes optimistic cache filtering to recognize morph join columns in `isRecordMatchingFilter` by computing valid join-column keys from `morphRelations` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f208fa23b1135af114f484f6d65041a816d6e718. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
+11
-7
@@ -6,22 +6,26 @@ export const STANDARD_INDEX_FIELD_UNIVERSAL_IDENTIFIERS: Record<
|
||||
Record<string, string[]>
|
||||
> = {
|
||||
attachment: {
|
||||
taskIdIndex: [STANDARD_OBJECTS.attachment.fields.task.universalIdentifier],
|
||||
noteIdIndex: [STANDARD_OBJECTS.attachment.fields.note.universalIdentifier],
|
||||
taskIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.targetTask.universalIdentifier,
|
||||
],
|
||||
noteIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.targetNote.universalIdentifier,
|
||||
],
|
||||
personIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.person.universalIdentifier,
|
||||
STANDARD_OBJECTS.attachment.fields.targetPerson.universalIdentifier,
|
||||
],
|
||||
companyIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.company.universalIdentifier,
|
||||
STANDARD_OBJECTS.attachment.fields.targetCompany.universalIdentifier,
|
||||
],
|
||||
opportunityIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.opportunity.universalIdentifier,
|
||||
STANDARD_OBJECTS.attachment.fields.targetOpportunity.universalIdentifier,
|
||||
],
|
||||
dashboardIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.dashboard.universalIdentifier,
|
||||
STANDARD_OBJECTS.attachment.fields.targetDashboard.universalIdentifier,
|
||||
],
|
||||
workflowIdIndex: [
|
||||
STANDARD_OBJECTS.attachment.fields.workflow.universalIdentifier,
|
||||
STANDARD_OBJECTS.attachment.fields.targetWorkflow.universalIdentifier,
|
||||
],
|
||||
},
|
||||
blocklist: {
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
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 { ATTACHMENT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-migration/constant/standard-field-ids';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-17:migrate-attachment-to-morph-relations',
|
||||
description:
|
||||
'Migrate attachment relations to morph relation fields and set feature flag',
|
||||
})
|
||||
export class MigrateAttachmentToMorphRelationsCommand 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_ATTACHMENT_MIGRATED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(`Migrating attachments for workspace ${workspaceId}`);
|
||||
|
||||
if (isMigrated) {
|
||||
this.logger.log(`Attachment migration already completed. Skipping...`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
this.logger.log(
|
||||
`Would have migrated attachments for workspace ${workspaceId}. Skipping...`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const tableName = 'attachment';
|
||||
|
||||
const customObjectMetadata = await this.objectMetadataRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
isCustom: true,
|
||||
},
|
||||
});
|
||||
const customObjectMetadataNames = customObjectMetadata.map(
|
||||
(objectMetadata) => objectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
const fieldNameMigrations = [
|
||||
{ old: 'task', new: 'targetTask' },
|
||||
{ old: 'note', new: 'targetNote' },
|
||||
{ old: 'person', new: 'targetPerson' },
|
||||
{ old: 'company', new: 'targetCompany' },
|
||||
{ old: 'opportunity', new: 'targetOpportunity' },
|
||||
{ old: 'dashboard', new: 'targetDashboard' },
|
||||
{ old: 'workflow', new: 'targetWorkflow' },
|
||||
...customObjectMetadataNames.map((customObjectName) => ({
|
||||
old: `${customObjectName}`,
|
||||
new: `target${capitalize(customObjectName)}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const fieldMigrations = fieldNameMigrations.map(
|
||||
({ old: oldFieldName, new: newFieldName }) => ({
|
||||
old: `${oldFieldName}Id`,
|
||||
new: `${newFieldName}Id`,
|
||||
}),
|
||||
);
|
||||
|
||||
const attachmentObjectMetadata =
|
||||
await this.objectMetadataRepository.findOne({
|
||||
where: {
|
||||
workspaceId,
|
||||
nameSingular: 'attachment',
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
if (!attachmentObjectMetadata) {
|
||||
this.logger.error(
|
||||
`🟥 Attachment 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 attachment 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 = ATTACHMENT_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 = attachmentObjectMetadata.fields.find(
|
||||
(field) =>
|
||||
field.type === 'RELATION' &&
|
||||
field.relationTargetObjectMetadataId === relatedObject.id,
|
||||
);
|
||||
|
||||
if (!fieldToMigrate) {
|
||||
this.logger.log(
|
||||
`No RELATION field found for ${objectName} in attachment`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newFieldName = `target${capitalize(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 attachment fieldmetadata`);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
[FeatureFlagKey.IS_ATTACHMENT_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_ATTACHMENT_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 attachment to morph relations (rolled transaction back on ${workspaceId})`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkspaceEntity,
|
||||
ObjectMetadataEntity,
|
||||
FieldMetadataEntity,
|
||||
FeatureFlagEntity,
|
||||
AttachmentWorkspaceEntity,
|
||||
]),
|
||||
DataSourceModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceCacheModule,
|
||||
FieldMetadataModule,
|
||||
ObjectMetadataModule,
|
||||
ApplicationModule,
|
||||
],
|
||||
providers: [MigrateAttachmentToMorphRelationsCommand],
|
||||
exports: [MigrateAttachmentToMorphRelationsCommand],
|
||||
})
|
||||
export class V1_17_UpgradeVersionCommandModule {}
|
||||
+2
@@ -5,6 +5,7 @@ import { V1_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
import { V1_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module';
|
||||
import { V1_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-15/1-15-upgrade-version-command.module';
|
||||
import { V1_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module';
|
||||
import { V1_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module';
|
||||
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
@@ -16,6 +17,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
V1_14_UpgradeVersionCommandModule,
|
||||
V1_15_UpgradeVersionCommandModule,
|
||||
V1_16_UpgradeVersionCommandModule,
|
||||
V1_17_UpgradeVersionCommandModule,
|
||||
DataSourceModule,
|
||||
],
|
||||
providers: [UpgradeCommand],
|
||||
|
||||
+9
@@ -45,6 +45,7 @@ import { MakeViewFilterUniversalIdentifierAndApplicationIdNotNullableMigrationCo
|
||||
import { MakeViewGroupUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-view-group-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { MakeViewUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-view-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command';
|
||||
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
@@ -107,6 +108,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
protected readonly makeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
protected readonly identifyRemainingEntitiesMetadataCommand: IdentifyRemainingEntitiesMetadataCommand,
|
||||
protected readonly makeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
|
||||
// 1.17 Commands
|
||||
protected readonly migrateAttachmentToMorphRelationsCommand: MigrateAttachmentToMorphRelationsCommand,
|
||||
) {
|
||||
super(
|
||||
workspaceRepository,
|
||||
@@ -176,12 +180,17 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
.makeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
];
|
||||
|
||||
const commands_1170: VersionCommands = [
|
||||
this.migrateAttachmentToMorphRelationsCommand,
|
||||
];
|
||||
|
||||
this.allCommands = {
|
||||
'1.12.0': commands_1120,
|
||||
'1.13.0': commands_1130,
|
||||
'1.14.0': commands_1140,
|
||||
'1.15.0': commands_1150,
|
||||
'1.16.0': commands_1160,
|
||||
'1.17.0': commands_1170,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user