Fixed Apollo cache bug (#16523)

Fixes https://github.com/twentyhq/twenty/issues/16520

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-12-12 17:45:50 +01:00
committed by GitHub
parent f0af947331
commit ee47a773bd
22 changed files with 826 additions and 104 deletions
@@ -1,6 +1,9 @@
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlRefConnection = Omit<RecordGqlConnection, 'edges'> & {
export type RecordGqlRefConnection = Omit<
RecordGqlConnectionEdgesRequired,
'edges'
> & {
edges: RecordGqlRefEdge[];
};
@@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getRecordFromRecordNode should convert a simple record node 1`] = `
{
"__typename": "Person",
"email": "john@example.com",
"id": "123",
"name": "John Doe",
}
`;
exports[`getRecordFromRecordNode should handle array values 1`] = `
{
"__typename": "Person",
"id": "123",
"scores": [
100,
200,
300,
],
"tags": [
"developer",
"designer",
],
}
`;
exports[`getRecordFromRecordNode should handle connection fields with edges 1`] = `
{
"__typename": "Company",
"id": "123",
"name": "Acme Inc",
"people": [
{
"__typename": "Person",
"id": "456",
"name": "John Doe",
},
{
"__typename": "Person",
"id": "789",
"name": "Jane Smith",
},
],
}
`;
exports[`getRecordFromRecordNode should handle deeply nested structures 1`] = `
{
"__typename": "Workspace",
"id": "123",
"settings": {
"__typename": "WorkspaceSettings",
"display": {
"__typename": "DisplaySettings",
"layout": "compact",
"theme": "dark",
},
},
}
`;
exports[`getRecordFromRecordNode should handle mixed nested objects and connections 1`] = `
{
"__typename": "Company",
"address": {
"__typename": "Address",
"city": "New York",
"country": "USA",
},
"employees": [
{
"__typename": "Person",
"id": "456",
"name": {
"__typename": "FullName",
"firstName": "John",
"lastName": "Doe",
},
},
],
"id": "123",
"name": "Acme Inc",
}
`;
exports[`getRecordFromRecordNode should handle nested object fields 1`] = `
{
"__typename": "Person",
"id": "123",
"name": {
"__typename": "FullName",
"firstName": "John",
"lastName": "Doe",
},
}
`;
exports[`getRecordFromRecordNode should handle null and undefined values 1`] = `
{
"__typename": "Person",
"email": null,
"id": "123",
"name": "John Doe",
"phone": undefined,
}
`;
@@ -0,0 +1,140 @@
import { getRecordFromRecordNode } from '../getRecordFromRecordNode';
describe('getRecordFromRecordNode', () => {
it('should convert a simple record node', () => {
const recordNode = {
id: '123',
__typename: 'Person',
name: 'John Doe',
email: 'john@example.com',
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle nested object fields', () => {
const recordNode = {
id: '123',
__typename: 'Person',
name: {
__typename: 'FullName',
firstName: 'John',
lastName: 'Doe',
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle connection fields with edges', () => {
const recordNode = {
id: '123',
__typename: 'Company',
name: 'Acme Inc',
people: {
edges: [
{
node: {
id: '456',
__typename: 'Person',
name: 'John Doe',
},
},
{
node: {
id: '789',
__typename: 'Person',
name: 'Jane Smith',
},
},
],
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle null and undefined values', () => {
const recordNode = {
id: '123',
__typename: 'Person',
name: 'John Doe',
email: null,
phone: undefined,
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle array values', () => {
const recordNode = {
id: '123',
__typename: 'Person',
tags: ['developer', 'designer'],
scores: [100, 200, 300],
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle deeply nested structures', () => {
const recordNode = {
id: '123',
__typename: 'Workspace',
settings: {
__typename: 'WorkspaceSettings',
display: {
__typename: 'DisplaySettings',
theme: 'dark',
layout: 'compact',
},
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle mixed nested objects and connections', () => {
const recordNode = {
id: '123',
__typename: 'Company',
name: 'Acme Inc',
address: {
__typename: 'Address',
city: 'New York',
country: 'USA',
},
employees: {
edges: [
{
node: {
id: '456',
__typename: 'Person',
name: {
__typename: 'FullName',
firstName: 'John',
lastName: 'Doe',
},
},
},
],
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
});
@@ -1,36 +1,132 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { RelationType } from '~/generated-metadata/graphql';
import { isObjectRecordConnection } from '../isObjectRecordConnection';
describe('isObjectRecordConnection', () => {
const relationDefinitionMap: { [K in RelationType]: boolean } = {
[RelationType.ONE_TO_MANY]: true,
[RelationType.MANY_TO_ONE]: false,
};
it.each(Object.entries(relationDefinitionMap))(
'.$relation',
(relation, expected) => {
const emptyRecord = {};
const result = isObjectRecordConnection(
it('should return true for valid connection with edges', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
type: relation,
} as NonNullable<FieldMetadataItem['relation']>,
emptyRecord,
);
expect(result).toEqual(expected);
},
);
it('should throw on unknown relation direction', () => {
const emptyRecord = {};
expect(() =>
isObjectRecordConnection(
__typename: 'PersonEdge',
node: {
id: '123',
},
},
{
direction: 'UNKNOWN_TYPE',
} as any,
emptyRecord,
),
).toThrowError();
__typename: 'PersonEdge',
node: {
id: '456',
},
},
],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(true);
});
it('should return true for valid connection with empty edges array', () => {
const storeValue = {
__typename: 'CompanyConnection',
edges: [],
};
const result = isObjectRecordConnection('company', storeValue);
expect(result).toBe(true);
});
it('should return true for valid connection without edges (optional)', () => {
const storeValue = {
__typename: 'PersonConnection',
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(true);
});
it('should return false for incorrect __typename', () => {
const storeValue = {
__typename: 'WrongConnection',
edges: [],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect edge __typename', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'WrongEdge',
node: {
id: '123',
},
},
],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(false);
});
it('should return true regardless of node content', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
id: '123',
name: 'John Doe',
},
},
],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(true);
});
it('should return false for null value', () => {
const result = isObjectRecordConnection('person', null);
expect(result).toBe(false);
});
it('should return false for undefined value', () => {
const result = isObjectRecordConnection('person', undefined);
expect(result).toBe(false);
});
it('should return false for primitive value', () => {
const result = isObjectRecordConnection('person', 'not an object');
expect(result).toBe(false);
});
it('should handle camelCase object names', () => {
const storeValue = {
__typename: 'CalendarEventConnection',
edges: [
{
__typename: 'CalendarEventEdge',
node: {
id: '123',
},
},
],
};
const result = isObjectRecordConnection('calendarEvent', storeValue);
expect(result).toBe(true);
});
});
@@ -0,0 +1,152 @@
import { isObjectRecordConnectionWithRefs } from '../isObjectRecordConnectionWithRefs';
describe('isObjectRecordConnectionWithRefs', () => {
it('should return true for valid connection with edges', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
__ref: 'Person:123',
},
},
{
__typename: 'PersonEdge',
node: {
__ref: 'Person:456',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(true);
});
it('should return true for valid connection with empty edges array', () => {
const storeValue = {
__typename: 'CompanyConnection',
edges: [],
};
const result = isObjectRecordConnectionWithRefs('company', storeValue);
expect(result).toBe(true);
});
it('should return false for connection without edges (required)', () => {
const storeValue = {
__typename: 'PersonConnection',
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect __typename', () => {
const storeValue = {
__typename: 'WrongConnection',
edges: [],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect edge __typename', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'WrongEdge',
node: {
__ref: 'Person:123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect __ref prefix', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
__ref: 'Company:123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for null value', () => {
const result = isObjectRecordConnectionWithRefs('person', null);
expect(result).toBe(false);
});
it('should return false for undefined value', () => {
const result = isObjectRecordConnectionWithRefs('person', undefined);
expect(result).toBe(false);
});
it('should return false for primitive value', () => {
const result = isObjectRecordConnectionWithRefs('person', 'not an object');
expect(result).toBe(false);
});
it('should handle camelCase object names', () => {
const storeValue = {
__typename: 'CalendarEventConnection',
edges: [
{
__typename: 'CalendarEventEdge',
node: {
__ref: 'CalendarEvent:123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs(
'calendarEvent',
storeValue,
);
expect(result).toBe(true);
});
it('should return false when node is missing __ref', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
id: '123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
});
@@ -2,7 +2,7 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
@@ -40,5 +40,5 @@ export const getRecordConnectionFromRecords = <T extends ObjectRecord>({
}),
...(withPageInfo && { pageInfo: getEmptyPageInfo() }),
...(withPageInfo && { totalCount: records.length }),
} as RecordGqlConnection;
} as RecordGqlConnectionEdgesRequired;
};
@@ -1,11 +1,11 @@
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getRecordsFromRecordConnection = <T extends ObjectRecord>({
recordConnection,
}: {
recordConnection: RecordGqlConnection;
recordConnection: RecordGqlConnectionEdgesRequired;
}): T[] => {
return recordConnection?.edges?.map((edge) =>
getRecordFromRecordNode<T>({ recordNode: edge.node }),
@@ -1,20 +1,30 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type StoreValue } from '@apollo/client';
import { z } from 'zod';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { assertUnreachable } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
import { capitalize } from 'twenty-shared/utils';
export const isObjectRecordConnection = (
relation: NonNullable<FieldMetadataItem['relation']>,
value: unknown,
): value is RecordGqlConnection => {
switch (relation.type) {
case RelationType.ONE_TO_MANY: {
return true;
}
case RelationType.MANY_TO_ONE:
return false;
default: {
return assertUnreachable(relation.type);
}
}
objectNameSingular: string,
storeValue: StoreValue,
): storeValue is RecordGqlConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const cachedObjectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
edges: z
.array(
z.object({
__typename: z.literal(objectEdgeTypeName),
node: z.object(),
}),
)
.optional(),
});
const cachedConnectionValidation =
cachedObjectConnectionSchema.safeParse(storeValue);
return cachedConnectionValidation.success;
};
@@ -3,8 +3,8 @@ import { type Nullable } from 'twenty-ui/utilities';
export type RecordGqlConnection = {
__typename?: string;
edges: RecordGqlEdge[];
pageInfo: {
edges?: RecordGqlEdge[];
pageInfo?: {
__typename?: Nullable<string>;
hasNextPage?: Nullable<boolean>;
hasPreviousPage?: Nullable<boolean>;
@@ -0,0 +1,7 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
export type RecordGqlConnectionEdgesRequired = Omit<
RecordGqlConnection,
'edges'
> &
Required<Pick<RecordGqlConnection, 'edges'>>;
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlOperationFindDuplicatesResult = {
[objectNamePlural: string]: RecordGqlConnection[];
[objectNamePlural: string]: RecordGqlConnectionEdgesRequired[];
};
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlOperationFindManyResult = {
[objectNamePlural: string]: RecordGqlConnection;
[objectNamePlural: string]: RecordGqlConnectionEdgesRequired;
};
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlOperationSearchResult = {
[objectNamePlural: string]: RecordGqlConnection;
[objectNamePlural: string]: RecordGqlConnectionEdgesRequired;
};
@@ -1,4 +1,4 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { gql } from '@apollo/client';
import { peopleQueryResult } from '~/testing/mock-data/people';
@@ -36,7 +36,7 @@ export const query = gql`
export const mockPageSize = 2;
export const peopleMockWithIdsOnly: RecordGqlConnection = {
export const peopleMockWithIdsOnly: RecordGqlConnectionEdgesRequired = {
...peopleQueryResult.people,
edges: peopleQueryResult.people.edges.map((edge) => ({
...edge,
@@ -72,7 +72,7 @@ export const variablesThirdRequest = {
};
const paginateRequestResponse = (
response: RecordGqlConnection,
response: RecordGqlConnectionEdgesRequired,
start: number,
end: number,
hasNextPage: boolean,
@@ -86,7 +86,7 @@ const paginateRequestResponse = (
startCursor: response.edges[start].cursor,
endCursor: response.edges[end].cursor,
hasNextPage,
} satisfies RecordGqlConnection['pageInfo'],
} satisfies RecordGqlConnectionEdgesRequired['pageInfo'],
totalCount,
};
};
@@ -5,7 +5,7 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlOperationFindDuplicatesResult } from '@/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults';
import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
@@ -20,7 +20,7 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
skip,
}: ObjectMetadataItemIdentifier & {
objectRecordIds: string[] | undefined;
onCompleted?: (data: RecordGqlConnection[]) => void;
onCompleted?: (data: RecordGqlConnectionEdgesRequired[]) => void;
skip?: boolean;
}) => {
const findDuplicateQueryStateIdentifier = objectNameSingular;
@@ -69,7 +69,7 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
const results = useMemo(
() =>
objectResults?.map((result: RecordGqlConnection) => {
objectResults?.map((result: RecordGqlConnectionEdgesRequired) => {
return result
? (getRecordsFromRecordConnection({
recordConnection: result,
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type CombinedFindManyRecordsQueryResult = {
[namePlural: string]: RecordGqlConnection;
[namePlural: string]: RecordGqlConnectionEdgesRequired;
};
@@ -0,0 +1,136 @@
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { type SearchRecord } from '~/generated-metadata/graphql';
import { sortMorphItems } from '../sortMorphItems';
const createMorphItem = (
recordId: string,
isSelected: boolean,
): RecordPickerPickableMorphItem => ({
recordId,
objectMetadataId: 'object-1',
isSelected,
isMatchingSearchFilter: true,
});
const createSearchRecord = (recordId: string): SearchRecord => ({
recordId,
label: `Record ${recordId}`,
objectNameSingular: 'person',
tsRank: 0,
tsRankCD: 0,
});
describe('sortMorphItems', () => {
it('should sort selected items before non-selected items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('1', false),
createMorphItem('2', true),
createMorphItem('3', false),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result[0].recordId).toBe('2');
expect(result[0].isSelected).toBe(true);
expect(result[1].isSelected).toBe(false);
expect(result[2].isSelected).toBe(false);
});
it('should sort by search record order within non-selected items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('3', false),
createMorphItem('1', false),
createMorphItem('2', false),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result.map((item) => item.recordId)).toEqual(['1', '2', '3']);
});
it('should sort by search record order within selected items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('3', true),
createMorphItem('1', true),
createMorphItem('2', true),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result.map((item) => item.recordId)).toEqual(['1', '2', '3']);
});
it('should handle mixed selected and non-selected items with correct ordering', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('4', false),
createMorphItem('2', true),
createMorphItem('1', false),
createMorphItem('3', true),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
createSearchRecord('4'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result.map((item) => item.recordId)).toEqual(['2', '3', '1', '4']);
expect(result[0].isSelected).toBe(true);
expect(result[1].isSelected).toBe(true);
expect(result[2].isSelected).toBe(false);
expect(result[3].isSelected).toBe(false);
});
it('should handle empty morphItems array', () => {
const morphItems: RecordPickerPickableMorphItem[] = [];
const searchRecords: SearchRecord[] = [createSearchRecord('1')];
const result = sortMorphItems(morphItems, searchRecords);
expect(result).toEqual([]);
});
it('should handle empty searchRecords array', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('1', false),
createMorphItem('2', true),
];
const searchRecords: SearchRecord[] = [];
const result = sortMorphItems(morphItems, searchRecords);
expect(result[0].recordId).toBe('2');
expect(result[0].isSelected).toBe(true);
});
it('should place items not present in searchRecords before indexed items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('unknown', false),
createMorphItem('1', false),
];
const searchRecords: SearchRecord[] = [createSearchRecord('1')];
const result = sortMorphItems(morphItems, searchRecords);
// Items not in searchRecords get rank -1, so they come before items with rank >= 0
expect(result[0].recordId).toBe('unknown');
expect(result[1].recordId).toBe('1');
});
});