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
@@ -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,64 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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 { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { resolveMorphRelationsFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-morph-relations-from-flat-field-metadata.util';
import { resolveRelationFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-relation-from-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
type EnrichFieldMetadataEventArgs = {
record: Record<string, unknown>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
};
export const enrichFieldMetadataEventWithRelations = ({
record,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}: EnrichFieldMetadataEventArgs): Record<string, unknown> => {
const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: record.id as string,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(flatFieldMetadata)) {
return record;
}
try {
if (
isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.RELATION)
) {
const relation = resolveRelationFromFlatFieldMetadata({
sourceFlatFieldMetadata: flatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return isDefined(relation) ? { ...record, relation } : record;
}
if (
isFlatFieldMetadataOfType(
flatFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
const morphRelations = resolveMorphRelationsFromFlatFieldMetadata({
morphFlatFieldMetadata: flatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return { ...record, morphRelations };
}
} catch {
return record;
}
return record;
};
@@ -1,7 +0,0 @@
import { v5 } from 'uuid';
const EVENT_STREAM_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
export const eventStreamIdToChannelId = (eventStreamId: string): string => {
return v5(eventStreamId, EVENT_STREAM_NAMESPACE);
};
@@ -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,93 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
type AsyncIteratorLifecycleOptions<T> = {
initialValue?: T;
onHeartbeat?: () => Promise<boolean>;
heartbeatIntervalMs?: number;
onCleanup?: () => Promise<void>;
};
export function wrapAsyncIteratorWithLifecycle<T>(
iterator: AsyncIterableIterator<T>,
options: AsyncIteratorLifecycleOptions<T>,
): AsyncIterableIterator<T> {
const { initialValue, onHeartbeat, heartbeatIntervalMs, onCleanup } = options;
let heartbeatInterval: NodeJS.Timeout | null = null;
let hasYieldedInitialValue = false;
const startHeartbeat = () => {
if (onHeartbeat && heartbeatIntervalMs) {
heartbeatInterval = setInterval(async () => {
try {
await onHeartbeat();
} catch {
// Heartbeat failure shouldn't crash the stream
}
}, heartbeatIntervalMs);
}
};
const cleanup = async () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (onCleanup) {
await onCleanup();
}
};
return {
next: async () => {
if (!isDefined(heartbeatInterval)) {
startHeartbeat();
}
if (isDefined(initialValue) && !hasYieldedInitialValue) {
hasYieldedInitialValue = true;
return { done: false, value: initialValue };
}
let result: IteratorResult<T>;
try {
result = await iterator.next();
} catch (error) {
await cleanup();
throw error;
}
if (result.done) {
await cleanup();
}
return result;
},
return: async () => {
let result: IteratorResult<T>;
try {
await cleanup();
} finally {
result = (await iterator.return?.()) ?? {
done: true,
value: undefined,
};
}
return result;
},
throw: async (error) => {
await cleanup();
if (iterator.throw) {
return iterator.throw(error);
}
throw error;
},
[Symbol.asyncIterator]() {
return this;
},
};
}
@@ -1,30 +0,0 @@
import { Catch } from '@nestjs/common';
import { GqlExceptionFilter } from '@nestjs/graphql';
import { assertUnreachable } from 'twenty-shared/utils';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
EventStreamException,
EventStreamExceptionCode,
} from 'src/engine/subscriptions/event-stream.exception';
@Catch(EventStreamException)
export class WorkspaceEventEmitterExceptionFilter
implements GqlExceptionFilter
{
catch(exception: EventStreamException) {
switch (exception.code) {
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
case EventStreamExceptionCode.EVENT_STREAM_DOES_NOT_EXIST:
case EventStreamExceptionCode.NOT_AUTHORIZED:
throw new InternalServerError(exception.message, {
subCode: exception.code,
userFriendlyMessage: exception.userFriendlyMessage,
});
default: {
throw assertUnreachable(exception.code);
}
}
}
}
@@ -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 {}
@@ -1,251 +0,0 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Subscription } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
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 } 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 {
EventStreamException,
EventStreamExceptionCode,
} 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';
import { eventStreamIdToChannelId } from './utils/get-channel-id-from-event-stream-id';
@MetadataResolver()
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(
WorkspaceEventEmitterExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class WorkspaceEventEmitterResolver {
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: (
payload: EventStreamPayload,
variables: { eventStreamId: string },
) => {
return {
eventStreamId: variables.eventStreamId,
objectRecordEventsWithQueryIds: payload.objectRecordEventsWithQueryIds,
metadataEvents: payload.metadataEvents,
};
},
})
async onEventSubscription(
@Args('eventStreamId') eventStreamId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
) {
const eventStreamChannelId = eventStreamIdToChannelId(eventStreamId);
const streamData = await this.eventStreamService.getStreamData(
workspace.id,
eventStreamChannelId,
);
if (isDefined(streamData)) {
throw new EventStreamException(
'Event stream already exists',
EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS,
);
}
await this.eventStreamService.createEventStream({
workspaceId: workspace.id,
eventStreamChannelId,
authContext: {
userId: user?.id,
userWorkspaceId,
apiKeyId: apiKey?.id,
},
});
let iterator: AsyncIterableIterator<EventStreamPayload>;
try {
iterator = await this.subscriptionService.subscribeToEventStream({
workspaceId: workspace.id,
eventStreamChannelId,
});
} catch (error) {
await this.eventStreamService.destroyEventStream({
workspaceId: workspace.id,
eventStreamChannelId,
});
throw error;
}
return wrapAsyncIteratorWithLifecycle(iterator, {
initialValue: {
objectRecordEventsWithQueryIds: [],
metadataEvents: [],
},
onHeartbeat: () =>
this.eventStreamService.refreshEventStreamTTL({
workspaceId: workspace.id,
eventStreamChannelId,
}),
heartbeatIntervalMs: EVENT_STREAM_TTL_MS / 5,
onCleanup: () =>
this.eventStreamService.destroyEventStream({
workspaceId: workspace.id,
eventStreamChannelId,
}),
});
}
@Mutation(() => Boolean)
async addQueryToEventStream(
@Args('input') input: AddQuerySubscriptionInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
): Promise<boolean> {
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
const streamData = await this.eventStreamService.getStreamData(
workspace.id,
eventStreamChannelId,
);
if (!isDefined(streamData)) {
throw new EventStreamException(
'Event stream does not exist',
EventStreamExceptionCode.EVENT_STREAM_DOES_NOT_EXIST,
);
}
const isAuthorized = await this.eventStreamService.isAuthorized({
streamData,
authContext: {
userWorkspaceId,
apiKeyId: apiKey?.id,
},
});
if (!isAuthorized) {
throw new EventStreamException(
'You are not authorized to add a query to this event stream',
EventStreamExceptionCode.NOT_AUTHORIZED,
);
}
await this.eventStreamService.addQuery({
workspaceId: workspace.id,
eventStreamChannelId,
queryId: input.queryId,
operationSignature: input.operationSignature,
});
return true;
}
@Mutation(() => Boolean)
async removeQueryFromEventStream(
@Args('input') input: RemoveQueryFromEventStreamInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
): Promise<boolean> {
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
const streamData = await this.eventStreamService.getStreamData(
workspace.id,
eventStreamChannelId,
);
if (!isDefined(streamData)) {
throw new EventStreamException(
'Event stream does not exist',
EventStreamExceptionCode.EVENT_STREAM_DOES_NOT_EXIST,
);
}
const isAuthorized = await this.eventStreamService.isAuthorized({
streamData,
authContext: {
userWorkspaceId,
apiKeyId: apiKey?.id,
},
});
if (!isAuthorized) {
throw new EventStreamException(
'You are not authorized to remove a query from this event stream',
EventStreamExceptionCode.NOT_AUTHORIZED,
);
}
await this.eventStreamService.removeQuery({
workspaceId: workspace.id,
eventStreamChannelId,
queryId: input.queryId,
});
return true;
}
}
@@ -1,786 +0,0 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
import { type ObjectRecordEvent } from 'twenty-shared/database-events';
import {
Nullable,
ObjectRecord,
type ObjectsPermissionsByRoleId,
type RecordGqlOperationFilter,
type RecordGqlOperationSignature,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import {
combineFilters,
isDefined,
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';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
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 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 {
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';
import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util';
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 { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name';
@Injectable()
export class WorkspaceEventEmitterService {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly processNestedRelationsHelper: ProcessNestedRelationsHelper,
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);
if (activeStreamIds.length === 0) {
return;
}
const streamsData = await this.eventStreamService.getStreamsData(
workspaceId,
activeStreamIds,
);
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,
);
}
}
await this.eventStreamService.removeFromActiveStreams(
workspaceId,
streamIdsToRemove,
);
}
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> {
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>,
permissionsContext: {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap: Record<string, string>;
rolesPermissions: ObjectsPermissionsByRoleId;
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): Promise<void> {
const { userWorkspaceId } = streamData.authContext;
if (!isDefined(userWorkspaceId)) {
return;
}
const roleId = permissionsContext.userWorkspaceRoleMap[userWorkspaceId];
if (!isDefined(roleId)) {
return;
}
const objectPermissions =
permissionsContext.rolesPermissions[roleId]?.[
workspaceEventBatch.objectMetadata.id
];
if (!objectPermissions?.canReadObjectRecords) {
return;
}
const matchedEvents: {
queryIds: string[];
objectRecordEvent: ObjectRecordSubscriptionEvent;
}[] = [];
const objectNameSingular = workspaceEventBatch.objectMetadata.nameSingular;
const subscriberRLSFilter = this.buildSubscriberRLSFilter(
streamData.authContext,
roleId,
workspaceEventBatch.objectMetadata,
permissionsContext,
flatWorkspaceMemberMaps,
);
const restrictedFields = objectPermissions.restrictedFields;
for (const event of workspaceEventBatch.events) {
const { action } = parseEventNameOrThrow(workspaceEventBatch.name);
const eventWithObjectName: ObjectRecordSubscriptionEvent = {
action,
objectNameSingular,
...event,
};
const filteredEvent = this.filterRestrictedFieldsFromEvent(
eventWithObjectName,
restrictedFields,
permissionsContext.flatFieldMetadataMaps,
);
const filteredProperties = filteredEvent.properties as {
updatedFields?: string[];
};
if (
isDefined(filteredProperties.updatedFields) &&
filteredProperties.updatedFields.length === 0
) {
continue;
}
const matchedQueryIds = this.getMatchingObjectRecordQueryIds(
streamData.queries,
filteredEvent,
subscriberRLSFilter,
workspaceEventBatch.objectMetadata,
permissionsContext.flatFieldMetadataMaps,
);
if (matchedQueryIds.length === 0) {
continue;
}
matchedEvents.push({
queryIds: matchedQueryIds,
objectRecordEvent: filteredEvent,
});
}
if (matchedEvents.length > 0) {
await this.enrichEventBatchWithNestedRelations({
objectMetadata: workspaceEventBatch.objectMetadata,
events: matchedEvents.map((e) => e.objectRecordEvent),
streamData,
permissionsContext,
workspaceId: workspaceEventBatch.workspaceId,
roleId,
});
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: matchedEvents,
metadataEvents: [],
};
await this.subscriptionService.publishToEventStream({
workspaceId: workspaceEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
}
private async enrichEventBatchWithNestedRelations({
streamData,
objectMetadata,
events,
workspaceId,
permissionsContext,
roleId,
}: {
streamData: EventStreamData;
objectMetadata: FlatObjectMetadata;
events: ObjectRecordEvent[];
workspaceId: string;
roleId: string;
permissionsContext: {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap: UserWorkspaceRoleMap;
rolesPermissions: ObjectsPermissionsByRoleId;
};
}) {
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const allRecords: ObjectRecord[] = [];
for (const event of events) {
if ('before' in event.properties) {
const recordBefore = event.properties.before as Nullable<ObjectRecord>;
if (isDefined(recordBefore)) {
allRecords.push(recordBefore);
}
}
if ('after' in event.properties) {
const recordAfter = event.properties.after as Nullable<ObjectRecord>;
if (isDefined(recordAfter)) {
allRecords.push(recordAfter);
}
}
}
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [roleId],
};
const globalWorkspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSourceReplica();
const selectedFields = this.commonSelectFieldsHelper.computeFromDepth({
depth: 1,
flatObjectMetadata: objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectsPermissions: permissionsContext.rolesPermissions[roleId],
onlyUseLabelIdentifierFieldsInRelations: true,
recurseIntoJunctionTableRelations: true,
});
const commonQueryParser = new GraphqlQueryParser(
objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
const selectedFieldsResult =
commonQueryParser.parseSelectedFields(selectedFields);
await this.processNestedRelationsHelper.processNestedRelations({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem: objectMetadata,
parentObjectRecords: allRecords,
authContext: streamData.authContext as unknown as WorkspaceAuthContext,
limit: QUERY_MAX_RECORDS_FROM_RELATION,
rolePermissionConfig,
workspaceDataSource: globalWorkspaceDataSource,
relations: selectedFieldsResult.relations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
selectedFields: selectedFieldsResult.select,
});
}
private buildSubscriberRLSFilter(
subscriberAuthContext: SerializableAuthContext,
roleId: string,
objectMetadata: FlatObjectMetadata,
permissionsContext: {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): RecordGqlOperationFilter | null {
const workspaceMember = isDefined(subscriberAuthContext.workspaceMemberId)
? flatWorkspaceMemberMaps.byId[subscriberAuthContext.workspaceMemberId]
: undefined;
return buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
permissionsContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
permissionsContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: permissionsContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
workspaceMember,
});
}
private filterRestrictedFieldsFromEvent(
event: ObjectRecordSubscriptionEvent,
restrictedFields: RestrictedFieldsPermissions | undefined,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): ObjectRecordSubscriptionEvent {
if (!restrictedFields || Object.keys(restrictedFields).length === 0) {
return event;
}
const restrictedFieldNames = new Set(
Object.entries(restrictedFields)
.filter(([, permissions]) => permissions.canRead === false)
.map(([fieldMetadataId]) => {
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
return fieldMetadata?.name;
})
.filter(isDefined),
);
if (restrictedFieldNames.size === 0) {
return event;
}
const filterRecord = (record: object | undefined): object | undefined => {
if (!record) {
return record;
}
return Object.fromEntries(
Object.entries(record).filter(
([key]) => !restrictedFieldNames.has(key),
),
);
};
const properties = event.properties as {
before?: object;
after?: object;
updatedFields?: string[];
diff?: object;
};
const filteredBefore = filterRecord(properties.before);
const filteredAfter = filterRecord(properties.after);
const filteredDiff = filterRecord(properties.diff);
const filteredProperties = {
...properties,
...(filteredBefore !== undefined && { before: filteredBefore }),
...(filteredAfter !== undefined && { after: filteredAfter }),
...(filteredDiff !== undefined && { diff: filteredDiff }),
updatedFields: properties.updatedFields?.filter(
(field) => !restrictedFieldNames.has(field),
),
};
return {
...event,
properties: filteredProperties,
} as ObjectRecordSubscriptionEvent;
}
private getMatchingObjectRecordQueryIds(
queries: Record<string, RecordOrMetadataGqlOperationSignature>,
event: ObjectRecordSubscriptionEvent,
subscriberRLSFilter: RecordGqlOperationFilter | null,
objectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): string[] {
const matchedQueryIds: string[] = [];
for (const [queryId, operationSignature] of Object.entries(queries)) {
if (!isRecordGqlOperationSignature(operationSignature)) {
continue;
}
if (
this.isQueryMatchingObjectRecordEvent(
operationSignature,
event,
subscriberRLSFilter,
objectMetadata,
flatFieldMetadataMaps,
)
) {
matchedQueryIds.push(queryId);
}
}
return matchedQueryIds;
}
private isQueryMatchingObjectRecordEvent(
operationSignature: RecordGqlOperationSignature,
event: ObjectRecordSubscriptionEvent,
subscriberRLSFilter: RecordGqlOperationFilter | null,
objectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): boolean {
if (operationSignature.objectNameSingular !== event.objectNameSingular) {
return false;
}
const properties = event.properties as {
after?: object;
before?: object;
};
const record = properties?.after ?? properties?.before;
if (!isDefined(record)) {
return false;
}
const queryFilter = operationSignature.variables?.filter ?? {};
const filtersToApply: RecordGqlOperationFilter[] = [queryFilter];
if (subscriberRLSFilter && Object.keys(subscriberRLSFilter).length > 0) {
filtersToApply.push(subscriberRLSFilter);
}
const combinedFilter = combineFilters(filtersToApply);
if (Object.keys(combinedFilter).length === 0) {
return true;
}
const shouldIgnoreSoftDeleteDefaultFilter =
event.action === DatabaseEventAction.DELETED ||
event.action === DatabaseEventAction.RESTORED;
return isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: combinedFilter,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
shouldIgnoreSoftDeleteDefaultFilter,
});
}
private async fetchPermissionsContext(workspaceId: string): Promise<{
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap: Record<string, string>;
rolesPermissions: ObjectsPermissionsByRoleId;
}> {
const {
flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps,
userWorkspaceRoleMap,
rolesPermissions,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatRowLevelPermissionPredicateMaps',
'flatRowLevelPermissionPredicateGroupMaps',
'flatFieldMetadataMaps',
'userWorkspaceRoleMap',
'rolesPermissions',
]);
return {
flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps,
userWorkspaceRoleMap,
rolesPermissions,
};
}
}