Skip to content

Commit dc06883

Browse files
committed
Support legacy activity target columns during upgrade
1 parent c10ba0c commit dc06883

7 files changed

Lines changed: 428 additions & 28 deletions

packages/twenty-server/src/database/commands/upgrade-version-command/2-38/2-38-workspace-command-1788425677783-enforce-activity-target-uniqueness.command.ts

Lines changed: 106 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import { WorkspaceIteratorService } from 'src/database/commands/command-runners/
77
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
88
import { getStandardFlatEntitiesToCreateOrThrow } from 'src/database/commands/upgrade-version-command/2-10/utils/get-standard-flat-entities-to-create-or-throw.util';
99
import { buildDuplicateActivityTargetQuery } from 'src/database/commands/upgrade-version-command/2-38/utils/build-duplicate-activity-target-query.util';
10+
import { rebindFlatIndexToWorkspaceColumns } from 'src/database/commands/upgrade-version-command/2-38/utils/rebind-flat-index-to-workspace-columns.util';
11+
import { resolveActivityTargetColumns } from 'src/database/commands/upgrade-version-command/2-38/utils/resolve-activity-target-columns.util';
1012
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
1113
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
14+
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
15+
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
1216
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
1317
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
1418
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@@ -17,23 +21,32 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
1721
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
1822

1923
const ACTIVITY_TARGET_CONFIGS = [
20-
{ tableName: 'taskTarget', parentColumnName: 'taskId' },
21-
{ tableName: 'noteTarget', parentColumnName: 'noteId' },
24+
{
25+
tableName: 'taskTarget',
26+
parentColumnName: 'taskId',
27+
uniqueIndexUniversalIdentifiers: [
28+
STANDARD_OBJECTS.taskTarget.indexes.taskPersonUniqueIndex
29+
.universalIdentifier,
30+
STANDARD_OBJECTS.taskTarget.indexes.taskCompanyUniqueIndex
31+
.universalIdentifier,
32+
STANDARD_OBJECTS.taskTarget.indexes.taskOpportunityUniqueIndex
33+
.universalIdentifier,
34+
],
35+
},
36+
{
37+
tableName: 'noteTarget',
38+
parentColumnName: 'noteId',
39+
uniqueIndexUniversalIdentifiers: [
40+
STANDARD_OBJECTS.noteTarget.indexes.notePersonUniqueIndex
41+
.universalIdentifier,
42+
STANDARD_OBJECTS.noteTarget.indexes.noteCompanyUniqueIndex
43+
.universalIdentifier,
44+
STANDARD_OBJECTS.noteTarget.indexes.noteOpportunityUniqueIndex
45+
.universalIdentifier,
46+
],
47+
},
2248
] as const;
2349

