1658 post mortem 0710 send batch events in webhook (#15022)

Use concurrent calls

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2025-10-11 12:48:40 +02:00
committed by GitHub
parent 68c86871dd
commit c681fb7fb6
58 changed files with 920 additions and 521 deletions
@@ -56,6 +56,7 @@ import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
type PermissionOptions = {
shouldBypassPermissionChecks?: boolean;
@@ -1195,22 +1196,26 @@ export class WorkspaceEntityManager extends EntityManager {
(entity) => !beforeUpdateMapById[entity.id],
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: updatedEntities,
beforeEntities: updatedEntities.map(
(entity) => beforeUpdateMapById[entity.id],
),
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: updatedEntities,
beforeEntities: updatedEntities.map(
(entity) => beforeUpdateMapById[entity.id],
),
}),
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.CREATED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: createdEntities,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.CREATED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: createdEntities,
}),
);
const permissionCheckApplies =
permissionOptionsFromArgs?.shouldBypassPermissionChecks !== true &&
@@ -1387,12 +1392,14 @@ export class WorkspaceEntityManager extends EntityManager {
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.DESTROYED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: formattedResult,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.DESTROYED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: formattedResult,
}),
);
return isEntityArray ? formattedResult : formattedResult[0];
}
@@ -1500,12 +1507,14 @@ export class WorkspaceEntityManager extends EntityManager {
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.DELETED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: formattedResult,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.DELETED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: formattedResult,
}),
);
return isEntityArray ? formattedResult : formattedResult[0];
}
@@ -1609,12 +1618,14 @@ export class WorkspaceEntityManager extends EntityManager {
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.RESTORED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: formattedResult,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.RESTORED,
objectMetadataItem,
workspaceId: this.internalContext.workspaceId,
entities: formattedResult,
}),
);
return isEntityArray ? formattedResult : formattedResult[0];
}
@@ -27,6 +27,7 @@ import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/app
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
export class WorkspaceDeleteQueryBuilder<
T extends ObjectLiteral,
@@ -122,13 +123,15 @@ export class WorkspaceDeleteQueryBuilder<
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.DESTROYED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedBefore,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.DESTROYED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedBefore,
authContext: this.authContext,
}),
);
return {
raw: result.raw,
@@ -30,6 +30,7 @@ import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/reposito
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
export class WorkspaceInsertQueryBuilder<
T extends ObjectLiteral,
@@ -160,21 +161,25 @@ export class WorkspaceInsertQueryBuilder<
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.CREATED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedResultForEvent,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.CREATED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedResultForEvent,
authContext: this.authContext,
}),
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.UPSERTED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedResultForEvent,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPSERTED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedResultForEvent,
authContext: this.authContext,
}),
);
// TypeORM returns all entity columns for insertions
const resultWithoutInsertionExtraColumns = !isDefined(result.raw)
@@ -23,6 +23,7 @@ import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/reposito
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
export class WorkspaceSoftDeleteQueryBuilder<
T extends ObjectLiteral,
@@ -85,13 +86,15 @@ export class WorkspaceSoftDeleteQueryBuilder<
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.DELETED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.DELETED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
authContext: this.authContext,
}),
);
return {
raw: after.raw,
@@ -29,6 +29,7 @@ import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repo
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
export class WorkspaceUpdateQueryBuilder<
T extends ObjectLiteral,
@@ -152,23 +153,27 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
}),
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.UPSERTED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPSERTED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
}),
);
const formattedResult = formatResult<T[]>(
result.raw,
@@ -293,23 +298,27 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext.objectMetadataMaps,
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPDATED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
}),
);
await this.internalContext.eventEmitterService.emitMutationEvent({
action: DatabaseEventAction.UPSERTED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
});
this.internalContext.eventEmitterService.emitDatabaseBatchEvent(
formatTwentyOrmEventToDatabaseBatchEvent({
action: DatabaseEventAction.UPSERTED,
objectMetadataItem: objectMetadata,
workspaceId: this.internalContext.workspaceId,
entities: formattedAfter,
beforeEntities: formattedBefore,
authContext: this.authContext,
}),
);
const formattedResults = formatResult<T[]>(
results.flatMap((result) => result.raw),
@@ -0,0 +1,174 @@
import { isDefined } from 'twenty-shared/utils';
import type { ObjectLiteral } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import type { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import type { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordUpsertEvent } from 'src/engine/core-modules/event-emitter/types/object-record-upsert.event';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import type { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { type DatabaseBatchEventInput } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
export const formatTwentyOrmEventToDatabaseBatchEvent = <
T extends ObjectLiteral,
>({
action,
objectMetadataItem,
workspaceId,
authContext,
entities,
beforeEntities,
}: {
action: DatabaseEventAction;
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
workspaceId: string;
authContext?: AuthContext;
entities: T | T[];
beforeEntities?: T | T[];
}): DatabaseBatchEventInput<T, DatabaseEventAction> | undefined => {
if (objectMetadataItem.standardId === STANDARD_OBJECT_IDS.timelineActivity) {
return;
}
const objectMetadataNameSingular = objectMetadataItem.nameSingular;
const fields = Object.values(objectMetadataItem.fieldsById ?? {});
const entityArray = isDefined(entities)
? Array.isArray(entities)
? entities
: [entities]
: [];
let events: (
| ObjectRecordCreateEvent<T>
| ObjectRecordUpdateEvent<T>
| ObjectRecordDeleteEvent<T>
| ObjectRecordUpsertEvent<T>
)[] = [];
switch (action) {
case DatabaseEventAction.CREATED:
events = entityArray.map((after) => {
const event = new ObjectRecordCreateEvent<T>();
event.userId = authContext?.user?.id;
event.recordId = after.id;
event.properties = { after };
return event;
});
break;
case DatabaseEventAction.UPDATED:
events = entityArray
.map((after, idx) => {
if (!beforeEntities) {
throw new Error('beforeEntities is required for UPDATED action');
}
const before = Array.isArray(beforeEntities)
? beforeEntities?.[idx]
: beforeEntities;
const diff = objectRecordChangedValues(
before,
after,
objectMetadataItem,
) as Partial<ObjectRecordDiff<T>>;
const updatedFields = Object.keys(diff);
if (updatedFields.length === 0) {
return;
}
const event = new ObjectRecordUpdateEvent<T>();
event.userId = authContext?.user?.id;
event.recordId = after.id;
event.properties = {
before,
after,
updatedFields,
diff,
};
return event;
})
.filter(isDefined);
break;
case DatabaseEventAction.DELETED:
events = entityArray.map((before) => {
const event = new ObjectRecordDeleteEvent<T>();
event.userId = authContext?.user?.id;
event.recordId = before.id;
event.properties = { before };
return event;
});
break;
case DatabaseEventAction.DESTROYED:
events = entityArray.map((before) => {
const event = new ObjectRecordDestroyEvent<T>();
event.userId = authContext?.user?.id;
event.recordId = before.id;
event.properties = { before };
return event;
});
break;
case DatabaseEventAction.UPSERTED:
events = entityArray.map((after, index) => {
const event = new ObjectRecordUpsertEvent<T>();
event.userId = authContext?.user?.id;
event.recordId = after.id;
const before = beforeEntities
? Array.isArray(beforeEntities)
? beforeEntities[index]
: beforeEntities
: undefined;
let updatedFields;
let diff;
diff = objectRecordChangedValues(
before ?? {},
after,
objectMetadataItem,
) as Partial<ObjectRecordDiff<T>>;
updatedFields = Object.keys(diff);
event.properties = {
after,
...(before && { before }),
...(diff && { diff }),
...(updatedFields && { updatedFields }),
};
return event;
});
break;
default:
return;
}
if (!events.length) {
return;
}
return {
objectMetadataNameSingular,
action,
events,
objectMetadata: { ...objectMetadataItem, fields },
workspaceId,
};
};