Separate metadata and object record publisher (#18740)

- Separate both publishers
- Renaming
- Removal of the old SSE endpoint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas Trompette
2026-03-19 14:24:38 +01:00
committed by GitHub
parent 0d27a255a9
commit 5526d2e5d0
29 changed files with 1398 additions and 2313 deletions
@@ -3687,21 +3687,6 @@ export type ObjectStandardOverrides = {
translations?: Maybe<Scalars['JSON']>;
};
export type OnDbEvent = {
__typename?: 'OnDbEvent';
action: DatabaseEventAction;
eventDate: Scalars['DateTime'];
objectNameSingular: Scalars['String'];
record: Scalars['JSON'];
updatedFields?: Maybe<Array<Scalars['String']>>;
};
export type OnDbEventInput = {
action?: InputMaybe<DatabaseEventAction>;
objectNameSingular?: InputMaybe<Scalars['String']>;
recordId?: InputMaybe<Scalars['UUID']>;
};
/** Onboarding status */
export enum OnboardingStatus {
BOOK_ONBOARDING = 'BOOK_ONBOARDING',
@@ -4738,7 +4723,6 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
logicFunctionLogs: LogicFunctionLogs;
onDbEvent: OnDbEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
@@ -4748,11 +4732,6 @@ export type SubscriptionLogicFunctionLogsArgs = {
};
export type SubscriptionOnDbEventArgs = {
input: OnDbEventInput;
};
export type SubscriptionOnEventSubscriptionArgs = {
eventStreamId: Scalars['String'];
};
@@ -1236,107 +1236,6 @@ enum PageLayoutType {
DASHBOARD
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
after: JSON
diff: JSON
}
type MetadataEvent {
type: MetadataEventAction!
metadataName: String!
recordId: String!
properties: ObjectRecordEventProperties!
updatedCollectionHash: String
}
"""Metadata Event Action"""
enum MetadataEventAction {
CREATED
UPDATED
DELETED
}
type ObjectRecordEvent {
action: DatabaseEventAction!
objectNameSingular: String!
recordId: String!
userId: String
workspaceMemberId: String
properties: ObjectRecordEventProperties!
}
"""Database Event Action"""
enum DatabaseEventAction {
CREATED
UPDATED
DELETED
DESTROYED
RESTORED
UPSERTED
}
type ObjectRecordEventWithQueryIds {
queryIds: [String!]!
objectRecordEvent: ObjectRecordEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type OnDbEvent {
action: DatabaseEventAction!
objectNameSingular: String!
eventDate: DateTime!
record: JSON!
updatedFields: [String!]
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
@@ -1523,6 +1422,14 @@ type ApprovedAccessDomain {
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type WorkspaceInvitation {
id: UUID!
email: String!
@@ -1958,6 +1865,91 @@ type WorkspaceInviteHashValid {
isValid: Boolean!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
after: JSON
diff: JSON
}
type MetadataEvent {
type: MetadataEventAction!
metadataName: String!
recordId: String!
properties: ObjectRecordEventProperties!
updatedCollectionHash: String
}
"""Metadata Event Action"""
enum MetadataEventAction {
CREATED
UPDATED
DELETED
}
type ObjectRecordEvent {
action: DatabaseEventAction!
objectNameSingular: String!
recordId: String!
userId: String
workspaceMemberId: String
properties: ObjectRecordEventProperties!
}
"""Database Event Action"""
enum DatabaseEventAction {
CREATED
UPDATED
DELETED
DESTROYED
RESTORED
UPSERTED
}
type ObjectRecordEventWithQueryIds {
queryIds: [String!]!
objectRecordEvent: ObjectRecordEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
@@ -4316,17 +4308,10 @@ enum FileFolder {
}
type Subscription {
onDbEvent(input: OnDbEventInput!): OnDbEvent!
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
}
input OnDbEventInput {
action: DatabaseEventAction
objectNameSingular: String
recordId: UUID
}
input LogicFunctionLogsInput {
applicationId: UUID
applicationUniversalIdentifier: UUID
@@ -963,101 +963,6 @@ export interface PageLayout {
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD'
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
after?: Scalars['JSON']
diff?: Scalars['JSON']
__typename: 'ObjectRecordEventProperties'
}
export interface MetadataEvent {
type: MetadataEventAction
metadataName: Scalars['String']
recordId: Scalars['String']
properties: ObjectRecordEventProperties
updatedCollectionHash?: Scalars['String']
__typename: 'MetadataEvent'
}
/** Metadata Event Action */
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
export interface ObjectRecordEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
recordId: Scalars['String']
userId?: Scalars['String']
workspaceMemberId?: Scalars['String']
properties: ObjectRecordEventProperties
__typename: 'ObjectRecordEvent'
}
/** Database Event Action */
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
export interface ObjectRecordEventWithQueryIds {
queryIds: Scalars['String'][]
objectRecordEvent: ObjectRecordEvent
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
export interface OnDbEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
eventDate: Scalars['DateTime']
record: Scalars['JSON']
updatedFields?: Scalars['String'][]
__typename: 'OnDbEvent'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
@@ -1241,6 +1146,15 @@ export interface ApprovedAccessDomain {
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface WorkspaceInvitation {
id: Scalars['UUID']
email: Scalars['String']
@@ -1687,6 +1601,83 @@ export interface WorkspaceInviteHashValid {
__typename: 'WorkspaceInviteHashValid'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
after?: Scalars['JSON']
diff?: Scalars['JSON']
__typename: 'ObjectRecordEventProperties'
}
export interface MetadataEvent {
type: MetadataEventAction
metadataName: Scalars['String']
recordId: Scalars['String']
properties: ObjectRecordEventProperties
updatedCollectionHash?: Scalars['String']
__typename: 'MetadataEvent'
}
/** Metadata Event Action */
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
export interface ObjectRecordEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
recordId: Scalars['String']
userId?: Scalars['String']
workspaceMemberId?: Scalars['String']
properties: ObjectRecordEventProperties
__typename: 'ObjectRecordEvent'
}
/** Database Event Action */
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
export interface ObjectRecordEventWithQueryIds {
queryIds: Scalars['String'][]
objectRecordEvent: ObjectRecordEvent
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
@@ -2881,7 +2872,6 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball'
export interface Subscription {
onDbEvent: OnDbEvent
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
__typename: 'Subscription'
@@ -3897,100 +3887,6 @@ export interface PageLayoutGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
after?: boolean | number
diff?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MetadataEventGenqlSelection{
type?: boolean | number
metadataName?: boolean | number
recordId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
updatedCollectionHash?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
recordId?: boolean | number
userId?: boolean | number
workspaceMemberId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
objectRecordEvent?: ObjectRecordEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnDbEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
eventDate?: boolean | number
record?: boolean | number
updatedFields?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -4181,6 +4077,16 @@ export interface ApprovedAccessDomainGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface WorkspaceInvitationGenqlSelection{
id?: boolean | number
email?: boolean | number
@@ -4676,6 +4582,80 @@ export interface WorkspaceInviteHashValidGenqlSelection{
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
after?: boolean | number
diff?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MetadataEventGenqlSelection{
type?: boolean | number
metadataName?: boolean | number
recordId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
updatedCollectionHash?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
recordId?: boolean | number
userId?: boolean | number
workspaceMemberId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
objectRecordEvent?: ObjectRecordEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionExecutionResultGenqlSelection{
/** Execution result in JSON format */
data?: boolean | number
@@ -6266,15 +6246,12 @@ export interface WorkspaceMigrationInput {actions: WorkspaceMigrationDeleteActio
export interface WorkspaceMigrationDeleteActionInput {type: WorkspaceMigrationActionType,metadataName: AllMetadataName,universalIdentifier: Scalars['String']}
export interface SubscriptionGenqlSelection{
onDbEvent?: (OnDbEventGenqlSelection & { __args: {input: OnDbEventInput} })
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnDbEventInput {action?: (DatabaseEventAction | null),objectNameSingular?: (Scalars['String'] | null),recordId?: (Scalars['UUID'] | null)}
export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null),applicationUniversalIdentifier?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null)}
@@ -6822,78 +6799,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
}
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
return MetadataEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
return EventSubscription_possibleTypes.includes(obj.__typename)
}
const OnDbEvent_possibleTypes: string[] = ['OnDbEvent']
export const isOnDbEvent = (obj?: { __typename?: any } | null): obj is OnDbEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isOnDbEvent"')
return OnDbEvent_possibleTypes.includes(obj.__typename)
}
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
@@ -7062,6 +6967,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const WorkspaceInvitation_possibleTypes: string[] = ['WorkspaceInvitation']
export const isWorkspaceInvitation = (obj?: { __typename?: any } | null): obj is WorkspaceInvitation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceInvitation"')
@@ -7550,6 +7463,62 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
}
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
return MetadataEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
return EventSubscription_possibleTypes.includes(obj.__typename)
}
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
@@ -8591,29 +8560,6 @@ export const enumPageLayoutType = {
DASHBOARD: 'DASHBOARD' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const
}
export const enumDatabaseEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const,
DESTROYED: 'DESTROYED' as const,
RESTORED: 'RESTORED' as const,
UPSERTED: 'UPSERTED' as const
}
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8692,6 +8638,29 @@ export const enumRelationType = {
MANY_TO_ONE: 'MANY_TO_ONE' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const
}
export const enumDatabaseEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const,
DESTROYED: 'DESTROYED' as const,
RESTORED: 'RESTORED' as const,
UPSERTED: 'UPSERTED' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { registerEnumType } from '@nestjs/graphql';
export enum DatabaseEventAction {
CREATED = 'created',
UPDATED = 'updated',
@@ -6,3 +8,8 @@ export enum DatabaseEventAction {
RESTORED = 'restored',
UPSERTED = 'upserted',
}
registerEnumType(DatabaseEventAction, {
name: 'DatabaseEventAction',
description: 'Database Event Action',
});
@@ -20,7 +20,7 @@ import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/cal
import { WorkspaceEventBatchForWebhook } from 'src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type';
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job';
@Injectable()
@@ -32,7 +32,7 @@ export class EntityEventsToDbListener {
private readonly webhookQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.triggerQueue)
private readonly triggerQueueService: MessageQueueService,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
private readonly objectRecordEventPublisher: ObjectRecordEventPublisher,
) {}
@OnDatabaseBatchEvent('*', DatabaseEventAction.CREATED)
@@ -79,7 +79,7 @@ export class EntityEventsToDbListener {
};
const promises = [
this.workspaceEventEmitterService.publish(batchEvent),
this.objectRecordEventPublisher.publish(batchEvent),
this.webhookQueueService.add<WorkspaceEventBatchForWebhook<T>>(
CallWebhookJobsJob.name,
batchEventForWebhook,
@@ -65,7 +65,6 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { MetadataEventEmitterModule } from 'src/engine/metadata-event-emitter/metadata-event-emitter.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
@@ -110,7 +109,6 @@ import { FileModule } from './file/file.module';
PostgresCredentialsModule,
WorkflowApiModule,
WorkspaceEventEmitterModule,
MetadataEventEmitterModule,
ActorModule,
TelemetryModule,
AdminPanelModule,
@@ -1,14 +0,0 @@
import { Global, Module } from '@nestjs/common';
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';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({
imports: [SubscriptionsModule, WorkspaceCacheModule],
providers: [MetadataEventEmitter, MetadataEventsToDbListener],
exports: [MetadataEventEmitter],
})
export class MetadataEventEmitterModule {}
@@ -6,7 +6,7 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
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 { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job';
import { type CallMetadataWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type';
@@ -1,29 +0,0 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { ObjectRecord } from 'twenty-shared/types';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
registerEnumType(DatabaseEventAction, {
name: 'DatabaseEventAction',
description: 'Database Event Action',
});
@ObjectType('OnDbEvent')
export class OnDbEventDTO {
@Field(() => DatabaseEventAction)
action: DatabaseEventAction;
@Field(() => String)
objectNameSingular: string;
@Field()
eventDate: Date;
@Field(() => GraphQLJSON)
record: ObjectRecord;
@Field(() => [String], { nullable: true })
updatedFields?: string[];
}
@@ -1,16 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class OnDbEventInput {
@Field(() => DatabaseEventAction, { nullable: true })
action?: DatabaseEventAction;
@Field(() => String, { nullable: true })
objectNameSingular?: string;
@Field(() => UUIDScalarType, { nullable: true })
recordId?: string;
}
@@ -1,5 +1,4 @@
export enum SubscriptionChannel {
DATABASE_EVENT_CHANNEL = 'DATABASE_EVENT_CHANNEL',
LOGIC_FUNCTION_LOGS_CHANNEL = 'LOGIC_FUNCTION_LOGS_CHANNEL',
EVENT_STREAM_CHANNEL = 'EVENT_STREAM_CHANNEL',
}
@@ -10,9 +10,7 @@ import {
} from 'src/engine/subscriptions/event-stream.exception';
@Catch(EventStreamException)
export class WorkspaceEventEmitterExceptionFilter
implements GqlExceptionFilter
{
export class EventStreamExceptionFilter implements GqlExceptionFilter {
catch(exception: EventStreamException) {
switch (exception.code) {
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
@@ -19,10 +19,8 @@ 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 } 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';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { EventStreamExceptionFilter } from 'src/engine/subscriptions/event-stream-exception.filter';
import {
EventStreamException,
EventStreamExceptionCode,
@@ -30,57 +28,19 @@ import {
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';
import { eventStreamIdToChannelId } from './utils/get-channel-id-from-event-stream-id';
import { eventStreamIdToChannelId } from 'src/engine/subscriptions/utils/get-channel-id-from-event-stream-id';
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/subscriptions/utils/wrap-async-iterator-with-lifecycle';
@MetadataResolver()
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(
WorkspaceEventEmitterExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class WorkspaceEventEmitterResolver {
@UseFilters(EventStreamExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
export class EventStreamResolver {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
) {}
@Subscription(() => OnDbEventDTO, {
filter: (
payload: { onDbEvent: OnDbEventDTO },
variables: { input: OnDbEventInput },
) => {
const isActionMatching =
!isDefined(variables.input.action) ||
payload.onDbEvent.action === variables.input.action;
const isObjectNameSingularMatching =
!isDefined(variables.input.objectNameSingular) ||
payload.onDbEvent.objectNameSingular ===
variables.input.objectNameSingular;
const isRecordIdMatching =
!isDefined(variables.input.recordId) ||
payload.onDbEvent.record.id === variables.input.recordId;
return (
isActionMatching && isObjectNameSingularMatching && isRecordIdMatching
);
},
})
onDbEvent(
@Args('input') _: OnDbEventInput,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return this.subscriptionService.subscribe({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: workspace.id,
});
}
@Subscription(() => EventSubscriptionDTO, {
nullable: true,
resolve: (
@@ -6,8 +6,8 @@ import { isDefined } from 'twenty-shared/utils';
import { getWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { computeMetadataEventName } from 'src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util';
import { MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { computeMetadataEventName } from 'src/engine/subscriptions/metadata-event/utils/compute-metadata-event-name.util';
import {
AllMetadataEventName,
AllMetadataEventType,
@@ -0,0 +1,250 @@
import { Injectable } from '@nestjs/common';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
@Injectable()
export class MetadataEventPublisher {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(metadataEventBatch: MetadataEventBatch): Promise<void> {
const workspaceId = metadataEventBatch.workspaceId;
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
if (activeStreamIds.length === 0) {
return;
}
const streamsData = await this.eventStreamService.getStreamsData(
workspaceId,
activeStreamIds,
);
const enrichedBatch =
await this.enrichMetadataEventBatch(metadataEventBatch);
const streamIdsToRemove: string[] = [];
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
await this.publishToStream({
streamChannelId,
metadataEventBatch: enrichedBatch,
});
}
await this.eventStreamService.removeFromActiveStreams(
workspaceId,
streamIdsToRemove,
);
}
private async enrichMetadataEventBatch(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
switch (metadataEventBatch.metadataName) {
case 'fieldMetadata':
return this.enrichFieldMetadataEventsWithRelations(
metadataEventBatch as MetadataEventBatch<'fieldMetadata'>,
);
case 'navigationMenuItem':
return this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch as MetadataEventBatch<'navigationMenuItem'>,
);
case 'objectMetadata':
return this.resolveObjectMetadataStandardOverrides(
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
);
default:
return metadataEventBatch;
}
}
private async publishToStream({
streamChannelId,
metadataEventBatch,
}: {
streamChannelId: string;
metadataEventBatch: MetadataEventBatch;
}): Promise<void> {
if (!isNonEmptyArray(metadataEventBatch.events)) {
return;
}
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch<'fieldMetadata'>,
): Promise<MetadataEventBatch<'fieldMetadata'>> {
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch<'navigationMenuItem'>,
): Promise<MetadataEventBatch<'navigationMenuItem'>> {
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch<'objectMetadata'>,
): MetadataEventBatch<'objectMetadata'> {
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
}
@@ -6,12 +6,12 @@ import { AllMetadataName } from 'twenty-shared/metadata';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
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 { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
@Injectable()
@@ -19,7 +19,7 @@ export class MetadataEventsToDbListener {
constructor(
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly webhookQueueService: MessageQueueService,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
private readonly metadataEventPublisher: MetadataEventPublisher,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -68,7 +68,7 @@ export class MetadataEventsToDbListener {
const updatedCollectionHash = cacheHashes[cacheKeyName];
await this.workspaceEventEmitterService.publish({
await this.metadataEventPublisher.publish({
...metadataEventBatch,
updatedCollectionHash,
});
@@ -22,8 +22,7 @@ import { type EventStreamData } from 'src/engine/subscriptions/types/event-strea
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
jest.mock(
'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util',
@@ -70,8 +69,8 @@ const buildFlatFieldMetadataMaps = (
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
describe('WorkspaceEventEmitterService', () => {
let service: WorkspaceEventEmitterService;
describe('ObjectRecordEventPublisher', () => {
let service: ObjectRecordEventPublisher;
let mockSubscriptionService: jest.Mocked<
Pick<SubscriptionService, 'publish' | 'publishToEventStream'>
>;
@@ -273,7 +272,7 @@ describe('WorkspaceEventEmitterService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspaceEventEmitterService,
ObjectRecordEventPublisher,
{
provide: SubscriptionService,
useValue: mockSubscriptionService,
@@ -302,19 +301,11 @@ describe('WorkspaceEventEmitterService', () => {
provide: CommonSelectFieldsHelper,
useValue: new CommonSelectFieldsHelper(),
},
{
provide: NavigationMenuItemRecordIdentifierService,
useValue: {
enrichNavigationMenuItemEventsWithRecordIdentifiers: jest
.fn()
.mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get<WorkspaceEventEmitterService>(
WorkspaceEventEmitterService,
service = module.get<ObjectRecordEventPublisher>(
ObjectRecordEventPublisher,
);
});
@@ -13,7 +13,6 @@ import {
import {
combineFilters,
isDefined,
isNonEmptyArray,
isRecordGqlOperationSignature,
} from 'twenty-shared/utils';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
@@ -25,20 +24,14 @@ import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-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 { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
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';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/workspace-event-emitter/utils/enrich-field-metadata-event-with-relations.util';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event';
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 {
@@ -54,8 +47,9 @@ import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twent
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 { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name';
@Injectable()
export class WorkspaceEventEmitterService {
export class ObjectRecordEventPublisher {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
@@ -64,57 +58,12 @@ export class WorkspaceEventEmitterService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly commonSelectFieldsHelper: CommonSelectFieldsHelper,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
if (!this.isMetadataEventBatch(eventBatch)) {
await this.publishToLegacyChannel(eventBatch);
}
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: eventBatch.name,
event: eventData,
});
const event = {
action: operation,
objectNameSingular: nameSingular,
eventDate: new Date(),
record,
...(updatedFields && { updatedFields }),
};
await this.subscriptionService.publish({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: eventBatch.workspaceId,
payload: { onDbEvent: event },
});
}
}
private async publishToEventStreams(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
const workspaceId = eventBatch.workspaceId;
const isMetadata = this.isMetadataEventBatch(eventBatch);
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
@@ -128,53 +77,28 @@ export class WorkspaceEventEmitterService {
activeStreamIds,
);
const { permissionsContext, flatWorkspaceMemberMaps } =
await this.fetchObjectRecordStreamContext(workspaceId);
const streamIdsToRemove: string[] = [];
const objectRecordStreamContext = !isMetadata
? await this.fetchObjectRecordStreamContext(workspaceId)
: undefined;
const enrichedMetadataEventBatch = isMetadata
? await this.enrichFieldMetadataEventsWithRelations(
eventBatch as MetadataEventBatch,
)
.then((batch) =>
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
batch,
),
)
.then((batch) => this.resolveObjectMetadataStandardOverrides(batch))
: undefined;
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
if (isMetadata && isDefined(enrichedMetadataEventBatch)) {
await this.processMetadataStreamEvents(
streamChannelId,
streamData,
enrichedMetadataEventBatch,
);
} else if (!isMetadata) {
if (Object.keys(streamData.queries).length === 0) {
continue;
}
if (!isDefined(objectRecordStreamContext)) {
continue;
}
await this.processObjectRecordStreamEvents(
streamChannelId,
streamData,
eventBatch as WorkspaceEventBatch<ObjectRecordEvent>,
objectRecordStreamContext.permissionsContext,
objectRecordStreamContext.flatWorkspaceMemberMaps,
);
if (Object.keys(streamData.queries).length === 0) {
continue;
}
await this.processObjectRecordStreamEvents({
streamChannelId,
streamData,
workspaceEventBatch: eventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
});
}
await this.eventStreamService.removeFromActiveStreams(
@@ -193,198 +117,25 @@ export class WorkspaceEventEmitterService {
return { permissionsContext, flatWorkspaceMemberMaps };
}
private async processMetadataStreamEvents(
streamChannelId: string,
_streamData: EventStreamData,
metadataEventBatch: MetadataEventBatch,
): Promise<void> {
if (!isNonEmptyArray(metadataEventBatch.events)) {
return;
}
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'fieldMetadata') {
return metadataEventBatch;
}
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'navigationMenuItem') {
return metadataEventBatch;
}
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch,
): MetadataEventBatch {
if (metadataEventBatch.metadataName !== 'objectMetadata') {
return metadataEventBatch;
}
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
private async processObjectRecordStreamEvents({
streamChannelId,
streamData,
workspaceEventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
}: {
streamChannelId: string;
streamData: EventStreamData;
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
permissionsContext: {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap: Record<string, string>;
rolesPermissions: ObjectsPermissionsByRoleId;
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): Promise<void> {
};
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps;
}): Promise<void> {
const { userWorkspaceId } = streamData.authContext;
if (!isDefined(userWorkspaceId)) {
@@ -470,7 +221,9 @@ export class WorkspaceEventEmitterService {
if (matchedEvents.length > 0) {
await this.enrichEventBatchWithNestedRelations({
objectMetadata: workspaceEventBatch.objectMetadata,
events: matchedEvents.map((e) => e.objectRecordEvent),
events: matchedEvents.map(
(matchedEvent) => matchedEvent.objectRecordEvent,
),
streamData,
permissionsContext,
workspaceId: workspaceEventBatch.workspaceId,
@@ -1,14 +1,26 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { EventStreamResolver } from 'src/engine/subscriptions/event-stream.resolver';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
import { MetadataEventsToDbListener } from 'src/engine/subscriptions/metadata-event/metadata-events-to-db.listener';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({
imports: [
RedisClientModule,
@@ -16,8 +28,26 @@ import { SubscriptionService } from 'src/engine/subscriptions/subscription.servi
CacheLockModule,
MetricsModule,
TypeOrmModule.forFeature([WorkspaceEntity]),
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
SubscriptionService,
EventStreamService,
EventStreamResolver,
ObjectRecordEventPublisher,
MetadataEventPublisher,
MetadataEventEmitter,
MetadataEventsToDbListener,
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
CommonSelectFieldsHelper,
],
exports: [
SubscriptionService,
ObjectRecordEventPublisher,
MetadataEventEmitter,
],
providers: [SubscriptionService, EventStreamService],
exports: [SubscriptionService, EventStreamService],
})
export class SubscriptionsModule {}
@@ -1,512 +0,0 @@
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);
});
});
});
@@ -1,198 +0,0 @@
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),
);
};
@@ -1,32 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceEventEmitterResolver } from 'src/engine/workspace-event-emitter/workspace-event-emitter.resolver';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
@Global()
@Module({
imports: [
SubscriptionsModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
WorkspaceEventEmitter,
WorkspaceEventEmitterService,
WorkspaceEventEmitterResolver,
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
CommonSelectFieldsHelper,
],
exports: [WorkspaceEventEmitter, WorkspaceEventEmitterService],
providers: [WorkspaceEventEmitter],
exports: [WorkspaceEventEmitter],
})
export class WorkspaceEventEmitterModule {}
@@ -8,7 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import {