24-
const ACTIVITY_TARGET_UNIQUE_INDEX_UNIVERSAL_IDENTIFIERS = [
25-
STANDARD_OBJECTS.taskTarget.indexes.taskPersonUniqueIndex.universalIdentifier,
26-
STANDARD_OBJECTS.taskTarget.indexes.taskCompanyUniqueIndex
27-
.universalIdentifier,
28-
STANDARD_OBJECTS.taskTarget.indexes.taskOpportunityUniqueIndex
29-
.universalIdentifier,
30-
STANDARD_OBJECTS.noteTarget.indexes.notePersonUniqueIndex.universalIdentifier,
31-
STANDARD_OBJECTS.noteTarget.indexes.noteCompanyUniqueIndex
32-
.universalIdentifier,
33-
STANDARD_OBJECTS.noteTarget.indexes.noteOpportunityUniqueIndex
34-
.universalIdentifier,
35-
];
36-
3750
@RegisteredWorkspaceCommand('2.38.0', 1788425677783)
3851
@Command({
3952
name: 'upgrade:2-38:enforce-activity-target-uniqueness',
@@ -85,23 +98,63 @@ export class EnforceActivityTargetUniquenessCommand extends ProvisionedWorkspace
8598
}
8699

87100
let duplicateCount = 0;
101+
const uniqueIndexUniversalIdentifiers: string[] = [];
102+
const columnNamesByIndexUniversalIdentifier = new Map<string, string[]>();
88103

89104
for (const targetConfig of ACTIVITY_TARGET_CONFIGS) {
105+
const rows = await dataSource.query<Array<{ column_name: string }>>(
106+
`SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2`,
107+
[schemaName, targetConfig.tableName],
108+
);
109+
const targetColumns = resolveActivityTargetColumns(
110+
new Set(rows.map(({ column_name }) => column_name)),
111+
);
112+
113+
if (!isDefined(targetColumns)) {
114+
this.logger.error(
115+
`MANUAL REPAIR REQUIRED: skipping activity target uniqueness for ${targetConfig.tableName} in workspace ${workspaceId}: expected target columns are incomplete`,
116+
);
117+
118+
continue;
119+
}
120+
90121
const [result] = await dataSource.query<Array<{ count: number }>>(
91122
buildDuplicateActivityTargetQuery({
92123
schemaName,
93-
...targetConfig,
124+
tableName: targetConfig.tableName,
125+
parentColumnName: targetConfig.parentColumnName,
126+
targetColumns,
94127
deleteDuplicates: !options.dryRun,
95128
}),
96129
);
97130

98131
duplicateCount += result?.count ?? 0;
132+
uniqueIndexUniversalIdentifiers.push(
133+
...targetConfig.uniqueIndexUniversalIdentifiers,
134+
);
135+
targetColumns.forEach((targetColumn, index) => {
136+
const uniqueIndexUniversalIdentifier =
137+
targetConfig.uniqueIndexUniversalIdentifiers[index];
138+
139+
if (!isDefined(uniqueIndexUniversalIdentifier)) {
140+
throw new Error(
141+
`Could not resolve activity target index at position ${index}`,
142+
);
143+
}
144+
145+
columnNamesByIndexUniversalIdentifier.set(
146+
uniqueIndexUniversalIdentifier,
147+
[targetConfig.parentColumnName, targetColumn.columnName],
148+
);
149+
});
99150
}
100151

101-
const { flatIndexMaps } = await this.workspaceCacheService.getOrRecompute(
102-
workspaceId,
103-
['flatIndexMaps'],
104-
);
152+
const { flatIndexMaps, flatFieldMetadataMaps, flatObjectMetadataMaps } =
153+
await this.workspaceCacheService.getOrRecompute(workspaceId, [
154+
'flatIndexMaps',
155+
'flatFieldMetadataMaps',
156+
'flatObjectMetadataMaps',
157+
]);
105158
const { twentyStandardFlatApplication } =
106159
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
107160
{ workspaceId },
@@ -112,13 +165,43 @@ export class EnforceActivityTargetUniquenessCommand extends ProvisionedWorkspace
112165
workspaceId,
113166
twentyStandardApplicationId: twentyStandardFlatApplication.id,
114167
});
115-
const indexesToCreate =
168+
const standardIndexesToCreate =
116169
getStandardFlatEntitiesToCreateOrThrow<FlatIndexMetadata>({
117170
standardFlatEntityMaps: standardAllFlatEntityMaps.flatIndexMaps,
118171
existingFlatEntityMaps: flatIndexMaps,
119-
universalIdentifiers:
120-
ACTIVITY_TARGET_UNIQUE_INDEX_UNIVERSAL_IDENTIFIERS,
172+
universalIdentifiers: uniqueIndexUniversalIdentifiers,
121173
});
174+
const indexesToCreate = standardIndexesToCreate.map(
175+
(standardFlatIndexMetadata) => {
176+
const flatObjectMetadata = findFlatEntityByUniversalIdentifierOrThrow({
177+
universalIdentifier:
178+
standardFlatIndexMetadata.objectMetadataUniversalIdentifier,
179+
flatEntityMaps: flatObjectMetadataMaps,
180+
});
181+
const columnNames = columnNamesByIndexUniversalIdentifier.get(
182+
standardFlatIndexMetadata.universalIdentifier,
183+
);
184+
185+
if (!isDefined(columnNames)) {
186+
throw new Error(
187+
`Could not resolve columns for activity target index ${standardFlatIndexMetadata.universalIdentifier}`,
188+
);
189+
}
190+
191+
return rebindFlatIndexToWorkspaceColumns({
192+
flatIndexMetadata: standardFlatIndexMetadata,
193+
flatObjectMetadata,
194+
objectFlatFieldMetadatas: Object.values(
195+
flatFieldMetadataMaps.byUniversalIdentifier,
196+
).filter(
197+
(flatFieldMetadata): flatFieldMetadata is FlatFieldMetadata =>
198+
isDefined(flatFieldMetadata) &&
199+
flatFieldMetadata.objectMetadataId === flatObjectMetadata.id,
200+
),
201+
columnNames,
202+
});
203+
},
204+
);
122205

