Add SSE for metadata and plug front components (#17998)

Create the necessary tooling to listen to metadata events and plug it to
the front components. Now we have a hot reload like experience when we
edit a component in an app.

## Backend

- Split `EventWithQueryIds` into `ObjectRecordEventWithQueryIds` and
`MetadataEventWithQueryIds`
- Publish metadata event batches to active SSE streams in
`MetadataEventsToDbListener`

## Frontend

- Create a metadata event dispatching pipeline: SSE metadata events are
grouped by metadata name, transformed into
`MetadataOperationBrowserEventDetail` objects, and dispatched as browser
`CustomEvents`
- Add `useListenToMetadataOperationBrowserEvent` hook for consuming
metadata operation events filtered by metadata name and operation type
- Rename `useListenToObjectRecordEventsForQuery` to
`useListenToEventsForQuery`, now accepting both
`RecordGqlOperationSignature` and `MetadataGqlOperationSignature`
- Implement `useOnFrontComponentUpdated` which subscribes to front
component metadata events and updates the Apollo cache when the
component is modified
- Add `builtComponentChecksum` to the front component query and appends
it to the component URL for browser cache invalidation
This commit is contained in:
Raphaël Bosi
2026-02-18 12:26:20 +01:00
committed by GitHub
parent e3753bf822
commit 2455c859b4
76 changed files with 1636 additions and 173 deletions
@@ -22,14 +22,14 @@ import {
type CUSTOM_DOMAIN_DEACTIVATED_EVENT,
type CustomDomainDeactivatedTrackEvent,
} from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import {
type MONITORING_EVENT,
type MonitoringTrackEvent,
} from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
import {
type LOGIC_FUNCTION_EXECUTED_EVENT,
type LogicFunctionExecutedTrackEvent,
} from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed';
import {
type MONITORING_EVENT,
type MonitoringTrackEvent,
} from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
import {
type USER_SIGNUP_EVENT,
type UserSignupTrackEvent,
@@ -8,13 +8,15 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job';
import { AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
@Injectable()
export class MetadataEventsToDbListener {
constructor(
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly webhookQueueService: MessageQueueService,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
) {}
@OnEvent('metadata.*.created')
@@ -49,5 +51,9 @@ export class MetadataEventsToDbListener {
>(CallWebhookJobsForMetadataJob.name, metadataEventBatch, {
retryLimit: 3,
});
if (metadataEventBatch.events.length > 0) {
await this.workspaceEventEmitterService.publish(metadataEventBatch);
}
}
}
@@ -1,10 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
@Global()
@Module({
imports: [SubscriptionsModule],
providers: [MetadataEventEmitter, MetadataEventsToDbListener],
exports: [MetadataEventEmitter],
})
@@ -10,6 +10,7 @@ import { FrontComponentResolver } from 'src/engine/metadata-modules/front-compon
import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service';
import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -21,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
TokenModule,
PermissionsModule,
FlatFrontComponentModule,
SubscriptionsModule,
],
controllers: [FrontComponentController],
providers: [
@@ -129,10 +129,14 @@ export class FrontComponentService {
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatFrontComponentToCreate.id,
flatEntityMaps: recomputedFlatFrontComponentMaps,
});
const createdFlatFrontComponent = findFlatEntityByIdInFlatEntityMapsOrThrow(
{
flatEntityId: flatFrontComponentToCreate.id,
flatEntityMaps: recomputedFlatFrontComponentMaps,
},
);
return createdFlatFrontComponent;
}
async updateOne({
@@ -200,10 +204,14 @@ export class FrontComponentService {
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatFrontComponentMaps,
});
const updatedFlatFrontComponent = findFlatEntityByIdInFlatEntityMapsOrThrow(
{
flatEntityId: id,
flatEntityMaps: recomputedFlatFrontComponentMaps,
},
);
return updatedFlatFrontComponent;
}
async destroyOne({
@@ -1,7 +1,8 @@
import { Field, InputType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { type RecordOrMetadataGqlOperationSignature } from 'src/engine/subscriptions/types/event-stream-data.type';
@InputType()
export class AddQuerySubscriptionInput {
@@ -12,5 +13,5 @@ export class AddQuerySubscriptionInput {
queryId: string;
@Field(() => GraphQLJSON)
operationSignature: RecordGqlOperationSignature;
operationSignature: RecordOrMetadataGqlOperationSignature;
}
@@ -1,14 +1,24 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { MetadataEventDTO } from './metadata-event.dto';
import { ObjectRecordEventDTO } from './object-record-event.dto';
@ObjectType('EventWithQueryIds')
export class EventWithQueryIdsDTO {
@ObjectType('ObjectRecordEventWithQueryIds')
export class ObjectRecordEventWithQueryIdsDTO {
@Field(() => [String])
queryIds: string[];
@Field(() => ObjectRecordEventDTO)
event: ObjectRecordEventDTO;
objectRecordEvent: ObjectRecordEventDTO;
}
@ObjectType('MetadataEventWithQueryIds')
export class MetadataEventWithQueryIdsDTO {
@Field(() => [String])
queryIds: string[];
@Field(() => MetadataEventDTO)
metadataEvent: MetadataEventDTO;
}
@ObjectType('EventSubscription')
@@ -16,6 +26,9 @@ export class EventSubscriptionDTO {
@Field(() => String)
eventStreamId: string;
@Field(() => [EventWithQueryIdsDTO])
eventWithQueryIdsList: EventWithQueryIdsDTO[];
@Field(() => [ObjectRecordEventWithQueryIdsDTO])
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIdsDTO[];
@Field(() => [MetadataEventWithQueryIdsDTO])
metadataEventsWithQueryIds: MetadataEventWithQueryIdsDTO[];
}
@@ -0,0 +1,19 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ObjectRecordEventPropertiesDTO } from 'src/engine/subscriptions/dtos/object-record-event-properties.dto';
import { MetadataEventAction } from 'src/engine/subscriptions/enums/metadata-event-action.enum';
@ObjectType('MetadataEvent')
export class MetadataEventDTO {
@Field(() => MetadataEventAction)
type: MetadataEventAction;
@Field(() => String)
metadataName: string;
@Field(() => String)
recordId: string;
@Field(() => ObjectRecordEventPropertiesDTO)
properties: ObjectRecordEventPropertiesDTO;
}
@@ -0,0 +1,12 @@
import { registerEnumType } from '@nestjs/graphql';
export enum MetadataEventAction {
CREATED = 'created',
UPDATED = 'updated',
DELETED = 'deleted',
}
registerEnumType(MetadataEventAction, {
name: 'MetadataEventAction',
description: 'Metadata Event Action',
});
@@ -1,6 +1,5 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@@ -15,7 +14,10 @@ import {
EventStreamException,
EventStreamExceptionCode,
} from 'src/engine/subscriptions/event-stream.exception';
import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type';
import {
type EventStreamData,
type RecordOrMetadataGqlOperationSignature,
} from 'src/engine/subscriptions/types/event-stream-data.type';
@Injectable()
export class EventStreamService implements OnModuleInit {
@@ -180,7 +182,7 @@ export class EventStreamService implements OnModuleInit {
workspaceId: string;
eventStreamChannelId: string;
queryId: string;
operationSignature: RecordGqlOperationSignature;
operationSignature: RecordOrMetadataGqlOperationSignature;
}): Promise<void> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
const existing = await this.cacheStorageService.get<EventStreamData>(key);
@@ -1,10 +1,17 @@
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import {
type MetadataGqlOperationSignature,
type RecordGqlOperationSignature,
} from 'twenty-shared/types';
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
export type RecordOrMetadataGqlOperationSignature =
| RecordGqlOperationSignature
| MetadataGqlOperationSignature;
export type EventStreamData = {
authContext: SerializableAuthContext;
workspaceId: string;
queries: Record<string, RecordGqlOperationSignature>;
queries: Record<string, RecordOrMetadataGqlOperationSignature>;
createdAt: number;
};
@@ -0,0 +1,13 @@
import { type ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
export type EventStreamPayload = {
objectRecordEventsWithQueryIds: {
queryIds: string[];
objectRecordEvent: ObjectRecordSubscriptionEvent;
}[];
metadataEventsWithQueryIds: {
queryIds: string[];
metadataEvent: MetadataEvent;
}[];
};
@@ -352,8 +352,12 @@ describe('WorkspaceEventEmitterService', () => {
expect(publishCall.workspaceId).toBe(workspaceId);
expect(publishCall.eventStreamChannelId).toBe(streamChannelId);
expect(publishCall.payload).toHaveLength(1);
expect(publishCall.payload[0].queryIds).toContain('query-1');
expect(publishCall.payload.objectRecordEventsWithQueryIds).toHaveLength(
1,
);
expect(
publishCall.payload.objectRecordEventsWithQueryIds[0].queryIds,
).toContain('query-1');
});
it('should not publish events when object-level read permission is denied', async () => {
@@ -519,12 +523,14 @@ describe('WorkspaceEventEmitterService', () => {
mockSubscriptionService.publishToEventStream as jest.Mock
).mock.calls[0][0];
expect(publishCall.payload[0].event.properties.after).not.toHaveProperty(
'secretField',
);
expect(publishCall.payload[0].event.properties.after).toHaveProperty(
'name',
);
expect(
publishCall.payload.objectRecordEventsWithQueryIds[0].objectRecordEvent
.properties.after,
).not.toHaveProperty('secretField');
expect(
publishCall.payload.objectRecordEventsWithQueryIds[0].objectRecordEvent
.properties.after,
).toHaveProperty('name');
});
it('should skip update events when all updated fields are restricted', async () => {
@@ -658,7 +664,8 @@ describe('WorkspaceEventEmitterService', () => {
mockSubscriptionService.publishToEventStream as jest.Mock
).mock.calls[0][0];
const eventPayload = publishCall.payload[0].event;
const eventPayload =
publishCall.payload.objectRecordEventsWithQueryIds[0].objectRecordEvent;
expect(eventPayload.properties.updatedFields).toEqual(['name']);
expect(eventPayload.properties.diff).not.toHaveProperty('secretField');
@@ -829,7 +836,9 @@ describe('WorkspaceEventEmitterService', () => {
mockSubscriptionService.publishToEventStream as jest.Mock
).mock.calls[0][0];
expect(publishCall.payload).toHaveLength(2);
expect(publishCall.payload.objectRecordEventsWithQueryIds).toHaveLength(
2,
);
});
it('should handle multiple matching queries', async () => {
@@ -868,8 +877,12 @@ describe('WorkspaceEventEmitterService', () => {
mockSubscriptionService.publishToEventStream as jest.Mock
).mock.calls[0][0];
expect(publishCall.payload[0].queryIds).toContain('query-1');
expect(publishCall.payload[0].queryIds).toContain('query-2');
expect(
publishCall.payload.objectRecordEventsWithQueryIds[0].queryIds,
).toContain('query-1');
expect(
publishCall.payload.objectRecordEventsWithQueryIds[0].queryIds,
).toContain('query-2');
});
it('should use before record for delete events', async () => {
@@ -0,0 +1,512 @@
import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util';
const record = {
id: '1',
name: 'Test Object',
label: 'testObject',
isActive: true,
count: 42,
};
describe('isMetadataRecordMatchingFilter', () => {
describe('empty filters', () => {
it('should match any record when filter is empty', () => {
expect(isMetadataRecordMatchingFilter({ record, filter: {} })).toBe(true);
});
});
describe('and filter', () => {
it('should match when all sub-filters match', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: true } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when any sub-filter fails', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: false } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when and array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { and: [] },
}),
).toBe(true);
});
it('should throw when and value is not an array', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { and: 'invalid' } as any,
}),
).toThrow('Unexpected value for "and" filter');
});
});
describe('or filter', () => {
it('should match when any sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong Name' } }, { name: { eq: 'Test Object' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when no sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong' } }, { name: { eq: 'Also Wrong' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when or array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { or: [] },
}),
).toBe(true);
});
it('should treat or with an object as an and', () => {
const filter = {
or: { name: { eq: 'Test Object' } },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should throw when or value is neither array nor object', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { or: 'invalid' } as any,
}),
).toThrow('Unexpected value for "or" filter');
});
});
describe('not filter', () => {
it('should negate a matching filter', () => {
const filter = { not: { name: { eq: 'Test Object' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should negate a non-matching filter', () => {
const filter = { not: { name: { eq: 'Wrong' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should match when not contains an empty object', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { not: {} },
}),
).toBe(true);
});
});
describe('implicit and (multi-key filter)', () => {
it('should treat multiple keys as an implicit AND', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: true },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should fail if any key in implicit AND does not match', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: false },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
});
describe('eq operator', () => {
it('should match equal string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Test Object' } },
}),
).toBe(true);
});
it('should not match different string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Other' } },
}),
).toBe(false);
});
it('should match equal boolean values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { isActive: { eq: true } },
}),
).toBe(true);
});
});
describe('neq operator', () => {
it('should match when values are different', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Other' } },
}),
).toBe(true);
});
it('should not match when values are equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Test Object' } },
}),
).toBe(false);
});
});
describe('in operator', () => {
it('should match when value is in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['Test Object', 'Other'] } },
}),
).toBe(true);
});
it('should not match when value is not in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['A', 'B'] } },
}),
).toBe(false);
});
it('should return false when in value is not an array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: 'not-array' } } as any,
}),
).toBe(false);
});
});
describe('is operator', () => {
it('should match NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NULL' } },
}),
).toBe(true);
});
it('should not match NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NULL' } },
}),
).toBe(false);
});
it('should match NOT_NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NOT_NULL' } },
}),
).toBe(true);
});
it('should not match NOT_NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NOT_NULL' } },
}),
).toBe(false);
});
});
describe('like operator', () => {
it('should match with wildcard prefix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%Object' } },
}),
).toBe(true);
});
it('should match with wildcard suffix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'Test%' } },
}),
).toBe(true);
});
it('should match with wildcards on both sides', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%est Obj%' } },
}),
).toBe(true);
});
it('should not match when pattern does not match', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'wrong%' } },
}),
).toBe(false);
});
it('should escape regex special characters in pattern', () => {
const specialRecord = { ...record, name: 'foo.bar' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { like: 'foo.bar' } },
}),
).toBe(true);
// A dot in the pattern should NOT match any character
expect(
isMetadataRecordMatchingFilter({
record: { ...record, name: 'fooXbar' },
filter: { name: { like: 'foo.bar' } },
}),
).toBe(false);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { like: '42' } },
}),
).toBe(false);
});
});
describe('ilike operator', () => {
it('should match case-insensitively', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { ilike: '%test object%' } },
}),
).toBe(true);
});
it('should escape regex special characters', () => {
const specialRecord = { ...record, name: 'foo(bar)' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { ilike: 'FOO(BAR)' } },
}),
).toBe(true);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { ilike: '42' } },
}),
).toBe(false);
});
});
describe('gt operator', () => {
it('should match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 40 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 42 } },
}),
).toBe(false);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 50 } },
}),
).toBe(false);
});
});
describe('gte operator', () => {
it('should match when value is greater or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 42 } },
}),
).toBe(true);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 43 } },
}),
).toBe(false);
});
});
describe('lt operator', () => {
it('should match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 50 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 42 } },
}),
).toBe(false);
});
});
describe('lte operator', () => {
it('should match when value is less or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 42 } },
}),
).toBe(true);
});
it('should not match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 41 } },
}),
).toBe(false);
});
});
describe('unknown operator', () => {
it('should throw for unsupported operators', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { name: { unknownOp: 'value' } } as any,
}),
).toThrow('Unsupported filter operator');
});
});
describe('nested logical operators', () => {
it('should handle deeply nested and/or/not', () => {
const filter = {
and: [
{
or: [
{ name: { eq: 'Wrong' } },
{ not: { isActive: { eq: false } } },
],
},
{ count: { gte: 40 } },
],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should handle not with nested and', () => {
const filter = {
not: {
and: [{ name: { eq: 'Test Object' } }, { count: { gt: 100 } }],
},
};
// name matches but count > 100 fails, so AND = false, NOT = true
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
});
describe('edge cases', () => {
it('should skip non-object field filters', () => {
// When fieldFilter is a primitive, it is skipped (returns true)
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: 'Test Object' },
}),
).toBe(true);
});
it('should skip undefined field filters', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: undefined },
}),
).toBe(true);
});
it('should handle records with missing fields', () => {
expect(
isMetadataRecordMatchingFilter({
record: { id: '1' },
filter: { name: { eq: 'Test' } },
}),
).toBe(false);
});
});
});
@@ -0,0 +1,198 @@
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type MetadataFilter = Record<string, unknown>;
type MetadataScalarFilter = {
is?: 'NULL' | 'NOT_NULL';
eq?: unknown;
neq?: unknown;
in?: unknown[];
like?: string;
ilike?: string;
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
const isEmptyFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length === 0;
const isAndFilter = (filter: MetadataFilter): boolean =>
'and' in filter && isDefined(filter.and);
const isOrFilter = (filter: MetadataFilter): boolean =>
'or' in filter && isDefined(filter.or);
const isNotFilter = (filter: MetadataFilter): boolean =>
'not' in filter && isDefined(filter.not);
const isImplicitAndFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length > 1;
const escapeRegExp = (str: string): string =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const isMetadataRecordMatchingFilter = ({
record,
filter,
}: {
record: Record<string, unknown>;
filter: MetadataFilter;
}): boolean => {
if (isEmptyFilter(filter)) {
return true;
}
if (isImplicitAndFilter(filter)) {
return Object.entries(filter).every(([key, value]) =>
isMetadataRecordMatchingFilter({
record,
filter: { [key]: value },
}),
);
}
if (isAndFilter(filter)) {
const andValue = filter.and;
if (!Array.isArray(andValue)) {
throw new Error(
'Unexpected value for "and" filter: ' + JSON.stringify(andValue),
);
}
return (
andValue.length === 0 ||
andValue.every((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isOrFilter(filter)) {
const orValue = filter.or;
if (Array.isArray(orValue)) {
return (
orValue.length === 0 ||
orValue.some((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isObject(orValue)) {
return isMetadataRecordMatchingFilter({
record,
filter: orValue as MetadataFilter,
});
}
throw new Error(
'Unexpected value for "or" filter: ' + JSON.stringify(orValue),
);
}
if (isNotFilter(filter)) {
const notValue = filter.not;
if (isObject(notValue) && isEmptyFilter(notValue as MetadataFilter)) {
return true;
}
return !isMetadataRecordMatchingFilter({
record,
filter: notValue as MetadataFilter,
});
}
return Object.entries(filter).every(([fieldName, fieldFilter]) => {
if (!isDefined(fieldFilter) || !isObject(fieldFilter)) {
return true;
}
const recordValue = record[fieldName];
return isScalarValueMatchingFilter(
recordValue,
fieldFilter as MetadataScalarFilter,
);
});
};
const isScalarValueMatchingFilter = (
value: unknown,
fieldFilter: MetadataScalarFilter,
): boolean => {
if ('is' in fieldFilter) {
if (fieldFilter.is === 'NULL') {
return !isDefined(value);
}
return isDefined(value);
}
if ('eq' in fieldFilter) {
return value === fieldFilter.eq;
}
if ('neq' in fieldFilter) {
return value !== fieldFilter.neq;
}
if ('in' in fieldFilter) {
if (!Array.isArray(fieldFilter.in)) {
return false;
}
return fieldFilter.in.includes(value);
}
if ('like' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.like)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`).test(value);
}
if ('ilike' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.ilike)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`, 'i').test(value);
}
if ('gt' in fieldFilter) {
return isDefined(value) && (value as number) > (fieldFilter.gt as number);
}
if ('gte' in fieldFilter) {
return isDefined(value) && (value as number) >= (fieldFilter.gte as number);
}
if ('lt' in fieldFilter) {
return isDefined(value) && (value as number) < (fieldFilter.lt as number);
}
if ('lte' in fieldFilter) {
return isDefined(value) && (value as number) <= (fieldFilter.lte as number);
}
throw new Error(
'Unsupported filter operator: ' + JSON.stringify(fieldFilter),
);
};
@@ -18,10 +18,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant';
import { AddQuerySubscriptionInput } from 'src/engine/subscriptions/dtos/add-query-subscription.input';
import {
EventSubscriptionDTO,
EventWithQueryIdsDTO,
} from 'src/engine/subscriptions/dtos/event-subscription.dto';
import { EventSubscriptionDTO } from 'src/engine/subscriptions/dtos/event-subscription.dto';
import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto';
import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input';
import { RemoveQueryFromEventStreamInput } from 'src/engine/subscriptions/dtos/remove-query-subscription.input';
@@ -32,6 +29,7 @@ import {
} from 'src/engine/subscriptions/event-stream.exception';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/workspace-event-emitter/utils/wrap-async-iterator-with-lifecycle';
import { WorkspaceEventEmitterExceptionFilter } from 'src/engine/workspace-event-emitter/workspace-event-emitter-exception.filter';
@@ -86,12 +84,13 @@ export class WorkspaceEventEmitterResolver {
@Subscription(() => EventSubscriptionDTO, {
nullable: true,
resolve: (
payload: EventWithQueryIdsDTO[],
payload: EventStreamPayload,
variables: { eventStreamId: string },
) => {
return {
eventStreamId: variables.eventStreamId,
eventWithQueryIdsList: payload,
objectRecordEventsWithQueryIds: payload.objectRecordEventsWithQueryIds,
metadataEventsWithQueryIds: payload.metadataEventsWithQueryIds,
};
},
})
@@ -126,7 +125,7 @@ export class WorkspaceEventEmitterResolver {
},
});
let iterator: AsyncIterableIterator<EventWithQueryIdsDTO[]>;
let iterator: AsyncIterableIterator<EventStreamPayload>;
try {
iterator = await this.subscriptionService.subscribeToEventStream({
@@ -142,7 +141,10 @@ export class WorkspaceEventEmitterResolver {
}
return wrapAsyncIteratorWithLifecycle(iterator, {
initialValue: [],
initialValue: {
objectRecordEventsWithQueryIds: [],
metadataEventsWithQueryIds: [],
},
onHeartbeat: () =>
this.eventStreamService.refreshEventStreamTTL({
workspaceId: workspace.id,
@@ -7,9 +7,16 @@ import {
ObjectRecord,
type ObjectsPermissionsByRoleId,
type RecordGqlOperationFilter,
type RecordGqlOperationSignature,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { combineFilters, isDefined } from 'twenty-shared/utils';
import {
combineFilters,
isDefined,
isMetadataGqlOperationSignature,
isNonEmptyArray,
isRecordGqlOperationSignature,
} from 'twenty-shared/utils';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
@@ -18,6 +25,7 @@ import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -30,7 +38,11 @@ import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhoo
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type';
import {
type EventStreamData,
type RecordOrMetadataGqlOperationSignature,
} from 'src/engine/subscriptions/types/event-stream-data.type';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -38,7 +50,9 @@ import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils
import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util';
import { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
@Injectable()
export class WorkspaceEventEmitterService {
@@ -53,13 +67,29 @@ export class WorkspaceEventEmitterService {
) {}
async publish(
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
const [nameSingular, operation] = workspaceEventBatch.name.split('.');
if (!this.isMetadataEventBatch(eventBatch)) {
await this.publishToLegacyChannel(eventBatch);
}
for (const eventData of workspaceEventBatch.events) {
await this.publishToEventStreams(eventBatch);
}
private isMetadataEventBatch(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): eventBatch is MetadataEventBatch {
return 'metadataName' in eventBatch;
}
private async publishToLegacyChannel(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
): Promise<void> {
const [nameSingular, operation] = eventBatch.name.split('.');
for (const eventData of eventBatch.events) {
const { record, updatedFields } = transformEventToWebhookEvent({
eventName: workspaceEventBatch.name,
eventName: eventBatch.name,
event: eventData,
});
@@ -71,21 +101,19 @@ export class WorkspaceEventEmitterService {
...(updatedFields && { updatedFields }),
};
// Publish individual events to legacy channel (onDbEvent)
await this.subscriptionService.publish({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: workspaceEventBatch.workspaceId,
workspaceId: eventBatch.workspaceId,
payload: { onDbEvent: event },
});
}
await this.publishToEventStreams(workspaceEventBatch);
}
private async publishToEventStreams(
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
const workspaceId = workspaceEventBatch.workspaceId;
const workspaceId = eventBatch.workspaceId;
const isMetadata = this.isMetadataEventBatch(eventBatch);
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
@@ -99,15 +127,12 @@ export class WorkspaceEventEmitterService {
activeStreamIds,
);
const permissionsContext = await this.fetchPermissionsContext(workspaceId);
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatWorkspaceMemberMaps',
]);
const streamIdsToRemove: string[] = [];
const objectRecordStreamContext = !isMetadata
? await this.fetchObjectRecordStreamContext(workspaceId)
: undefined;
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
@@ -118,13 +143,25 @@ export class WorkspaceEventEmitterService {
continue;
}
await this.processStreamEvents(
streamChannelId,
streamData,
workspaceEventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
);
if (isMetadata) {
await this.processMetadataStreamEvents(
streamChannelId,
streamData,
eventBatch as MetadataEventBatch,
);
} else {
if (!isDefined(objectRecordStreamContext)) {
continue;
}
await this.processObjectRecordStreamEvents(
streamChannelId,
streamData,
eventBatch as WorkspaceEventBatch<ObjectRecordEvent>,
objectRecordStreamContext.permissionsContext,
objectRecordStreamContext.flatWorkspaceMemberMaps,
);
}
}
await this.eventStreamService.removeFromActiveStreams(
@@ -133,7 +170,102 @@ export class WorkspaceEventEmitterService {
);
}
private async processStreamEvents(
private async fetchObjectRecordStreamContext(workspaceId: string) {
const permissionsContext = await this.fetchPermissionsContext(workspaceId);
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatWorkspaceMemberMaps',
]);
return { permissionsContext, flatWorkspaceMemberMaps };
}
private async processMetadataStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
metadataEventBatch: MetadataEventBatch,
): Promise<void> {
const metadataEventsWithQueryIds: {
queryIds: string[];
metadataEvent: MetadataEvent;
}[] = [];
for (const metadataEvent of metadataEventBatch.events) {
const matchedQueryIds = this.getMatchingMetadataQueryIds(
streamData.queries,
metadataEvent,
);
if (!isNonEmptyArray(matchedQueryIds)) {
continue;
}
metadataEventsWithQueryIds.push({
queryIds: matchedQueryIds,
metadataEvent,
});
}
if (!isNonEmptyArray(metadataEventsWithQueryIds)) {
return;
}
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEventsWithQueryIds,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private getMatchingMetadataQueryIds(
queries: Record<string, RecordOrMetadataGqlOperationSignature>,
metadataEvent: MetadataEvent,
): string[] {
const properties = metadataEvent.properties as {
after?: Record<string, unknown>;
before?: Record<string, unknown>;
};
const record = properties?.after ?? properties?.before;
return Object.entries(queries)
.filter(([, operationSignature]) => {
if (!isMetadataGqlOperationSignature(operationSignature)) {
return false;
}
if (operationSignature.metadataName !== metadataEvent.metadataName) {
return false;
}
const queryFilter = (
operationSignature.variables as {
filter?: Record<string, unknown>;
}
)?.filter;
if (!isDefined(queryFilter) || Object.keys(queryFilter).length === 0) {
return true;
}
if (!isDefined(record)) {
return false;
}
return isMetadataRecordMatchingFilter({
record,
filter: queryFilter,
});
})
.map(([queryId]) => queryId);
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
@@ -169,7 +301,7 @@ export class WorkspaceEventEmitterService {
const matchedEvents: {
queryIds: string[];
event: ObjectRecordEvent & { objectNameSingular: string };
objectRecordEvent: ObjectRecordSubscriptionEvent;
}[] = [];
const objectNameSingular = workspaceEventBatch.objectMetadata.nameSingular;
@@ -210,7 +342,7 @@ export class WorkspaceEventEmitterService {
continue;
}
const matchedQueryIds = this.getMatchingQueryIds(
const matchedQueryIds = this.getMatchingObjectRecordQueryIds(
streamData.queries,
filteredEvent,
subscriberRLSFilter,
@@ -224,24 +356,29 @@ export class WorkspaceEventEmitterService {
matchedEvents.push({
queryIds: matchedQueryIds,
event: filteredEvent,
objectRecordEvent: filteredEvent,
});
}
if (matchedEvents.length > 0) {
await this.enrichEventBatchWithNestedRelations({
objectMetadata: workspaceEventBatch.objectMetadata,
events: matchedEvents.map((e) => e.event),
events: matchedEvents.map((e) => e.objectRecordEvent),
streamData,
permissionsContext,
workspaceId: workspaceEventBatch.workspaceId,
roleId,
});
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: matchedEvents,
metadataEventsWithQueryIds: [],
};
await this.subscriptionService.publishToEventStream({
workspaceId: workspaceEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload: matchedEvents,
payload,
});
}
}
@@ -435,14 +572,8 @@ export class WorkspaceEventEmitterService {
} as ObjectRecordSubscriptionEvent;
}
private getMatchingQueryIds(
queries: Record<
string,
{
objectNameSingular: string;
variables?: { filter?: RecordGqlOperationFilter };
}
>,
private getMatchingObjectRecordQueryIds(
queries: Record<string, RecordOrMetadataGqlOperationSignature>,
event: ObjectRecordSubscriptionEvent,
subscriberRLSFilter: RecordGqlOperationFilter | null,
objectMetadata: FlatObjectMetadata,
@@ -451,8 +582,12 @@ export class WorkspaceEventEmitterService {
const matchedQueryIds: string[] = [];
for (const [queryId, operationSignature] of Object.entries(queries)) {
if (!isRecordGqlOperationSignature(operationSignature)) {
continue;
}
if (
this.isQueryMatchingEvent(
this.isQueryMatchingObjectRecordEvent(
operationSignature,
event,
subscriberRLSFilter,
@@ -467,11 +602,8 @@ export class WorkspaceEventEmitterService {
return matchedQueryIds;
}
private isQueryMatchingEvent(
operationSignature: {
objectNameSingular: string;
variables?: { filter?: RecordGqlOperationFilter };
},
private isQueryMatchingObjectRecordEvent(
operationSignature: RecordGqlOperationSignature,
event: ObjectRecordSubscriptionEvent,
subscriberRLSFilter: RecordGqlOperationFilter | null,
objectMetadata: FlatObjectMetadata,
@@ -485,6 +617,7 @@ export class WorkspaceEventEmitterService {
after?: object;
before?: object;
};
const record = properties?.after ?? properties?.before;
if (!isDefined(record)) {
@@ -0,0 +1,28 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
export const METADATA_EVENTS_TO_EMIT = {
frontComponent: true,
objectMetadata: true,
fieldMetadata: true,
view: true,
viewField: true,
viewFieldGroup: true,
viewGroup: true,
viewFilter: true,
viewFilterGroup: true,
role: true,
roleTarget: true,
agent: true,
skill: true,
pageLayout: true,
pageLayoutWidget: true,
pageLayoutTab: true,
commandMenuItem: true,
navigationMenuItem: true,
rowLevelPermissionPredicate: true,
rowLevelPermissionPredicateGroup: true,
index: true,
logicFunction: true,
webhook: false,
} as const satisfies { [P in AllMetadataName]: boolean };
@@ -1,6 +1,7 @@
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant';
import {
type CreateMetadataEvent,
type MetadataEvent,
@@ -9,6 +10,14 @@ import { flatEntityToScalarFlatEntity } from 'src/engine/workspace-manager/works
export const deriveMetadataEventsFromCreateAction = (
flatAction: AllFlatWorkspaceMigrationAction<'create'>,
): MetadataEvent[] => {
const events = deriveAllMetadataEventsFromCreateAction(flatAction);
return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]);
};
const deriveAllMetadataEventsFromCreateAction = (
flatAction: AllFlatWorkspaceMigrationAction<'create'>,
): MetadataEvent[] => {
switch (flatAction.metadataName) {
case 'fieldMetadata': {
@@ -5,6 +5,7 @@ import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
import { flatEntityToScalarFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/flat-entity-to-scalar-flat-entity.util';
@@ -16,6 +17,18 @@ export type DeriveMetadataEventsFromDeleteActionArgs = {
export const deriveMetadataEventsFromDeleteAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromDeleteActionArgs): MetadataEvent[] => {
const events = deriveAllMetadataEventsFromDeleteAction({
flatAction,
allFlatEntityMaps,
});
return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]);
};
const deriveAllMetadataEventsFromDeleteAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromDeleteActionArgs): MetadataEvent[] => {
switch (flatAction.metadataName) {
case 'fieldMetadata':
@@ -7,6 +7,7 @@ import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-m
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
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 { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant';
import {
type CreateMetadataEvent,
type DeleteMetadataEvent,
@@ -61,6 +62,18 @@ const buildUpdateMetadataEvent = <TMetadataName extends AllMetadataName>({
export const deriveMetadataEventsFromUpdateAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromUpdateActionArgs): MetadataEvent[] => {
const events = deriveAllMetadataEventsFromUpdateAction({
flatAction,
allFlatEntityMaps,
});
return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]);
};
const deriveAllMetadataEventsFromUpdateAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromUpdateActionArgs): MetadataEvent[] => {
switch (flatAction.metadataName) {
case 'index': {