Support referencing updatedFields array in databaseEvent triggers (#17240)

Closes https://github.com/twentyhq/core-team-issues/issues/1838

DatabaseEventTriggers can now define a field-level granularity on which
updates they should react to.
After a discussion with the team, we have decided to rely on field names
and not UID, just as we do for objects. The rationale is that we want to
keep a pleasant devX and consider it's not on twenty to ensure
continuity of api usage around an object if their name is updated: for
instance if a user has based their webhook on the name of an object, if
they decide to update it, they have to take care of updating their
webhook.
This commit is contained in:
Marie
2026-01-19 16:33:49 +01:00
committed by GitHub
parent f381516d30
commit 1a38152451
9 changed files with 451 additions and 32 deletions
@@ -491,7 +491,8 @@ export default defineFunction({
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent',
eventName: 'person.created',
eventName: 'person.updated',
updatedFields: ['name'],
},
],
});
@@ -501,8 +502,8 @@ Common trigger types:
- **route**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**:
> e.g. `path: '/post-card/create',` -> call on `<APP_URL>/s/post-card/create`
- **cron**: Runs your function on a schedule using a CRON expression.
- **databaseEvent**: Runs on workspace object lifecycle events
> e.g. `person.created`
- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function.
> e.g. `person.updated`
Notes:
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
@@ -1,7 +1,9 @@
import { t } from '@lingui/core/macro';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconButton, type SelectOption } from 'twenty-ui/input';
import {
IconBox,
IconNorthStar,
@@ -9,12 +11,10 @@ import {
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { IconButton, type SelectOption } from 'twenty-ui/input';
const OBJECT_DROPDOWN_WIDTH = 340;
const ACTION_DROPDOWN_WIDTH = 140;
const OBJECT_DROPDOWN_WIDTH = 240;
const ACTION_DROPDOWN_WIDTH = 240;
const OBJECT_MOBILE_WIDTH = 150;
const ACTION_MOBILE_WIDTH = 140;
@@ -40,7 +40,7 @@ export const SettingsDatabaseEventsForm = ({
removeOperation,
disabled = false,
}: {
events: { object: string | null; action: string }[];
events: { object: string | null; action: string; updatedFields?: string[] }[];
updateOperation?: (
index: number,
field: 'object' | 'action',
@@ -64,12 +64,23 @@ export const SettingsDatabaseEventsForm = ({
})),
];
const actionOptions: SelectOption<string>[] = [
{ label: t`All`, value: '*', Icon: IconNorthStar },
{ label: t`Created`, value: 'created', Icon: IconPlus },
{ label: t`Updated`, value: 'updated', Icon: IconBox },
{ label: t`Deleted`, value: 'deleted', Icon: IconTrash },
];
const getActionOptions = (
updatedFields?: string[],
): SelectOption<string>[] => {
const hasSpecificFields =
isDefined(updatedFields) && updatedFields.length > 0;
return [
{ label: t`All`, value: '*', Icon: IconNorthStar },
{ label: t`Created`, value: 'created', Icon: IconPlus },
{
label: hasSpecificFields ? t`Updated (on specific fields)` : t`Updated`,
value: 'updated',
Icon: IconBox,
},
{ label: t`Deleted`, value: 'deleted', Icon: IconTrash },
];
};
return (
<>
@@ -89,7 +100,7 @@ export const SettingsDatabaseEventsForm = ({
<Select
dropdownId={`operation-webhook-type-select-${index}`}
value={operation.action}
options={actionOptions}
options={getActionOptions(operation.updatedFields)}
onChange={(newValue) =>
updateOperation?.(index, 'action', newValue)
}
@@ -1,16 +1,16 @@
import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type ServerlessFunction } from '~/generated/graphql';
import { useLingui } from '@lingui/react/macro';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { SettingsDatabaseEventsForm } from '@/settings/components/SettingsDatabaseEventsForm';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { SettingsDatabaseEventsForm } from '@/settings/components/SettingsDatabaseEventsForm';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useLingui } from '@lingui/react/macro';
import { Tag } from 'twenty-ui/components';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { type ServerlessFunction } from '~/generated/graphql';
export const StyledRouteTriggerTableRow = styled(TableRow)`
grid-template-columns: 1fr 120px 120px;
@@ -54,7 +54,7 @@ export const SettingsServerlessFunctionTriggersTab = ({
const [object, action]: [string, string] =
event.settings.eventName.split('.');
return { object, action };
return { object, action, updatedFields: event.settings.updatedFields };
});
const hasNoTriggers =
@@ -1115,6 +1115,7 @@ export class ApplicationSyncService {
update: {
settings: {
eventName: triggerToSync.eventName,
updatedFields: triggerToSync.updatedFields,
},
},
};
@@ -1133,6 +1134,7 @@ export class ApplicationSyncService {
const createDatabaseEventTriggerInput = {
settings: {
eventName: triggerToCreate.eventName,
updatedFields: triggerToCreate.updatedFields,
},
universalIdentifier: triggerToCreate.universalIdentifier,
serverlessFunctionId,
@@ -15,6 +15,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
export type DatabaseEventTriggerSettings = {
eventName: string;
updatedFields?: string[];
};
@Entity('databaseEventTrigger')
@@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import chunk from 'lodash.chunk';
import { Repository } from 'typeorm';
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
@@ -11,12 +11,12 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
import { transformEventBatchToEventPayloads } from 'src/engine/metadata-modules/database-event-trigger/utils/transform-event-batch-to-event-payloads';
import {
ServerlessFunctionTriggerJob,
ServerlessFunctionTriggerJobData,
} from 'src/engine/metadata-modules/serverless-function/jobs/serverless-function-trigger.job';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { transformEventBatchToEventPayloads } from 'src/engine/metadata-modules/database-event-trigger/utils/transform-event-batch-to-event-payloads';
const DATABASE_EVENT_JOBS_CHUNK_SIZE = 20;
@@ -53,6 +53,10 @@ export class CallDatabaseEventTriggerJobsJob {
workspaceEventBatch,
});
if (serverlessFunctionPayloads.length === 0) {
return;
}
const serverlessFunctionPayloadsChunks = chunk(
serverlessFunctionPayloads,
DATABASE_EVENT_JOBS_CHUNK_SIZE,
@@ -0,0 +1,357 @@
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { type DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
import { transformEventBatchToEventPayloads } from 'src/engine/metadata-modules/database-event-trigger/utils/transform-event-batch-to-event-payloads';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import type { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
const createMockDatabaseEventListener = (
overrides: Partial<DatabaseEventTriggerEntity> = {},
): DatabaseEventTriggerEntity =>
({
id: 'listener-1',
workspaceId: 'workspace-1',
settings: {
eventName: 'company.updated',
},
serverlessFunction: {
id: 'function-1',
},
...overrides,
}) as DatabaseEventTriggerEntity;
const createMockEvent = (
overrides: Partial<ObjectRecordEvent> = {},
): ObjectRecordEvent =>
({
recordId: 'record-1',
properties: {
after: {},
},
...overrides,
}) as ObjectRecordEvent;
const createMockWorkspaceEventBatch = (
overrides: Partial<WorkspaceEventBatch<ObjectRecordEvent>> = {},
): WorkspaceEventBatch<ObjectRecordEvent> => ({
name: 'company.updated',
workspaceId: 'workspace-1',
objectMetadata: getFlatObjectMetadataMock({
universalIdentifier: 'company-uuid',
nameSingular: 'company',
}),
events: [createMockEvent()],
...overrides,
});
describe('transformEventBatchToEventPayloads', () => {
describe('basic transformation', () => {
it('should transform a single event batch with a single listener', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch();
const databaseEventListeners = [createMockDatabaseEventListener()];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
serverlessFunctionId: 'function-1',
workspaceId: 'workspace-1',
payload: expect.objectContaining({
name: 'company.updated',
workspaceId: 'workspace-1',
recordId: 'record-1',
}),
});
});
it('should create multiple payloads for multiple events in a batch', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
events: [
createMockEvent({ recordId: 'record-1' }),
createMockEvent({ recordId: 'record-2' }),
createMockEvent({ recordId: 'record-3' }),
],
});
const databaseEventListeners = [createMockDatabaseEventListener()];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(3);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-1', 'record-2', 'record-3']);
});
it('should create payloads for each listener', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch();
const databaseEventListeners = [
createMockDatabaseEventListener({
id: 'listener-1',
serverlessFunction: { id: 'function-1' },
} as Partial<DatabaseEventTriggerEntity>),
createMockDatabaseEventListener({
id: 'listener-2',
serverlessFunction: { id: 'function-2' },
} as Partial<DatabaseEventTriggerEntity>),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(2);
expect(result.map((r) => r.serverlessFunctionId)).toEqual([
'function-1',
'function-2',
]);
});
});
describe('updatedFields filtering', () => {
it('should include all events when updatedFields is undefined', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['name'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['address'] },
}),
],
});
const databaseEventListeners = [
createMockDatabaseEventListener({
settings: { eventName: 'company.updated' },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(2);
});
it('should include all events when updatedFields is empty array', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['name'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['address'] },
}),
],
});
const databaseEventListeners = [
createMockDatabaseEventListener({
settings: { eventName: 'company.updated', updatedFields: [] },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(2);
});
it('should filter events to only those matching updatedFields', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['name'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['address'] },
}),
createMockEvent({
recordId: 'record-3',
properties: { after: {}, updatedFields: ['name', 'description'] },
}),
],
});
const databaseEventListeners = [
createMockDatabaseEventListener({
settings: { eventName: 'company.updated', updatedFields: ['name'] },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(2);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-1', 'record-3']);
});
it('should filter events matching any of the specified updatedFields', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['name'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['address'] },
}),
createMockEvent({
recordId: 'record-3',
properties: { after: {}, updatedFields: ['phone'] },
}),
],
});
const databaseEventListeners = [
createMockDatabaseEventListener({
settings: {
eventName: 'company.updated',
updatedFields: ['name', 'address'],
},
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(2);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-1', 'record-2']);
});
it('should return no events when none match the updatedFields filter', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['name'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['address'] },
}),
],
});
const databaseEventListeners = [
createMockDatabaseEventListener({
settings: { eventName: 'company.updated', updatedFields: ['phone'] },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(0);
});
it('should handle different updatedFields filters per listener', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['name'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['address'] },
}),
],
});
const databaseEventListeners = [
createMockDatabaseEventListener({
id: 'listener-1',
settings: { eventName: 'company.updated', updatedFields: ['name'] },
serverlessFunction: { id: 'function-1' },
} as Partial<DatabaseEventTriggerEntity>),
createMockDatabaseEventListener({
id: 'listener-2',
settings: {
eventName: 'company.updated',
updatedFields: ['address'],
},
serverlessFunction: { id: 'function-2' },
} as Partial<DatabaseEventTriggerEntity>),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(2);
const function1Payloads = result.filter(
(r) => r.serverlessFunctionId === 'function-1',
);
const function2Payloads = result.filter(
(r) => r.serverlessFunctionId === 'function-2',
);
expect(function1Payloads).toHaveLength(1);
expect((function1Payloads[0].payload as ObjectRecordEvent).recordId).toBe(
'record-1',
);
expect(function2Payloads).toHaveLength(1);
expect((function2Payloads[0].payload as ObjectRecordEvent).recordId).toBe(
'record-2',
);
});
});
describe('edge cases', () => {
it('should return empty array when no listeners provided', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch();
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners: [],
});
expect(result).toHaveLength(0);
});
it('should return empty array when no events in batch', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
events: [],
});
const databaseEventListeners = [createMockDatabaseEventListener()];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
databaseEventListeners,
});
expect(result).toHaveLength(0);
});
});
});
@@ -1,11 +1,13 @@
import { isDefined } from 'twenty-shared/utils';
import type {
DatabaseEventPayload,
ObjectRecordEvent,
} from 'twenty-shared/database-events';
import type { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
import { type ServerlessFunctionTriggerJobData } from 'src/engine/metadata-modules/serverless-function/jobs/serverless-function-trigger.job';
import type { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
export const transformEventBatchToEventPayloads = ({
workspaceEventBatch,
@@ -15,11 +17,19 @@ export const transformEventBatchToEventPayloads = ({
databaseEventListeners: DatabaseEventTriggerEntity[];
}): ServerlessFunctionTriggerJobData[] => {
const result: ServerlessFunctionTriggerJobData[] = [];
const { events, ...batchEventInfo } = workspaceEventBatch;
const [, operation] = workspaceEventBatch.name.split('.');
for (const databaseEventListener of databaseEventListeners) {
const { events, ...batchEventInfo } = workspaceEventBatch;
const triggerUpdatedFields = databaseEventListener.settings.updatedFields;
for (const event of events) {
const filteredEvents = filterEventsByUpdatedFields({
events,
operation,
triggerUpdatedFields,
});
for (const event of filteredEvents) {
const payload: DatabaseEventPayload = { ...batchEventInfo, ...event };
result.push({
@@ -32,3 +42,35 @@ export const transformEventBatchToEventPayloads = ({
return result;
};
const filterEventsByUpdatedFields = ({
events,
operation,
triggerUpdatedFields,
}: {
events: ObjectRecordEvent[];
operation: string;
triggerUpdatedFields?: string[];
}): ObjectRecordEvent[] => {
if (
operation !== 'updated' ||
!isDefined(triggerUpdatedFields) ||
triggerUpdatedFields.length === 0
) {
return events;
}
return events.filter((event) => {
const eventUpdatedFields = (
event.properties as { updatedFields?: string[] }
)?.updatedFields;
if (!isDefined(eventUpdatedFields) || eventUpdatedFields.length === 0) {
return false;
}
return eventUpdatedFields.some((fieldName: string) =>
triggerUpdatedFields.includes(fieldName),
);
});
};
@@ -33,6 +33,7 @@ export type ServerlessFunctionManifest = SyncableEntityOptions & {
export type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
updatedFields?: string[];
};
export type CronTrigger = {