Files
twenty/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts
T
Félix Malfait d8ea406b80 fix(front): skip unknown fields in SSE optimistic updates instead of dropping the event (#22474)
## Rationale

When an SSE record-update event carries a field the tab's metadata cache
doesn't know (someone added a custom field after this tab loaded),
`computeOptimisticRecordFromInput` throws `Should never occur,
encountered unknown fields …`. The catch in
`useTriggerEventStreamCreation` swallows the throw, so the **entire
event is discarded** — the tab silently stops reflecting that update.

**Production evidence (Sentry):** the `Error while processing SSE
message` family — ~860 events / ~480 users in the last 30 days, ongoing
([TWENTY-FRONT-7PD](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-7PD)
et al.), with the sampled stack landing exactly on this throw. Related:
[TWENTY-FRONT-633](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-633)
(903 users).

## Why this is the root cause, not a symptom patch

The throw is an assertion that unknown fields "should never occur".
That's the correct contract for the 4 local-mutation callers
(`useUpdateOneRecord`, `useCreateOneRecord`, `useCreateManyRecords`,
`useRunWorkflowVersion`) — there, an unknown field is a programming bug.
But for the SSE caller the input comes from the **server**, which can
legitimately be ahead of the tab's metadata. Schema convergence is the
metadata-event pipeline's job (it flows over the same SSE channel); the
record pipeline's job is to tolerate the window. So the fix moves the
decision to the right caller instead of weakening the assertion for
everyone:

- `getUnknownRecordInputFields` — detection logic extracted, shared
- mutation callers: still throw (behavior unchanged)
- SSE update path: filters unknown fields and applies the rest of the
event

Dropping the *fields* loses nothing: the tab couldn't render them anyway
without the metadata, and the metadata event that follows triggers the
proper refresh.

## User impact

~480 users/month currently get silently stale tabs (list/kanban rows not
reflecting teammates' updates) whenever any custom field is added while
they have Twenty open. After this fix, updates keep flowing; only the
not-yet-known field is skipped until metadata converges.

## Test plan

- [x] Unit tests for `getUnknownRecordInputFields` (known fields,
`__typename`, unknown fields, relation join columns)
- [x] Existing `computeOptimisticRecordFromInput` tests cover the
unchanged throw path
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22474?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 09:31:42 +02:00

179 lines
6.4 KiB
TypeScript

import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
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 { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueriesForObjectMetadataItem } from '@/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields';
import { captureMessage } from '@sentry/react';
import { useCallback } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
DatabaseEventAction,
type ObjectRecordEvent,
} from '~/generated-metadata/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: EnrichedObjectMetadataItem;
}) => {
const updateEvents = objectRecordEvents.filter((objectRecordEvent) => {
return objectRecordEvent.action === DatabaseEventAction.UPDATED;
});
for (const updateEvent of updateEvents) {
const recordFromEvent = updateEvent.properties.after;
if (!isDefined(recordFromEvent)) {
continue;
}
const unknownRecordInputFields = getUnknownRecordInputFields({
objectMetadataItem,
recordInput: recordFromEvent,
});
if (unknownRecordInputFields.length > 0) {
captureMessage(
`SSE update event for ${objectMetadataItem.nameSingular} carried fields unknown to this tab's metadata: ${unknownRecordInputFields.join(', ')}`,
'warning',
);
}
const updatedRecord =
unknownRecordInputFields.length > 0
? Object.fromEntries(
Object.entries(recordFromEvent).filter(
([recordKey]) =>
!unknownRecordInputFields.includes(recordKey),
),
)
: recordFromEvent;
const computedOptimisticRecord = {
...computeOptimisticRecordFromInput({
cache: apolloCoreClient.cache,
objectMetadataItem,
objectMetadataItems,
recordInput: updatedRecord,
objectPermissionsByObjectMetadataId,
currentWorkspaceMember: null,
}),
id: updatedRecord.id,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
const recordGqlFields = generateDepthRecordGqlFieldsFromRecord({
objectMetadataItem,
objectMetadataItems,
record: computedOptimisticRecord,
depth: 0,
});
const cachedRecord = getRecordFromCache({
cache: apolloCoreClient.cache,
objectMetadataItem,
objectMetadataItems,
recordId: updatedRecord.id,
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
if (
isDefined(cachedRecord?.updatedAt) &&
isDefined(updatedRecord.updatedAt) &&
new Date(updatedRecord.updatedAt as string).getTime() <
new Date(cachedRecord!.updatedAt as string).getTime()
) {
continue;
}
const cachedRecordWithConnection = getRecordNodeFromRecord({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences: false,
});
if (
!isDefined(cachedRecord) ||
!isDefined(cachedRecordWithConnection)
) {
continue;
}
upsertRecordsInStore({ partialRecords: [updatedRecord] });
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: computedOptimisticRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
const computedOptimisticRecordWithConnection = getRecordNodeFromRecord({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
});
if (!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,
};
};