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. -->
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
import { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields';
|
||||
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
|
||||
|
||||
describe('getUnknownRecordInputFields', () => {
|
||||
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
|
||||
|
||||
it('should return an empty array when every field is known', () => {
|
||||
const result = getUnknownRecordInputFields({
|
||||
objectMetadataItem: personObjectMetadataItem,
|
||||
recordInput: {
|
||||
city: 'Paris',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore the __typename key', () => {
|
||||
const result = getUnknownRecordInputFields({
|
||||
objectMetadataItem: personObjectMetadataItem,
|
||||
recordInput: {
|
||||
__typename: 'Person',
|
||||
city: 'Paris',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return fields absent from the object metadata', () => {
|
||||
const result = getUnknownRecordInputFields({
|
||||
objectMetadataItem: personObjectMetadataItem,
|
||||
recordInput: {
|
||||
city: 'Paris',
|
||||
someFieldCreatedInAnotherTab: 'value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(['someFieldCreatedInAnotherTab']);
|
||||
});
|
||||
|
||||
it('should accept relation join column names', () => {
|
||||
const result = getUnknownRecordInputFields({
|
||||
objectMetadataItem: personObjectMetadataItem,
|
||||
recordInput: {
|
||||
companyId: '20202020-0713-40a5-8216-82802401d33e',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
+5
-36
@@ -2,13 +2,11 @@ import { isNull, isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getFieldMetadataFromGqlField } from '@/object-record/cache/utils/getFieldMetadataFromGqlField';
|
||||
import { getMorphRelationFromFieldMetadataAndGqlField } from '@/object-record/cache/utils/getMorphRelationFromFieldMetadataAndGqlField';
|
||||
import {
|
||||
getRecordFromCache,
|
||||
type GetRecordFromCacheArgs,
|
||||
} from '@/object-record/cache/utils/getRecordFromCache';
|
||||
import { GRAPHQL_TYPENAME_KEY } from '@/object-record/constants/GraphqlTypenameKey';
|
||||
import { type FieldActorValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { isFieldActor } from '@/object-record/record-field/ui/types/guards/isFieldActor';
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
@@ -17,6 +15,7 @@ import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFiel
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { buildOptimisticActorFieldValueFromCurrentWorkspaceMember } from '@/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember';
|
||||
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
|
||||
import { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields';
|
||||
import {
|
||||
computeMorphRelationGqlFieldName,
|
||||
computeRelationGqlFieldJoinColumnName,
|
||||
@@ -40,41 +39,11 @@ export const computeOptimisticRecordFromInput = ({
|
||||
currentWorkspaceMember,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}: ComputeOptimisticCacheRecordInputArgs) => {
|
||||
const unknownRecordInputFields = Object.keys(recordInput).filter(
|
||||
(recordKey) => {
|
||||
const correspondingFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.name === recordKey,
|
||||
);
|
||||
const unknownRecordInputFields = getUnknownRecordInputFields({
|
||||
objectMetadataItem,
|
||||
recordInput,
|
||||
});
|
||||
|
||||
const potentialRelationJoinColumnNameFieldMetadataItem =
|
||||
objectMetadataItem.fields.find(
|
||||
(field) =>
|
||||
field.type === FieldMetadataType.RELATION &&
|
||||
computeRelationGqlFieldJoinColumnName({ name: field.name }) ===
|
||||
recordKey,
|
||||
);
|
||||
|
||||
const potentialMorphRelationJoinColumnNameFieldMetadataItem =
|
||||
objectMetadataItem.fields.find((field) => {
|
||||
if (!isFieldMorphRelation(field)) return false;
|
||||
|
||||
return isDefined(
|
||||
getFieldMetadataFromGqlField({
|
||||
objectMetadataItem,
|
||||
gqlField: recordKey,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const isUnknownField =
|
||||
!isDefined(correspondingFieldMetadataItem) &&
|
||||
!isDefined(potentialRelationJoinColumnNameFieldMetadataItem) &&
|
||||
!isDefined(potentialMorphRelationJoinColumnNameFieldMetadataItem);
|
||||
|
||||
const isTypenameField = recordKey === GRAPHQL_TYPENAME_KEY;
|
||||
return isUnknownField && !isTypenameField;
|
||||
},
|
||||
);
|
||||
if (unknownRecordInputFields.length > 0) {
|
||||
throw new Error(
|
||||
`Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadataItem ${objectMetadataItem.nameSingular}`,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getFieldMetadataFromGqlField } from '@/object-record/cache/utils/getFieldMetadataFromGqlField';
|
||||
import { GRAPHQL_TYPENAME_KEY } from '@/object-record/constants/GraphqlTypenameKey';
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import {
|
||||
computeRelationGqlFieldJoinColumnName,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getUnknownRecordInputFields = ({
|
||||
objectMetadataItem,
|
||||
recordInput,
|
||||
}: {
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
recordInput: Partial<ObjectRecord>;
|
||||
}): string[] => {
|
||||
return Object.keys(recordInput).filter((recordKey) => {
|
||||
const correspondingFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.name === recordKey,
|
||||
);
|
||||
|
||||
const potentialRelationJoinColumnNameFieldMetadataItem =
|
||||
objectMetadataItem.fields.find(
|
||||
(field) =>
|
||||
field.type === FieldMetadataType.RELATION &&
|
||||
computeRelationGqlFieldJoinColumnName({ name: field.name }) ===
|
||||
recordKey,
|
||||
);
|
||||
|
||||
const potentialMorphRelationJoinColumnNameFieldMetadataItem =
|
||||
objectMetadataItem.fields.find((field) => {
|
||||
if (!isFieldMorphRelation(field)) return false;
|
||||
|
||||
return isDefined(
|
||||
getFieldMetadataFromGqlField({
|
||||
objectMetadataItem,
|
||||
gqlField: recordKey,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const isUnknownField =
|
||||
!isDefined(correspondingFieldMetadataItem) &&
|
||||
!isDefined(potentialRelationJoinColumnNameFieldMetadataItem) &&
|
||||
!isDefined(potentialMorphRelationJoinColumnNameFieldMetadataItem);
|
||||
|
||||
const isTypenameField = recordKey === GRAPHQL_TYPENAME_KEY;
|
||||
|
||||
return isUnknownField && !isTypenameField;
|
||||
});
|
||||
};
|
||||
+7
-7
@@ -123,6 +123,8 @@ export const useTriggerEventStreamCreation = () => {
|
||||
(item) => item.objectRecordEvent,
|
||||
);
|
||||
|
||||
dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents);
|
||||
|
||||
triggerOptimisticEffectFromSseEvents({
|
||||
objectRecordEvents,
|
||||
});
|
||||
@@ -130,8 +132,6 @@ export const useTriggerEventStreamCreation = () => {
|
||||
dispatchObjectRecordEventsFromSseToBrowserEvents(
|
||||
objectRecordEventsWithQueryIds,
|
||||
);
|
||||
|
||||
dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents);
|
||||
},
|
||||
error: (error) => {
|
||||
captureException(error);
|
||||
@@ -184,6 +184,11 @@ export const useTriggerEventStreamCreation = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const metadataEvents =
|
||||
result?.data?.onEventSubscription?.metadataEvents ?? [];
|
||||
|
||||
dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents);
|
||||
|
||||
triggerOptimisticEffectFromSseEvents({
|
||||
objectRecordEvents,
|
||||
});
|
||||
@@ -191,11 +196,6 @@ export const useTriggerEventStreamCreation = () => {
|
||||
dispatchObjectRecordEventsFromSseToBrowserEvents(
|
||||
objectRecordEventsWithQueryIds,
|
||||
);
|
||||
|
||||
const metadataEvents =
|
||||
result?.data?.onEventSubscription?.metadataEvents ?? [];
|
||||
|
||||
dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+26
-2
@@ -11,6 +11,8 @@ 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 {
|
||||
@@ -39,12 +41,34 @@ export const useTriggerOptimisticEffectFromSseUpdateEvents = () => {
|
||||
});
|
||||
|
||||
for (const updateEvent of updateEvents) {
|
||||
const updatedRecord = updateEvent.properties.after;
|
||||
const recordFromEvent = updateEvent.properties.after;
|
||||
|
||||
if (!isDefined(updatedRecord)) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user