Compare commits

..
Author SHA1 Message Date
Sonarly Claude CodeandClaude Opus 4.6 98d3232083 fix: handle stale filter fields in optimistic cache updates
When a saved view filter references a field that no longer exists in the
object metadata (e.g. 'industry' on company), isRecordMatchingFilter
throws an error that crashes the page. This adds try-catch guards around
all isRecordMatchingFilter call sites in Apollo optimistic effect
utilities to gracefully handle stale filters instead of crashing.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-19 10:07:48 +00:00
082400f751 Add objectRecordCounts query to /metadata endpoint (#18054)
## Summary

- Adds an `objectRecordCounts` query on the `/metadata` GraphQL endpoint
that returns approximate record counts for all objects in the workspace
- Uses PostgreSQL's `pg_class.reltuples` catalog stats — a single
instant query instead of N `COUNT(*)` table scans
- Replaces the previous `CombinedFindManyRecords` approach which hit the
server's 20 root resolver limit and silently showed 0 for all counts on
the settings Data Model page

### Server
- `ObjectRecordCountDTO` — GraphQL type with `objectNamePlural` and
`totalCount`
- `ObjectRecordCountService` — reads `pg_class` catalog for the
workspace schema
- Query added to `ObjectMetadataResolver` with `@MetadataResolver()` +
`NoPermissionGuard`

### Frontend
- `OBJECT_RECORD_COUNTS` query added to
`object-metadata/graphql/queries.ts`
- `useCombinedGetTotalCount` simplified to a zero-argument hook using
the new query
- `SettingsObjectTable` simplified to a single hook call

---------

Co-authored-by: Cursor <[email protected]>
2026-02-19 09:02:31 +00:00
15 changed files with 326 additions and 88 deletions
@@ -3378,6 +3378,12 @@ export type ObjectPermissionInput = {
objectMetadataId: Scalars['UUID'];
};
export type ObjectRecordCount = {
__typename?: 'ObjectRecordCount';
objectNamePlural: Scalars['String'];
totalCount: Scalars['Int'];
};
export type ObjectRecordEvent = {
__typename?: 'ObjectRecordEvent';
action: DatabaseEventAction;
@@ -3751,6 +3757,7 @@ export type Query = {
navigationMenuItem?: Maybe<NavigationMenuItem>;
navigationMenuItems: Array<NavigationMenuItem>;
object: Object;
objectRecordCounts: Array<ObjectRecordCount>;
objects: ObjectConnection;
pieChartData: PieChartDataOutput;
skill?: Maybe<Skill>;
@@ -5956,6 +5963,11 @@ export type ObjectMetadataItemsQueryVariables = Exact<{ [key: string]: never; }>
export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } };
export type ObjectRecordCountsQueryVariables = Exact<{ [key: string]: never; }>;
export type ObjectRecordCountsQuery = { __typename?: 'Query', objectRecordCounts: Array<{ __typename?: 'ObjectRecordCount', objectNamePlural: string, totalCount: number }> };
export type SkipBookOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@@ -11442,6 +11454,41 @@ export function useObjectMetadataItemsLazyQuery(baseOptions?: Apollo.LazyQueryHo
export type ObjectMetadataItemsQueryHookResult = ReturnType<typeof useObjectMetadataItemsQuery>;
export type ObjectMetadataItemsLazyQueryHookResult = ReturnType<typeof useObjectMetadataItemsLazyQuery>;
export type ObjectMetadataItemsQueryResult = Apollo.QueryResult<ObjectMetadataItemsQuery, ObjectMetadataItemsQueryVariables>;
export const ObjectRecordCountsDocument = gql`
query ObjectRecordCounts {
objectRecordCounts {
objectNamePlural
totalCount
}
}
`;
/**
* __useObjectRecordCountsQuery__
*
* To run a query within a React component, call `useObjectRecordCountsQuery` and pass it any options that fit your needs.
* When your component renders, `useObjectRecordCountsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useObjectRecordCountsQuery({
* variables: {
* },
* });
*/
export function useObjectRecordCountsQuery(baseOptions?: Apollo.QueryHookOptions<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>(ObjectRecordCountsDocument, options);
}
export function useObjectRecordCountsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>(ObjectRecordCountsDocument, options);
}
export type ObjectRecordCountsQueryHookResult = ReturnType<typeof useObjectRecordCountsQuery>;
export type ObjectRecordCountsLazyQueryHookResult = ReturnType<typeof useObjectRecordCountsLazyQuery>;
export type ObjectRecordCountsQueryResult = Apollo.QueryResult<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>;
export const SkipBookOnboardingStepDocument = gql`
mutation SkipBookOnboardingStep {
skipBookOnboardingStep {
@@ -58,11 +58,17 @@ export const processGroupByConnectionWithRecords = ({
let totalCountDelta = 0;
for (const record of records) {
const recordMatchesFilter = isRecordMatchingFilter({
record,
filter: queryFilter ?? {},
objectMetadataItem,
});
let recordMatchesFilter: boolean;
try {
recordMatchesFilter = isRecordMatchingFilter({
record,
filter: queryFilter ?? {},
objectMetadataItem,
});
} catch {
recordMatchesFilter = false;
}
const belongsToGroup = doesRecordBelongToGroup(
record,
@@ -118,11 +118,17 @@ export const triggerUpdateGroupByQueriesOptimisticEffect = ({
> = new Map();
for (const record of records) {
const recordMatchesFilter = isRecordMatchingFilter({
record,
filter: queryFilter ?? {},
objectMetadataItem,
});
let recordMatchesFilter: boolean;
try {
recordMatchesFilter = isRecordMatchingFilter({
record,
filter: queryFilter ?? {},
objectMetadataItem,
});
} catch {
recordMatchesFilter = false;
}
if (
shouldMatchRootQueryFilter &&
@@ -136,14 +136,18 @@ export const triggerCreateRecordsOptimisticEffect = ({
isDefined(rootQueryFilter) &&
shouldMatchRootQueryFilter === true
) {
const recordToCreateMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: recordToCreate,
filter: rootQueryFilter,
objectMetadataItem,
});
try {
const recordToCreateMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: recordToCreate,
filter: rootQueryFilter,
objectMetadataItem,
});
if (!recordToCreateMatchesThisRootQueryFilter) {
if (!recordToCreateMatchesThisRootQueryFilter) {
return false;
}
} catch {
return false;
}
}
@@ -57,14 +57,20 @@ export const triggerDestroyRecordsOptimisticEffect = ({
rootQueryCachedResponse,
);
const recordsMatchingRootQueryFilter = recordsToDestroy.filter(
(record) =>
isRecordMatchingFilter({
record,
filter: rootQueryVariables?.filter ?? {},
objectMetadataItem,
}),
);
let recordsMatchingRootQueryFilter: RecordGqlNode[];
try {
recordsMatchingRootQueryFilter = recordsToDestroy.filter(
(record) =>
isRecordMatchingFilter({
record,
filter: rootQueryVariables?.filter ?? {},
objectMetadataItem,
}),
);
} catch {
recordsMatchingRootQueryFilter = [];
}
const newTotalCount = isDefined(totalCount)
? Math.max(totalCount - recordsMatchingRootQueryFilter.length, 0)
@@ -76,17 +76,24 @@ export const triggerUpdateRecordOptimisticEffect = ({
const rootQueryFilter = rootQueryVariables?.filter;
const rootQueryOrderBy = rootQueryVariables?.orderBy;
const updatedRecordMatchesThisRootQueryFilter = isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
let updatedRecordMatchesThisRootQueryFilter: boolean;
let currentRecordIndexInRootQueryEdges: boolean;
const currentRecordIndexInRootQueryEdges = isRecordMatchingFilter({
record: currentRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
try {
updatedRecordMatchesThisRootQueryFilter = isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
currentRecordIndexInRootQueryEdges = isRecordMatchingFilter({
record: currentRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
} catch {
return rootQueryCachedResponse;
}
const totalCount = readField<number | undefined>(
'totalCount',
@@ -80,12 +80,18 @@ export const triggerUpdateRecordOptimisticEffectByBatch = ({
const rootQueryOrderBy = rootQueryVariables?.orderBy;
for (const updatedRecord of updatedRecords) {
const updatedRecordMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
let updatedRecordMatchesThisRootQueryFilter: boolean;
try {
updatedRecordMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
} catch {
continue;
}
const updatedRecordFoundInRootQueryEdges = isDefined(
rootQueryCurrentEdges.find(
@@ -19,3 +19,12 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
}
}
`;
export const OBJECT_RECORD_COUNTS = gql`
query ObjectRecordCounts {
objectRecordCounts {
objectNamePlural
totalCount
}
}
`;
@@ -1,48 +1,27 @@
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { type CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
import { OBJECT_RECORD_COUNTS } from '@/object-metadata/graphql/queries';
export const useCombinedGetTotalCount = ({
objectMetadataItems,
skip = false,
}: {
objectMetadataItems: ObjectMetadataItem[];
skip?: boolean;
}) => {
const operationSignatures = objectMetadataItems.map(
(objectMetadataItem) =>
({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
fields: {
id: true,
},
}) satisfies RecordGqlOperationSignature,
);
type ObjectRecordCountsResult = {
objectRecordCounts: {
objectNamePlural: string;
totalCount: number;
}[];
};
const apolloCoreClient = useApolloCoreClient();
const findManyQuery = useGenerateCombinedFindManyRecordsQuery({
operationSignatures,
});
export const useCombinedGetTotalCount = () => {
const { data } = useQuery<ObjectRecordCountsResult>(OBJECT_RECORD_COUNTS);
const { data } = useQuery<CombinedFindManyRecordsQueryResult>(
findManyQuery ?? EMPTY_QUERY,
{
skip,
client: apolloCoreClient,
},
);
const totalCountByObjectMetadataItemNamePlural = Object.fromEntries(
Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [
namePlural,
objectRecordConnection.totalCount,
]),
const totalCountByObjectMetadataItemNamePlural = useMemo(
() =>
Object.fromEntries(
(data?.objectRecordCounts ?? []).map((item) => [
item.objectNamePlural,
item.totalCount,
]),
),
[data],
);
return {
@@ -58,11 +58,8 @@ export const SettingsObjectTable = ({
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const { totalCountByObjectMetadataItemNamePlural } = useCombinedGetTotalCount(
{
objectMetadataItems,
},
);
const { totalCountByObjectMetadataItemNamePlural } =
useCombinedGetTotalCount();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@@ -0,0 +1,10 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('ObjectRecordCount')
export class ObjectRecordCountDTO {
@Field(() => String)
objectNamePlural: string;
@Field(() => Int)
totalCount: number;
}
@@ -25,6 +25,7 @@ import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadat
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@@ -93,6 +94,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
providers: [
ObjectMetadataService,
ObjectMetadataResolver,
ObjectRecordCountService,
ObjectMetadataToolsFactory,
],
exports: [ObjectMetadataService, ObjectMetadataToolsFactory],
@@ -1,8 +1,16 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Context, Mutation, Parent, ResolveField } from '@nestjs/graphql';
import {
Args,
Context,
Mutation,
Parent,
Query,
ResolveField,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -10,7 +18,6 @@ import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.typ
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
@@ -19,8 +26,10 @@ import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dto
import { CreateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ObjectRecordCountDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@@ -35,9 +44,18 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
export class ObjectMetadataResolver {
constructor(
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectRecordCountService: ObjectRecordCountService,
private readonly i18nService: I18nService,
) {}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Query(() => [ObjectRecordCountDTO])
async objectRecordCounts(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ObjectRecordCountDTO[]> {
return this.objectRecordCountService.getRecordCounts(workspaceId);
}
@ResolveField(() => String, { nullable: true })
async labelPlural(
@Parent() objectMetadata: ObjectMetadataDTO,
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type ObjectRecordCountDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@Injectable()
export class ObjectRecordCountService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async getRecordCounts(workspaceId: string): Promise<ObjectRecordCountDTO[]> {
const { flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const flatObjectMetadatas = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
).filter(isDefined);
const schemaName = getWorkspaceSchemaName(workspaceId);
const dataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
const rows: { relname: string; approximate_count: number }[] =
await dataSource.query(
`SELECT relname, reltuples::bigint AS approximate_count
FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = $1
AND c.relkind = 'r'`,
[schemaName],
undefined,
{ shouldBypassPermissionChecks: true },
);
const countByTableName = new Map<string, number>();
for (const row of rows) {
countByTableName.set(
row.relname,
Math.max(0, Number(row.approximate_count)),
);
}
return flatObjectMetadatas.map((flatObjectMetadata) => ({
objectNamePlural: flatObjectMetadata.namePlural,
totalCount:
countByTableName.get(
computeTableName(
flatObjectMetadata.nameSingular,
flatObjectMetadata.isCustom,
),
) ?? 0,
}));
}
}
@@ -0,0 +1,73 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const OBJECT_RECORD_COUNTS_QUERY = gql`
query ObjectRecordCounts {
objectRecordCounts {
objectNamePlural
totalCount
}
}
`;
describe('objectRecordCounts', () => {
it('should return counts for all workspace objects', async () => {
const response = await makeMetadataAPIRequest({
query: OBJECT_RECORD_COUNTS_QUERY,
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.objectRecordCounts).toBeDefined();
const counts: { objectNamePlural: string; totalCount: number }[] =
response.body.data.objectRecordCounts;
expect(Array.isArray(counts)).toBe(true);
expect(counts.length).toBeGreaterThan(0);
for (const entry of counts) {
expect(typeof entry.objectNamePlural).toBe('string');
expect(entry.objectNamePlural.length).toBeGreaterThan(0);
expect(typeof entry.totalCount).toBe('number');
expect(entry.totalCount).toBeGreaterThanOrEqual(0);
}
});
it('should include well-known standard objects', async () => {
const response = await makeMetadataAPIRequest({
query: OBJECT_RECORD_COUNTS_QUERY,
});
const counts: { objectNamePlural: string; totalCount: number }[] =
response.body.data.objectRecordCounts;
const objectNames = counts.map((entry) => entry.objectNamePlural);
expect(objectNames).toContain('people');
expect(objectNames).toContain('companies');
expect(objectNames).toContain('opportunities');
});
it('should return non-negative totalCount values', async () => {
const response = await makeMetadataAPIRequest({
query: OBJECT_RECORD_COUNTS_QUERY,
});
const counts: { objectNamePlural: string; totalCount: number }[] =
response.body.data.objectRecordCounts;
const negativeEntries = counts.filter((entry) => entry.totalCount < 0);
expect(negativeEntries).toHaveLength(0);
});
it('should reject requests with an invalid token', async () => {
const response = await makeMetadataAPIRequest(
{ query: OBJECT_RECORD_COUNTS_QUERY },
INVALID_ACCESS_TOKEN,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBeGreaterThan(0);
});
});