feat: emit metadata events for schema changes with actor context for webhooks (#17622)

## Summary

This PR adds **metadata eventing**: when schema metadata
(objectMetadata, fieldMetadata, view, viewField, etc.) is created,
updated, or deleted, we now emit events that can trigger webhooks and
future audit logs. It also adds **actor context** (`userId`,
`workspaceMemberId`) to those events so subscribers can attribute
changes to a user or API key.

## What changed

### 1. Metadata eventing (first commit)

- **MetadataEventEmitter**  
New service that emits batch events after successful workspace
migrations. Event names follow `metadata.{entity}.{action}` (e.g.
`metadata.objectMetadata.created`, `metadata.fieldMetadata.updated`).
- **MetadataEventsToDbListener**  
Listens for metadata events and enqueues webhook delivery via
`CallWebhookJobsForMetadataJob`.
- **Event types** (twenty-shared)  
`MetadataEventAction`, `MetadataEventBatch`, and record event types for
create/update/delete.
- **WorkspaceMigrationValidateBuildAndRunService**  
Calls the metadata event emitter after running migrations so all
metadata changes (from any module) emit events from a single place.
- **Create events**  
Sourced from the create action payload (`flatEntity` /
`flatFieldMetadatas`) because `fromToAllFlatEntityMaps` does not provide
a before/after diff for creates. Update/delete events still use the
fromToAllFlatEntityMaps comparison.

### 2. Actor context (second commit)

- **MetadataEventEmitter**  
Accepts optional `actorContext` (`userId`, `workspaceMemberId`) and
includes it on emitted batch events.
- **WorkspaceMigrationValidateBuildAndRunService**  
Passes `actorContext` from the request into the metadata event emitter.
- **Metadata resolvers & services**  
All metadata modules resolve `@AuthUser({ allowUndefined: true })` and
`@AuthUserWorkspaceId()` and pass `userId` and `workspaceMemberId`
through to the migration/event pipeline. Both are optional so
API-key–authenticated requests (no user) still emit events without a
user identity.
  
Shared some questions on Discord about the PR.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Abdullah.
2026-02-12 22:14:25 +05:00
committed by GitHub
parent f6b7ab2251
commit 13c2234856
30 changed files with 731 additions and 82 deletions
@@ -7,6 +7,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
@@ -53,6 +54,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMigrationBuildOrchestratorService: WorkspaceMigrationBuildOrchestratorService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly metadataEventEmitter: MetadataEventEmitter,
twentyConfigService: TwentyConfigService,
) {
const logLevels = twentyConfigService.get('LOG_LEVELS');
@@ -248,7 +250,13 @@ export class WorkspaceMigrationValidateBuildAndRunService {
})
: validateAndBuildResult.workspaceMigration;
await this.workspaceMigrationRunnerService.run(workspaceMigration);
const { metadataEvents } =
await this.workspaceMigrationRunnerService.run(workspaceMigration);
this.metadataEventEmitter.emitMetadataEvents({
metadataEvents,
workspaceId: args.workspaceId,
});
}
public async validateBuildAndRunWorkspaceMigration({
@@ -9,12 +9,14 @@ import {
export type AllUniversalWorkspaceMigrationAction<
TActionType extends
WorkspaceMigrationActionType = WorkspaceMigrationActionType,
> = MetadataUniversalWorkspaceMigrationAction<AllMetadataName, TActionType>;
TMetadataName extends AllMetadataName = AllMetadataName,
> = MetadataUniversalWorkspaceMigrationAction<TMetadataName, TActionType>;
export type AllFlatWorkspaceMigrationAction<
TActionType extends
WorkspaceMigrationActionType = WorkspaceMigrationActionType,
> = MetadataFlatWorkspaceMigrationAction<AllMetadataName, TActionType>;
TMetadataName extends AllMetadataName = AllMetadataName,
> = MetadataFlatWorkspaceMigrationAction<TMetadataName, TActionType>;
export { WorkspaceMigrationActionType };
@@ -6,7 +6,6 @@ import { QueryRunner } from 'typeorm';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { ALL_METADATA_ENTITY_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { AllFlatEntityTypesByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { MetadataRelatedFlatEntityMapsKeys } from 'src/engine/metadata-modules/flat-entity/types/metadata-related-flat-entity-maps-keys.type';
@@ -61,9 +60,15 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService<
TActionType extends WorkspaceMigrationActionType,
TMetadataName extends AllMetadataName,
TUniversalAction extends // TODO create abstracted type utils
AllUniversalWorkspaceMigrationAction = AllFlatEntityTypesByMetadataName[TMetadataName]['universalActions'][TActionType],
AllUniversalWorkspaceMigrationAction = AllUniversalWorkspaceMigrationAction<
TActionType,
TMetadataName
>,
TFlatAction extends
AllFlatWorkspaceMigrationAction = AllFlatEntityTypesByMetadataName[TMetadataName]['flatActions'][TActionType],
AllFlatWorkspaceMigrationAction = AllFlatWorkspaceMigrationAction<
TActionType,
TMetadataName
>,
> {
public actionType: TActionType;
public metadataName: TMetadataName;
@@ -8,7 +8,6 @@ import { DataSource } from 'typeorm';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util';
@@ -22,6 +21,7 @@ import {
WorkspaceMigrationRunnerExceptionCode,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
import { WorkspaceMigrationRunnerActionHandlerRegistryService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/registry/workspace-migration-runner-action-handler-registry.service';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
@Injectable()
export class WorkspaceMigrationRunnerService {
@@ -5,6 +5,7 @@ import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/
type BaseMetadataEvent<T extends AllMetadataName, TPayload extends object> = {
metadataName: T;
recordId: string;
properties: TPayload;
};
@@ -12,7 +13,7 @@ export type DeleteMetadataEvent<T extends AllMetadataName> = BaseMetadataEvent<
T,
{ before: MetadataFlatEntity<T> }
> & {
type: 'delete';
type: 'deleted';
};
export type UpdateMetadataEventDiff<
@@ -37,16 +38,21 @@ export type UpdateMetadataEvent<
before: MetadataFlatEntity<T>;
after: MetadataFlatEntity<T>;
}
> & { type: 'update' };
> & { type: 'updated' };
export type CreateMetadataEvent<T extends AllMetadataName> = BaseMetadataEvent<
T,
{
after: MetadataFlatEntity<T>;
}
> & { type: 'create' };
> & { type: 'created' };
export type MetadataEvent<T extends AllMetadataName = AllMetadataName> =
| DeleteMetadataEvent<T>
| UpdateMetadataEvent<T>
| CreateMetadataEvent<T>;
export type AllMetadataEventType = MetadataEvent['type'];
export type AllMetadataEventName =
`metadata.${AllMetadataName}.${MetadataEvent['type']}`;
@@ -13,7 +13,8 @@ export const deriveMetadataEventsFromCreateAction = (
case 'fieldMetadata': {
return flatAction.flatFieldMetadatas.map(
(flatFieldMetadata): CreateMetadataEvent<'fieldMetadata'> => ({
type: 'create',
type: 'created',
recordId: flatFieldMetadata.id,
metadataName: 'fieldMetadata',
properties: {
after: flatFieldMetadata,
@@ -23,8 +24,9 @@ export const deriveMetadataEventsFromCreateAction = (
}
case 'objectMetadata': {
const objectEvent: CreateMetadataEvent<'objectMetadata'> = {
type: 'create',
type: 'created',
metadataName: 'objectMetadata',
recordId: flatAction.flatEntity.id,
properties: {
after: flatAction.flatEntity,
},
@@ -32,7 +34,8 @@ export const deriveMetadataEventsFromCreateAction = (
const fieldEvents: MetadataEvent[] = flatAction.flatFieldMetadatas.map(
(flatFieldMetadata): CreateMetadataEvent<'fieldMetadata'> => ({
type: 'create',
type: 'created',
recordId: flatFieldMetadata.id,
metadataName: 'fieldMetadata',
properties: {
after: flatFieldMetadata,
@@ -65,7 +68,8 @@ export const deriveMetadataEventsFromCreateAction = (
case 'webhook': {
return [
{
type: 'create',
type: 'created',
recordId: flatAction.flatEntity.id,
metadataName: flatAction.metadataName,
properties: {
after: flatAction.flatEntity,
@@ -52,8 +52,9 @@ export const deriveMetadataEventsFromDeleteAction = ({
return [
{
type: 'delete',
type: 'deleted',
metadataName: flatAction.metadataName,
recordId: flatAction.entityId,
properties: {
before: flatEntityToDelete,
},
@@ -8,11 +8,11 @@ import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-e
import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type';
import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import {
type UpdateMetadataEventDiff,
type CreateMetadataEvent,
type DeleteMetadataEvent,
type MetadataEvent,
type UpdateMetadataEvent,
type UpdateMetadataEventDiff,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
export type DeriveMetadataEventsFromUpdateActionArgs = {
@@ -42,8 +42,9 @@ const buildUpdateMetadataEvent = <TMetadataName extends AllMetadataName>({
) as UpdateMetadataEventDiff<TMetadataName, (typeof updatedFields)[number]>;
return {
type: 'update',
type: 'updated',
metadataName,
recordId: before.id,
properties: {
updatedFields,
diff,
@@ -68,18 +69,20 @@ export const deriveMetadataEventsFromUpdateAction = ({
const deleteIndexMetadataEvent: DeleteMetadataEvent<'index'> = {
metadataName: 'index',
recordId: fromFlatEntity.id,
properties: {
before: fromFlatEntity,
},
type: 'delete',
type: 'deleted',
};
const createIndexMetadataEvent: CreateMetadataEvent<'index'> = {
metadataName: 'index',
recordId: toFlatEntity.id,
properties: {
after: toFlatEntity,
},
type: 'create',
type: 'created',
};
return [deleteIndexMetadataEvent, createIndexMetadataEvent];