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
@@ -0,0 +1,28 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
export const METADATA_EVENTS_TO_EMIT = {
frontComponent: true,
objectMetadata: true,
fieldMetadata: true,
view: true,
viewField: true,
viewFieldGroup: true,
viewGroup: true,
viewFilter: true,
viewFilterGroup: true,
role: true,
roleTarget: true,
agent: true,
skill: true,
pageLayout: true,
pageLayoutWidget: true,
pageLayoutTab: true,
commandMenuItem: true,
navigationMenuItem: true,
rowLevelPermissionPredicate: true,
rowLevelPermissionPredicateGroup: true,
index: true,
logicFunction: true,
webhook: false,
} as const satisfies { [P in AllMetadataName]: boolean };
@@ -1,6 +1,7 @@
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant';
import {
type CreateMetadataEvent,
type MetadataEvent,
@@ -9,6 +10,14 @@ import { flatEntityToScalarFlatEntity } from 'src/engine/workspace-manager/works
export const deriveMetadataEventsFromCreateAction = (
flatAction: AllFlatWorkspaceMigrationAction<'create'>,
): MetadataEvent[] => {
const events = deriveAllMetadataEventsFromCreateAction(flatAction);
return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]);
};
const deriveAllMetadataEventsFromCreateAction = (
flatAction: AllFlatWorkspaceMigrationAction<'create'>,
): MetadataEvent[] => {
switch (flatAction.metadataName) {
case 'fieldMetadata': {
@@ -5,6 +5,7 @@ import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
import { flatEntityToScalarFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/flat-entity-to-scalar-flat-entity.util';
@@ -16,6 +17,18 @@ export type DeriveMetadataEventsFromDeleteActionArgs = {
export const deriveMetadataEventsFromDeleteAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromDeleteActionArgs): MetadataEvent[] => {
const events = deriveAllMetadataEventsFromDeleteAction({
flatAction,
allFlatEntityMaps,
});
return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]);
};
const deriveAllMetadataEventsFromDeleteAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromDeleteActionArgs): MetadataEvent[] => {
switch (flatAction.metadataName) {
case 'fieldMetadata':
@@ -7,6 +7,7 @@ import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-m
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type';
import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { METADATA_EVENTS_TO_EMIT } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant';
import {
type CreateMetadataEvent,
type DeleteMetadataEvent,
@@ -61,6 +62,18 @@ const buildUpdateMetadataEvent = <TMetadataName extends AllMetadataName>({
export const deriveMetadataEventsFromUpdateAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromUpdateActionArgs): MetadataEvent[] => {
const events = deriveAllMetadataEventsFromUpdateAction({
flatAction,
allFlatEntityMaps,
});
return events.filter((event) => METADATA_EVENTS_TO_EMIT[event.metadataName]);
};
const deriveAllMetadataEventsFromUpdateAction = ({
flatAction,
allFlatEntityMaps,
}: DeriveMetadataEventsFromUpdateActionArgs): MetadataEvent[] => {
switch (flatAction.metadataName) {
case 'index': {