diff --git a/.changeset/eager-boxes-wash.md b/.changeset/eager-boxes-wash.md new file mode 100644 index 00000000..6edd741d --- /dev/null +++ b/.changeset/eager-boxes-wash.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": minor +"@graphprotocol/hypergraph": minor +--- + +Change behavior of entity validation by filtering out invalid entities of relations and adding them to `invalidRelationEntities` + \ No newline at end of file diff --git a/.changeset/loose-candles-attack.md b/.changeset/loose-candles-attack.md new file mode 100644 index 00000000..5e4ef0c3 --- /dev/null +++ b/.changeset/loose-candles-attack.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": minor +"@graphprotocol/hypergraph": minor +--- + +enrich `invalidEntities` to expose both the raw payload and decode error for each invalid entity + \ No newline at end of file diff --git a/.changeset/rotten-animals-hunt.md b/.changeset/rotten-animals-hunt.md new file mode 100644 index 00000000..3c22f4f8 --- /dev/null +++ b/.changeset/rotten-animals-hunt.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": minor +"@graphprotocol/hypergraph": minor +--- + +Entity.findOnePublic will return { entity: null, invalidEntity: { raw: {…}, error: … } } instead of throwing an error in case it's an invalid entity + \ No newline at end of file diff --git a/.changeset/shaky-guests-report.md b/.changeset/shaky-guests-report.md new file mode 100644 index 00000000..c9ae6de1 --- /dev/null +++ b/.changeset/shaky-guests-report.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +Add a logInvalidResults toggle to `Entity.findOnePublic/findManyPublic`, plus pass-through support in the React provider and hooks, so apps can selectively silence or surface schema-validation warnings while still receiving the invalid payload lists. + \ No newline at end of file diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index a660adb5..4efde94f 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,4 +1,4 @@ -import { useEntities, usePublicSpaces } from '@graphprotocol/hypergraph-react'; +import { useEntities, useEntity, usePublicSpaces } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; import { Podcast, Topic } from '@/schema'; @@ -22,19 +22,23 @@ function RouteComponent() { // }, 1000); // }, []); - // const { data: podcast } = useEntity(Podcast, { - // id: 'f5d27d3e-3a51-452d-bac2-702574381633', - // mode: 'public', - // space: space, - // include: { - // listenOn: {}, - // hosts: { - // avatar: {}, - // }, - // episodes: {}, - // }, - // }); - // console.log({ podcast }); + const { + data: podcast, + invalidEntity, + invalidRelationEntities, + } = useEntity(Podcast, { + id: 'f5d27d3e-3a51-452d-bac2-702574381633', + mode: 'public', + space: space, + include: { + listenOn: {}, + hosts: { + avatar: {}, + }, + episodes: {}, + }, + }); + console.log({ podcast, invalidEntity, invalidRelationEntities }); const { data, isLoading, isError } = useEntities(Podcast, { mode: 'public', diff --git a/docs/docs/query-private-data.md b/docs/docs/query-private-data.md index 5f3400f4..53a85dd8 100644 --- a/docs/docs/query-private-data.md +++ b/docs/docs/query-private-data.md @@ -50,7 +50,7 @@ Please learn more about filtering in the [Filtering query results](#filtering-qu useEntities for private data returns: - data - a list of entities defined in your schema -- invalidEntities - a list of entities that are in your space storage with correct type, but can't be parsed to your schema +- invalidEntities - each entry contains the invalid raw payload (`raw`) alongside the decode `error` - deleted - a list of entities that are marked as deleted, we keep them around to be able to later be able to publish the deleted information to the public knowledge graph ```ts diff --git a/docs/docs/query-public-data.md b/docs/docs/query-public-data.md index dcea4529..7ad4618e 100644 --- a/docs/docs/query-public-data.md +++ b/docs/docs/query-public-data.md @@ -83,17 +83,18 @@ Please learn more about filtering in the [Filtering query results](#filtering-qu ### Returned data -useEntities for private data returns: +useEntities for public data returns: - data - a list of entities defined in your schema -- invalidEntities - a list of entities that are in your space storage with correct type, but can't be parsed to your schema +- invalidEntities - each entry includes the invalid raw payload (`raw`) plus the corresponding `error` explaining why decoding failed +- invalidRelationEntities - each entry includes the invalid raw payload (`raw`) plus the corresponding `error` explaining why decoding failed - isPending - a boolean indicating if the query is loading - isError - a boolean indicating if the query failed In addition you have access to the full response from `@tanstack/react-query`'s `useQuery` hook, which is used internally to query the public data. ```ts -const { data, isPending, isError } = useEntities(Event, { mode: 'public' }); +const { data, invalidEntities, invalidRelationEntities, isPending, isError } = useEntities(Event, { mode: 'public' }); ``` ## Fetching a single public entity @@ -106,7 +107,7 @@ When you only need a single entity—for example to power a detail page—you ca import { useEntity } from '@graphprotocol/hypergraph-react'; import { Project } from '../schema'; -const { data: project, isPending, isError } = useEntity(Project, { +const { data: project, invalidEntity, invalidRelationEntities, isPending, isError } = useEntity(Project, { id: '9f130661-8c3f-4db7-9bdc-3ce69631c5ef', space: '3f32353d-3b27-4a13-b71a-746f06e1f7db', mode: 'public', diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index be33703b..c93814da 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -124,6 +124,7 @@ export type HypergraphAppCtx = { error: string; }; syncServerUri: string; + logInvalidResults: boolean; }; export const HypergraphAppContext = createContext({ @@ -209,6 +210,7 @@ export const HypergraphAppContext = createContext({ throw new Error('processConnectAuthSuccess is missing'); }, syncServerUri: '', + logInvalidResults: true, }); export function useHypergraphApp() { @@ -229,6 +231,7 @@ export type HypergraphAppProviderProps = Readonly<{ chainId?: number; children: ReactNode; appId: string; + logInvalidResults?: boolean; }>; const mockStorage = { @@ -245,6 +248,7 @@ export function HypergraphAppProvider({ chainId = Connect.GEO_TESTNET.id, appId, children, + logInvalidResults = true, }: HypergraphAppProviderProps) { const [websocketConnection, setWebsocketConnection] = useState(); const [isConnecting, setIsConnecting] = useState(true); @@ -1567,6 +1571,7 @@ export function HypergraphAppProvider({ redirectToConnect: redirectToConnectForContext, processConnectAuthSuccess: processConnectAuthSuccessForContext, syncServerUri, + logInvalidResults, }} > diff --git a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts index 5ce1a48c..b5abb073 100644 --- a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts +++ b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts @@ -3,6 +3,7 @@ import { useInfiniteQuery as useInfiniteQueryTanstack } from '@tanstack/react-qu import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; +import { useHypergraphApp } from '../HypergraphAppContext.js'; import type { QueryPublicParams } from '../internal/types.js'; import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; @@ -10,8 +11,19 @@ export const useEntitiesPublicInfinite = ( type: S, params?: QueryPublicParams, ) => { - const { enabled = true, filter, include, space: spaceFromParams, spaces, first = 2, offset = 0 } = params ?? {}; + const { + enabled = true, + filter, + include, + space: spaceFromParams, + spaces, + first = 2, + offset = 0, + logInvalidResults: logInvalidResultsParam, + } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const { logInvalidResults: contextLogInvalidResults = true } = useHypergraphApp(); + const logInvalidResults = logInvalidResultsParam ?? contextLogInvalidResults ?? true; const space = spaceFromParams ?? spaceFromContext; const spaceSelectionKey = spaces ?? space; const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( @@ -27,6 +39,7 @@ export const useEntitiesPublicInfinite = ( ...(spaces ? { spaces } : { space }), first, offset: pageParam, + logInvalidResults, }); }, getNextPageParam: (_lastPage, pages) => { diff --git a/packages/hypergraph-react/src/hooks/use-entities.tsx b/packages/hypergraph-react/src/hooks/use-entities.tsx index 883702b9..1e006ead 100644 --- a/packages/hypergraph-react/src/hooks/use-entities.tsx +++ b/packages/hypergraph-react/src/hooks/use-entities.tsx @@ -1,5 +1,6 @@ import type { Entity } from '@graphprotocol/hypergraph'; import type * as Schema from 'effect/Schema'; +import { useHypergraphApp } from '../HypergraphAppContext.js'; import { useEntitiesPrivate } from '../internal/use-entities-private.js'; import { useEntitiesPublic } from '../internal/use-entities-public.js'; import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; @@ -26,11 +27,25 @@ type UseEntitiesParams = ( } | undefined; backlinksTotalCountsTypeId1?: string | undefined; + logInvalidResults?: boolean; }; export function useEntities(type: S, params: UseEntitiesParams) { - const { mode, filter, include, space, spaces, first, offset, orderBy, backlinksTotalCountsTypeId1 } = params; + const { + mode, + filter, + include, + space, + spaces, + first, + offset, + orderBy, + backlinksTotalCountsTypeId1, + logInvalidResults: logInvalidResultsParam, + } = params; const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const { logInvalidResults: contextLogInvalidResults = true } = useHypergraphApp(); + const logInvalidResults = logInvalidResultsParam ?? contextLogInvalidResults ?? true; const resolvedSpace = space ?? spaceFromContext; const publicSpaceParams = spaces ? { spaces } : { space: resolvedSpace }; const publicResult = useEntitiesPublic(type, { @@ -42,6 +57,7 @@ export function useEntities(type: S, orderBy, backlinksTotalCountsTypeId1, ...publicSpaceParams, + logInvalidResults, }); const localResult = useEntitiesPrivate(type, { enabled: mode === 'private', filter, include, space: resolvedSpace }); diff --git a/packages/hypergraph-react/src/hooks/use-entity.tsx b/packages/hypergraph-react/src/hooks/use-entity.tsx index 2f97f97d..920825cc 100644 --- a/packages/hypergraph-react/src/hooks/use-entity.tsx +++ b/packages/hypergraph-react/src/hooks/use-entity.tsx @@ -1,5 +1,6 @@ import type { Entity, Id } from '@graphprotocol/hypergraph'; import type * as Schema from 'effect/Schema'; +import { useHypergraphApp } from '../HypergraphAppContext.js'; import { useEntityPrivate } from '../internal/use-entity-private.js'; import { useEntityPublic } from '../internal/use-entity-public.js'; @@ -10,14 +11,27 @@ export function useEntity( space?: string; mode: 'private' | 'public'; include?: Entity.EntityInclude | undefined; + logInvalidResults?: boolean; }, ) { - const resultPublic = useEntityPublic(type, { ...params, enabled: params.mode === 'public' }); - const resultPrivate = useEntityPrivate(type, { ...params, enabled: params.mode === 'private' }); + const { mode, logInvalidResults: logInvalidResultsParam, ...restParams } = params; + const { logInvalidResults: contextLogInvalidResults = true } = useHypergraphApp(); + const logInvalidResults = logInvalidResultsParam ?? contextLogInvalidResults ?? true; + const resultPublic = useEntityPublic(type, { + ...restParams, + logInvalidResults, + enabled: mode === 'public', + }); + const resultPrivate = useEntityPrivate(type, { ...restParams, enabled: mode === 'private' }); - if (params.mode === 'public') { + if (mode === 'public') { return resultPublic; } - return resultPrivate; + return { + ...resultPublic, + data: resultPrivate.data, + invalidEntity: resultPrivate.invalidEntity, + invalidRelationEntities: [], + }; } diff --git a/packages/hypergraph-react/src/internal/use-entities-public.tsx b/packages/hypergraph-react/src/internal/use-entities-public.tsx index d50b7106..895716a5 100644 --- a/packages/hypergraph-react/src/internal/use-entities-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entities-public.tsx @@ -17,6 +17,7 @@ export const useEntitiesPublic = (type: S, offset, orderBy, backlinksTotalCountsTypeId1, + logInvalidResults = true, } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; @@ -46,10 +47,16 @@ export const useEntitiesPublic = (type: S, offset, orderBy, backlinksTotalCountsTypeId1, + logInvalidResults, }); }, enabled, }); - return { ...result, data: result.data?.data || [], invalidEntities: result.data?.invalidEntities || [] }; + return { + ...result, + data: result.data?.data || [], + invalidEntities: result.data?.invalidEntities || [], + invalidRelationEntities: result.data?.invalidRelationEntities || [], + }; }; diff --git a/packages/hypergraph-react/src/internal/use-entity-private.tsx b/packages/hypergraph-react/src/internal/use-entity-private.tsx index 6ea1f038..ab2f81f7 100644 --- a/packages/hypergraph-react/src/internal/use-entity-private.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-private.tsx @@ -18,7 +18,7 @@ export function useEntityPrivate( const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled }); const prevEntityRef = useRef<{ data: Entity.Entity | undefined; - invalidEntity: Record | undefined; + invalidEntity: Entity.InvalidEntity | undefined; isPending: boolean; isError: boolean; }>({ data: undefined, invalidEntity: undefined, isPending: false, isError: false }); diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index d78f4a52..65d8230d 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -11,10 +11,11 @@ type UseEntityPublicParams = { space?: string; // TODO: restrict multi-level nesting to the actual relation keys include?: Entity.EntityInclude | undefined; + logInvalidResults?: boolean; }; export const useEntityPublic = (type: S, params: UseEntityPublicParams) => { - const { id, enabled = true, space: spaceFromParams, include } = params; + const { id, enabled = true, space: spaceFromParams, include, logInvalidResults = true } = params; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; @@ -29,10 +30,16 @@ export const useEntityPublic = (type: S, p id, space, include, + logInvalidResults, }); }, enabled: enabled && !!id && !!space, }); - return { ...result, data: result.data ?? null, invalidEntity: null }; + return { + ...result, + data: result.data?.entity ?? null, + invalidEntity: result.data?.invalidEntity ?? null, + invalidRelationEntities: result.data?.invalidRelationEntities ?? [], + }; }; diff --git a/packages/hypergraph-react/test/HypergraphAppContext.test.tsx b/packages/hypergraph-react/test/HypergraphAppContext.test.tsx index 02c27e94..3c7d2928 100644 --- a/packages/hypergraph-react/test/HypergraphAppContext.test.tsx +++ b/packages/hypergraph-react/test/HypergraphAppContext.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest'; -import { cleanup, renderHook } from '@testing-library/react'; +import { cleanup, renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; @@ -33,11 +33,10 @@ describe('HypergraphAppContext', () => { const { result: authenticatedResult } = renderHook(() => useHypergraphAuth(), { wrapper }); // hook won't work until the Provider loaded automerge and then renders the children expect(authenticatedResult.current).toEqual(null); - // after automerge is loaded, the hook will be rendered and the authenticated state will be set - // TODO: use something more reliable than setTimeout - setTimeout(() => { - expect(authenticatedResult.current.authenticated).toEqual(false); - expect(authenticatedResult.current.identity).toBeNull(); - }, 50); + // wait until automerge is loaded and the provider exposes auth state + await waitFor(() => { + expect(authenticatedResult.current?.authenticated).toEqual(false); + }); + expect(authenticatedResult.current?.identity).toBeNull(); }); }); diff --git a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx index 83dace06..2fa010fb 100644 --- a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx +++ b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx @@ -122,17 +122,19 @@ describe('HypergraphSpaceContext', () => { wrapper, }, ); - expect(queryEntityResult.current).toEqual({ - data: createdEntity, - invalidEntity: undefined, - isPending: false, - isError: false, + await waitFor(() => { + expect(queryEntityResult.current.data).toEqual(createdEntity); }); + expect(queryEntityResult.current.invalidEntity).toBeUndefined(); + expect(queryEntityResult.current.isError).toBe(false); } rerender(); - expect(queryEntitiesResult.current).toEqual({ deletedEntities: [], entities: [createdEntity] }); + await waitFor(() => { + expect(queryEntitiesResult.current.entities).toEqual([createdEntity]); + expect(queryEntitiesResult.current.deletedEntities).toEqual([]); + }); }); }); @@ -176,26 +178,29 @@ describe('HypergraphSpaceContext', () => { const { result: queryEntityResult } = renderHook(() => useEntity(Person, { id: id, mode: 'private' }), { wrapper, }); - expect(queryEntityResult.current).toEqual({ - data: { - // @ts-expect-error - TODO: fix the types error + await waitFor(() => { + expect(queryEntityResult.current.data).toEqual({ ...createdEntity, __deleted: false, __schema: Person, - }, - invalidEntity: undefined, - isPending: false, - isError: false, + }); }); + expect(queryEntityResult.current.invalidEntity).toBeUndefined(); + expect(queryEntityResult.current.isError).toBe(false); const { result: queryEntitiesResult, rerender } = renderHook(() => useEntitiesPrivate(Person), { wrapper }); rerender(); - expect(queryEntitiesResult.current).toEqual({ - deletedEntities: [], - // @ts-expect-error - TODO: fix the types error - entities: [{ ...createdEntity, __deleted: false, __schema: Person }], + await waitFor(() => { + expect(queryEntitiesResult.current.deletedEntities).toEqual([]); + expect(queryEntitiesResult.current.entities).toEqual([ + { + ...createdEntity, + __deleted: false, + __schema: Person, + }, + ]); }); }); }); @@ -238,9 +243,11 @@ describe('HypergraphSpaceContext', () => { rerenderQueryEntities(); - expect(queryEntitiesResult.current.entities).toHaveLength(0); - expect(queryEntitiesResult.current.entities).toEqual([]); - expect(queryEntitiesResult.current.deletedEntities).toHaveLength(1); + await waitFor(() => { + expect(queryEntitiesResult.current.entities).toHaveLength(0); + expect(queryEntitiesResult.current.entities).toEqual([]); + expect(queryEntitiesResult.current.deletedEntities).toHaveLength(1); + }); }); }); }); diff --git a/packages/hypergraph-react/vitest.config.ts b/packages/hypergraph-react/vitest.config.ts index fe67e65c..5bd704a9 100644 --- a/packages/hypergraph-react/vitest.config.ts +++ b/packages/hypergraph-react/vitest.config.ts @@ -7,6 +7,8 @@ const config: UserConfigExport = { plugins: [react()], test: { environment: 'jsdom', + include: ['test/**/*.{test,spec}.{ts,tsx}'], + exclude: ['build/**', 'dist/**', 'publish/**', 'node_modules/**'], }, }; diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 7cd7da5b..b8da179a 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -2,10 +2,11 @@ import { Graph } from '@graphprotocol/grc-20'; import { Constants, type Entity, Utils } from '@graphprotocol/hypergraph'; import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; +import type * as ParseResult from 'effect/ParseResult'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; import { request } from 'graphql-request'; -import type { RelationsListWithNodes } from '../utils/convert-relations.js'; +import type { InvalidRelationEntity, RelationsListWithNodes } from '../utils/convert-relations.js'; import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; import type { SpaceSelection } from './internal/space-selection.js'; @@ -25,6 +26,7 @@ export type FindManyPublicParams = SpaceSe } | undefined; backlinksTotalCountsTypeId1?: string | undefined; + logInvalidResults?: boolean | undefined; }; const buildEntitiesQuery = ( @@ -110,6 +112,13 @@ type ValuesList = { point: string; }[]; +type RawEntity = Record; + +export type InvalidEntity = { + raw: RawEntity; + error: ParseResult.ParseError; +}; + export type EntityQueryResult = { entities: ({ id: string; @@ -134,10 +143,11 @@ export const parseResult = ( const schemaWithId = Utils.addIdSchemaField(type); const decode = Schema.decodeUnknownEither(schemaWithId); const data: (Entity.Entity & { backlinksTotalCountsTypeId1?: number })[] = []; - const invalidEntities: Record[] = []; + const invalidEntities: InvalidEntity[] = []; + const invalidRelationEntities: InvalidRelationEntity[] = []; for (const queryEntity of queryData.entities) { - let rawEntity: Record = { + let rawEntity: RawEntity = { id: queryEntity.id, }; @@ -162,11 +172,19 @@ export const parseResult = ( } } + const { rawEntity: relationEntities, invalidRelations } = Utils.convertRelations( + queryEntity, + ast, + relationInfoLevel1, + ); // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), + ...relationEntities, }; + if (invalidRelations.length > 0) { + invalidRelationEntities.push(...invalidRelations); + } const decodeResult = decode({ ...rawEntity, @@ -181,10 +199,10 @@ export const parseResult = ( backlinksTotalCountsTypeId1: queryEntity.backlinksTotalCountsTypeId1?.totalCount, }); } else { - invalidEntities.push(rawEntity); + invalidEntities.push({ raw: rawEntity, error: decodeResult.left }); } } - return { data, invalidEntities }; + return { data, invalidEntities, invalidRelationEntities }; }; export const findManyPublic = async ( @@ -200,6 +218,7 @@ export const findManyPublic = async ( offset = 0, orderBy, backlinksTotalCountsTypeId1, + logInvalidResults = true, } = params ?? {}; // constructing the relation type ids for the query @@ -271,6 +290,14 @@ export const findManyPublic = async ( const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, queryVariables); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds); - return { data, invalidEntities }; + const { data, invalidEntities, invalidRelationEntities } = parseResult(result, type, relationTypeIds); + if (logInvalidResults) { + if (invalidEntities.length > 0) { + console.warn('Entities where decoding failed were dropped', invalidEntities); + } + if (invalidRelationEntities.length > 0) { + console.warn('Relation entities where decoding failed were dropped', invalidRelationEntities); + } + } + return { data, invalidEntities, invalidRelationEntities }; }; diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index 055a883a..8b140baa 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -18,6 +18,7 @@ export type FindOnePublicParams = { space: string; // TODO: restrict multi-level nesting to the actual relation keys include?: Entity.EntityInclude | undefined; + logInvalidResults?: boolean | undefined; }; const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { @@ -49,7 +50,11 @@ const parseResult = ( relationInfoLevel1: RelationTypeIdInfo[], ) => { if (!queryData.entity) { - return null; + return { + entity: null, + invalidEntity: null, + invalidRelationEntities: [], + }; } const schemaWithId = Utils.addIdSchemaField(type); @@ -80,29 +85,38 @@ const parseResult = ( } } + const { rawEntity: relationEntities, invalidRelations } = Utils.convertRelations( + queryEntity, + ast, + relationInfoLevel1, + ); // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), + ...relationEntities, }; - const decodeResult = decode({ ...rawEntity, __deleted: false, }); if (Either.isRight(decodeResult)) { - return { ...decodeResult.right } as Entity.Entity; + return { + entity: { ...decodeResult.right } as Entity.Entity, + invalidEntity: null, + invalidRelationEntities: invalidRelations, + }; } - // if (process.env.NODE_ENV !== 'production') { - console.warn('Invalid entity', rawEntity); - // } - throw new Error('Invalid entity'); + return { + entity: null, + invalidEntity: { raw: rawEntity, error: decodeResult.left }, + invalidRelationEntities: invalidRelations, + }; }; export const findOnePublic = async (type: S, params: FindOnePublicParams) => { - const { id, space, include } = params; + const { id, space, include, logInvalidResults = true } = params; // constructing the relation type ids for the query const relationTypeIds = Utils.getRelationTypeIds(type, include); @@ -114,5 +128,14 @@ export const findOnePublic = async (type: spaceId: space, }); - return parseResult(result, type, relationTypeIds); + const parsed = parseResult(result, type, relationTypeIds); + if (logInvalidResults) { + if (parsed.invalidEntity) { + console.warn('Entity decoding failed', parsed.invalidEntity); + } + if (parsed.invalidRelationEntities.length > 0) { + console.warn('Relation entities where decoding failed were dropped', parsed.invalidRelationEntities); + } + } + return parsed; }; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index bad8d144..3a9e1318 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -74,6 +74,6 @@ export const searchManyPublic = async ( offset, }); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds); - return { data, invalidEntities }; + const { data, invalidEntities, invalidRelationEntities } = parseResult(result, type, relationTypeIds); + return { data, invalidEntities, invalidRelationEntities }; }; diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index 385ebadd..f8d5f353 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -1,6 +1,8 @@ import { Constants, Utils } from '@graphprotocol/hypergraph'; +import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; -import type * as Schema from 'effect/Schema'; +import type * as ParseResult from 'effect/ParseResult'; +import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; import { convertPropertyValue } from './convert-property-value.js'; import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; @@ -46,12 +48,30 @@ type RecursiveQueryEntity = { type RawEntityValue = string | boolean | number | unknown[] | Date | { id: string }; type RawEntity = Record; type NestedRawEntity = RawEntity & { _relation: { id: string } & Record }; + +export type InvalidRelationEntity = { + parentEntityId: string; + propertyName: string; + propertyTypeId: string; + relationId: string; + relationTypeId: string; + toEntityId: string; + raw: NestedRawEntity; + error: ParseResult.ParseError; +}; + +type ConvertRelationsResult = { + rawEntity: RawEntity; + invalidRelations: InvalidRelationEntity[]; +}; + export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( queryEntity: RecursiveQueryEntity, ast: SchemaAST.TypeLiteral, relationInfo: RelationTypeIdInfo[] = [], -) => { +): ConvertRelationsResult => { const rawEntity: RawEntity = {}; + const invalidRelations: InvalidRelationEntity[] = []; for (const prop of ast.propertySignatures) { const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); @@ -93,6 +113,9 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( } } + const relationSchema = Schema.make(relationTransformation); + const decodeRelation = Schema.decodeUnknownEither(relationSchema); + if (allRelationsWithTheCorrectPropertyTypeId) { for (const relationEntry of allRelationsWithTheCorrectPropertyTypeId) { let nestedRawEntity: NestedRawEntity = { @@ -102,7 +125,7 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( }, }; - const relationsForRawNestedEntity = convertRelations( + const childConversion = convertRelations( relationEntry.toEntity, relationTransformation, relationMetadata?.children ?? [], @@ -110,8 +133,9 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( nestedRawEntity = { ...nestedRawEntity, - ...relationsForRawNestedEntity, + ...childConversion.rawEntity, }; + invalidRelations.push(...childConversion.invalidRelations); for (const nestedProp of relationTransformation.propertySignatures) { const propType = @@ -156,8 +180,21 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( } } - // TODO: in the end every entry should be validated using the Schema?!? - rawEntity[String(prop.name)] = [...(rawEntity[String(prop.name)] as unknown[]), nestedRawEntity]; + const decodedRelation = decodeRelation(nestedRawEntity); + if (Either.isRight(decodedRelation)) { + rawEntity[String(prop.name)] = [...(rawEntity[String(prop.name)] as unknown[]), nestedRawEntity]; + } else { + invalidRelations.push({ + parentEntityId: queryEntity.id, + propertyName: String(prop.name), + propertyTypeId: result.value, + relationId: relationEntry.id, + relationTypeId: relationEntry.typeId, + toEntityId: relationEntry.toEntity.id, + raw: nestedRawEntity, + error: decodedRelation.left, + }); + } } } @@ -167,5 +204,5 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( } } - return rawEntity; + return { rawEntity, invalidRelations }; }; diff --git a/packages/hypergraph/test/entity/find-many-public.test.ts b/packages/hypergraph/test/entity/find-many-public.test.ts new file mode 100644 index 00000000..cd7b9e8a --- /dev/null +++ b/packages/hypergraph/test/entity/find-many-public.test.ts @@ -0,0 +1,119 @@ +import { Id } from '@graphprotocol/grc-20'; +import { describe, expect, it } from 'vitest'; +import { parseResult } from '../../src/entity/find-many-public.js'; +import * as Entity from '../../src/entity/index.js'; +import * as Type from '../../src/type/type.js'; +import { getRelationTypeIds } from '../../src/utils/get-relation-type-ids.js'; +import { getRelationAlias } from '../../src/utils/relation-query-helpers.js'; + +const TITLE_PROPERTY_ID = Id('79c1a951-0074-4010-87d0-7501ef9d7b3d'); +const CHILDREN_RELATION_PROPERTY_ID = Id('ca7c7167-2502-49c4-90b0-84c147f9b12b'); +const CHILD_NAME_PROPERTY_ID = Id('25584af0-3941-4ab9-86f7-a603305b19bb'); + +const Child = Entity.Schema( + { + name: Type.String, + }, + { + types: [Id('3c2ae3aa-4ec1-41e3-bc4c-1fe7a5e07bc1')], + properties: { + name: CHILD_NAME_PROPERTY_ID, + }, + }, +); + +const Parent = Entity.Schema( + { + title: Type.String, + children: Type.Relation(Child), + }, + { + types: [Id('af571d8c-06d4-4add-8cfa-4c6b50412254')], + properties: { + title: TITLE_PROPERTY_ID, + children: CHILDREN_RELATION_PROPERTY_ID, + }, + }, +); + +const buildValueEntry = ( + propertyId: string, + value: Partial<{ string: string; boolean: boolean; number: number; time: string; point: string }> = {}, +) => ({ + propertyId, + string: value.string ?? '', + boolean: value.boolean ?? false, + number: value.number ?? 0, + time: value.time ?? new Date(0).toISOString(), + point: value.point ?? '0,0', +}); + +describe('findManyPublic parseResult', () => { + it('collects invalidEntities when decoding fails', () => { + const queryData = { + entities: [ + { + id: 'parent-valid', + name: 'Parent valid', + valuesList: [buildValueEntry(TITLE_PROPERTY_ID, { string: 'Parent valid' })], + backlinksTotalCountsTypeId1: null, + }, + { + id: 'parent-invalid', + name: 'Parent invalid', + valuesList: [], + backlinksTotalCountsTypeId1: null, + }, + ], + }; + + const result = parseResult(queryData, Parent, []); + + expect(result.data).toHaveLength(1); + expect(result.invalidEntities).toHaveLength(1); + expect(result.invalidEntities[0].raw.id).toBe('parent-invalid'); + expect(result.invalidRelationEntities).toHaveLength(0); + }); + + it('collects invalidRelationEntities when nested relations fail to decode', () => { + const relationAlias = getRelationAlias(CHILDREN_RELATION_PROPERTY_ID); + const relationInfo = getRelationTypeIds(Parent, { children: {} }); + + const queryData = { + entities: [ + { + id: 'parent-with-invalid-child', + name: 'Parent with invalid child', + valuesList: [buildValueEntry(TITLE_PROPERTY_ID, { string: 'Parent with invalid child' })], + backlinksTotalCountsTypeId1: null, + [relationAlias]: { + nodes: [ + { + id: 'relation-1', + entity: { valuesList: [] }, + toEntity: { + id: 'child-bad', + name: 'Child bad', + valuesList: [], + }, + typeId: CHILDREN_RELATION_PROPERTY_ID, + }, + ], + }, + }, + ], + }; + + // @ts-expect-error + const result = parseResult(queryData, Parent, relationInfo); + + expect(result.data).toHaveLength(1); + expect(result.data[0].children).toEqual([]); + expect(result.invalidEntities).toHaveLength(0); + expect(result.invalidRelationEntities).toHaveLength(1); + expect(result.invalidRelationEntities[0]).toMatchObject({ + parentEntityId: 'parent-with-invalid-child', + propertyName: 'children', + }); + }); +}); diff --git a/packages/hypergraph/test/entity/find-one-public.test.ts b/packages/hypergraph/test/entity/find-one-public.test.ts new file mode 100644 index 00000000..add8b2cd --- /dev/null +++ b/packages/hypergraph/test/entity/find-one-public.test.ts @@ -0,0 +1,123 @@ +import { Id } from '@graphprotocol/grc-20'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { findOnePublic } from '../../src/entity/find-one-public.js'; +import * as Entity from '../../src/entity/index.js'; +import * as Type from '../../src/type/type.js'; +import { getRelationAlias } from '../../src/utils/relation-query-helpers.js'; + +const mockRequest = vi.hoisted(() => vi.fn()); + +vi.mock('graphql-request', () => ({ + request: mockRequest, +})); + +const TITLE_PROPERTY_ID = Id('c6c9ad0f-f333-4f50-8e92-8d93bc38b63c'); +const CHILDREN_RELATION_PROPERTY_ID = Id('1e8caeb9-3e64-4dd3-b7a4-3d9cc714d4f2'); +const CHILD_NAME_PROPERTY_ID = Id('7a9e63df-80e3-4c44-baf4-844a6b9511cd'); + +const Child = Entity.Schema( + { + name: Type.String, + }, + { + types: [Id('1a6d868a-cb0c-4b2d-aef8-8e4e8a9a6a55')], + properties: { + name: CHILD_NAME_PROPERTY_ID, + }, + }, +); + +const Parent = Entity.Schema( + { + title: Type.String, + children: Type.Relation(Child), + }, + { + types: [Id('6a9fee9c-0a62-4272-b64d-99c83f3f970b')], + properties: { + title: TITLE_PROPERTY_ID, + children: CHILDREN_RELATION_PROPERTY_ID, + }, + }, +); + +const buildValueEntry = ( + propertyId: string, + value: Partial<{ string: string; boolean: boolean; number: number; time: string; point: string }> = {}, +) => ({ + propertyId, + string: value.string ?? '', + boolean: value.boolean ?? false, + number: value.number ?? 0, + time: value.time ?? new Date(0).toISOString(), + point: value.point ?? '0,0', +}); + +describe('findOnePublic', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('surfaces invalidEntity data when decoding fails', async () => { + mockRequest.mockResolvedValueOnce({ + entity: { + id: 'parent-1', + name: 'Parent 1', + valuesList: [], + }, + }); + + const result = await findOnePublic(Parent, { id: 'parent-1', space: 'space-1' }); + + expect(result.entity).toBeNull(); + expect(result.invalidEntity).not.toBeNull(); + expect(result.invalidEntity?.raw.id).toBe('parent-1'); + expect(result.invalidRelationEntities).toHaveLength(0); + }); + + it('collects invalidRelationEntities when nested relations fail to decode', async () => { + const relationAlias = getRelationAlias(CHILDREN_RELATION_PROPERTY_ID); + + const entity = { + id: 'parent-2', + name: 'Parent 2', + valuesList: [buildValueEntry(TITLE_PROPERTY_ID, { string: 'Parent 2' })], + [relationAlias]: { + nodes: [ + { + id: 'relation-1', + entity: { valuesList: [] }, + toEntity: { + id: 'child-1', + name: 'Child 1', + valuesList: [], + }, + typeId: CHILDREN_RELATION_PROPERTY_ID, + }, + ], + }, + }; + + mockRequest.mockResolvedValueOnce({ + entity, + }); + + const result = await findOnePublic(Parent, { + id: 'parent-2', + space: 'space-1', + include: { + children: {}, + }, + }); + + expect(result.entity).not.toBeNull(); + expect(result.entity?.title).toBe('Parent 2'); + expect(result.entity?.children).toEqual([]); + expect(result.invalidEntity).toBeNull(); + expect(result.invalidRelationEntities).toHaveLength(1); + expect(result.invalidRelationEntities[0]).toMatchObject({ + parentEntityId: 'parent-2', + propertyName: 'children', + }); + }); +});