fix(server): include relation join column names in updatedFields of update events (#21405)

## Context

Since #21052, update-event diffs are keyed by the relation field name
(e.g. `company`) instead of the join column name (e.g. `companyId`).
`updatedFields` is derived from the diff keys, so any **workflow
database-event trigger** (or webhook) configured with a field filter on
a relation join column **silently stopped firing** — no run is created
at all.

We hit this in production: a `cloudWorkspace.updated` trigger filtered
on `twentyContactId` stopped creating runs the same day #21052 was
deployed. Updating the record's relation produced `updatedFields:
["twentyContact"]`, which no longer matches the stored settings `fields:
["twentyContactId"]` in
`WorkflowDatabaseEventTriggerListener.shouldTriggerJob`.

## Solution

Keep the diff keyed by relation field name (the timeline rendering from
#21052 relies on it — adding both keys to the diff would display
relation changes twice), but expose **both** the relation field name and
its join column name in `updatedFields`:

- New `computeUpdatedFieldsFromDiff()` in
`object-record-changed-values.ts`: expands MANY_TO_ONE relation diff
keys with their join column name.
- Used in `formatTwentyOrmEventToDatabaseBatchEvent` for
UPDATED/DELETED/RESTORED and UPSERTED events instead of
`Object.keys(diff)`.

This restores matching for pre-existing trigger/webhook configurations
(join column names) while keeping configurations using relation field
names working.

## Test plan

- [x] Unit tests: relation diff keyed by relation name; `updatedFields`
contains both `company` and `companyId`
- [x] End-to-end util test on UPDATED event: `updatedFields: ['company',
'companyId']`, diff keyed by `company`
- [x] Downstream consumer specs pass (workflow trigger listener,
webhooks, subscriptions, logic-function triggers)
- [x] Verified against the production workspace that a `twentyContactId`
update currently produces no workflow run with the old behavior
This commit is contained in:
Thomas Trompette
2026-06-10 16:48:24 +02:00
committed by GitHub
parent 6971d6fa95
commit 2514cab860
4 changed files with 216 additions and 6 deletions
@@ -1,6 +1,9 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import {
computeUpdatedFieldsFromDiff,
objectRecordChangedValues,
} from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -198,4 +201,110 @@ describe('objectRecordChangedValues', () => {
});
expect(result).not.toHaveProperty('position');
});
describe('with a MANY_TO_ONE relation field', () => {
const relationFieldId = 'company-field-id';
const relationUniversalId = 'company-universal-id';
const objectMetadataWithRelation: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [relationFieldId],
};
const flatFieldMetadataMapsWithRelation: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[relationUniversalId]: {
id: relationFieldId,
name: 'company',
type: FieldMetadataType.RELATION,
universalIdentifier: relationUniversalId,
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
},
} as FlatFieldMetadata,
},
universalIdentifierById: {
[relationFieldId]: relationUniversalId,
},
universalIdentifiersByApplicationId: {},
};
it('records join column changes under the relation field name', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516p',
companyId: 'old-company-id',
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516p',
companyId: 'new-company-id',
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithRelation,
flatFieldMetadataMapsWithRelation,
);
expect(result).toEqual({
company: {
before: { id: 'old-company-id' },
after: { id: 'new-company-id' },
},
});
});
it('computes updatedFields with both relation field name and join column name', () => {
const diff = {
company: {
before: { id: 'old-company-id' },
after: { id: 'new-company-id' },
},
name: { before: 'Original', after: 'Updated' },
};
const updatedFields = computeUpdatedFieldsFromDiff(
diff,
objectMetadataWithRelation,
flatFieldMetadataMapsWithRelation,
);
expect(updatedFields).toEqual(['company', 'companyId', 'name']);
});
it('uses the canonical computed join column name even when settings diverge', () => {
const flatFieldMetadataMapsWithDivergentJoinColumn: FlatEntityMaps<FlatFieldMetadata> =
{
...flatFieldMetadataMapsWithRelation,
byUniversalIdentifier: {
[relationUniversalId]: {
...flatFieldMetadataMapsWithRelation.byUniversalIdentifier[
relationUniversalId
],
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'legacyCompanyId',
},
} as FlatFieldMetadata,
},
};
const diff = {
company: {
before: { id: 'old-company-id' },
after: { id: 'new-company-id' },
},
};
const updatedFields = computeUpdatedFieldsFromDiff(
diff,
objectMetadataWithRelation,
flatFieldMetadataMapsWithDivergentJoinColumn,
);
expect(updatedFields).toEqual(['company', 'companyId']);
});
});
});
@@ -44,6 +44,39 @@ const getJoinColumnNameForRelationField = (
);
};
export const computeUpdatedFieldsFromDiff = (
diff: Record<string, unknown>,
objectMetadataItem: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): string[] => {
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
objectMetadataItem,
);
return Object.keys(diff).flatMap((diffKey) => {
const fieldId = fieldIdByName[diffKey];
if (!isDefined(fieldId)) {
return [diffKey];
}
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (isDefined(field) && isManyToOneRelationField(field)) {
return [
diffKey,
computeMorphOrRelationFieldJoinColumnName({ name: field.name }),
];
}
return [diffKey];
});
};
export const objectRecordChangedValues = (
oldRecord: Partial<ObjectRecord>,
newRecord: Partial<ObjectRecord>,
@@ -1,5 +1,5 @@
import { type ObjectRecordUpdateEvent } from 'twenty-shared/database-events';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -181,5 +181,62 @@ describe('formatTwentyOrmEventToDatabaseBatchEvent', () => {
expect(updateEvent2.properties?.before?.name).toBe('Jane Doe');
expect(updateEvent2.properties?.after?.name).toBe('Jane Doe Updated');
});
it('should include both relation field name and join column name in updatedFields', () => {
const companyField = createMockField({
id: 'company-id',
type: FieldMetadataType.RELATION,
name: 'company',
label: 'Company',
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
},
} as Parameters<typeof createMockField>[0]);
const flatFieldMetadataMapsWithRelation: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
'name-id': nameField,
'company-id': companyField,
},
universalIdentifierById: {
'name-id': 'name-id',
'company-id': 'company-id',
},
universalIdentifiersByApplicationId: {},
};
const flatObjectMetadataWithRelation = {
...flatObjectMetadata,
fieldIds: ['name-id', 'company-id'],
} as FlatObjectMetadata;
const result = formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem: flatObjectMetadataWithRelation,
flatFieldMetadataMaps: flatFieldMetadataMapsWithRelation,
workspaceId: mockWorkspaceId,
authContext: mockAuthContext,
recordsAfter: [{ id: 'record-1', companyId: 'new-company-id' }],
recordsBefore: [{ id: 'record-1', companyId: 'old-company-id' }],
});
const updateEvent = result?.events[0] as ObjectRecordUpdateEvent<{
id: string;
companyId: string;
}>;
expect(updateEvent.properties?.updatedFields).toEqual([
'company',
'companyId',
]);
expect(updateEvent.properties?.diff).toEqual({
company: {
before: { id: 'old-company-id' },
after: { id: 'new-company-id' },
},
});
});
});
});
@@ -17,7 +17,10 @@ import type { ObjectLiteral } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import {
computeUpdatedFieldsFromDiff,
objectRecordChangedValues,
} from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import type { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -128,7 +131,11 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = <
flatFieldMetadataMaps,
) as Partial<ObjectRecordDiff<T>>;
const updatedFields = Object.keys(diff);
const updatedFields = computeUpdatedFieldsFromDiff(
diff,
objectMetadataItem,
flatFieldMetadataMaps,
);
if (updatedFields.length === 0) {
return;
@@ -226,7 +233,11 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = <
flatFieldMetadataMaps,
) as Partial<ObjectRecordDiff<T>>;
updatedFields = Object.keys(diff);
updatedFields = computeUpdatedFieldsFromDiff(
diff,
objectMetadataItem,
flatFieldMetadataMaps,
);
event.properties = {
after: recordAfter,