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,2 @@
export const METADATA_OPERATION_BROWSER_EVENT_NAME =
'metadata-operation-browser-event';
@@ -0,0 +1,53 @@
import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName';
import { type MetadataOperation } from '@/browser-event/types/MetadataOperation';
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useEffect } from 'react';
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
export const useListenToMetadataOperationBrowserEvent = <
T extends Record<string, unknown>,
>({
onMetadataOperationBrowserEvent,
metadataName,
operationTypes,
}: {
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<T>,
) => void;
metadataName?: AllMetadataName;
operationTypes?: MetadataOperation<T>['type'][];
}) => {
useEffect(() => {
const handleMetadataOperationEvent = (
event: CustomEvent<MetadataOperationBrowserEventDetail<T>>,
) => {
const detail = event.detail;
if (isDefined(metadataName) && detail.metadataName !== metadataName) {
return;
}
if (
isNonEmptyArray(operationTypes) &&
!operationTypes.includes(detail.operation.type)
) {
return;
}
onMetadataOperationBrowserEvent(detail);
};
window.addEventListener(
METADATA_OPERATION_BROWSER_EVENT_NAME,
handleMetadataOperationEvent as EventListener,
);
return () => {
window.removeEventListener(
METADATA_OPERATION_BROWSER_EVENT_NAME,
handleMetadataOperationEvent as EventListener,
);
};
}, [metadataName, onMetadataOperationBrowserEvent, operationTypes]);
};
@@ -1,6 +1,6 @@
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { useEffect } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
@@ -0,0 +1,14 @@
export type MetadataOperation<T extends Record<string, unknown>> =
| {
type: 'create';
createdRecord: T;
}
| {
type: 'update';
updatedRecord: T;
updatedFields?: string[];
}
| {
type: 'delete';
deletedRecordId: string;
};
@@ -0,0 +1,9 @@
import { type MetadataOperation } from '@/browser-event/types/MetadataOperation';
import { type AllMetadataName } from 'twenty-shared/metadata';
export type MetadataOperationBrowserEventDetail<
T extends Record<string, unknown>,
> = {
metadataName: AllMetadataName;
operation: MetadataOperation<T>;
};
@@ -0,0 +1,14 @@
import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName';
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
export const dispatchMetadataOperationBrowserEvent = <
T extends Record<string, unknown>,
>(
detail: MetadataOperationBrowserEventDetail<T>,
) => {
window.dispatchEvent(
new CustomEvent(METADATA_OPERATION_BROWSER_EVENT_NAME, {
detail,
}),
);
};
@@ -1,5 +1,5 @@
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
export const dispatchObjectRecordOperationBrowserEvent = (
detail: ObjectRecordOperationBrowserEventDetail,
@@ -1,5 +1,6 @@
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
@@ -21,8 +22,6 @@ export const FrontComponentRenderer = ({
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext();
const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
const handleError = useCallback(
(error?: Error) => {
if (!isDefined(error)) {
@@ -43,6 +42,15 @@ export const FrontComponentRenderer = ({
onError: handleError,
});
useOnFrontComponentUpdated({
frontComponentId,
});
const componentUrl = getFrontComponentUrl({
frontComponentId,
checksum: data?.frontComponent?.builtComponentChecksum,
});
if (
loading ||
!isDefined(data?.frontComponent) ||
@@ -6,6 +6,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
id
name
applicationId
builtComponentChecksum
applicationTokenPair {
applicationAccessToken {
token
@@ -0,0 +1,37 @@
import { useUpdateFrontComponentApolloCache } from '@/front-components/hooks/useUpdateFrontComponentApolloCache';
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import {
AllMetadataName,
type FrontComponent,
} from '~/generated-metadata/graphql';
type UseOnFrontComponentUpdatedArgs = {
frontComponentId: string;
};
export const useOnFrontComponentUpdated = ({
frontComponentId,
}: UseOnFrontComponentUpdatedArgs) => {
const queryId = `front-component-updated-${frontComponentId}`;
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.frontComponent,
variables: {
filter: { id: { eq: frontComponentId } },
},
},
});
const { updateFrontComponentApolloCache } =
useUpdateFrontComponentApolloCache({
frontComponentId,
});
useListenToMetadataOperationBrowserEvent<FrontComponent>({
metadataName: AllMetadataName.frontComponent,
onMetadataOperationBrowserEvent: updateFrontComponentApolloCache,
});
};
@@ -0,0 +1,54 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import {
FindOneFrontComponentDocument,
type FindOneFrontComponentQuery,
type FrontComponent,
} from '~/generated-metadata/graphql';
type UseUpdateFrontComponentApolloCacheArgs = {
frontComponentId: string;
};
export const useUpdateFrontComponentApolloCache = ({
frontComponentId,
}: UseUpdateFrontComponentApolloCacheArgs) => {
const apolloClient = useApolloClient();
const updateFrontComponentApolloCache = (
detail: MetadataOperationBrowserEventDetail<FrontComponent>,
) => {
if (detail.operation.type !== 'update') {
return;
}
const { updatedRecord } = detail.operation;
if (!isDefined(updatedRecord) || updatedRecord.id !== frontComponentId) {
return;
}
apolloClient.cache.updateQuery<FindOneFrontComponentQuery>(
{
query: FindOneFrontComponentDocument,
variables: { id: frontComponentId },
},
(existingData) => {
if (!isDefined(existingData?.frontComponent)) {
return existingData;
}
return {
...existingData,
frontComponent: {
...existingData.frontComponent,
...updatedRecord,
},
};
},
);
};
return { updateFrontComponentApolloCache };
};
@@ -0,0 +1,14 @@
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { isDefined } from 'twenty-shared/utils';
export const getFrontComponentUrl = ({
frontComponentId,
checksum,
}: {
frontComponentId: string;
checksum?: string;
}): string => {
return isDefined(checksum)
? `${REST_API_BASE_URL}/front-components/${frontComponentId}?checksum=${checksum}`
: `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
};
@@ -2,11 +2,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { renderHook } from '@testing-library/react';
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
jest.mock('@/object-record/utils/dispatchObjectRecordOperationBrowserEvent');
jest.mock('@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent');
jest.mock('@/object-record/hooks/useUpdateManyRecords', () => ({
useUpdateManyRecords: jest.fn(),
}));
@@ -7,7 +7,7 @@ import {
} from '@/object-record/hooks/useCreateManyRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
@@ -19,7 +19,7 @@ import { type FieldActorForInputValue } from '@/object-record/record-field/ui/ty
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
@@ -26,7 +26,7 @@ import {
import { type BaseObjectRecord } from '@/object-record/types/BaseObjectRecord';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
@@ -15,7 +15,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
@@ -14,7 +14,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
@@ -11,7 +11,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@@ -9,7 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -16,7 +16,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -5,7 +5,7 @@ import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIn
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
@@ -8,7 +8,7 @@ import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQue
import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
import { getOperationName } from '@apollo/client/utilities';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
@@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@@ -1,4 +1,4 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/useRemoveRecordsFromBoard';
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
@@ -7,7 +7,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useRecoilCallback } from 'recoil';
@@ -3,7 +3,7 @@ import { useContext } from 'react';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
export const RecordBoardSSESubscribeEffect = () => {
const { recordBoardId } = useContext(RecordBoardContext);
@@ -13,7 +13,7 @@ export const RecordBoardSSESubscribeEffect = () => {
const queryId = `record-board-${recordBoardId}`;
useListenToObjectRecordEventsForQuery({
useListenToEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: objectMetadataItem.nameSingular,
@@ -3,7 +3,7 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar
import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
@@ -32,7 +32,7 @@ export const RecordCalendarSSESubscribeEffect = () => {
const queryId = `record-calendar-${recordCalendarId}`;
useListenToObjectRecordEventsForQuery({
useListenToEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: objectMetadataItem.nameSingular,
@@ -1,4 +1,4 @@
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
type RecordShowPageSSESubscribeEffectProps = {
objectNameSingular: string;
@@ -11,7 +11,7 @@ export const RecordShowPageSSESubscribeEffect = ({
}: RecordShowPageSSESubscribeEffectProps) => {
const queryId = `record-show-${objectNameSingular}-${recordId}`;
useListenToObjectRecordEventsForQuery({
useListenToEventsForQuery({
queryId,
operationSignature: {
objectNameSingular,
@@ -1,10 +1,10 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION } from '@/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition';
import { useGetShouldResetTableVirtualizationForUpdateInputs } from '@/object-record/record-table/virtualization/hooks/useGetShouldResetTableVirtualizationForUpdateInputs';
import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { useDebouncedCallback } from 'use-debounce';
export const RecordTableVirtualizedDataChangedEffect = () => {
@@ -4,7 +4,7 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
@@ -26,7 +26,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
useListenToObjectRecordEventsForQuery({
useListenToEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: objectMetadataItem.nameSingular,
@@ -37,11 +37,11 @@ export const SSEEventStreamEffect = () => {
const { triggerEventStreamDestroy } = useTriggerEventStreamDestroy();
useEffect(() => {
const isSseClientAvailabble =
const isSseClientAvailable =
!isCreatingSseEventStream && !isDestroyingEventStream;
const willCreateEventStream =
isSseClientAvailabble &&
isSseClientAvailable &&
isLoggedIn &&
isSseDbEventsEnabled &&
isDefined(currentUser) &&
@@ -51,7 +51,7 @@ export const SSEEventStreamEffect = () => {
isNonEmptyArray(objectMetadataItems);
const willDestroyEventStream =
isSseClientAvailabble &&
isSseClientAvailable &&
isNonEmptyString(sseEventStreamId) &&
shouldDestroyEventStream;
@@ -4,8 +4,8 @@ export const ON_EVENT_SUBSCRIPTION = gql`
subscription OnEventSubscription($eventStreamId: String!) {
onEventSubscription(eventStreamId: $eventStreamId) {
eventStreamId
eventWithQueryIdsList {
event {
objectRecordEventsWithQueryIds {
objectRecordEvent {
action
objectNameSingular
recordId
@@ -20,6 +20,20 @@ export const ON_EVENT_SUBSCRIPTION = gql`
}
queryIds
}
metadataEventsWithQueryIds {
metadataEvent {
type
metadataName
recordId
properties {
updatedFields
before
after
diff
}
}
queryIds
}
}
}
`;
@@ -0,0 +1,54 @@
import { dispatchMetadataOperationBrowserEvent } from '@/browser-event/utils/dispatchMetadataOperationBrowserEvent';
import { turnSseMetadataEventsToMetadataOperationBrowserEvents } from '@/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents';
import { useCallback } from 'react';
import {
type AllMetadataName,
type MetadataEvent,
type MetadataEventWithQueryIds,
} from '~/generated-metadata/graphql';
const groupSseMetadataEventsByMetadataName = (
sseMetadataEvents: MetadataEvent[],
): Map<AllMetadataName, MetadataEvent[]> => {
const eventsByMetadataName = new Map<AllMetadataName, MetadataEvent[]>();
for (const event of sseMetadataEvents) {
const metadataName = event.metadataName as AllMetadataName;
const existing = eventsByMetadataName.get(metadataName) ?? [];
eventsByMetadataName.set(metadataName, [...existing, event]);
}
return eventsByMetadataName;
};
export const useDispatchMetadataEventsFromSseToBrowserEvents = <
T extends Record<string, unknown>,
>() => {
const dispatchMetadataEventsFromSseToBrowserEvents = useCallback(
(metadataEventsWithQueryIds: MetadataEventWithQueryIds[]) => {
const sseMetadataEvents = metadataEventsWithQueryIds.map(
(item) => item.metadataEvent,
);
const eventsByMetadataName =
groupSseMetadataEventsByMetadataName(sseMetadataEvents);
for (const [metadataName, events] of eventsByMetadataName) {
const metadataOperationBrowserEvents =
turnSseMetadataEventsToMetadataOperationBrowserEvents<T>({
metadataName,
sseMetadataEvents: events,
});
for (const browserEvent of metadataOperationBrowserEvents) {
dispatchMetadataOperationBrowserEvent(browserEvent);
}
}
},
[],
);
return { dispatchMetadataEventsFromSseToBrowserEvents };
};
@@ -1,19 +1,19 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
import { turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents } from '@/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type EventWithQueryIds } from '~/generated-metadata/graphql';
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback(
(eventsWithQueryIds: EventWithQueryIds[]) => {
const objectRecordEvents = eventsWithQueryIds.map((eventWithQueryIds) => {
return eventWithQueryIds.event;
});
(objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]) => {
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
(item) => item.objectRecordEvent,
);
const objectRecordEventsByObjectMetadataItemNameSingular =
groupObjectRecordSseEventsByObjectMetadataItemNameSingular({
@@ -2,14 +2,19 @@ import { requiredQueryListenersState } from '@/sse-db-event/states/requiredQuery
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import {
type MetadataGqlOperationSignature,
type RecordGqlOperationSignature,
} from 'twenty-shared/types';
export const useListenToObjectRecordEventsForQuery = ({
export const useListenToEventsForQuery = ({
queryId,
operationSignature,
}: {
queryId: string;
operationSignature: RecordGqlOperationSignature;
operationSignature:
| RecordGqlOperationSignature
| MetadataGqlOperationSignature;
}) => {
const changeQueryIdListenState = useRecoilCallback(
({ set, snapshot }) =>
@@ -1,4 +1,5 @@
import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription';
import { useDispatchMetadataEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents';
import { useDispatchObjectRecordEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents';
import { useTriggerOptimisticEffectFromSseEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents';
import { disposeFunctionForEventStreamState } from '@/sse-db-event/states/disposeFunctionByEventStreamMapState';
@@ -23,6 +24,9 @@ export const useTriggerEventStreamCreation = () => {
isCreatingSseEventStreamState,
);
const { dispatchMetadataEventsFromSseToBrowserEvents } =
useDispatchMetadataEventsFromSseToBrowserEvents();
const { dispatchObjectRecordEventsFromSseToBrowserEvents } =
useDispatchObjectRecordEventsFromSseToBrowserEvents();
@@ -73,9 +77,55 @@ export const useTriggerEventStreamCreation = () => {
},
},
{
next: (
value: ExecutionResult<{
onEventSubscription: EventSubscription;
}>,
) => {
if (isDefined(value?.errors)) {
captureException(
new Error(
`SSE subscription error: ${value.errors[0]?.message}`,
),
);
set(shouldDestroyEventStreamState, true);
return;
}
if (!hasReceivedFirstEvent) {
hasReceivedFirstEvent = true;
set(sseEventStreamReadyState, true);
}
const eventSubscription = value?.data?.onEventSubscription;
const objectRecordEventsWithQueryIds =
eventSubscription?.objectRecordEventsWithQueryIds ?? [];
const metadataEventsWithQueryIds =
eventSubscription?.metadataEventsWithQueryIds ?? [];
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
(item) => item.objectRecordEvent,
);
triggerOptimisticEffectFromSseEvents({
objectRecordEvents,
});
dispatchObjectRecordEventsFromSseToBrowserEvents(
objectRecordEventsWithQueryIds,
);
dispatchMetadataEventsFromSseToBrowserEvents(
metadataEventsWithQueryIds,
);
},
error: (error) => {
captureException(error);
},
complete: () => {},
error: () => {},
next: () => {},
},
{
message: ({ data, event }) => {
@@ -109,12 +159,12 @@ export const useTriggerEventStreamCreation = () => {
const objectRecordEventsWithQueryIds =
result?.data?.onEventSubscription
?.eventWithQueryIdsList ?? [];
?.objectRecordEventsWithQueryIds ?? [];
const objectRecordEvents =
objectRecordEventsWithQueryIds.map(
(eventWithQueryIds) => {
return eventWithQueryIds.event;
(objectRecordEventWithQueryIds) => {
return objectRecordEventWithQueryIds.objectRecordEvent;
},
);
@@ -144,9 +194,9 @@ export const useTriggerEventStreamCreation = () => {
setIsCreatingSseEventStream(false);
},
[
dispatchMetadataEventsFromSseToBrowserEvents,
dispatchObjectRecordEventsFromSseToBrowserEvents,
setIsCreatingSseEventStream,
triggerOptimisticEffectFromSseEvents,
],
);
@@ -1,8 +1,16 @@
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { createState } from '@/ui/utilities/state/utils/createState';
import {
type MetadataGqlOperationSignature,
type RecordGqlOperationSignature,
} from 'twenty-shared/types';
export const activeQueryListenersState = createState<
{ queryId: string; operationSignature: RecordGqlOperationSignature }[]
{
queryId: string;
operationSignature:
| RecordGqlOperationSignature
| MetadataGqlOperationSignature;
}[]
>({
key: 'activeQueryListenersState',
defaultValue: [],
@@ -1,8 +1,16 @@
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { createState } from '@/ui/utilities/state/utils/createState';
import {
type MetadataGqlOperationSignature,
type RecordGqlOperationSignature,
} from 'twenty-shared/types';
export const requiredQueryListenersState = createState<
{ queryId: string; operationSignature: RecordGqlOperationSignature }[]
{
queryId: string;
operationSignature:
| RecordGqlOperationSignature
| MetadataGqlOperationSignature;
}[]
>({
key: 'requiredQueryListenersState',
defaultValue: [],
@@ -1,22 +1,20 @@
import { type ObjectRecordEventsByQueryId } from '@/sse-db-event/types/ObjectRecordEventsByQueryId';
import { getObjectRecordEventsForQueryEventName } from '@/sse-db-event/utils/getObjectRecordEventsForQueryEventName';
import { isDefined } from 'twenty-shared/utils';
import { type EventWithQueryIds } from '~/generated-metadata/graphql';
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
export const dispatchObjectRecordEventsWithQueryIds = (
objectRecordEventsWithQueryIds: EventWithQueryIds[],
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[],
) => {
const objectRecordEventsByQueryId: ObjectRecordEventsByQueryId = {};
for (const objectRecordEventWithQueryIds of objectRecordEventsWithQueryIds) {
for (const queryId of objectRecordEventWithQueryIds.queryIds) {
for (const item of objectRecordEventsWithQueryIds) {
for (const queryId of item.queryIds) {
if (!isDefined(objectRecordEventsByQueryId[queryId])) {
objectRecordEventsByQueryId[queryId] = [];
}
objectRecordEventsByQueryId[queryId].push(
objectRecordEventWithQueryIds.event,
);
objectRecordEventsByQueryId[queryId].push(item.objectRecordEvent);
}
}
@@ -0,0 +1,66 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { isDefined } from 'twenty-shared/utils';
import {
MetadataEventAction,
type AllMetadataName,
type MetadataEvent,
} from '~/generated-metadata/graphql';
export const turnSseMetadataEventsToMetadataOperationBrowserEvents = <
T extends Record<string, unknown>,
>({
metadataName,
sseMetadataEvents,
}: {
metadataName: AllMetadataName;
sseMetadataEvents: MetadataEvent[];
}): MetadataOperationBrowserEventDetail<T>[] => {
return sseMetadataEvents
.map((event): MetadataOperationBrowserEventDetail<T> | null => {
switch (event.type) {
case MetadataEventAction.CREATED: {
const createdRecord = event.properties.after;
if (!isDefined(createdRecord)) {
return null;
}
return {
metadataName,
operation: {
type: 'create',
createdRecord,
},
};
}
case MetadataEventAction.UPDATED: {
const updatedRecord = event.properties.after;
if (!isDefined(updatedRecord)) {
return null;
}
return {
metadataName,
operation: {
type: 'update',
updatedRecord,
updatedFields: event.properties.updatedFields ?? undefined,
},
};
}
case MetadataEventAction.DELETED: {
return {
metadataName,
operation: {
type: 'delete',
deletedRecordId: event.recordId,
},
};
}
default:
return null;
}
})
.filter(isDefined);
};
@@ -1,5 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { getObjectRecordOperationUpdateInputs } from '@/sse-db-event/utils/getObjectRecordOperationUpdateInputs';
import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
@@ -1,7 +1,7 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { useStopWorkflowRunMutation } from '~/generated/graphql';
export const useStopWorkflowRun = () => {
@@ -1,5 +1,5 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
export const WorkflowRunSSESubscribeEffect = ({
workflowRunId,
@@ -8,7 +8,7 @@ export const WorkflowRunSSESubscribeEffect = ({
}) => {
const queryId = `workflow-run-${workflowRunId}`;
useListenToObjectRecordEventsForQuery({
useListenToEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
@@ -3,8 +3,8 @@ import { useSetRecoilState } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { shouldWorkflowRefetchRequestFamilyState } from '@/workflow/states/shouldWorkflowRefetchRequestFamilyState';
export const WorkflowSSESubscribeEffect = ({
@@ -23,7 +23,7 @@ export const WorkflowSSESubscribeEffect = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
useListenToObjectRecordEventsForQuery({
useListenToEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,