fix(metadata): resolve junction targets order-independently during mgration (#21193)

A junction relation points at a target field on the join object that
another action may create later (two junctions into the same join
reference each other). The builder validator now also looks up the
target in the to be created set, and the runner mints every field id up
front so the target resolves regardless of action order, the same way
relation pairs are already handled.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
neo773
2026-06-05 23:02:25 +05:30
committed by GitHub
parent 20d9244639
commit bfb83e93b2
9 changed files with 328 additions and 33 deletions
@@ -19,6 +19,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { enrichCreateWorkspaceMigrationActionsWithIds } from 'src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util';
import { AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@@ -48,12 +49,17 @@ export class ApplicationManifestResolver {
},
);
await this.workspaceMigrationRunnerService.run({
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: {
actions: actions as AllUniversalWorkspaceMigrationAction[],
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
idByUniversalIdentifierByMetadataName: {},
});
await this.workspaceMigrationRunnerService.run({
workspaceMigration,
workspaceId,
});
@@ -14,6 +14,7 @@ import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/wo
type ValidateJunctionTargetSettingsArgs = {
universalFlatFieldMetadata: UniversalFlatFieldMetadata<MorphOrRelationFieldMetadataType>;
flatFieldMetadataMaps: UniversalFlatEntityMaps<UniversalFlatFieldMetadata>;
remainingFlatFieldMetadataMaps?: UniversalFlatEntityMaps<UniversalFlatFieldMetadata>;
};
const createError = (
@@ -31,6 +32,7 @@ const createError = (
export const validateJunctionTargetSettings = ({
universalFlatFieldMetadata,
flatFieldMetadataMaps,
remainingFlatFieldMetadataMaps,
}: ValidateJunctionTargetSettingsArgs): FlatFieldMetadataValidationError[] => {
const { universalSettings } = universalFlatFieldMetadata;
@@ -58,10 +60,17 @@ export const validateJunctionTargetSettings = ({
];
}
const targetField = findFlatEntityByUniversalIdentifier({
universalIdentifier: junctionTargetFieldUniversalIdentifier,
flatEntityMaps: flatFieldMetadataMaps,
});
const targetField =
(isDefined(remainingFlatFieldMetadataMaps)
? findFlatEntityByUniversalIdentifier({
universalIdentifier: junctionTargetFieldUniversalIdentifier,
flatEntityMaps: remainingFlatFieldMetadataMaps,
})
: undefined) ??
findFlatEntityByUniversalIdentifier({
universalIdentifier: junctionTargetFieldUniversalIdentifier,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(targetField)) {
return [
@@ -243,6 +243,7 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
...validateJunctionTargetSettings({
universalFlatFieldMetadata: universalFlatFieldMetadataToValidate,
flatFieldMetadataMaps,
remainingFlatFieldMetadataMaps: remainingFlatEntityMapsToValidate,
}),
);
@@ -0,0 +1,133 @@
import { enrichCreateWorkspaceMigrationActionsWithIds } from 'src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util';
import { type UniversalCreateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { type WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration.type';
const buildCreateFieldAction = ({
fieldUniversalIdentifier,
relatedFieldUniversalIdentifier,
junctionTargetFieldUniversalIdentifier,
}: {
fieldUniversalIdentifier: string;
relatedFieldUniversalIdentifier?: string;
junctionTargetFieldUniversalIdentifier?: string;
}): UniversalCreateFieldAction =>
({
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: fieldUniversalIdentifier,
universalSettings: junctionTargetFieldUniversalIdentifier
? { junctionTargetFieldUniversalIdentifier }
: undefined,
},
relatedUniversalFlatFieldMetadata: relatedFieldUniversalIdentifier
? { universalIdentifier: relatedFieldUniversalIdentifier }
: undefined,
}) as unknown as UniversalCreateFieldAction;
const buildWorkspaceMigration = (
actions: UniversalCreateFieldAction[],
): WorkspaceMigration =>
({
applicationUniversalIdentifier: 'app',
actions,
}) as unknown as WorkspaceMigration;
describe('enrichCreateWorkspaceMigrationActionsWithIds', () => {
it('should assign an id to a create field action that has none', () => {
const action = buildCreateFieldAction({ fieldUniversalIdentifier: 'a' });
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: buildWorkspaceMigration([action]),
idByUniversalIdentifierByMetadataName: {},
});
const [enrichedAction] =
workspaceMigration.actions as UniversalCreateFieldAction[];
expect(enrichedAction.id).toEqual(expect.any(String));
});
it('should stamp a field id and a related field id consistently', () => {
const action = buildCreateFieldAction({
fieldUniversalIdentifier: 'source',
relatedFieldUniversalIdentifier: 'target',
});
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: buildWorkspaceMigration([action]),
idByUniversalIdentifierByMetadataName: {},
});
const [enrichedAction] =
workspaceMigration.actions as UniversalCreateFieldAction[];
expect(enrichedAction.id).toEqual(expect.any(String));
expect(enrichedAction.relatedFieldId).toEqual(expect.any(String));
expect(enrichedAction.id).not.toBe(enrichedAction.relatedFieldId);
});
it('should resolve a junction target field id to the same id the target action is created with', () => {
const targetAction = buildCreateFieldAction({
fieldUniversalIdentifier: 'target',
});
const junctionAction = buildCreateFieldAction({
fieldUniversalIdentifier: 'junction',
junctionTargetFieldUniversalIdentifier: 'target',
});
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: buildWorkspaceMigration([
junctionAction,
targetAction,
]),
idByUniversalIdentifierByMetadataName: {},
});
const [enrichedJunctionAction, enrichedTargetAction] =
workspaceMigration.actions as UniversalCreateFieldAction[];
expect(enrichedJunctionAction.fieldIdByUniversalIdentifier?.target).toBe(
enrichedTargetAction.id,
);
});
it('should set relatedFieldId to the id already minted for the target by an earlier action', () => {
const targetAction = buildCreateFieldAction({
fieldUniversalIdentifier: 'target',
});
const junctionAction = buildCreateFieldAction({
fieldUniversalIdentifier: 'junction',
relatedFieldUniversalIdentifier: 'target',
});
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: buildWorkspaceMigration([
targetAction,
junctionAction,
]),
idByUniversalIdentifierByMetadataName: {},
});
const [enrichedTargetAction, enrichedJunctionAction] =
workspaceMigration.actions as UniversalCreateFieldAction[];
expect(enrichedJunctionAction.relatedFieldId).toBe(enrichedTargetAction.id);
});
it('should use the provided external id over a generated one', () => {
const action = buildCreateFieldAction({ fieldUniversalIdentifier: 'a' });
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
workspaceMigration: buildWorkspaceMigration([action]),
idByUniversalIdentifierByMetadataName: {
fieldMetadata: { a: 'external-id' },
},
});
const [enrichedAction] =
workspaceMigration.actions as UniversalCreateFieldAction[];
expect(enrichedAction.id).toBe('external-id');
});
});
@@ -1,6 +1,8 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type UniversalCreateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { type UniversalCreateObjectAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/types/workspace-migration-object-action';
import { type UniversalCreatePageLayoutAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/page-layout/types/workspace-migration-page-layout-action.type';
import { type WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration.type';
@@ -59,6 +61,116 @@ const buildTabIdByUniversalIdentifier = ({
return tabIdByUniversalIdentifier;
};
const buildFieldIdByUniversalIdentifierForFieldActions = ({
actions,
providedFieldIdByUniversalIdentifier,
}: {
actions: WorkspaceMigration['actions'];
providedFieldIdByUniversalIdentifier?: Record<string, string>;
}): Record<string, string> | undefined => {
const fieldIdByUniversalIdentifier = {
...providedFieldIdByUniversalIdentifier,
};
const setFieldIdIfMissing = ({
universalIdentifier,
fallbackId,
}: {
universalIdentifier: string;
fallbackId?: string;
}) => {
if (isDefined(fieldIdByUniversalIdentifier[universalIdentifier])) {
return;
}
fieldIdByUniversalIdentifier[universalIdentifier] = fallbackId ?? v4();
};
for (const action of actions) {
if (action.type !== 'create' || action.metadataName !== 'fieldMetadata') {
continue;
}
setFieldIdIfMissing({
universalIdentifier: action.flatEntity.universalIdentifier,
fallbackId: action.id,
});
if (isDefined(action.relatedUniversalFlatFieldMetadata)) {
setFieldIdIfMissing({
universalIdentifier:
action.relatedUniversalFlatFieldMetadata.universalIdentifier,
fallbackId: action.relatedFieldId,
});
}
}
if (Object.keys(fieldIdByUniversalIdentifier).length === 0) {
return undefined;
}
return fieldIdByUniversalIdentifier;
};
const getJunctionTargetFieldUniversalIdentifier = (
universalSettings: UniversalCreateFieldAction['flatEntity']['universalSettings'],
): string | null | undefined => {
if (
!isDefined(universalSettings) ||
!('junctionTargetFieldUniversalIdentifier' in universalSettings)
) {
return undefined;
}
return universalSettings.junctionTargetFieldUniversalIdentifier;
};
const buildReferencedFieldIdByUniversalIdentifierForFieldAction = ({
action,
fieldIdByUniversalIdentifier,
}: {
action: UniversalCreateFieldAction;
fieldIdByUniversalIdentifier?: Record<string, string>;
}): Record<string, string> | undefined => {
if (!isDefined(fieldIdByUniversalIdentifier)) {
return undefined;
}
const referencedFieldIdByUniversalIdentifier: Record<string, string> = {};
const addReference = (universalIdentifier: string | null | undefined) => {
if (
!isDefined(universalIdentifier) ||
!isDefined(fieldIdByUniversalIdentifier[universalIdentifier])
) {
return;
}
referencedFieldIdByUniversalIdentifier[universalIdentifier] =
fieldIdByUniversalIdentifier[universalIdentifier];
};
for (const universalFlatFieldMetadata of [
action.flatEntity,
action.relatedUniversalFlatFieldMetadata,
].filter(isDefined)) {
addReference(
universalFlatFieldMetadata.relationTargetFieldMetadataUniversalIdentifier,
);
addReference(
getJunctionTargetFieldUniversalIdentifier(
universalFlatFieldMetadata.universalSettings,
),
);
}
if (Object.keys(referencedFieldIdByUniversalIdentifier).length === 0) {
return undefined;
}
return referencedFieldIdByUniversalIdentifier;
};
export const enrichCreateWorkspaceMigrationActionsWithIds = ({
workspaceMigration,
idByUniversalIdentifierByMetadataName,
@@ -71,6 +183,13 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
const pageLayoutTabIdByUniversalIdentifier =
idByUniversalIdentifierByMetadataName.pageLayoutTab;
const fieldIdByUniversalIdentifier =
buildFieldIdByUniversalIdentifierForFieldActions({
actions: workspaceMigration.actions,
providedFieldIdByUniversalIdentifier:
fieldMetadataIdByUniversalIdentifier,
});
const enrichedActions = workspaceMigration.actions.map((action) => {
if (action.type !== 'create') {
return action;
@@ -80,6 +199,7 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
idByUniversalIdentifierByMetadataName[action.metadataName];
if (
action.metadataName !== 'fieldMetadata' &&
!isDefined(idByUniversalIdentifier) &&
!isDefined(fieldMetadataIdByUniversalIdentifier) &&
!isDefined(pageLayoutTabIdByUniversalIdentifier)
@@ -92,7 +212,7 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
const id = isDefined(idByUniversalIdentifier)
? idByUniversalIdentifier[action.flatEntity.universalIdentifier]
: undefined;
const fieldIdByUniversalIdentifier = isDefined(
const objectFieldIdByUniversalIdentifier = isDefined(
fieldMetadataIdByUniversalIdentifier,
)
? buildFieldIdByUniversalIdentifierForObjectAction({
@@ -104,28 +224,39 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
return {
...action,
id,
fieldIdByUniversalIdentifier,
fieldIdByUniversalIdentifier: objectFieldIdByUniversalIdentifier,
};
}
case 'fieldMetadata': {
if (!isDefined(fieldMetadataIdByUniversalIdentifier)) {
return action;
}
const typedAction = action as UniversalCreateFieldAction;
const id =
fieldIdByUniversalIdentifier?.[
typedAction.flatEntity.universalIdentifier
];
const relatedFieldId = isDefined(
action.relatedUniversalFlatFieldMetadata,
)
? fieldMetadataIdByUniversalIdentifier[
action.relatedUniversalFlatFieldMetadata.universalIdentifier
]
: undefined;
const relatedFieldId =
isDefined(typedAction.relatedUniversalFlatFieldMetadata) &&
isDefined(fieldIdByUniversalIdentifier)
? fieldIdByUniversalIdentifier[
typedAction.relatedUniversalFlatFieldMetadata
.universalIdentifier
]
: typedAction.relatedFieldId;
const referencedFieldIdByUniversalIdentifier =
buildReferencedFieldIdByUniversalIdentifierForFieldAction({
action: typedAction,
fieldIdByUniversalIdentifier,
});
return {
...action,
id: fieldMetadataIdByUniversalIdentifier[
action.flatEntity.universalIdentifier
],
...typedAction,
id,
relatedFieldId,
...(isDefined(referencedFieldIdByUniversalIdentifier) && {
fieldIdByUniversalIdentifier:
referencedFieldIdByUniversalIdentifier,
}),
};
}
case 'pageLayout': {
@@ -386,12 +386,11 @@ export class WorkspaceMigrationValidateBuildAndRunService {
return validateAndBuildResult;
}
const workspaceMigration = isDefined(idByUniversalIdentifierByMetadataName)
? enrichCreateWorkspaceMigrationActionsWithIds({
idByUniversalIdentifierByMetadataName,
workspaceMigration: validateAndBuildResult.workspaceMigration,
})
: validateAndBuildResult.workspaceMigration;
const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({
idByUniversalIdentifierByMetadataName:
idByUniversalIdentifierByMetadataName ?? {},
workspaceMigration: validateAndBuildResult.workspaceMigration,
});
if (workspaceMigration.actions.length === 0) {
return {
@@ -18,6 +18,8 @@ export type UniversalCreateFieldAction =
>;
// Optional ID for the related field (for API metadata).
relatedFieldId?: string;
// IDs for fields created in the same migration, keyed by universal identifier.
fieldIdByUniversalIdentifier?: Record<string, string>;
};
export type UniversalUpdateFieldAction =
@@ -52,19 +52,31 @@ export class CreateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
const allFieldIdToBeCreatedInActionByUniversalIdentifierMap = new Map<
string,
string
>();
>(Object.entries(action.fieldIdByUniversalIdentifier ?? {}));
allFieldIdToBeCreatedInActionByUniversalIdentifierMap.set(
action.flatEntity.universalIdentifier,
action.id ?? v4(),
);
if (
!allFieldIdToBeCreatedInActionByUniversalIdentifierMap.has(
action.flatEntity.universalIdentifier,
)
) {
allFieldIdToBeCreatedInActionByUniversalIdentifierMap.set(
action.flatEntity.universalIdentifier,
action.id ?? v4(),
);
}
if (isDefined(action.relatedUniversalFlatFieldMetadata)) {
if (
isDefined(action.relatedUniversalFlatFieldMetadata) &&
!allFieldIdToBeCreatedInActionByUniversalIdentifierMap.has(
action.relatedUniversalFlatFieldMetadata.universalIdentifier,
)
) {
allFieldIdToBeCreatedInActionByUniversalIdentifierMap.set(
action.relatedUniversalFlatFieldMetadata.universalIdentifier,
action.relatedFieldId ?? v4(),
);
}
const universalFlatFieldMetadatas = isDefined(
action.relatedUniversalFlatFieldMetadata,
)
@@ -31,6 +31,7 @@ exports[`syncApplication should create a TEXT field on the standard Company obje
"universalSettings": null,
"updatedAt": Any<String>,
},
"id": Any<String>,
"metadataName": "fieldMetadata",
"type": "create",
},
@@ -412,6 +413,7 @@ exports[`syncApplication should delete old field and create equivalent one when
"universalSettings": null,
"updatedAt": Any<String>,
},
"id": Any<String>,
"metadataName": "fieldMetadata",
"type": "create",
},