123206
this.logger.log(
124207
`${options.dryRun ? '[DRY RUN] Would remove' : 'Removed'} ${duplicateCount} duplicate activity target(s) and ${options.dryRun ? 'would create' : 'will create'} ${indexesToCreate.length} unique index(es) for workspace ${workspaceId}`,

packages/twenty-server/src/database/commands/upgrade-version-command/2-38/utils/__tests__/build-duplicate-activity-target-query.util.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ const QUERY_ARGS = {
44
schemaName: 'workspace_123',
55
tableName: 'taskTarget',
66
parentColumnName: 'taskId',
7-
};
7+
targetColumns: [
8+
{ type: 'person', columnName: 'targetPersonId' },
9+
{ type: 'company', columnName: 'targetCompanyId' },
10+
{ type: 'opportunity', columnName: 'targetOpportunityId' },
11+
],
12+
} satisfies Omit<
13+
Parameters<typeof buildDuplicateActivityTargetQuery>[0],
14+
'deleteDuplicates'
15+
>;
816

917
describe('buildDuplicateActivityTargetQuery', () => {
1018
it('partitions each morph target type independently and deletes duplicates', () => {
@@ -22,8 +30,24 @@ describe('buildDuplicateActivityTargetQuery', () => {
2230
expect(query).toContain(
2331
'ORDER BY\n ("activityTarget"."deletedAt" IS NULL) DESC',
2432
);
33+
expect(query).toContain('DELETE FROM "workspace_123"."taskTarget"');
34+
});
35+
36+
it('supports legacy activity target column names', () => {
37+
const query = buildDuplicateActivityTargetQuery({
38+
...QUERY_ARGS,
39+
targetColumns: [
40+
{ type: 'person', columnName: 'personId' },
41+
{ type: 'company', columnName: 'companyId' },
42+
{ type: 'opportunity', columnName: 'opportunityId' },
43+
],
44+
deleteDuplicates: true,
45+
});
46+
47+
expect(query).toContain(`('person', "activityTarget"."personId")`);
48+
expect(query).toContain(`('company', "activityTarget"."companyId")`);
2549
expect(query).toContain(
26-
'DELETE FROM "workspace_123"."taskTarget"',
50+
`('opportunity', "activityTarget"."opportunityId")`,
2751
);
2852
});
2953

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
FieldMetadataType,
3+
IndexType,
4+
RelationType,
5+
} from 'twenty-shared/types';
6+
7+
import { rebindFlatIndexToWorkspaceColumns } from 'src/database/commands/upgrade-version-command/2-38/utils/rebind-flat-index-to-workspace-columns.util';
8+
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
9+
import { getFlatIndexMetadataMock } from 'src/engine/metadata-modules/flat-index-metadata/__mocks__/get-flat-index-metadata.mock';
10+
import { TASK_TARGET_FLAT_OBJECT_MOCK } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/task-target-flat-object.mock';
11+
12+
const CREATED_AT = '2026-09-04T00:00:00.000Z';
13+
const INDEX_ID = 'index-id';
14+
const INDEX_UNIVERSAL_IDENTIFIER = '4adf4d5a-ad69-4c5c-bc62-2807816b3aa8';
15+
16+
const taskField = getFlatFieldMetadataMock({
17+
id: 'legacy-task-field-id',
18+
universalIdentifier: 'legacy-task-field-universal-identifier',
19+
objectMetadataId: TASK_TARGET_FLAT_OBJECT_MOCK.id,
20+
type: FieldMetadataType.RELATION,
21+
name: 'task',
22+
settings: {
23+
relationType: RelationType.MANY_TO_ONE,
24+
joinColumnName: 'taskId',
25+
},
26+
universalSettings: {
27+
relationType: RelationType.MANY_TO_ONE,
28+
joinColumnName: 'taskId',
29+
},
30+
});
31+
const personField = getFlatFieldMetadataMock({
32+
id: 'legacy-person-field-id',
33+
universalIdentifier: 'legacy-person-field-universal-identifier',
34+
objectMetadataId: TASK_TARGET_FLAT_OBJECT_MOCK.id,
35+
type: FieldMetadataType.RELATION,
36+
name: 'person',
37+
settings: {
38+
relationType: RelationType.MANY_TO_ONE,
39+
joinColumnName: 'personId',
40+
},
41+
universalSettings: {
42+
relationType: RelationType.MANY_TO_ONE,
43+
joinColumnName: 'personId',
44+
},
45+
});
46+
const flatIndexMetadata = getFlatIndexMetadataMock({
47+
id: INDEX_ID,
48+
universalIdentifier: INDEX_UNIVERSAL_IDENTIFIER,
49+
objectMetadataId: 'standard-task-target-object-id',
50+
objectMetadataUniversalIdentifier:
51+
TASK_TARGET_FLAT_OBJECT_MOCK.universalIdentifier,
52+
applicationUniversalIdentifier:
53+
TASK_TARGET_FLAT_OBJECT_MOCK.applicationUniversalIdentifier,
54+
indexType: IndexType.BTREE,
55+
indexWhereClause: '"deletedAt" IS NULL',
56+
isUnique: true,
57+
name: 'standard-index-name',
58+
flatIndexFieldMetadatas: [taskField, personField].map(
59+
(fieldMetadata, order) => ({
60+
id: `index-field-${order}`,
61+
workspaceId: TASK_TARGET_FLAT_OBJECT_MOCK.workspaceId,
62+
indexMetadataId: INDEX_ID,
63+
fieldMetadataId: `standard-field-${order}`,
64+
order,
65+
subFieldName: null,
66+
createdAt: CREATED_AT,
67+
updatedAt: CREATED_AT,
68+
}),
69+
),
70+
universalFlatIndexFieldMetadatas: [taskField, personField].map(
71+
(_, order) => ({
72+
indexMetadataUniversalIdentifier: INDEX_UNIVERSAL_IDENTIFIER,
73+
fieldMetadataUniversalIdentifier: `standard-field-${order}`,
74+
order,
75+
subFieldName: null,
76+
createdAt: CREATED_AT,
77+
updatedAt: CREATED_AT,
78+
}),
79+
),
80+
});
81+
82+
describe('rebindFlatIndexToWorkspaceColumns', () => {
83+
it('binds index fields and its name to legacy workspace relations', () => {
84+
const result = rebindFlatIndexToWorkspaceColumns({
85+
flatIndexMetadata,
86+
flatObjectMetadata: TASK_TARGET_FLAT_OBJECT_MOCK,
87+
objectFlatFieldMetadatas: [taskField, personField],
88+
columnNames: ['taskId', 'personId'],
89+
});
90+
91+
expect(result.objectMetadataId).toBe(TASK_TARGET_FLAT_OBJECT_MOCK.id);
92+
expect(
93+
result.flatIndexFieldMetadatas.map(
94+
({ fieldMetadataId }) => fieldMetadataId,
95+
),
96+
).toEqual([taskField.id, personField.id]);
97+
expect(
98+
result.universalFlatIndexFieldMetadatas.map(
99+
({ fieldMetadataUniversalIdentifier }) =>
100+
fieldMetadataUniversalIdentifier,
101+
),
102+
).toEqual([taskField.universalIdentifier, personField.universalIdentifier]);
103+
expect(result.name).not.toBe(flatIndexMetadata.name);
104+
});
105+
106+
it('throws when a workspace relation column cannot be resolved', () => {
107+
expect(() =>
108+
rebindFlatIndexToWorkspaceColumns({
109+
flatIndexMetadata,
110+
flatObjectMetadata: TASK_TARGET_FLAT_OBJECT_MOCK,
111+
objectFlatFieldMetadatas: [taskField],
112+
columnNames: ['taskId', 'personId'],
113+
}),
114+
).toThrow('Could not find relation field for column personId');
115+
});
116+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { resolveActivityTargetColumns } from 'src/database/commands/upgrade-version-command/2-38/utils/resolve-activity-target-columns.util';
2+
3+
describe('resolveActivityTargetColumns', () => {
4+
it('prefers the current column names', () => {
5+
expect(
6+
resolveActivityTargetColumns(
7+
new Set([
8+
'targetPersonId',
9+
'personId',
10+
'targetCompanyId',
11+
'companyId',
12+
'targetOpportunityId',
13+
'opportunityId',
14+
]),
15+
),
16+
).toEqual([
17+
{ type: 'person', columnName: 'targetPersonId' },
18+
{ type: 'company', columnName: 'targetCompanyId' },
19+
{ type: 'opportunity', columnName: 'targetOpportunityId' },
20+
]);
21+
});
22+
23+
it('falls back to the legacy column names', () => {
24+
expect(
25+
resolveActivityTargetColumns(
26+
new Set(['personId', 'companyId', 'opportunityId']),
27+
),
28+
).toEqual([
29+
{ type: 'person', columnName: 'personId' },
30+
{ type: 'company', columnName: 'companyId' },
31+
{ type: 'opportunity', columnName: 'opportunityId' },
32+
]);
33+
});
34+
35+
it('returns undefined when a target column is missing', () => {
36+
expect(
37+
resolveActivityTargetColumns(new Set(['personId', 'companyId'])),
38+
).toBeUndefined();
39+
});
40+
});

0 commit comments

Comments
 (0)