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:
+111
-2
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+33
@@ -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>,
|
||||
|
||||
+58
-1
@@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+14
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user