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

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

## Backend

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

## Frontend

- Create a metadata event dispatching pipeline: SSE metadata events are
grouped by metadata name, transformed into
`MetadataOperationBrowserEventDetail` objects, and dispatched as browser
`CustomEvents`
- Add `useListenToMetadataOperationBrowserEvent` hook for consuming
metadata operation events filtered by metadata name and operation type
- Rename `useListenToObjectRecordEventsForQuery` to
`useListenToEventsForQuery`, now accepting both
`RecordGqlOperationSignature` and `MetadataGqlOperationSignature`
- Implement `useOnFrontComponentUpdated` which subscribes to front
component metadata events and updates the Apollo cache when the
component is modified
- Add `builtComponentChecksum` to the front component query and appends
it to the component URL for browser cache invalidation
This commit is contained in:
Raphaël Bosi
2026-02-18 12:26:20 +01:00
committed by GitHub
parent e3753bf822
commit 2455c859b4
76 changed files with 1636 additions and 173 deletions
@@ -1,7 +1,8 @@
import { Field, InputType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { type RecordOrMetadataGqlOperationSignature } from 'src/engine/subscriptions/types/event-stream-data.type';
@InputType()
export class AddQuerySubscriptionInput {
@@ -12,5 +13,5 @@ export class AddQuerySubscriptionInput {
queryId: string;
@Field(() => GraphQLJSON)
operationSignature: RecordGqlOperationSignature;
operationSignature: RecordOrMetadataGqlOperationSignature;
}
@@ -1,14 +1,24 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { MetadataEventDTO } from './metadata-event.dto';
import { ObjectRecordEventDTO } from './object-record-event.dto';
@ObjectType('EventWithQueryIds')
export class EventWithQueryIdsDTO {
@ObjectType('ObjectRecordEventWithQueryIds')
export class ObjectRecordEventWithQueryIdsDTO {
@Field(() => [String])
queryIds: string[];
@Field(() => ObjectRecordEventDTO)
event: ObjectRecordEventDTO;
objectRecordEvent: ObjectRecordEventDTO;
}
@ObjectType('MetadataEventWithQueryIds')
export class MetadataEventWithQueryIdsDTO {
@Field(() => [String])
queryIds: string[];
@Field(() => MetadataEventDTO)
metadataEvent: MetadataEventDTO;
}
@ObjectType('EventSubscription')
@@ -16,6 +26,9 @@ export class EventSubscriptionDTO {
@Field(() => String)
eventStreamId: string;
@Field(() => [EventWithQueryIdsDTO])
eventWithQueryIdsList: EventWithQueryIdsDTO[];
@Field(() => [ObjectRecordEventWithQueryIdsDTO])
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIdsDTO[];
@Field(() => [MetadataEventWithQueryIdsDTO])
metadataEventsWithQueryIds: MetadataEventWithQueryIdsDTO[];
}
@@ -0,0 +1,19 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ObjectRecordEventPropertiesDTO } from 'src/engine/subscriptions/dtos/object-record-event-properties.dto';
import { MetadataEventAction } from 'src/engine/subscriptions/enums/metadata-event-action.enum';
@ObjectType('MetadataEvent')
export class MetadataEventDTO {
@Field(() => MetadataEventAction)
type: MetadataEventAction;
@Field(() => String)
metadataName: string;
@Field(() => String)
recordId: string;
@Field(() => ObjectRecordEventPropertiesDTO)
properties: ObjectRecordEventPropertiesDTO;
}
@@ -0,0 +1,12 @@
import { registerEnumType } from '@nestjs/graphql';
export enum MetadataEventAction {
CREATED = 'created',
UPDATED = 'updated',
DELETED = 'deleted',
}
registerEnumType(MetadataEventAction, {
name: 'MetadataEventAction',
description: 'Metadata Event Action',
});
@@ -1,6 +1,5 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@@ -15,7 +14,10 @@ import {
EventStreamException,
EventStreamExceptionCode,
} from 'src/engine/subscriptions/event-stream.exception';
import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type';
import {
type EventStreamData,
type RecordOrMetadataGqlOperationSignature,
} from 'src/engine/subscriptions/types/event-stream-data.type';
@Injectable()
export class EventStreamService implements OnModuleInit {
@@ -180,7 +182,7 @@ export class EventStreamService implements OnModuleInit {
workspaceId: string;
eventStreamChannelId: string;
queryId: string;
operationSignature: RecordGqlOperationSignature;
operationSignature: RecordOrMetadataGqlOperationSignature;
}): Promise<void> {
const key = this.getEventStreamKey(workspaceId, eventStreamChannelId);
const existing = await this.cacheStorageService.get<EventStreamData>(key);
@@ -1,10 +1,17 @@
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import {
type MetadataGqlOperationSignature,
type RecordGqlOperationSignature,
} from 'twenty-shared/types';
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
export type RecordOrMetadataGqlOperationSignature =
| RecordGqlOperationSignature
| MetadataGqlOperationSignature;
export type EventStreamData = {
authContext: SerializableAuthContext;
workspaceId: string;
queries: Record<string, RecordGqlOperationSignature>;
queries: Record<string, RecordOrMetadataGqlOperationSignature>;
createdAt: number;
};
@@ -0,0 +1,13 @@
import { type ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
export type EventStreamPayload = {
objectRecordEventsWithQueryIds: {
queryIds: string[];
objectRecordEvent: ObjectRecordSubscriptionEvent;
}[];
metadataEventsWithQueryIds: {
queryIds: string[];
metadataEvent: MetadataEvent;
}[];
};