d8ea406b80
## 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. -->
179 lines
6.4 KiB
TypeScript
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,
|
|
};
|
|
};
|