diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem.ts
new file mode 100644
index 0000000000..c132d841ca
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem.ts
@@ -0,0 +1,26 @@
+import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
+import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+import { getGroupByAggregateQueryName } from '@/object-record/record-aggregate/utils/getGroupByAggregateQueryName';
+import { getAggregateQueryName } from '@/object-record/utils/getAggregateQueryName';
+
+export const useRefetchAggregateQueriesForObjectMetadataItem = () => {
+ const apolloCoreClient = useApolloCoreClient();
+
+ const refetchAggregateQueriesForObjectMetadataItem = async ({
+ objectMetadataItem,
+ }: {
+ objectMetadataItem: ObjectMetadataItem;
+ }) => {
+ const queryName = getAggregateQueryName(objectMetadataItem.namePlural);
+ const groupByAggregateQueryName = getGroupByAggregateQueryName({
+ objectMetadataNamePlural: objectMetadataItem.namePlural,
+ });
+ await apolloCoreClient.refetchQueries({
+ include: [queryName, groupByAggregateQueryName],
+ });
+ };
+
+ return {
+ refetchAggregateQueriesForObjectMetadataItem,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx
new file mode 100644
index 0000000000..95f133260b
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx
@@ -0,0 +1,46 @@
+import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
+import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
+import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
+import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
+import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
+
+export const RecordBoardDataChangedEffect = () => {
+ const { objectMetadataItem } = useRecordIndexContextOrThrow();
+ const { triggerRecordBoardInitialQuery } =
+ useTriggerRecordBoardInitialQuery();
+ const { getShouldInitializeRecordBoardForUpdateInputs } =
+ useGetShouldInitializeRecordBoardForUpdateInputs();
+
+ const handleObjectRecordOperation = (
+ objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
+ ) => {
+ const objectRecordOperation = objectRecordOperationEventDetail.operation;
+
+ const isUpdateOperation =
+ objectRecordOperation.type === 'update-one' ||
+ objectRecordOperation.type === 'update-many';
+
+ if (isUpdateOperation) {
+ const updateInputs =
+ objectRecordOperation.type === 'update-one'
+ ? [objectRecordOperation.result.updateInput]
+ : objectRecordOperation.result.updateInputs;
+
+ const shouldInitializeForUpdateOperation =
+ getShouldInitializeRecordBoardForUpdateInputs(updateInputs);
+
+ if (shouldInitializeForUpdateOperation) {
+ triggerRecordBoardInitialQuery();
+ }
+ } else {
+ triggerRecordBoardInitialQuery();
+ }
+ };
+
+ useListenToObjectRecordOperationBrowserEvent({
+ onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,
+ objectMetadataItemId: objectMetadataItem.id,
+ });
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx
index 87c82fa808..16e3a0ffcc 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx
@@ -1,7 +1,9 @@
import { RecordBoardClickOutsideEffect } from '@/object-record/record-board/components/RecordBoardClickOutsideEffect';
+import { RecordBoardDataChangedEffect } from '@/object-record/record-board/components/RecordBoardDataChangedEffect';
import { RecordBoardQueryEffect } from '@/object-record/record-board/components/RecordBoardQueryEffect';
import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect';
import { RecordBoardSelectRecordsEffect } from '@/object-record/record-board/components/RecordBoardSelectRecordsEffect';
+import { RecordBoardSSESubscribeEffect } from '@/object-record/record-board/components/RecordBoardSSESubscribeEffect';
import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect';
import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect';
@@ -11,6 +13,8 @@ export const RecordBoardEffects = () => {
+
+
diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx
new file mode 100644
index 0000000000..f9022387db
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSSESubscribeEffect.tsx
@@ -0,0 +1,28 @@
+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';
+
+export const RecordBoardSSESubscribeEffect = () => {
+ const { recordBoardId } = useContext(RecordBoardContext);
+ const { objectMetadataItem } = useRecordIndexContextOrThrow();
+ const { combinedFilters, orderBy } =
+ useRecordIndexGroupCommonQueryVariables();
+
+ const queryId = `record-board-${recordBoardId}`;
+
+ useListenToObjectRecordEventsForQuery({
+ queryId,
+ operationSignature: {
+ objectNameSingular: objectMetadataItem.nameSingular,
+ variables: {
+ filter: combinedFilters,
+ orderBy,
+ },
+ },
+ });
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs.ts
new file mode 100644
index 0000000000..00f80566c8
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs.ts
@@ -0,0 +1,100 @@
+import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
+import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
+import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
+import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
+import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
+import { type ObjectRecordOperationUpdateInput } from '@/object-record/types/ObjectRecordOperationUpdateInput';
+import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { isDefined, mapById } from 'twenty-shared/utils';
+
+export const useGetShouldInitializeRecordBoardForUpdateInputs = () => {
+ const { objectMetadataItem } = useRecordIndexContextOrThrow();
+
+ const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
+ objectMetadataItem,
+ });
+
+ const currentRecordSorts = useRecoilComponentValue(
+ currentRecordSortsComponentState,
+ );
+
+ const currentRecordFilters = useRecoilComponentValue(
+ currentRecordFiltersComponentState,
+ );
+
+ const recordIndexGroupFieldMetadataItem = useRecoilComponentValue(
+ recordIndexGroupFieldMetadataItemComponentState,
+ );
+
+ const getShouldInitializeRecordBoardForUpdateInputs = (
+ updateInputs: ObjectRecordOperationUpdateInput[],
+ ) => {
+ const updatedFieldNames = new Set();
+ let thereIsAnUpdateOnAFilteredField = false;
+ let thereIsAnUpdateOnASortedField = false;
+ let thereIsAnUpdateOnAGroupField = false;
+
+ for (const updateInput of updateInputs) {
+ const fieldNamesForUpdateInput = updateInput.updatedFields.flatMap(
+ (updatedField) => Object.keys(updatedField ?? {}),
+ );
+
+ for (const fieldName of fieldNamesForUpdateInput) {
+ updatedFieldNames.add(fieldName);
+ }
+
+ const updatedFieldMetadataItems = activeFieldMetadataItems.filter(
+ (fieldMetadataItemToFilter) =>
+ fieldNamesForUpdateInput.includes(fieldMetadataItemToFilter.name) ||
+ (fieldMetadataItemToFilter.type === FieldMetadataType.RELATION &&
+ fieldNamesForUpdateInput.includes(
+ `${fieldMetadataItemToFilter.name}Id`,
+ )),
+ );
+
+ const updatedFieldMetadataItemIds =
+ updatedFieldMetadataItems.map(mapById);
+
+ const updateOnAFilteredField = currentRecordFilters.some((recordFilter) =>
+ updatedFieldMetadataItemIds.includes(recordFilter.fieldMetadataId),
+ );
+
+ const updateOnASortedField = currentRecordSorts.some((recordSort) =>
+ updatedFieldMetadataItemIds.includes(recordSort.fieldMetadataId),
+ );
+
+ if (isDefined(recordIndexGroupFieldMetadataItem)) {
+ if (
+ updatedFieldMetadataItemIds.includes(
+ recordIndexGroupFieldMetadataItem.id,
+ )
+ ) {
+ thereIsAnUpdateOnAGroupField = true;
+ }
+ }
+
+ if (updateOnAFilteredField) {
+ thereIsAnUpdateOnAFilteredField = true;
+ }
+
+ if (updateOnASortedField) {
+ thereIsAnUpdateOnASortedField = true;
+ }
+ }
+
+ if (updatedFieldNames.has('position')) {
+ return true;
+ }
+
+ return (
+ thereIsAnUpdateOnAFilteredField ||
+ thereIsAnUpdateOnASortedField ||
+ thereIsAnUpdateOnAGroupField
+ );
+ };
+
+ return {
+ getShouldInitializeRecordBoardForUpdateInputs,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx
new file mode 100644
index 0000000000..d2e4c78ea1
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect.tsx
@@ -0,0 +1,47 @@
+import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
+import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
+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 { 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';
+
+export const RecordCalendarSSESubscribeEffect = () => {
+ const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
+ RecordCalendarComponentInstanceContext,
+ );
+ const { objectMetadataItem } = useRecordCalendarContextOrThrow();
+ const recordCalendarSelectedDate = useRecoilComponentValue(
+ recordCalendarSelectedDateComponentState,
+ );
+ const { dateRangeFilter } = useRecordCalendarQueryDateRangeFilter(
+ recordCalendarSelectedDate,
+ );
+
+ const orderBy: RecordGqlOperationOrderBy =
+ !objectMetadataItem.isRemote &&
+ hasObjectMetadataItemPositionField(objectMetadataItem)
+ ? [
+ {
+ position: 'AscNullsFirst',
+ },
+ ]
+ : [];
+
+ const queryId = `record-calendar-${recordCalendarId}`;
+
+ useListenToObjectRecordEventsForQuery({
+ queryId,
+ operationSignature: {
+ objectNameSingular: objectMetadataItem.nameSingular,
+ variables: {
+ filter: dateRangeFilter,
+ orderBy,
+ },
+ },
+ });
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx
index ba77adc3b1..b9ed6e063d 100644
--- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx
@@ -2,6 +2,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
+import { RecordCalendarSSESubscribeEffect } from '@/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect';
import { RecordIndexCalendarDataLoaderEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect';
import { RecordIndexCalendarSelectedDateInitEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect';
import { RecordCalendarContextProvider } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
@@ -52,6 +53,7 @@ export const RecordIndexCalendarContainer = ({
}}
>
+
diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx
new file mode 100644
index 0000000000..0542c00e51
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowPageSSESubscribeEffect.tsx
@@ -0,0 +1,26 @@
+import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
+
+type RecordShowPageSSESubscribeEffectProps = {
+ objectNameSingular: string;
+ recordId: string;
+};
+
+export const RecordShowPageSSESubscribeEffect = ({
+ objectNameSingular,
+ recordId,
+}: RecordShowPageSSESubscribeEffectProps) => {
+ const queryId = `record-show-${objectNameSingular}-${recordId}`;
+
+ useListenToObjectRecordEventsForQuery({
+ queryId,
+ operationSignature: {
+ objectNameSingular,
+ variables: {
+ filter: { id: { eq: recordId } },
+ limit: 1,
+ },
+ },
+ });
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx
index 7a67457936..517ee62867 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx
@@ -9,6 +9,7 @@ import { RecordTableCellPortals } from '@/object-record/record-table/record-tabl
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { RecordTableVirtualizedDataChangedEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect';
+import { RecordTableVirtualizedSSESubscribeEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect';
import { RecordTableVirtualizedRowTreadmillEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -38,6 +39,7 @@ export const RecordTableNoRecordGroupBody = () => {
)}
+
);
diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx
new file mode 100644
index 0000000000..771f6f14df
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedSSESubscribeEffect.tsx
@@ -0,0 +1,46 @@
+import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
+import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
+import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
+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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
+import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
+
+export const RecordTableVirtualizedSSESubscribeEffect = () => {
+ const { objectMetadataItem } = useRecordIndexContextOrThrow();
+ const { filterValueDependencies } = useFilterValueDependencies();
+
+ const currentRecordFilters = useRecoilComponentValue(
+ currentRecordFiltersComponentState,
+ );
+
+ const currentRecordSorts = useRecoilComponentValue(
+ currentRecordSortsComponentState,
+ );
+
+ const currentRecordFilterGroups = useRecoilComponentValue(
+ currentRecordFilterGroupsComponentState,
+ );
+
+ const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
+
+ useListenToObjectRecordEventsForQuery({
+ queryId,
+ operationSignature: {
+ objectNameSingular: objectMetadataItem.nameSingular,
+ variables: {
+ filter: computeRecordGqlOperationFilter({
+ fields: objectMetadataItem.fields,
+ recordFilters: currentRecordFilters,
+ recordFilterGroups: currentRecordFilterGroups,
+ filterValueDependencies,
+ }),
+ orderBy: turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts),
+ },
+ },
+ });
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx
new file mode 100644
index 0000000000..2b7c7441ae
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx
@@ -0,0 +1,22 @@
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { SseClientContext } from '@/sse-db-event/contexts/SseClientContext';
+import { useSubscribeToSseEventStream } from '@/sse-db-event/hooks/useSubscribeToSseEventStream';
+import { useContext, useEffect } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+
+export const SSEEventStreamEffect = () => {
+ const sseClient = useContext(SseClientContext);
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const { subscribeToSseEventStream } = useSubscribeToSseEventStream();
+
+ useEffect(() => {
+ if (!isDefined(sseClient) || objectMetadataItems.length === 0) {
+ return;
+ }
+
+ subscribeToSseEventStream(sseClient);
+ }, [sseClient, subscribeToSseEventStream, objectMetadataItems]);
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx
index 0e0dbab789..74e8738af0 100644
--- a/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx
+++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx
@@ -1,4 +1,4 @@
-import { SSEProviderEffect } from '@/sse-db-event/components/SSEProviderEffect';
+import { SSEEventStreamEffect } from '@/sse-db-event/components/SSEEventStreamEffect';
import { SSEQuerySubscribeEffect } from '@/sse-db-event/components/SSEQuerySubscribeEffect';
import { SseClientContext } from '@/sse-db-event/contexts/SseClientContext';
import { useSseClient } from '@/sse-db-event/hooks/useSseClient.util';
@@ -26,7 +26,7 @@ export const SSEProvider = ({ children }: SSEProviderProps) => {
return (
-
+
{children}
diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEProviderEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEProviderEffect.tsx
deleted file mode 100644
index b9806d08f1..0000000000
--- a/packages/twenty-front/src/modules/sse-db-event/components/SSEProviderEffect.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { SseClientContext } from '@/sse-db-event/contexts/SseClientContext';
-import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription';
-import { sseEventStreamIdState } from '@/sse-db-event/states/sseEventStreamIdState';
-import { dispatchObjectRecordEventsWithQueryIds } from '@/sse-db-event/utils/dispatchObjectRecordEventsWithQueryIds';
-import { isNonEmptyString } from '@sniptt/guards';
-import { type ExecutionResult, print } from 'graphql';
-import { useContext, useEffect } from 'react';
-import { useRecoilState } from 'recoil';
-import { isDefined } from 'twenty-shared/utils';
-import { v4 } from 'uuid';
-import { type EventSubscription } from '~/generated/graphql';
-
-export const SSEProviderEffect = () => {
- const sseClient = useContext(SseClientContext);
-
- const [sseEventStreamId, setSseEventStreamId] = useRecoilState(
- sseEventStreamIdState,
- );
-
- useEffect(() => {
- if (!isDefined(sseClient)) {
- return;
- }
-
- if (!isNonEmptyString(sseEventStreamId)) {
- setSseEventStreamId(v4());
- return;
- }
-
- const unsubscribe = sseClient.subscribe(
- {
- query: print(ON_EVENT_SUBSCRIPTION),
- variables: {
- eventStreamId: sseEventStreamId,
- },
- },
- {
- next: (
- value: ExecutionResult<{ onEventSubscription: EventSubscription }>,
- ) => {
- const objectRecordEventsWithQueryIds =
- value?.data?.onEventSubscription?.eventWithQueryIdsList ?? [];
-
- dispatchObjectRecordEventsWithQueryIds(
- objectRecordEventsWithQueryIds,
- );
- },
- error: (error) => {
- // eslint-disable-next-line no-console
- console.error('Subscription error:', error);
- },
- complete: () => {},
- },
- );
-
- return () => {
- unsubscribe();
- };
- }, [sseClient, sseEventStreamId, setSseEventStreamId]);
-
- return null;
-};
diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx
index a75cc51f27..1b8bdc70f8 100644
--- a/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx
+++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx
@@ -6,6 +6,7 @@ import { requiredQueryListenersState } from '@/sse-db-event/states/requiredQuery
import { sseEventStreamIdState } from '@/sse-db-event/states/sseEventStreamIdState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useMutation } from '@apollo/client';
+import { isNonEmptyString } from '@sniptt/guards';
import { useEffect } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import {
@@ -103,7 +104,7 @@ export const SSEQuerySubscribeEffect = () => {
);
useEffect(() => {
- if (!sseEventStreamId) {
+ if (!isNonEmptyString(sseEventStreamId)) {
return;
}
diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts
new file mode 100644
index 0000000000..32e7c863f7
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents.ts
@@ -0,0 +1,56 @@
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/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/graphql';
+
+export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback(
+ (eventsWithQueryIds: EventWithQueryIds[]) => {
+ const objectRecordEvents = eventsWithQueryIds.map((eventWithQueryIds) => {
+ return eventWithQueryIds.event;
+ });
+
+ const objectRecordEventsByObjectMetadataItemNameSingular =
+ groupObjectRecordSseEventsByObjectMetadataItemNameSingular({
+ objectRecordEvents,
+ });
+
+ const objectMetadataItemNamesSingular = Array.from(
+ objectRecordEventsByObjectMetadataItemNameSingular.keys(),
+ );
+
+ for (const objectMetadataItemNameSingular of objectMetadataItemNamesSingular) {
+ const objectRecordEventsForThisObjectMetadataItem =
+ objectRecordEventsByObjectMetadataItemNameSingular.get(
+ objectMetadataItemNameSingular,
+ ) ?? [];
+
+ const objectMetadataItem = objectMetadataItems.find((metadataItem) => {
+ return metadataItem.nameSingular === objectMetadataItemNameSingular;
+ });
+
+ if (!isDefined(objectMetadataItem)) {
+ continue;
+ }
+
+ const objectRecordOperationBrowserEvents =
+ turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents({
+ objectMetadataItem,
+ objectRecordEvents: objectRecordEventsForThisObjectMetadataItem,
+ });
+
+ for (const browserEvent of objectRecordOperationBrowserEvents) {
+ dispatchObjectRecordOperationBrowserEvent(browserEvent);
+ }
+ }
+ },
+ [objectMetadataItems],
+ );
+
+ return { dispatchObjectRecordEventsFromSseToBrowserEvents };
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts
index 52223b53e2..0d121223d1 100644
--- a/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts
+++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useListenToObjectRecordEventsForQuery.ts
@@ -1,37 +1,16 @@
import { requiredQueryListenersState } from '@/sse-db-event/states/requiredQueryListenersState';
-import { getObjectRecordEventsForQueryEventName } from '@/sse-db-event/utils/getObjectRecordEventsForQueryEventName';
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 ObjectRecordEvent } from '~/generated/graphql';
export const useListenToObjectRecordEventsForQuery = ({
queryId,
operationSignature,
- onObjectRecordEvents,
}: {
queryId: string;
operationSignature: RecordGqlOperationSignature;
- onObjectRecordEvents: (objectRecordEvents: ObjectRecordEvent[]) => void;
}) => {
- useEffect(() => {
- const eventName = getObjectRecordEventsForQueryEventName(queryId);
-
- const handleOnObjectRecordEventsForQuery = (event: Event) => {
- const objectRecordEvents = (event as CustomEvent)
- .detail;
-
- onObjectRecordEvents(objectRecordEvents);
- };
-
- window.addEventListener(eventName, handleOnObjectRecordEventsForQuery);
-
- return () => {
- window.removeEventListener(eventName, handleOnObjectRecordEventsForQuery);
- };
- }, [onObjectRecordEvents, queryId]);
-
const changeQueryIdListenState = useRecoilCallback(
({ set, snapshot }) =>
(shouldListen: boolean, queryId: string) => {
diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useSubscribeToSseEventStream.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useSubscribeToSseEventStream.ts
new file mode 100644
index 0000000000..bb37966bc2
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useSubscribeToSseEventStream.ts
@@ -0,0 +1,83 @@
+import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription';
+import { useDispatchObjectRecordEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents';
+import { useTriggerOptimisticEffectFromSseEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents';
+import { sseEventStreamIdState } from '@/sse-db-event/states/sseEventStreamIdState';
+import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
+import { captureException } from '@sentry/react';
+import { isNonEmptyString } from '@sniptt/guards';
+import { print, type ExecutionResult } from 'graphql';
+import { type Client } from 'graphql-sse';
+import { useRecoilCallback } from 'recoil';
+import { v4 } from 'uuid';
+import { type EventSubscription } from '~/generated/graphql';
+
+export const useSubscribeToSseEventStream = () => {
+ const { dispatchObjectRecordEventsFromSseToBrowserEvents } =
+ useDispatchObjectRecordEventsFromSseToBrowserEvents();
+
+ const { triggerOptimisticEffectFromSseEvents } =
+ useTriggerOptimisticEffectFromSseEvents();
+
+ const subscribeToSseEventStream = useRecoilCallback(
+ ({ set, snapshot }) =>
+ (sseClientConnected: Client) => {
+ const currentSseEventStreamId = getSnapshotValue(
+ snapshot,
+ sseEventStreamIdState,
+ );
+
+ if (isNonEmptyString(currentSseEventStreamId)) {
+ return;
+ }
+
+ const newSseEventStreamId = v4();
+
+ set(sseEventStreamIdState, newSseEventStreamId);
+
+ sseClientConnected.subscribe(
+ {
+ query: print(ON_EVENT_SUBSCRIPTION),
+ variables: {
+ eventStreamId: newSseEventStreamId,
+ },
+ },
+ {
+ next: (
+ value: ExecutionResult<{
+ onEventSubscription: EventSubscription;
+ }>,
+ ) => {
+ const objectRecordEventsWithQueryIds =
+ value?.data?.onEventSubscription?.eventWithQueryIdsList ?? [];
+
+ const objectRecordEvents = objectRecordEventsWithQueryIds.map(
+ (eventWithQueryIds) => {
+ return eventWithQueryIds.event;
+ },
+ );
+
+ triggerOptimisticEffectFromSseEvents({
+ objectRecordEvents,
+ });
+
+ dispatchObjectRecordEventsFromSseToBrowserEvents(
+ objectRecordEventsWithQueryIds,
+ );
+ },
+ error: (error) => {
+ captureException(error);
+ },
+ complete: () => {},
+ },
+ );
+ },
+ [
+ triggerOptimisticEffectFromSseEvents,
+ dispatchObjectRecordEventsFromSseToBrowserEvents,
+ ],
+ );
+
+ return {
+ subscribeToSseEventStream,
+ };
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts
new file mode 100644
index 0000000000..6a62b899d0
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents.ts
@@ -0,0 +1,69 @@
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { useTriggerOptimisticEffectFromSseUpdateEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents';
+import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType';
+import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
+import { useCallback } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+import {
+ DatabaseEventAction,
+ type ObjectRecordEvent,
+} from '~/generated/graphql';
+
+export const useTriggerOptimisticEffectFromSseEvents = () => {
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const { triggerOptimisticEffectFromSseUpdateEvents } =
+ useTriggerOptimisticEffectFromSseUpdateEvents();
+
+ const triggerOptimisticEffectFromSseEvents = useCallback(
+ ({ objectRecordEvents }: { objectRecordEvents: ObjectRecordEvent[] }) => {
+ const objectRecordEventsByObjectMetadataItemNameSingular =
+ groupObjectRecordSseEventsByObjectMetadataItemNameSingular({
+ objectRecordEvents,
+ });
+
+ const objectMetadataItemNamesSingular = Array.from(
+ objectRecordEventsByObjectMetadataItemNameSingular.keys(),
+ );
+
+ for (const objectMetadataItemNameSingular of objectMetadataItemNamesSingular) {
+ const objectRecordEventsForThisObjectMetadataItem =
+ objectRecordEventsByObjectMetadataItemNameSingular.get(
+ objectMetadataItemNameSingular,
+ ) ?? [];
+
+ const objectMetadataItem = objectMetadataItems.find((metadataItem) => {
+ return metadataItem.nameSingular === objectMetadataItemNameSingular;
+ });
+
+ if (!isDefined(objectMetadataItem)) {
+ continue;
+ }
+
+ const { objectRecordEventsByEventType } =
+ groupObjectRecordSseEventsByEventType({
+ objectRecordEvents: objectRecordEventsForThisObjectMetadataItem,
+ });
+
+ const sseEventTypes = Array.from(objectRecordEventsByEventType.keys());
+
+ for (const sseEventType of sseEventTypes) {
+ const objectRecordEventsForThisEventType =
+ objectRecordEventsByEventType.get(sseEventType) ?? [];
+
+ switch (sseEventType) {
+ case DatabaseEventAction.UPDATED:
+ triggerOptimisticEffectFromSseUpdateEvents({
+ objectRecordEvents: objectRecordEventsForThisEventType,
+ objectMetadataItem,
+ });
+ break;
+ }
+ }
+ }
+ },
+ [objectMetadataItems, triggerOptimisticEffectFromSseUpdateEvents],
+ );
+
+ return { triggerOptimisticEffectFromSseEvents };
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts
new file mode 100644
index 0000000000..d332429ae2
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts
@@ -0,0 +1,122 @@
+import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
+import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
+import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
+import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
+import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject';
+import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
+import { useRefetchAggregateQueriesForObjectMetadataItem } from '@/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem';
+import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
+import { useCallback } from 'react';
+import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
+import {
+ DatabaseEventAction,
+ type ObjectRecordEvent,
+} from '~/generated/graphql';
+
+export const useTriggerOptimisticEffectFromSseUpdateEvents = () => {
+ const apolloCoreClient = useApolloCoreClient();
+ const { objectMetadataItems } = useObjectMetadataItems();
+ const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
+ const { refetchAggregateQueriesForObjectMetadataItem } =
+ useRefetchAggregateQueriesForObjectMetadataItem();
+ const { upsertRecordsInStore } = useUpsertRecordsInStore();
+
+ const triggerOptimisticEffectFromSseUpdateEvents = useCallback(
+ ({
+ objectRecordEvents,
+ objectMetadataItem,
+ }: {
+ objectRecordEvents: ObjectRecordEvent[];
+ objectMetadataItem: ObjectMetadataItem;
+ }) => {
+ const recordGqlFields = generateDepthRecordGqlFieldsFromObject({
+ objectMetadataItem,
+ objectMetadataItems,
+ depth: 1,
+ });
+
+ const updateEvents = objectRecordEvents.filter((objectRecordEvent) => {
+ return objectRecordEvent.action === DatabaseEventAction.UPDATED;
+ });
+
+ for (const updateEvent of updateEvents) {
+ const updatedRecord = updateEvent.properties.after;
+
+ if (!isDefined(updatedRecord)) {
+ continue;
+ }
+
+ upsertRecordsInStore({ partialRecords: [updatedRecord] });
+
+ const cachedRecord = getRecordFromCache({
+ cache: apolloCoreClient.cache,
+ objectMetadataItem,
+ objectMetadataItems,
+ recordId: updatedRecord.id,
+ recordGqlFields,
+ objectPermissionsByObjectMetadataId,
+ });
+
+ const cachedRecordWithConnection = getRecordNodeFromRecord({
+ record: cachedRecord,
+ objectMetadataItem,
+ objectMetadataItems,
+ recordGqlFields,
+ computeReferences: false,
+ });
+
+ const computedOptimisticRecord = {
+ ...updatedRecord,
+ id: updatedRecord.id,
+ __typename: getObjectTypename(objectMetadataItem.nameSingular),
+ };
+
+ const computedOptimisticRecordWithConnection = getRecordNodeFromRecord({
+ record: computedOptimisticRecord,
+ objectMetadataItem,
+ objectMetadataItems,
+ recordGqlFields,
+ });
+
+ if (
+ !isDefined(cachedRecordWithConnection) ||
+ !isDefined(computedOptimisticRecordWithConnection)
+ ) {
+ continue;
+ }
+
+ triggerUpdateRecordOptimisticEffect({
+ cache: apolloCoreClient.cache,
+ objectMetadataItem,
+ currentRecord: cachedRecordWithConnection,
+ updatedRecord: computedOptimisticRecordWithConnection,
+ objectMetadataItems,
+ objectPermissionsByObjectMetadataId,
+ upsertRecordsInStore,
+ });
+ }
+
+ if (isNonEmptyArray(updateEvents)) {
+ refetchAggregateQueriesForObjectMetadataItem({
+ objectMetadataItem,
+ });
+ }
+
+ return isNonEmptyArray(updateEvents);
+ },
+ [
+ apolloCoreClient.cache,
+ objectMetadataItems,
+ objectPermissionsByObjectMetadataId,
+ refetchAggregateQueriesForObjectMetadataItem,
+ upsertRecordsInStore,
+ ],
+ );
+
+ return {
+ triggerOptimisticEffectFromSseUpdateEvents,
+ };
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/getObjectRecordOperationUpdateInputs.ts b/packages/twenty-front/src/modules/sse-db-event/utils/getObjectRecordOperationUpdateInputs.ts
new file mode 100644
index 0000000000..efb725d290
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/utils/getObjectRecordOperationUpdateInputs.ts
@@ -0,0 +1,26 @@
+import { type ObjectRecordOperationUpdateInput } from '@/object-record/types/ObjectRecordOperationUpdateInput';
+import { isDefined } from 'twenty-shared/utils';
+import { type ObjectRecordEvent } from '~/generated/graphql';
+
+export const getObjectRecordOperationUpdateInputs = (
+ events: ObjectRecordEvent[],
+): ObjectRecordOperationUpdateInput[] => {
+ return events
+ .map((event) => {
+ const updatedFieldNames = event.properties?.updatedFields ?? [];
+ const updatedRecord = event.properties?.after;
+
+ if (!isDefined(updatedRecord)) {
+ return null;
+ }
+
+ return {
+ recordId: event.recordId,
+ updatedFields: updatedFieldNames.map((fieldName) => ({
+ [fieldName]: updatedRecord[fieldName],
+ })),
+ };
+ })
+ .filter(isDefined)
+ .filter((updateInput) => updateInput.updatedFields.length > 0);
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/groupObjectRecordSseEventsByEventType.ts b/packages/twenty-front/src/modules/sse-db-event/utils/groupObjectRecordSseEventsByEventType.ts
new file mode 100644
index 0000000000..83f658bcd0
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/utils/groupObjectRecordSseEventsByEventType.ts
@@ -0,0 +1,27 @@
+import {
+ type DatabaseEventAction,
+ type ObjectRecordEvent,
+} from '~/generated/graphql';
+
+export const groupObjectRecordSseEventsByEventType = ({
+ objectRecordEvents,
+}: {
+ objectRecordEvents: ObjectRecordEvent[];
+}) => {
+ const objectRecordEventsByEventType = new Map<
+ DatabaseEventAction,
+ ObjectRecordEvent[]
+ >();
+
+ for (const objectRecordEvent of objectRecordEvents) {
+ const existingObjectRecordEvents =
+ objectRecordEventsByEventType.get(objectRecordEvent.action) ?? [];
+
+ objectRecordEventsByEventType.set(objectRecordEvent.action, [
+ ...existingObjectRecordEvents,
+ objectRecordEvent,
+ ]);
+ }
+
+ return { objectRecordEventsByEventType };
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular.ts b/packages/twenty-front/src/modules/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular.ts
new file mode 100644
index 0000000000..5af512957e
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular.ts
@@ -0,0 +1,26 @@
+import { type ObjectRecordEvent } from '~/generated/graphql';
+
+export const groupObjectRecordSseEventsByObjectMetadataItemNameSingular = ({
+ objectRecordEvents,
+}: {
+ objectRecordEvents: ObjectRecordEvent[];
+}) => {
+ const objectRecordEventsByObjectMetadataItemNameSingular = new Map<
+ string,
+ ObjectRecordEvent[]
+ >();
+
+ for (const objectRecordEvent of objectRecordEvents) {
+ const existingObjectRecordEvents =
+ objectRecordEventsByObjectMetadataItemNameSingular.get(
+ objectRecordEvent.objectNameSingular,
+ ) ?? [];
+
+ objectRecordEventsByObjectMetadataItemNameSingular.set(
+ objectRecordEvent.objectNameSingular,
+ [...existingObjectRecordEvents, objectRecordEvent],
+ );
+ }
+
+ return objectRecordEventsByObjectMetadataItemNameSingular;
+};
diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts
new file mode 100644
index 0000000000..2c5cd0befa
--- /dev/null
+++ b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent.ts
@@ -0,0 +1,135 @@
+import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/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';
+import {
+ DatabaseEventAction,
+ type ObjectRecordEvent,
+} from '~/generated/graphql';
+
+export const turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents = ({
+ objectMetadataItem,
+ objectRecordEvents,
+}: {
+ objectMetadataItem: ObjectMetadataItem;
+ objectRecordEvents: ObjectRecordEvent[];
+}): ObjectRecordOperationBrowserEventDetail[] => {
+ const { objectRecordEventsByEventType } =
+ groupObjectRecordSseEventsByEventType({
+ objectRecordEvents,
+ });
+
+ const eventTypes = Array.from(objectRecordEventsByEventType.keys());
+
+ const objectRecordOperationBrowserEvents: ObjectRecordOperationBrowserEventDetail[] =
+ [];
+
+ for (const eventType of eventTypes) {
+ const objectRecordEventsForThisEventType =
+ objectRecordEventsByEventType.get(eventType) ?? [];
+
+ const hasSingleEvent = objectRecordEventsForThisEventType.length === 1;
+
+ switch (eventType) {
+ case DatabaseEventAction.UPDATED: {
+ const updateInputs = getObjectRecordOperationUpdateInputs(
+ objectRecordEventsForThisEventType,
+ );
+
+ if (hasSingleEvent) {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: {
+ type: 'update-one',
+ result: { updateInput: updateInputs[0] },
+ },
+ });
+ } else {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: {
+ type: 'update-many',
+ result: { updateInputs },
+ },
+ });
+ }
+ break;
+ }
+ case DatabaseEventAction.DESTROYED:
+ if (hasSingleEvent) {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: {
+ type: 'destroy-one',
+ },
+ });
+ } else {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: {
+ type: 'destroy-many',
+ },
+ });
+ }
+ break;
+ case DatabaseEventAction.RESTORED:
+ if (hasSingleEvent) {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'restore-one' },
+ });
+ } else {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'restore-many' },
+ });
+ }
+ break;
+ case DatabaseEventAction.UPSERTED:
+ if (hasSingleEvent) {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'create-one' },
+ });
+ } else {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'create-many' },
+ });
+ }
+ break;
+ case DatabaseEventAction.CREATED:
+ if (hasSingleEvent) {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'create-one' },
+ });
+ } else {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'create-many' },
+ });
+ }
+ break;
+ case DatabaseEventAction.DELETED:
+ if (hasSingleEvent) {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'delete-one' },
+ });
+ } else {
+ objectRecordOperationBrowserEvents.push({
+ objectMetadataItem,
+ operation: { type: 'delete-many' },
+ });
+ }
+ break;
+ default: {
+ assertUnreachable(eventType);
+ }
+ }
+ }
+
+ return objectRecordOperationBrowserEvents.filter(isDefined);
+};
diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx
index ed4fd05d9b..0f08d76883 100644
--- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx
+++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx
@@ -8,6 +8,7 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con
import { MainContainerLayoutWithCommandMenu } from '@/object-record/components/MainContainerLayoutWithCommandMenu';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { PageLayoutDispatcher } from '@/object-record/record-show/components/PageLayoutDispatcher';
+import { RecordShowPageSSESubscribeEffect } from '@/object-record/record-show/components/RecordShowPageSSESubscribeEffect';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId';
import { PageHeaderToggleCommandMenuButton } from '@/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton';
@@ -63,6 +64,10 @@ export const RecordShowPage = () => {
targetObjectNameSingular: objectNameSingular,
}}
/>
+
diff --git a/packages/twenty-server/src/engine/subscriptions/types/object-record-subscription-event.type.ts b/packages/twenty-server/src/engine/subscriptions/types/object-record-subscription-event.type.ts
index 359c7fc594..e72be402d4 100644
--- a/packages/twenty-server/src/engine/subscriptions/types/object-record-subscription-event.type.ts
+++ b/packages/twenty-server/src/engine/subscriptions/types/object-record-subscription-event.type.ts
@@ -1,5 +1,8 @@
import { type ObjectRecordEvent } from 'twenty-shared/database-events';
+import { type DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
+
export type ObjectRecordSubscriptionEvent = ObjectRecordEvent & {
+ action: DatabaseEventAction;
objectNameSingular: string;
};
diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
index 390118e996..673c2d6d0a 100644
--- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
@@ -1,6 +1,6 @@
import { isPlainObject } from '@nestjs/common/utils/shared.utils';
-import { isNull } from '@sniptt/guards';
+import { isNonEmptyString, isNull } from '@sniptt/guards';
import {
FieldActorSource,
FieldMetadataType,
@@ -155,7 +155,7 @@ export function formatResult(
compositeProperty.name,
fieldMetadata,
)
- : value;
+ : formatCompositeFieldValue(value, compositeProperty.name, fieldMetadata);
}
// After assembling composite fields, handle those with missing required subfields
@@ -259,6 +259,26 @@ function transformCompositeFieldNullValue(
);
}
+function formatCompositeFieldValue(
+ value: unknown,
+ compositePropertyName: string,
+ fieldMetadata: FlatFieldMetadata,
+) {
+ switch (fieldMetadata.type) {
+ case FieldMetadataType.CURRENCY: {
+ if (compositePropertyName === 'amountMicros') {
+ if (isNonEmptyString(value)) {
+ return parseInt(value);
+ }
+
+ return value;
+ }
+ }
+ }
+
+ return value;
+}
+
/**
* Handles composite fields with missing required subfields.
* - For nullable fields: sets to null if all required subfields are null
diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts
index 78607021b8..879b9da49d 100644
--- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts
+++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts
@@ -8,7 +8,9 @@ import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { type EventStreamData } from 'src/engine/subscriptions/types/event-stream-data.type';
+import { ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type';
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 {
@@ -98,7 +100,10 @@ export class WorkspaceEventEmitterService {
const objectNameSingular = workspaceEventBatch.objectMetadata.nameSingular;
for (const event of workspaceEventBatch.events) {
- const eventWithObjectName = {
+ const { action } = parseEventNameOrThrow(workspaceEventBatch.name);
+
+ const eventWithObjectName: ObjectRecordSubscriptionEvent = {
+ action,
objectNameSingular,
...event,
};