From e7c206d2097edfcccee94ad9224ed36449d121c5 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 17:29:41 +0100 Subject: [PATCH 01/14] split up relations per alias --- apps/events/src/routes/podcasts.lazy.tsx | 31 +- .../hypergraph/src/entity/find-many-public.ts | 458 ++++-------------- .../hypergraph/src/utils/convert-relations.ts | 32 +- .../src/utils/get-relation-type-ids.ts | 11 + 4 files changed, 148 insertions(+), 384 deletions(-) diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 5ba4f1b4..9631c5af 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,8 +1,6 @@ -import { Entity } from '@graphprotocol/hypergraph'; +import { Podcast } from '@/schema'; import { useEntities } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { Podcast } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -11,18 +9,18 @@ export const Route = createLazyFileRoute('/podcasts')({ function RouteComponent() { const space = 'e252f9e1-d3ad-4460-8bf1-54f93b02f220'; - useEffect(() => { - setTimeout(async () => { - const result = await Entity.findOnePublic(Podcast, { - id: 'f5d27d3e-3a51-452d-bac2-702574381633', - space: space, - include: { - listenOn: {}, - }, - }); - console.log('findOnePublic result:', result); - }, 1000); - }, []); + // useEffect(() => { + // setTimeout(async () => { + // const result = await Entity.findOnePublic(Podcast, { + // id: 'f5d27d3e-3a51-452d-bac2-702574381633', + // space: space, + // include: { + // listenOn: {}, + // }, + // }); + // console.log('findOnePublic result:', result); + // }, 1000); + // }, []); const { data, isLoading, isError } = useEntities(Podcast, { mode: 'public', @@ -30,6 +28,9 @@ function RouteComponent() { space: space, include: { listenOn: {}, + hosts: { + avatar: {}, + }, }, orderBy: { property: 'dateFounded', direction: 'asc' }, backlinksTotalCountsTypeId1: '972d201a-d780-4568-9e01-543f67b26bee', diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 0098363f..eb74fcb5 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -4,7 +4,8 @@ import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; +import { request } from 'graphql-request'; +import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; export type FindManyPublicParams = { filter?: Entity.EntityFilter> | undefined; @@ -22,58 +23,15 @@ export type FindManyPublicParams = { backlinksTotalCountsTypeId1?: string | undefined; }; -const entitiesQueryDocumentLevel0 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities( - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - first: $first - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - } -} -`; +// Helper to generate alias for relation type ID +const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; -const entitiesQueryDocumentLevel1 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, +// Helper to build relationsList fragment for a single type ID +const buildRelationsListFragment = (typeId: string, level: 1 | 2) => { + const alias = getRelationAlias(typeId); + const fragment = ` + ${alias}: relationsList( + filter: {spaceId: {is: $spaceId}, typeId: {is: "${typeId}"}}, ) { id entity { @@ -97,140 +55,57 @@ query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUI time point } + ${level === 1 ? '__LEVEL2_RELATIONS__' : ''} } typeId - } - } -} -`; + }`; + return fragment; +}; -const entitiesQueryDocumentLevel2 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - # filter: {spaceId: {is: $spaceId}, toEntity: {relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $relationTypeIdsLevel2}}}}} - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; +// Build level 2 relations fragment +const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => { + if (relationInfoLevel2.length === 0) return ''; + + return relationInfoLevel2.map((info) => buildRelationsListFragment(info.typeId, 2)).join('\n'); +}; + +// Build level 1 relations fragment +const buildLevel1RelationsFragment = ( + relationInfoLevel1: RelationTypeIdInfo[], + relationInfoLevel2: RelationTypeIdInfo[], +) => { + if (relationInfoLevel1.length === 0) return ''; + + const level2Fragment = buildLevel2RelationsFragment(relationInfoLevel2); + return relationInfoLevel1 + .map((info) => { + const fragment = buildRelationsListFragment(info.typeId, 1); + return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); + }) + .join('\n'); +}; + +const buildEntitiesQuery = ( + relationInfoLevel1: RelationTypeIdInfo[], + relationInfoLevel2: RelationTypeIdInfo[], + useOrderBy: boolean, +) => { + const level1Relations = buildLevel1RelationsFragment(relationInfoLevel1, relationInfoLevel2); + console.log('level1Relations', level1Relations); + + const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; + const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; + const orderByArgs = useOrderBy ? 'propertyId: $propertyId\n sortDirection: $sortDirection\n ' : ''; -const entitiesOrderedByPropertyQueryDocumentLevel0 = gql` -query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $propertyId: UUID!, $sortDirection: SortOrder!, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities: entitiesOrderedByProperty( - filter: { and: [{ + return ` +query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { + entities: ${queryName}( + ${orderByArgs}filter: { and: [{ relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, spaceIds: {in: [$spaceId]}, }, $filter]} first: $first offset: $offset - propertyId: $propertyId - sortDirection: $sortDirection - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - } -} -`; - -const entitiesOrderedByPropertyQueryDocumentLevel1 = gql` -query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $propertyId: UUID!, $sortDirection: SortOrder!, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities: entitiesOrderedByProperty( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - propertyId: $propertyId - sortDirection: $sortDirection ) { id name @@ -245,125 +120,38 @@ query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $relationTy backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { totalCount } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } + ${level1Relations} } -} -`; +}`; +}; -const entitiesOrderedByPropertyQueryDocumentLevel2 = gql` -query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $propertyId: UUID!, $sortDirection: SortOrder!, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities: entitiesOrderedByProperty( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - propertyId: $propertyId - sortDirection: $sortDirection - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; +type RelationsList = { + id: string; + entity: { + valuesList: { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; + }[]; + }; + toEntity: { + id: string; + name: string; + valuesList: { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; + }[]; + [key: string]: unknown; // For nested aliased relationsList fields + }; + typeId: string; +}[]; type EntityQueryResult = { entities: { @@ -380,64 +168,18 @@ type EntityQueryResult = { backlinksTotalCountsTypeId1: { totalCount: number; } | null; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; + [key: string]: unknown; // For aliased relationsList_* fields }[]; }; type GraphSortDirection = 'ASC' | 'DESC'; -export const parseResult = (queryData: EntityQueryResult, type: S) => { +export const parseResult = ( + queryData: EntityQueryResult, + type: S, + relationInfoLevel1: RelationTypeIdInfo[], + relationInfoLevel2: RelationTypeIdInfo[], +) => { const schemaWithId = Utils.addIdSchemaField(type); const decode = Schema.decodeUnknownEither(schemaWithId); const data: (Entity.Entity & { backlinksTotalCountsTypeId1?: number })[] = []; @@ -472,7 +214,7 @@ export const parseResult = (queryData: Ent // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast), + ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1, relationInfoLevel2), }; const decodeResult = decode({ @@ -507,8 +249,6 @@ export const findManyPublic = async ( Option.getOrElse(() => []), ); - const relationLevel = relationTypeIds.level2.length > 0 ? 2 : relationTypeIds.level1.length > 0 ? 1 : 0; - let orderByPropertyId: string | undefined; let sortDirection: GraphSortDirection | undefined; @@ -536,26 +276,14 @@ export const findManyPublic = async ( sortDirection = orderBy.direction === 'asc' ? 'ASC' : 'DESC'; } - const queryDocument = - relationLevel === 2 - ? orderBy - ? entitiesOrderedByPropertyQueryDocumentLevel2 - : entitiesQueryDocumentLevel2 - : relationLevel === 1 - ? orderBy - ? entitiesOrderedByPropertyQueryDocumentLevel1 - : entitiesQueryDocumentLevel1 - : orderBy - ? entitiesOrderedByPropertyQueryDocumentLevel0 - : entitiesQueryDocumentLevel0; + // Build the query dynamically with aliases for each relation type ID + const queryDocument = buildEntitiesQuery(relationTypeIds.infoLevel1, relationTypeIds.infoLevel2, Boolean(orderBy)); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; const queryVariables: Record = { spaceId: space, typeIds, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, first, filter: filterParams, offset, @@ -575,6 +303,6 @@ export const findManyPublic = async ( const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, queryVariables); - const { data, invalidEntities } = parseResult(result, type); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1, relationTypeIds.infoLevel2); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index c3146431..24dd02c2 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -3,6 +3,7 @@ import * as Option from 'effect/Option'; import type * 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'; type ValueList = { propertyId: string; @@ -13,6 +14,9 @@ type ValueList = { point: string; }[]; +// Helper to generate alias for relation type ID (must match the one in find-many-public.ts) +const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; + // A recursive representation of the entity structure returned by the public GraphQL // endpoint. `values` and `relations` are optional because the nested `to` selections // get slimmer the deeper we traverse in the query. This type intentionally mirrors @@ -22,6 +26,7 @@ type RecursiveQueryEntity = { name: string; valuesList?: ValueList; relationsList?: { + totalCount: number; id: string; toEntity: RecursiveQueryEntity; entity: { @@ -29,6 +34,7 @@ type RecursiveQueryEntity = { }; typeId: string; }[]; + [key: string]: unknown; // For aliased relationsList_* fields }; type RawEntityValue = string | boolean | number | unknown[] | Date | { id: string }; @@ -38,6 +44,8 @@ type NestedRawEntity = RawEntity & { _relation: { id: string } & Record( queryEntity: RecursiveQueryEntity, ast: SchemaAST.TypeLiteral, + relationInfoLevel1: RelationTypeIdInfo[] = [], + relationInfoLevel2: RelationTypeIdInfo[] = [], ) => { const rawEntity: RawEntity = {}; @@ -64,9 +72,20 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( continue; } - const allRelationsWithTheCorrectPropertyTypeId = queryEntity.relationsList?.filter( - (a) => a.typeId === result.value, - ); + // Get relations from aliased field if we have relationInfo, otherwise fallback to old behavior + let allRelationsWithTheCorrectPropertyTypeId: RecursiveQueryEntity['relationsList']; + + if (relationInfoLevel1.length > 0) { + // Use the aliased field to get relations for this specific type ID + const alias = getRelationAlias(result.value); + allRelationsWithTheCorrectPropertyTypeId = queryEntity[alias] as RecursiveQueryEntity['relationsList']; + } else { + // Fallback to old behavior (filtering from a single relationsList) + allRelationsWithTheCorrectPropertyTypeId = queryEntity.relationsList?.filter( + (a) => a.typeId === result.value, + ); + } + if (allRelationsWithTheCorrectPropertyTypeId) { for (const relationEntry of allRelationsWithTheCorrectPropertyTypeId) { let nestedRawEntity: NestedRawEntity = { @@ -76,7 +95,12 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( }, }; - const relationsForRawNestedEntity = convertRelations(relationEntry.toEntity, relationTransformation); + const relationsForRawNestedEntity = convertRelations( + relationEntry.toEntity, + relationTransformation, + relationInfoLevel2, + [], + ); nestedRawEntity = { ...nestedRawEntity, diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 75ca0c2a..ba2f0549 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -3,6 +3,11 @@ import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; +export type RelationTypeIdInfo = { + typeId: string; + propertyName: string; +}; + export const getRelationTypeIds = ( type: Schema.Schema.AnyNoContext, include: @@ -11,6 +16,8 @@ export const getRelationTypeIds = ( ) => { const relationTypeIdsLevel1: string[] = []; const relationTypeIdsLevel2: string[] = []; + const relationInfoLevel1: RelationTypeIdInfo[] = []; + const relationInfoLevel2: RelationTypeIdInfo[] = []; const ast = type.ast as SchemaAST.TypeLiteral; @@ -20,6 +27,7 @@ export const getRelationTypeIds = ( const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); if (Option.isSome(result) && include?.[String(prop.name)]) { relationTypeIdsLevel1.push(result.value); + relationInfoLevel1.push({ typeId: result.value, propertyName: String(prop.name) }); if (!SchemaAST.isTupleType(prop.type)) { continue; } @@ -39,6 +47,7 @@ export const getRelationTypeIds = ( const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); if (Option.isSome(nestedResult) && include?.[String(prop.name)]?.[String(nestedProp.name)]) { relationTypeIdsLevel2.push(nestedResult.value); + relationInfoLevel2.push({ typeId: nestedResult.value, propertyName: String(nestedProp.name) }); } } } @@ -47,5 +56,7 @@ export const getRelationTypeIds = ( return { level1: relationTypeIdsLevel1, level2: relationTypeIdsLevel2, + infoLevel1: relationInfoLevel1, + infoLevel2: relationInfoLevel2, }; }; From 4c9eb40a1a4d2baecefb63437ff4d9f346d81642 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 17:36:54 +0100 Subject: [PATCH 02/14] improve types --- .../hypergraph/src/entity/find-many-public.ts | 57 +++++++++---------- .../hypergraph/src/utils/convert-relations.ts | 39 +++++++------ 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index eb74fcb5..6987ca31 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -91,7 +91,6 @@ const buildEntitiesQuery = ( useOrderBy: boolean, ) => { const level1Relations = buildLevel1RelationsFragment(relationInfoLevel1, relationInfoLevel2); - console.log('level1Relations', level1Relations); const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; @@ -125,51 +124,47 @@ query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first: }`; }; -type RelationsList = { +type ValuesList = { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; +}[]; + +type RelationsListItem = { id: string; entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; + valuesList: ValuesList; }; toEntity: { id: string; name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - [key: string]: unknown; // For nested aliased relationsList fields + valuesList: ValuesList; + } & { + // For nested aliased relationsList_* fields at level 2 + [K: `relationsList_${string}`]: RelationsListWithTotalCount; }; typeId: string; -}[]; +}; + +type RelationsListWithTotalCount = { + totalCount: number; +} & RelationsListItem[]; type EntityQueryResult = { - entities: { + entities: ({ id: string; name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; + valuesList: ValuesList; backlinksTotalCountsTypeId1: { totalCount: number; } | null; - [key: string]: unknown; // For aliased relationsList_* fields - }[]; + } & { + // For aliased relationsList_* fields - provides proper typing with totalCount + [K: `relationsList_${string}`]: RelationsListWithTotalCount; + })[]; }; type GraphSortDirection = 'ASC' | 'DESC'; diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index 24dd02c2..27f46d14 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -17,6 +17,19 @@ type ValueList = { // Helper to generate alias for relation type ID (must match the one in find-many-public.ts) const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; +type RelationsListItem = { + id: string; + toEntity: RecursiveQueryEntity; + entity: { + valuesList?: ValueList; + }; + typeId: string; +}; + +type RelationsListWithTotalCount = { + totalCount: number; +} & RelationsListItem[]; + // A recursive representation of the entity structure returned by the public GraphQL // endpoint. `values` and `relations` are optional because the nested `to` selections // get slimmer the deeper we traverse in the query. This type intentionally mirrors @@ -25,16 +38,10 @@ type RecursiveQueryEntity = { id: string; name: string; valuesList?: ValueList; - relationsList?: { - totalCount: number; - id: string; - toEntity: RecursiveQueryEntity; - entity: { - valuesList?: ValueList; - }; - typeId: string; - }[]; - [key: string]: unknown; // For aliased relationsList_* fields + relationsList?: RelationsListItem[]; +} & { + // For aliased relationsList_* fields with proper typing + [K: `relationsList_${string}`]: RelationsListWithTotalCount; }; type RawEntityValue = string | boolean | number | unknown[] | Date | { id: string }; @@ -74,18 +81,18 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( // Get relations from aliased field if we have relationInfo, otherwise fallback to old behavior let allRelationsWithTheCorrectPropertyTypeId: RecursiveQueryEntity['relationsList']; - + if (relationInfoLevel1.length > 0) { // Use the aliased field to get relations for this specific type ID const alias = getRelationAlias(result.value); - allRelationsWithTheCorrectPropertyTypeId = queryEntity[alias] as RecursiveQueryEntity['relationsList']; + allRelationsWithTheCorrectPropertyTypeId = queryEntity[ + alias as keyof RecursiveQueryEntity + ] as RecursiveQueryEntity['relationsList']; } else { // Fallback to old behavior (filtering from a single relationsList) - allRelationsWithTheCorrectPropertyTypeId = queryEntity.relationsList?.filter( - (a) => a.typeId === result.value, - ); + allRelationsWithTheCorrectPropertyTypeId = queryEntity.relationsList?.filter((a) => a.typeId === result.value); } - + if (allRelationsWithTheCorrectPropertyTypeId) { for (const relationEntry of allRelationsWithTheCorrectPropertyTypeId) { let nestedRawEntity: NestedRawEntity = { From 6e50e905f24a60c66a928127dcc2e2dfce675f91 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 17:54:56 +0100 Subject: [PATCH 03/14] level 2 relations should only be included in the correct level 1 relation alias --- .../hypergraph/src/entity/find-many-public.ts | 18 ++++++----------- .../src/utils/get-relation-type-ids.ts | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 6987ca31..02483135 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -70,27 +70,21 @@ const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) }; // Build level 1 relations fragment -const buildLevel1RelationsFragment = ( - relationInfoLevel1: RelationTypeIdInfo[], - relationInfoLevel2: RelationTypeIdInfo[], -) => { +const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => { if (relationInfoLevel1.length === 0) return ''; - const level2Fragment = buildLevel2RelationsFragment(relationInfoLevel2); return relationInfoLevel1 .map((info) => { + const level2Fragment = buildLevel2RelationsFragment(info.children ?? []); const fragment = buildRelationsListFragment(info.typeId, 1); return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); }) .join('\n'); }; -const buildEntitiesQuery = ( - relationInfoLevel1: RelationTypeIdInfo[], - relationInfoLevel2: RelationTypeIdInfo[], - useOrderBy: boolean, -) => { - const level1Relations = buildLevel1RelationsFragment(relationInfoLevel1, relationInfoLevel2); +const buildEntitiesQuery = (relationInfoLevel1: RelationTypeIdInfo[], useOrderBy: boolean) => { + const level1Relations = buildLevel1RelationsFragment(relationInfoLevel1); + console.log('level1Relations', level1Relations); const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; @@ -272,7 +266,7 @@ export const findManyPublic = async ( } // Build the query dynamically with aliases for each relation type ID - const queryDocument = buildEntitiesQuery(relationTypeIds.infoLevel1, relationTypeIds.infoLevel2, Boolean(orderBy)); + const queryDocument = buildEntitiesQuery(relationTypeIds.infoLevel1, Boolean(orderBy)); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index ba2f0549..2cdf504c 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -6,6 +6,7 @@ import * as SchemaAST from 'effect/SchemaAST'; export type RelationTypeIdInfo = { typeId: string; propertyName: string; + children?: RelationTypeIdInfo[]; }; export const getRelationTypeIds = ( @@ -27,18 +28,24 @@ export const getRelationTypeIds = ( const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); if (Option.isSome(result) && include?.[String(prop.name)]) { relationTypeIdsLevel1.push(result.value); - relationInfoLevel1.push({ typeId: result.value, propertyName: String(prop.name) }); + + const level1Info: RelationTypeIdInfo = { typeId: result.value, propertyName: String(prop.name) }; + const nestedRelations: RelationTypeIdInfo[] = []; + if (!SchemaAST.isTupleType(prop.type)) { + relationInfoLevel1.push(level1Info); continue; } const relationTransformation = prop.type.rest[0]?.type; if (!relationTransformation || !SchemaAST.isTypeLiteral(relationTransformation)) { + relationInfoLevel1.push(level1Info); continue; } const typeIds2: string[] = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)( relationTransformation, ).pipe(Option.getOrElse(() => [])); if (typeIds2.length === 0) { + relationInfoLevel1.push(level1Info); continue; } for (const nestedProp of relationTransformation.propertySignatures) { @@ -47,9 +54,18 @@ export const getRelationTypeIds = ( const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); if (Option.isSome(nestedResult) && include?.[String(prop.name)]?.[String(nestedProp.name)]) { relationTypeIdsLevel2.push(nestedResult.value); - relationInfoLevel2.push({ typeId: nestedResult.value, propertyName: String(nestedProp.name) }); + const nestedInfo: RelationTypeIdInfo = { + typeId: nestedResult.value, + propertyName: String(nestedProp.name), + }; + nestedRelations.push(nestedInfo); + relationInfoLevel2.push(nestedInfo); } } + if (nestedRelations.length > 0) { + level1Info.children = nestedRelations; + } + relationInfoLevel1.push(level1Info); } } From 7070c2de1ea2c8a1b6efe02d7c9c6dc40da86ff5 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:13:28 +0100 Subject: [PATCH 04/14] useEntityPublic to use Entity.findOnePublic --- apps/events/src/routes/podcasts.lazy.tsx | 13 + .../src/internal/use-entity-public.tsx | 296 +----------------- .../hypergraph/src/entity/find-many-public.ts | 1 - 3 files changed, 19 insertions(+), 291 deletions(-) diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 9631c5af..6aed62d5 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -22,6 +22,19 @@ function RouteComponent() { // }, 1000); // }, []); + // const { data: podcast } = useEntity(Podcast, { + // id: 'f5d27d3e-3a51-452d-bac2-702574381633', + // mode: 'public', + // space: space, + // include: { + // listenOn: {}, + // hosts: { + // avatar: {}, + // }, + // }, + // }); + // console.log({ podcast }); + const { data, isLoading, isError } = useEntities(Podcast, { mode: 'public', first: 100, diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index c790a536..36205c1b 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -1,277 +1,10 @@ -import { Graph } from '@graphprotocol/grc-20'; -import { Constants, type Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity, Utils } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; -import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; -import * as Schema from 'effect/Schema'; +import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; -import { useMemo } from 'react'; import { useHypergraphSpaceInternal } from './use-hypergraph-space-internal.js'; -const entityQueryDocumentLevel0 = gql` -query entity($id: UUID!, $spaceId: UUID!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } -} -`; - -const entityQueryDocumentLevel1 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; - -const entityQueryDocumentLevel2 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; - -type EntityQueryResult = { - entity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - } | null; -}; - -export const parseResult = (queryData: EntityQueryResult, type: S) => { - if (!queryData.entity) { - return { data: null, invalidEntity: null }; - } - - const schemaWithId = Utils.addIdSchemaField(type); - const decode = Schema.decodeUnknownEither(schemaWithId); - const queryEntity = queryData.entity; - let rawEntity: Record = { - id: queryEntity.id, - }; - - const ast = type.ast as SchemaAST.TypeLiteral; - - for (const prop of ast.propertySignatures) { - const propType = - prop.isOptional && SchemaAST.isUnion(prop.type) - ? (prop.type.types.find((member) => !SchemaAST.isUndefinedKeyword(member)) ?? prop.type) - : prop.type; - - const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(propType); - - if (Option.isSome(result)) { - const value = queryEntity.valuesList.find((a) => a.propertyId === result.value); - if (value) { - const rawValue = Utils.convertPropertyValue(value, propType); - if (rawValue) { - rawEntity[String(prop.name)] = rawValue; - } - } - } - } - - // @ts-expect-error - rawEntity = { - ...rawEntity, - ...Utils.convertRelations(queryEntity, ast), - }; - - const decodeResult = decode({ - ...rawEntity, - __deleted: false, - }); - - if (Either.isRight(decodeResult)) { - return { - // injecting the schema to the entity to be able to access it in the preparePublish function - data: { ...decodeResult.right, __schema: type } as Entity.Entity, - invalidEntity: null, - }; - } - - return { data: null, invalidEntity: rawEntity }; -}; - type UseEntityPublicParams = { id: string; enabled?: boolean; @@ -295,31 +28,14 @@ export const useEntityPublic = (type: S, p const result = useQueryTanstack({ queryKey: ['hypergraph-public-entity', id, typeIds, space, relationTypeIds.level1, relationTypeIds.level2, include], queryFn: async () => { - let queryDocument = entityQueryDocumentLevel0; - if (relationTypeIds.level1.length > 0) { - queryDocument = entityQueryDocumentLevel1; - } - if (relationTypeIds.level2.length > 0) { - queryDocument = entityQueryDocumentLevel2; - } - - const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { + return Entity.findOnePublic(type, { id, - spaceId: space, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, + space, + include, }); - return result; }, enabled: enabled && !!id && !!space, }); - const { data, invalidEntity } = useMemo(() => { - if (result.data) { - return parseResult(result.data, type); - } - return { data: null, invalidEntity: null }; - }, [result.data, type]); - - return { ...result, data, invalidEntity }; + return { ...result, data: result.data || null, invalidEntity: null }; }; diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 02483135..9c590e5b 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -84,7 +84,6 @@ const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) const buildEntitiesQuery = (relationInfoLevel1: RelationTypeIdInfo[], useOrderBy: boolean) => { const level1Relations = buildLevel1RelationsFragment(relationInfoLevel1); - console.log('level1Relations', level1Relations); const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; From 993c4ae05948be11f919c05ca84457a8a1f8b5ed Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:29:32 +0100 Subject: [PATCH 05/14] refactor searchManyPublic to construct nested relations aliases --- apps/events/src/routes/podcasts.lazy.tsx | 26 +- .../hypergraph/src/entity/find-many-public.ts | 64 +---- .../src/entity/search-many-public.ts | 236 ++---------------- .../hypergraph/src/utils/convert-relations.ts | 4 +- .../src/utils/relation-query-helpers.ts | 61 +++++ 5 files changed, 95 insertions(+), 296 deletions(-) create mode 100644 packages/hypergraph/src/utils/relation-query-helpers.ts diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 6aed62d5..1855b13e 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,8 @@ import { Podcast } from '@/schema'; +import { Entity } from '@graphprotocol/hypergraph'; import { useEntities } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; +import { useEffect } from 'react'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -9,18 +11,18 @@ export const Route = createLazyFileRoute('/podcasts')({ function RouteComponent() { const space = 'e252f9e1-d3ad-4460-8bf1-54f93b02f220'; - // useEffect(() => { - // setTimeout(async () => { - // const result = await Entity.findOnePublic(Podcast, { - // id: 'f5d27d3e-3a51-452d-bac2-702574381633', - // space: space, - // include: { - // listenOn: {}, - // }, - // }); - // console.log('findOnePublic result:', result); - // }, 1000); - // }, []); + useEffect(() => { + setTimeout(async () => { + const result = await Entity.searchManyPublic(Podcast, { + query: 'Joe', + space: space, + // include: { + // listenOn: {}, + // }, + }); + console.log('searchManyPublic result:', result); + }, 1000); + }, []); // const { data: podcast } = useEntity(Podcast, { // id: 'f5d27d3e-3a51-452d-bac2-702574381633', diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 9c590e5b..0b8979d9 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -6,6 +6,7 @@ import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; import { request } from 'graphql-request'; import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; export type FindManyPublicParams = { filter?: Entity.EntityFilter> | undefined; @@ -23,67 +24,8 @@ export type FindManyPublicParams = { backlinksTotalCountsTypeId1?: string | undefined; }; -// Helper to generate alias for relation type ID -const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; - -// Helper to build relationsList fragment for a single type ID -const buildRelationsListFragment = (typeId: string, level: 1 | 2) => { - const alias = getRelationAlias(typeId); - const fragment = ` - ${alias}: relationsList( - filter: {spaceId: {is: $spaceId}, typeId: {is: "${typeId}"}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - ${level === 1 ? '__LEVEL2_RELATIONS__' : ''} - } - typeId - }`; - return fragment; -}; - -// Build level 2 relations fragment -const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => { - if (relationInfoLevel2.length === 0) return ''; - - return relationInfoLevel2.map((info) => buildRelationsListFragment(info.typeId, 2)).join('\n'); -}; - -// Build level 1 relations fragment -const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => { - if (relationInfoLevel1.length === 0) return ''; - - return relationInfoLevel1 - .map((info) => { - const level2Fragment = buildLevel2RelationsFragment(info.children ?? []); - const fragment = buildRelationsListFragment(info.typeId, 1); - return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); - }) - .join('\n'); -}; - const buildEntitiesQuery = (relationInfoLevel1: RelationTypeIdInfo[], useOrderBy: boolean) => { - const level1Relations = buildLevel1RelationsFragment(relationInfoLevel1); + const level1Relations = buildRelationsSelection(relationInfoLevel1); const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; @@ -146,7 +88,7 @@ type RelationsListWithTotalCount = { totalCount: number; } & RelationsListItem[]; -type EntityQueryResult = { +export type EntityQueryResult = { entities: ({ id: string; name: string; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index 781ce573..b10b81b4 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -3,8 +3,10 @@ import { Constants, type Entity, Utils } from '@graphprotocol/hypergraph'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; -import { parseResult } from './find-many-public.js'; +import { request } from 'graphql-request'; +import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; +import { type EntityQueryResult, parseResult } from './find-many-public.js'; export type SearchManyPublicParams = { query: string; @@ -16,35 +18,12 @@ export type SearchManyPublicParams = { offset?: number | undefined; }; -const searchQueryDocumentLevel0 = gql` -query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { - search( - query: $query - filter: { and: [{ - typeIds: {in: $typeIds}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - spaceId: $spaceId - first: $first - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } -} -`; +const buildSearchQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { + const relationsSelection = buildRelationsSelection(relationInfoLevel1); -const searchQueryDocumentLevel1 = gql` -query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { - search( + return ` +query searchEntities($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { + entities: search( query: $query filter: { and: [{ typeIds: {in: $typeIds}, @@ -64,185 +43,9 @@ query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $relationType time point } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } + ${relationsSelection} } -} -`; - -const searchQueryDocumentLevel2 = gql` -query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { - search( - query: $query - filter: { and: [{ - typeIds: {in: $typeIds}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - spaceId: $spaceId - first: $first - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; - -type SearchQueryResult = { - search: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - }[]; +}`; }; export const searchManyPublic = async ( @@ -258,28 +61,21 @@ export const searchManyPublic = async ( Option.getOrElse(() => []), ); - let queryDocument = searchQueryDocumentLevel0; - if (relationTypeIds.level1.length > 0) { - queryDocument = searchQueryDocumentLevel1; - } - if (relationTypeIds.level2.length > 0) { - queryDocument = searchQueryDocumentLevel2; - } + const queryDocument = buildSearchQuery(relationTypeIds.infoLevel1); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; - const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { + const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { spaceId: space, typeIds, query, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, first, filter: filterParams, offset, }); - // @ts-expect-error TODO: fix this - const { data, invalidEntities } = parseResult({ entities: result.search }, type); + console.log('searchManyPublic result:', result); + + const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1, relationTypeIds.infoLevel2); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index 27f46d14..1497113a 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -4,6 +4,7 @@ import type * 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'; +import { getRelationAlias } from './relation-query-helpers.js'; type ValueList = { propertyId: string; @@ -14,9 +15,6 @@ type ValueList = { point: string; }[]; -// Helper to generate alias for relation type ID (must match the one in find-many-public.ts) -const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; - type RelationsListItem = { id: string; toEntity: RecursiveQueryEntity; diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts new file mode 100644 index 00000000..ebfc7559 --- /dev/null +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -0,0 +1,61 @@ +import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; + +export const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; + +const buildRelationsListFragment = (typeId: string, level: 1 | 2) => { + const alias = getRelationAlias(typeId); + const nestedPlaceholder = level === 1 ? '__LEVEL2_RELATIONS__' : ''; + + return ` + ${alias}: relationsList( + filter: {spaceId: {is: $spaceId}, typeId: {is: "${typeId}"}}, + ) { + id + entity { + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + } + toEntity { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + ${nestedPlaceholder} + } + typeId + }`; +}; + +const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => { + if (relationInfoLevel2.length === 0) return ''; + + return relationInfoLevel2.map((info) => buildRelationsListFragment(info.typeId, 2)).join('\n'); +}; + +const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => { + if (relationInfoLevel1.length === 0) return ''; + + return relationInfoLevel1 + .map((info) => { + const level2Fragment = buildLevel2RelationsFragment(info.children ?? []); + const fragment = buildRelationsListFragment(info.typeId, 1); + return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); + }) + .join('\n'); +}; + +export const buildRelationsSelection = (relationInfoLevel1: RelationTypeIdInfo[]) => + buildLevel1RelationsFragment(relationInfoLevel1); + From 9a18176c40f342544165f38d99619b903de80949 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:36:42 +0100 Subject: [PATCH 06/14] refactor findOnePublic to construct nested relations aliases --- apps/events/src/routes/podcasts.lazy.tsx | 52 ++-- .../hypergraph/src/entity/find-one-public.ts | 223 ++---------------- 2 files changed, 46 insertions(+), 229 deletions(-) diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 1855b13e..41dae3a5 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,8 +1,6 @@ import { Podcast } from '@/schema'; -import { Entity } from '@graphprotocol/hypergraph'; -import { useEntities } from '@graphprotocol/hypergraph-react'; +import { useEntities, useEntity } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; -import { useEffect } from 'react'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -11,31 +9,31 @@ export const Route = createLazyFileRoute('/podcasts')({ function RouteComponent() { const space = 'e252f9e1-d3ad-4460-8bf1-54f93b02f220'; - useEffect(() => { - setTimeout(async () => { - const result = await Entity.searchManyPublic(Podcast, { - query: 'Joe', - space: space, - // include: { - // listenOn: {}, - // }, - }); - console.log('searchManyPublic result:', result); - }, 1000); - }, []); + // useEffect(() => { + // setTimeout(async () => { + // const result = await Entity.searchManyPublic(Podcast, { + // query: 'Joe', + // space: space, + // // include: { + // // listenOn: {}, + // // }, + // }); + // console.log('searchManyPublic result:', result); + // }, 1000); + // }, []); - // const { data: podcast } = useEntity(Podcast, { - // id: 'f5d27d3e-3a51-452d-bac2-702574381633', - // mode: 'public', - // space: space, - // include: { - // listenOn: {}, - // hosts: { - // avatar: {}, - // }, - // }, - // }); - // console.log({ podcast }); + const { data: podcast } = useEntity(Podcast, { + id: 'f5d27d3e-3a51-452d-bac2-702574381633', + mode: 'public', + space: space, + include: { + listenOn: {}, + hosts: { + avatar: {}, + }, + }, + }); + console.log({ podcast }); const { data, isLoading, isError } = useEntities(Podcast, { mode: 'public', diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index eed30cf8..7beac49e 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -4,73 +4,13 @@ import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; +import { request } from 'graphql-request'; +import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; +import type { EntityQueryResult as MultiEntityQueryResult } from './find-many-public.js'; type EntityQueryResult = { - entity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - } | null; + entity: MultiEntityQueryResult['entities'][number] | null; }; export type FindOnePublicParams = { @@ -80,74 +20,12 @@ export type FindOnePublicParams = { include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; }; -const entityQueryDocumentLevel0 = gql` -query entity($id: UUID!, $spaceId: UUID!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } -} -`; +const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { + const relationsSelection = buildRelationsSelection(relationInfoLevel1); + const relationsSelectionBlock = relationsSelection ? `\n ${relationsSelection}\n` : ''; -const entityQueryDocumentLevel1 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; - -const entityQueryDocumentLevel2 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!) { + return ` +query entity($id: UUID!, $spaceId: UUID!) { entity( id: $id, ) { @@ -160,68 +38,18 @@ query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $rel number time point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } + }${relationsSelectionBlock} } } `; +}; -const parseResult = (queryData: EntityQueryResult, type: S) => { +const parseResult = ( + queryData: EntityQueryResult, + type: S, + relationInfoLevel1: RelationTypeIdInfo[], + relationInfoLevel2: RelationTypeIdInfo[], +) => { if (!queryData.entity) { return null; } @@ -257,7 +85,7 @@ const parseResult = (queryData: EntityQuer // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast), + ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1, relationInfoLevel2), }; const decodeResult = decode({ @@ -281,21 +109,12 @@ export const findOnePublic = async (type: // constructing the relation type ids for the query const relationTypeIds = Utils.getRelationTypeIds(type, include); - const relationLevel = relationTypeIds.level2.length > 0 ? 2 : relationTypeIds.level1.length > 0 ? 1 : 0; - - const queryDocument = - relationLevel === 2 - ? entityQueryDocumentLevel2 - : relationLevel === 1 - ? entityQueryDocumentLevel1 - : entityQueryDocumentLevel0; + const queryDocument = buildEntityQuery(relationTypeIds.infoLevel1); const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { id, spaceId: space, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, }); - return parseResult(result, type); + return parseResult(result, type, relationTypeIds.infoLevel1, relationTypeIds.infoLevel2); }; From 83360714e3e371d0ae962975c9588e4377af501c Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:37:04 +0100 Subject: [PATCH 07/14] lint fixes --- apps/events/src/routes/podcasts.lazy.tsx | 2 +- packages/hypergraph/src/utils/relation-query-helpers.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 41dae3a5..d7e6abeb 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,6 @@ -import { Podcast } from '@/schema'; import { useEntities, useEntity } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; +import { Podcast } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts index ebfc7559..7efcf475 100644 --- a/packages/hypergraph/src/utils/relation-query-helpers.ts +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -58,4 +58,3 @@ const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) export const buildRelationsSelection = (relationInfoLevel1: RelationTypeIdInfo[]) => buildLevel1RelationsFragment(relationInfoLevel1); - From 6def3aa6f0b884a0c123929ecd1e9760696e282b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:41:07 +0100 Subject: [PATCH 08/14] cleanup relationTypeIds usage --- .../src/hooks/use-entities-public-infinite.ts | 16 ++-------------- .../src/internal/use-entities-public.tsx | 9 ++------- .../src/internal/use-entity-public.tsx | 7 ++----- .../src/utils/get-relation-type-ids.ts | 7 ------- 4 files changed, 6 insertions(+), 33 deletions(-) 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 ed77d5bc..c5b6ad1d 100644 --- a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts +++ b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts @@ -1,4 +1,4 @@ -import { Constants, Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity } from '@graphprotocol/hypergraph'; import { useInfiniteQuery as useInfiniteQueryTanstack } from '@tanstack/react-query'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; @@ -13,24 +13,12 @@ export const useEntitiesPublicInfinite = ( const { enabled = true, filter, include, space: spaceFromParams, first = 2, offset = 0 } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; - - // constructing the relation type ids for the query - const relationTypeIds = Utils.getRelationTypeIds(type, include); - const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); const result = useInfiniteQueryTanstack({ - queryKey: [ - 'hypergraph-public-entities', - space, - typeIds, - relationTypeIds.level1, - relationTypeIds.level2, - filter, - 'infinite', - ], + queryKey: ['hypergraph-public-entities', space, typeIds, include, filter, 'infinite'], queryFn: async ({ pageParam }) => { return Entity.findManyPublic(type, { filter, include, space, first, offset: pageParam }); }, diff --git a/packages/hypergraph-react/src/internal/use-entities-public.tsx b/packages/hypergraph-react/src/internal/use-entities-public.tsx index fc1dee5d..e78b7aa2 100644 --- a/packages/hypergraph-react/src/internal/use-entities-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entities-public.tsx @@ -1,4 +1,4 @@ -import { Constants, Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; @@ -19,10 +19,6 @@ export const useEntitiesPublic = (type: S, } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; - - // constructing the relation type ids for the query - const relationTypeIds = Utils.getRelationTypeIds(type, include); - const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); @@ -32,8 +28,7 @@ export const useEntitiesPublic = (type: S, 'hypergraph-public-entities', space, typeIds, - relationTypeIds.level1, - relationTypeIds.level2, + include, filter, first, offset, diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index 36205c1b..273b579e 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -1,4 +1,4 @@ -import { Constants, Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; @@ -18,15 +18,12 @@ export const useEntityPublic = (type: S, p const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; - // constructing the relation type ids for the query - const relationTypeIds = Utils.getRelationTypeIds(type, include); - const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); const result = useQueryTanstack({ - queryKey: ['hypergraph-public-entity', id, typeIds, space, relationTypeIds.level1, relationTypeIds.level2, include], + queryKey: ['hypergraph-public-entity', id, typeIds, space, include], queryFn: async () => { return Entity.findOnePublic(type, { id, diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 2cdf504c..fc587961 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -15,8 +15,6 @@ export const getRelationTypeIds = ( | { [K in keyof Schema.Schema.Type]?: Record> } | undefined, ) => { - const relationTypeIdsLevel1: string[] = []; - const relationTypeIdsLevel2: string[] = []; const relationInfoLevel1: RelationTypeIdInfo[] = []; const relationInfoLevel2: RelationTypeIdInfo[] = []; @@ -27,8 +25,6 @@ export const getRelationTypeIds = ( const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); if (Option.isSome(result) && include?.[String(prop.name)]) { - relationTypeIdsLevel1.push(result.value); - const level1Info: RelationTypeIdInfo = { typeId: result.value, propertyName: String(prop.name) }; const nestedRelations: RelationTypeIdInfo[] = []; @@ -53,7 +49,6 @@ export const getRelationTypeIds = ( const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); if (Option.isSome(nestedResult) && include?.[String(prop.name)]?.[String(nestedProp.name)]) { - relationTypeIdsLevel2.push(nestedResult.value); const nestedInfo: RelationTypeIdInfo = { typeId: nestedResult.value, propertyName: String(nestedProp.name), @@ -70,8 +65,6 @@ export const getRelationTypeIds = ( } return { - level1: relationTypeIdsLevel1, - level2: relationTypeIdsLevel2, infoLevel1: relationInfoLevel1, infoLevel2: relationInfoLevel2, }; From d3fa182d35d5030b54e8a1a4fa430c571e3621d9 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:46:52 +0100 Subject: [PATCH 09/14] use the hierarchical children data on each entry in infoLevel1 to drive recursion --- packages/hypergraph/src/entity/find-many-public.ts | 5 ++--- packages/hypergraph/src/entity/find-one-public.ts | 5 ++--- .../hypergraph/src/entity/search-many-public.ts | 2 +- packages/hypergraph/src/utils/convert-relations.ts | 14 ++++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 0b8979d9..a13c93bc 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -108,7 +108,6 @@ export const parseResult = ( queryData: EntityQueryResult, type: S, relationInfoLevel1: RelationTypeIdInfo[], - relationInfoLevel2: RelationTypeIdInfo[], ) => { const schemaWithId = Utils.addIdSchemaField(type); const decode = Schema.decodeUnknownEither(schemaWithId); @@ -144,7 +143,7 @@ export const parseResult = ( // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1, relationInfoLevel2), + ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), }; const decodeResult = decode({ @@ -233,6 +232,6 @@ export const findManyPublic = async ( const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, queryVariables); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1, relationTypeIds.infoLevel2); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index 7beac49e..0a3343c9 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -48,7 +48,6 @@ const parseResult = ( queryData: EntityQueryResult, type: S, relationInfoLevel1: RelationTypeIdInfo[], - relationInfoLevel2: RelationTypeIdInfo[], ) => { if (!queryData.entity) { return null; @@ -85,7 +84,7 @@ const parseResult = ( // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1, relationInfoLevel2), + ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), }; const decodeResult = decode({ @@ -116,5 +115,5 @@ export const findOnePublic = async (type: spaceId: space, }); - return parseResult(result, type, relationTypeIds.infoLevel1, relationTypeIds.infoLevel2); + return parseResult(result, type, relationTypeIds.infoLevel1); }; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index b10b81b4..b8eaa407 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -76,6 +76,6 @@ export const searchManyPublic = async ( console.log('searchManyPublic result:', result); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1, relationTypeIds.infoLevel2); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index 1497113a..09cabf19 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -49,8 +49,7 @@ type NestedRawEntity = RawEntity & { _relation: { id: string } & Record( queryEntity: RecursiveQueryEntity, ast: SchemaAST.TypeLiteral, - relationInfoLevel1: RelationTypeIdInfo[] = [], - relationInfoLevel2: RelationTypeIdInfo[] = [], + relationInfo: RelationTypeIdInfo[] = [], ) => { const rawEntity: RawEntity = {}; @@ -77,10 +76,14 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( continue; } - // Get relations from aliased field if we have relationInfo, otherwise fallback to old behavior + const relationMetadata = relationInfo.find( + (info) => info.typeId === result.value && info.propertyName === String(prop.name), + ); + + // Get relations from aliased field if we have relationInfo for this property, otherwise fallback to old behavior let allRelationsWithTheCorrectPropertyTypeId: RecursiveQueryEntity['relationsList']; - if (relationInfoLevel1.length > 0) { + if (relationMetadata) { // Use the aliased field to get relations for this specific type ID const alias = getRelationAlias(result.value); allRelationsWithTheCorrectPropertyTypeId = queryEntity[ @@ -103,8 +106,7 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( const relationsForRawNestedEntity = convertRelations( relationEntry.toEntity, relationTransformation, - relationInfoLevel2, - [], + relationMetadata?.children ?? [], ); nestedRawEntity = { From b18538dee702a28dcea07b9a8323f178adc7be62 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 18:49:15 +0100 Subject: [PATCH 10/14] cleanup getRelationTypeIds --- .../hypergraph/src/entity/find-many-public.ts | 4 ++-- .../hypergraph/src/entity/find-one-public.ts | 4 ++-- .../hypergraph/src/entity/search-many-public.ts | 4 ++-- .../src/utils/get-relation-type-ids.ts | 17 ++++++----------- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index a13c93bc..87fcb016 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -206,7 +206,7 @@ export const findManyPublic = async ( } // Build the query dynamically with aliases for each relation type ID - const queryDocument = buildEntitiesQuery(relationTypeIds.infoLevel1, Boolean(orderBy)); + const queryDocument = buildEntitiesQuery(relationTypeIds, Boolean(orderBy)); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; @@ -232,6 +232,6 @@ export const findManyPublic = async ( const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, queryVariables); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index 0a3343c9..6d9d1e88 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -108,12 +108,12 @@ export const findOnePublic = async (type: // constructing the relation type ids for the query const relationTypeIds = Utils.getRelationTypeIds(type, include); - const queryDocument = buildEntityQuery(relationTypeIds.infoLevel1); + const queryDocument = buildEntityQuery(relationTypeIds); const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { id, spaceId: space, }); - return parseResult(result, type, relationTypeIds.infoLevel1); + return parseResult(result, type, relationTypeIds); }; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index b8eaa407..56b965b7 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -61,7 +61,7 @@ export const searchManyPublic = async ( Option.getOrElse(() => []), ); - const queryDocument = buildSearchQuery(relationTypeIds.infoLevel1); + const queryDocument = buildSearchQuery(relationTypeIds); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; @@ -76,6 +76,6 @@ export const searchManyPublic = async ( console.log('searchManyPublic result:', result); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds.infoLevel1); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index fc587961..265d3a6e 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -15,8 +15,7 @@ export const getRelationTypeIds = ( | { [K in keyof Schema.Schema.Type]?: Record> } | undefined, ) => { - const relationInfoLevel1: RelationTypeIdInfo[] = []; - const relationInfoLevel2: RelationTypeIdInfo[] = []; + const relationInfo: RelationTypeIdInfo[] = []; const ast = type.ast as SchemaAST.TypeLiteral; @@ -29,19 +28,19 @@ export const getRelationTypeIds = ( const nestedRelations: RelationTypeIdInfo[] = []; if (!SchemaAST.isTupleType(prop.type)) { - relationInfoLevel1.push(level1Info); + relationInfo.push(level1Info); continue; } const relationTransformation = prop.type.rest[0]?.type; if (!relationTransformation || !SchemaAST.isTypeLiteral(relationTransformation)) { - relationInfoLevel1.push(level1Info); + relationInfo.push(level1Info); continue; } const typeIds2: string[] = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)( relationTransformation, ).pipe(Option.getOrElse(() => [])); if (typeIds2.length === 0) { - relationInfoLevel1.push(level1Info); + relationInfo.push(level1Info); continue; } for (const nestedProp of relationTransformation.propertySignatures) { @@ -54,18 +53,14 @@ export const getRelationTypeIds = ( propertyName: String(nestedProp.name), }; nestedRelations.push(nestedInfo); - relationInfoLevel2.push(nestedInfo); } } if (nestedRelations.length > 0) { level1Info.children = nestedRelations; } - relationInfoLevel1.push(level1Info); + relationInfo.push(level1Info); } } - return { - infoLevel1: relationInfoLevel1, - infoLevel2: relationInfoLevel2, - }; + return relationInfo; }; From 62492c6dee8e4c4633a853f440720d0d74f0f8dd Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 20:19:47 +0100 Subject: [PATCH 11/14] add support for Type.Backlink --- apps/events/src/routes/podcasts.lazy.tsx | 32 +++++++------- apps/events/src/schema.ts | 35 ++++++++++++++++ packages/hypergraph/src/constants.ts | 2 + .../hypergraph/src/entity/find-one-public.ts | 2 +- packages/hypergraph/src/type/type.ts | 42 +++++++++++++++++-- .../src/utils/get-relation-type-ids.ts | 18 +++++++- .../src/utils/relation-query-helpers.ts | 17 ++++---- 7 files changed, 120 insertions(+), 28 deletions(-) diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index d7e6abeb..f4be9b49 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,6 @@ -import { useEntities, useEntity } from '@graphprotocol/hypergraph-react'; -import { createLazyFileRoute } from '@tanstack/react-router'; import { Podcast } from '@/schema'; +import { useEntities } from '@graphprotocol/hypergraph-react'; +import { createLazyFileRoute } from '@tanstack/react-router'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -22,28 +22,30 @@ function RouteComponent() { // }, 1000); // }, []); - const { data: podcast } = useEntity(Podcast, { - id: 'f5d27d3e-3a51-452d-bac2-702574381633', - mode: 'public', - space: space, - include: { - listenOn: {}, - hosts: { - avatar: {}, - }, - }, - }); - console.log({ podcast }); + // 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, isLoading, isError } = useEntities(Podcast, { mode: 'public', - first: 100, + first: 10, space: space, include: { listenOn: {}, hosts: { avatar: {}, }, + episodes: {}, }, orderBy: { property: 'dateFounded', direction: 'asc' }, backlinksTotalCountsTypeId1: '972d201a-d780-4568-9e01-543f67b26bee', diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index 956ead92..1ac60c30 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -162,6 +162,39 @@ export const GenericEntity = Entity.Schema( }, ); +export const Episode2 = Entity.Schema( + { + name: Type.String, + description: Type.optional(Type.String), + airDate: Type.Date, + avatar: Type.Relation(Image), + duration: Type.optional(Type.Number), // in seconds + audioUrl: Type.optional(Type.String), + listenOn: Type.Relation(GenericEntity, { + properties: { + website: Type.optional(Type.String), + }, + }), + }, + { + types: [Id('972d201a-d780-4568-9e01-543f67b26bee')], + properties: { + name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + description: Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'), + airDate: Id('77999397-f78d-44a7-bbc5-d93a617af47c'), + duration: Id('76996acc-d10f-4cd5-9ac9-4a705b8e03b4'), + audioUrl: Id('87f919d5-560b-408c-be8d-318e2c5c098b'), + avatar: Id('1155beff-fad5-49b7-a2e0-da4777b8792c'), + listenOn: { + propertyId: Id('1367bac7-dcea-4b80-86ad-a4a4cdd7c2cb'), + properties: { + website: Id('eed38e74-e679-46bf-8a42-ea3e4f8fb5fb'), + }, + }, + }, + }, +); + export const Podcast = Entity.Schema( { name: Type.String, @@ -176,6 +209,7 @@ export const Podcast = Entity.Schema( website: Type.optional(Type.String), }, }), + episodes: Type.Backlink(Episode2), }, { types: [Id('4c81561d-1f95-4131-9cdd-dd20ab831ba2')], @@ -193,6 +227,7 @@ export const Podcast = Entity.Schema( website: Id('eed38e74-e679-46bf-8a42-ea3e4f8fb5fb'), }, }, + episodes: Id('f1873bbc-381f-4604-abad-76fed4f6d73f'), }, }, ); diff --git a/packages/hypergraph/src/constants.ts b/packages/hypergraph/src/constants.ts index 73f39e20..bf73c48b 100644 --- a/packages/hypergraph/src/constants.ts +++ b/packages/hypergraph/src/constants.ts @@ -9,3 +9,5 @@ export const RelationSchemaSymbol = Symbol.for('grc-20/relation/schema'); export const PropertyTypeSymbol = Symbol.for('grc-20/property/type'); export const RelationPropertiesSymbol = Symbol.for('grc-20/relation/properties'); + +export const RelationBacklinkSymbol = Symbol.for('grc-20/relation/backlink'); diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index 6d9d1e88..3ce2c878 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -23,7 +23,6 @@ export type FindOnePublicParams = { const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { const relationsSelection = buildRelationsSelection(relationInfoLevel1); const relationsSelectionBlock = relationsSelection ? `\n ${relationsSelection}\n` : ''; - return ` query entity($id: UUID!, $spaceId: UUID!) { entity( @@ -109,6 +108,7 @@ export const findOnePublic = async (type: const relationTypeIds = Utils.getRelationTypeIds(type, include); const queryDocument = buildEntityQuery(relationTypeIds); + console.log({ queryDocument }); const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { id, diff --git a/packages/hypergraph/src/type/type.ts b/packages/hypergraph/src/type/type.ts index bf767c44..c6651ade 100644 --- a/packages/hypergraph/src/type/type.ts +++ b/packages/hypergraph/src/type/type.ts @@ -4,6 +4,7 @@ import * as SchemaAST from 'effect/SchemaAST'; import { PropertyIdSymbol, PropertyTypeSymbol, + RelationBacklinkSymbol, RelationPropertiesSymbol, RelationSchemaSymbol, RelationSymbol, @@ -20,10 +21,21 @@ type SchemaBuilder = (propertyId: any) => SchemaBuilderReturn; type RelationPropertiesDefinition = Record; -type RelationOptions = { +type RelationOptionsBase = { + backlink?: boolean; +}; + +type RelationOptions = RelationOptionsBase & { properties: RP; }; +const hasRelationProperties = ( + options: RelationOptionsBase | RelationOptions | undefined, +): options is RelationOptions => { + if (!options) return false; + return 'properties' in options; +}; + type RelationMappingInput = RP extends RelationPropertiesDefinition ? { propertyId: string; @@ -98,6 +110,7 @@ export const Point = (propertyId: string) => export function Relation( schema: S, + options?: RelationOptionsBase, ): (mapping: RelationMappingInput) => Schema.Schema< readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], readonly (Schema.Schema.Encoded & { @@ -120,7 +133,7 @@ export function Relation< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, // biome-ignore lint/suspicious/noExplicitAny: implementation signature for overloads must use any ->(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : undefined): any { +>(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase): any { return (mapping: RelationMappingInput) => { const { propertyId, relationPropertyIds } = typeof mapping === 'string' @@ -135,8 +148,15 @@ export function Relation< const relationEntityPropertiesSchemas: Record = {}; - if (options?.properties) { - for (const [key, schemaType] of Object.entries(options.properties)) { + const normalizedOptions = options as + | RelationOptionsBase + | RelationOptions + | undefined; + + const relationProperties = hasRelationProperties(normalizedOptions) ? normalizedOptions.properties : undefined; + + if (relationProperties) { + for (const [key, schemaType] of Object.entries(relationProperties)) { const propertyMapping = relationPropertyIds?.[key]; relationEntityPropertiesSchemas[key] = schemaType(propertyMapping); } @@ -169,12 +189,15 @@ export function Relation< never >; + const isBacklinkRelation = !!normalizedOptions?.backlink; + return Schema.Array(schemaWithId).pipe( Schema.annotations({ [PropertyIdSymbol]: propertyId, [RelationSchemaSymbol]: schema, [RelationSymbol]: true, [PropertyTypeSymbol]: 'relation', + [RelationBacklinkSymbol]: isBacklinkRelation, }), ) as Schema.Schema< readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], @@ -184,6 +207,17 @@ export function Relation< }; } +export function Backlink< + S extends Schema.Schema.AnyNoContext, + RP extends RelationPropertiesDefinition | undefined = undefined, +>(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase) { + const normalizedOptions = { + ...(options ?? {}), + backlink: true, + } as RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase; + return Relation(schema, normalizedOptions); +} + export const optional = (schemaFn: (propertyId: string) => S) => (propertyId: string) => { diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 265d3a6e..737295ae 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -3,9 +3,12 @@ import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; +export type RelationListField = 'relationsList' | 'backlinksList'; + export type RelationTypeIdInfo = { typeId: string; propertyName: string; + listField: RelationListField; children?: RelationTypeIdInfo[]; }; @@ -24,7 +27,15 @@ export const getRelationTypeIds = ( const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); if (Option.isSome(result) && include?.[String(prop.name)]) { - const level1Info: RelationTypeIdInfo = { typeId: result.value, propertyName: String(prop.name) }; + const isBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)(prop.type).pipe( + Option.getOrElse(() => false), + ); + const listField: RelationListField = isBacklink ? 'backlinksList' : 'relationsList'; + const level1Info: RelationTypeIdInfo = { + typeId: result.value, + propertyName: String(prop.name), + listField, + }; const nestedRelations: RelationTypeIdInfo[] = []; if (!SchemaAST.isTupleType(prop.type)) { @@ -48,9 +59,14 @@ export const getRelationTypeIds = ( const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); if (Option.isSome(nestedResult) && include?.[String(prop.name)]?.[String(nestedProp.name)]) { + const nestedIsBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)( + nestedProp.type, + ).pipe(Option.getOrElse(() => false)); + const nestedListField: RelationListField = nestedIsBacklink ? 'backlinksList' : 'relationsList'; const nestedInfo: RelationTypeIdInfo = { typeId: nestedResult.value, propertyName: String(nestedProp.name), + listField: nestedListField, }; nestedRelations.push(nestedInfo); } diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts index 7efcf475..1508ee25 100644 --- a/packages/hypergraph/src/utils/relation-query-helpers.ts +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -2,13 +2,16 @@ import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; export const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; -const buildRelationsListFragment = (typeId: string, level: 1 | 2) => { - const alias = getRelationAlias(typeId); +const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => { + const alias = getRelationAlias(info.typeId); const nestedPlaceholder = level === 1 ? '__LEVEL2_RELATIONS__' : ''; + const listField = info.listField ?? 'relationsList'; + const toEntityField = listField === 'backlinksList' ? 'fromEntity' : 'toEntity'; + const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`; return ` - ${alias}: relationsList( - filter: {spaceId: {is: $spaceId}, typeId: {is: "${typeId}"}}, + ${alias}: ${listField}( + filter: {spaceId: {is: $spaceId}, typeId: {is: "${info.typeId}"}}, ) { id entity { @@ -21,7 +24,7 @@ const buildRelationsListFragment = (typeId: string, level: 1 | 2) => { point } } - toEntity { + ${toEntitySelectionHeader} { id name valuesList(filter: {spaceId: {is: $spaceId}}) { @@ -41,7 +44,7 @@ const buildRelationsListFragment = (typeId: string, level: 1 | 2) => { const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => { if (relationInfoLevel2.length === 0) return ''; - return relationInfoLevel2.map((info) => buildRelationsListFragment(info.typeId, 2)).join('\n'); + return relationInfoLevel2.map((info) => buildRelationsListFragment(info, 2)).join('\n'); }; const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => { @@ -50,7 +53,7 @@ const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) return relationInfoLevel1 .map((info) => { const level2Fragment = buildLevel2RelationsFragment(info.children ?? []); - const fragment = buildRelationsListFragment(info.typeId, 1); + const fragment = buildRelationsListFragment(info, 1); return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); }) .join('\n'); From 530d8f9aaa6c5a53e96e7804032f14aa79bb1a99 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 20:20:11 +0100 Subject: [PATCH 12/14] lint fixes --- apps/events/src/routes/podcasts.lazy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index f4be9b49..eb8081bb 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,6 @@ -import { Podcast } from '@/schema'; import { useEntities } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; +import { Podcast } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, From c5bb0513def0272d20fda7a42c113626fba1441b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 19 Nov 2025 20:20:52 +0100 Subject: [PATCH 13/14] add changeset --- .changeset/plain-turkeys-matter.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/plain-turkeys-matter.md diff --git a/.changeset/plain-turkeys-matter.md b/.changeset/plain-turkeys-matter.md new file mode 100644 index 00000000..0ab34dc2 --- /dev/null +++ b/.changeset/plain-turkeys-matter.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +add Type.Backlink + \ No newline at end of file From 07c24901fdd6dbc868ac30c9d069d2f129e576f6 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 20 Nov 2025 10:41:27 +0100 Subject: [PATCH 14/14] lint fix --- packages/hypergraph-react/src/internal/use-entity-public.tsx | 2 +- packages/hypergraph/src/entity/find-one-public.ts | 1 - packages/hypergraph/src/entity/search-many-public.ts | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index 273b579e..f723f368 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -34,5 +34,5 @@ export const useEntityPublic = (type: S, p enabled: enabled && !!id && !!space, }); - return { ...result, data: result.data || null, invalidEntity: null }; + return { ...result, data: result.data ?? null, invalidEntity: null }; }; diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index 3ce2c878..b37934c0 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -108,7 +108,6 @@ export const findOnePublic = async (type: const relationTypeIds = Utils.getRelationTypeIds(type, include); const queryDocument = buildEntityQuery(relationTypeIds); - console.log({ queryDocument }); const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { id, diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index 56b965b7..8c6c08fd 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -74,8 +74,6 @@ export const searchManyPublic = async ( offset, }); - console.log('searchManyPublic result:', result); - const { data, invalidEntities } = parseResult(result, type, relationTypeIds); return { data, invalidEntities }; };