diff --git a/packages/hypergraph/src/constants.ts b/packages/hypergraph/src/constants.ts index bf73c48b..7482cef6 100644 --- a/packages/hypergraph/src/constants.ts +++ b/packages/hypergraph/src/constants.ts @@ -11,3 +11,5 @@ 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'); + +export const ProposalBacklinkSymbol = Symbol.for('grc-20/relation/proposal-backlink'); diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 02374439..ef39202b 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -8,6 +8,7 @@ import { request } from 'graphql-request'; import type { InvalidRelationEntity, RelationsListWithNodes } from '../utils/convert-relations.js'; import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; +import { hydrateProposalBacklinks } from './internal/hydrate-proposal-backlinks.js'; import { normalizeSpaceIds } from './internal/normalize-space-ids.js'; import type { SpaceSelection } from './internal/space-selection.js'; import { normalizeSpaceSelection } from './internal/space-selection.js'; @@ -342,6 +343,9 @@ export const findManyPublic = async < relationTypeIds, includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam }, ); + + await hydrateProposalBacklinks(result.entities, data, relationTypeIds); + if (logInvalidResults) { if (invalidEntities.length > 0) { console.warn('Entities where decoding failed were dropped', invalidEntities); diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index ad842d75..6063b7bb 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -8,6 +8,7 @@ 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'; +import { hydrateProposalBacklinks } from './internal/hydrate-proposal-backlinks.js'; import { normalizeSpaceIds } from './internal/normalize-space-ids.js'; type EntityQueryResult = { @@ -176,6 +177,11 @@ export const findOnePublic = async < relationTypeIds, includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam }, ); + + if (result.entity && parsed.entity) { + await hydrateProposalBacklinks([result.entity], [parsed.entity], relationTypeIds); + } + if (logInvalidResults) { if (parsed.invalidEntity) { console.warn('Entity decoding failed', parsed.invalidEntity); diff --git a/packages/hypergraph/src/entity/internal/hydrate-proposal-backlinks.ts b/packages/hypergraph/src/entity/internal/hydrate-proposal-backlinks.ts new file mode 100644 index 00000000..b17e54aa --- /dev/null +++ b/packages/hypergraph/src/entity/internal/hydrate-proposal-backlinks.ts @@ -0,0 +1,140 @@ +import { Config } from '@graphprotocol/hypergraph'; +import { request } from 'graphql-request'; +import type { RelationsListWithNodes } from '../../utils/convert-relations.js'; +import type { RelationTypeIdInfo } from '../../utils/get-relation-type-ids.js'; +import { getRelationAlias } from '../../utils/relation-query-helpers.js'; + +type QueryEntityWithRelations = { + id: string; +} & Partial>; + +type ProposalRelationRef = { + proposalId: string; + relationId: string; +}; + +type ProposalQueryResult = { + id: string; +} & Record; + +type ProposalsByIdsQueryResult = { + proposals: ProposalQueryResult[]; +}; + +const proposalsByIdsQueryDocument = ` +query proposalsByIds($ids: [UUID!]!) { + proposals(filter: { id: { in: $ids } }) { + id + proposedBy + executedAt + spaceId + votingMode + startTime + endTime + quorum + threshold + name + createdAt + noCount + yesCount + createdAtBlock + } +} +`; + +const getProposalBacklinkInfo = (relationInfo: RelationTypeIdInfo[]) => + relationInfo.filter((info) => info.includeNodes && info.resolutionStrategy === 'proposalBacklink'); + +const collectProposalBacklinkRefs = (queryEntities: QueryEntityWithRelations[], relationInfo: RelationTypeIdInfo[]) => { + const backlinkInfo = getProposalBacklinkInfo(relationInfo); + const refsByParentId = new Map>(); + const proposalIds = new Set(); + + for (const queryEntity of queryEntities) { + const refsByPropertyName = new Map(); + + for (const info of backlinkInfo) { + const alias = getRelationAlias(info.typeId, info.targetTypeIds) as `relations_${string}`; + const relationNodes = queryEntity[alias]?.nodes ?? []; + const relationRefs: ProposalRelationRef[] = []; + + for (const relationNode of relationNodes) { + if (!relationNode?.toEntity?.id || !relationNode.id) { + continue; + } + relationRefs.push({ + proposalId: relationNode.toEntity.id, + relationId: relationNode.id, + }); + proposalIds.add(relationNode.toEntity.id); + } + + refsByPropertyName.set(info.propertyName, relationRefs); + } + + refsByParentId.set(queryEntity.id, refsByPropertyName); + } + + return { + refsByParentId, + proposalIds: Array.from(proposalIds), + }; +}; + +const fetchProposalsByIds = async (proposalIds: readonly string[]) => { + if (proposalIds.length === 0) { + return new Map(); + } + + const response = await request( + `${Config.getApiOrigin()}/graphql`, + proposalsByIdsQueryDocument, + { + ids: proposalIds, + }, + ); + + return new Map(response.proposals.map((proposal) => [proposal.id, proposal])); +}; + +export const hydrateProposalBacklinks = async ( + queryEntities: QueryEntityWithRelations[], + entities: T[], + relationInfo: RelationTypeIdInfo[], +) => { + const proposalBacklinkInfo = getProposalBacklinkInfo(relationInfo); + if (proposalBacklinkInfo.length === 0 || entities.length === 0) { + return; + } + + const { refsByParentId, proposalIds } = collectProposalBacklinkRefs(queryEntities, relationInfo); + const proposalsById = await fetchProposalsByIds(proposalIds); + + for (const entity of entities) { + const refsByPropertyName = refsByParentId.get(entity.id); + if (!refsByPropertyName) { + continue; + } + + const entityRecord = entity as Record; + + for (const info of proposalBacklinkInfo) { + const refs = refsByPropertyName.get(info.propertyName) ?? []; + entityRecord[info.propertyName] = refs.flatMap((ref) => { + const proposal = proposalsById.get(ref.proposalId); + if (!proposal) { + return []; + } + + return [ + { + ...proposal, + _relation: { + id: ref.relationId, + }, + }, + ]; + }); + } + } +}; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index 89a4248f..562cb0b5 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -6,6 +6,7 @@ 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'; +import { hydrateProposalBacklinks } from './internal/hydrate-proposal-backlinks.js'; export type SearchManyPublicParams< S extends Schema.Schema.AnyNoContext, @@ -97,5 +98,8 @@ export const searchManyPublic = async < relationTypeIds, includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam }, ); + + await hydrateProposalBacklinks(result.entities, data, relationTypeIds); + return { data, invalidEntities, invalidRelationEntities }; }; diff --git a/packages/hypergraph/src/type/type.ts b/packages/hypergraph/src/type/type.ts index 8838491b..5fb6622c 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, + ProposalBacklinkSymbol, RelationBacklinkSymbol, RelationPropertiesSymbol, RelationSchemaSymbol, @@ -23,6 +24,7 @@ type RelationPropertiesDefinition = Record; type RelationOptionsBase = { backlink?: boolean; + proposalBacklink?: boolean; }; type RelationOptions = RelationOptionsBase & { @@ -151,6 +153,22 @@ export const Point = (propertyId: string) => encode: (points: readonly number[]) => points.join(','), }).pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'point' })); +export const Proposal = Schema.Struct({ + proposedBy: Schema.String, + executedAt: Schema.String, + spaceId: Schema.String, + votingMode: Schema.String, + startTime: Schema.String, + endTime: Schema.String, + quorum: Schema.String, + threshold: Schema.String, + name: Schema.String, + createdAt: Schema.String, + noCount: Schema.String, + yesCount: Schema.String, + createdAtBlock: Schema.String, +}); + export function Relation( schema: S, options?: RelationOptionsBase, @@ -220,6 +238,7 @@ export function Relation< >; const isBacklinkRelation = !!normalizedOptions?.backlink; + const isProposalBacklinkRelation = !!normalizedOptions?.proposalBacklink; const relationSchema = Schema.Array(schemaWithId).pipe( Schema.annotations({ @@ -228,6 +247,7 @@ export function Relation< [RelationSymbol]: true, [PropertyTypeSymbol]: 'relation', [RelationBacklinkSymbol]: isBacklinkRelation, + [ProposalBacklinkSymbol]: isProposalBacklinkRelation, }), ); @@ -252,6 +272,15 @@ export function Backlink< return Relation(schema, normalizedOptions); } +export function ProposalBacklink(options?: RelationOptionsBase) { + const normalizedOptions = { + ...(options ?? {}), + backlink: true, + proposalBacklink: true, + } as RelationOptionsBase; + return Relation(Proposal, normalizedOptions); +} + export const optional = (schemaFn: (propertyId: string) => S) => (propertyId: string) => { diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index 0d415916..fb2e319d 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -90,6 +90,23 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( continue; } + const relationMetadata = relationInfo.find( + (info) => info.typeId === result.value && info.propertyName === String(prop.name), + ); + + // ProposalBacklink relations are resolved via a second proposals query step. + // Skip regular relation decoding here and leave the initialized empty array. + if (relationMetadata?.resolutionStrategy === 'proposalBacklink') { + if (relationMetadata.includeTotalCount) { + const alias = getRelationAlias(result.value, relationMetadata.targetTypeIds); + const relationConnection = queryEntity[alias as keyof RecursiveQueryEntity] as + | RelationsListWithNodes + | undefined; + rawEntity[`${String(prop.name)}TotalCount`] = relationConnection?.totalCount ?? 0; + } + continue; + } + const typeIds: string[] = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(relationTransformation).pipe( Option.getOrElse(() => []), ); @@ -97,10 +114,6 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( continue; } - 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: RelationsListItem[] | undefined; let relationConnection: RelationsListWithNodes | undefined; diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 2cf828fb..bc619aee 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -12,6 +12,7 @@ export type RelationTypeIdInfo = { listField: RelationListField; includeNodes: boolean; includeTotalCount: boolean; + resolutionStrategy?: 'proposalBacklink'; targetTypeIds?: readonly string[]; relationSpaces?: RelationSpacesOverride; valueSpaces?: RelationSpacesOverride; @@ -79,6 +80,9 @@ export const getRelationTypeIds = ( const isBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)(prop.type).pipe( Option.getOrElse(() => false), ); + const isProposalBacklink = SchemaAST.getAnnotation(Constants.ProposalBacklinkSymbol)(prop.type).pipe( + Option.getOrElse(() => false), + ); const listField: RelationListField = isBacklink ? 'backlinks' : 'relations'; const relationSpaces = includeBranch?._config?.relationSpaces; const valueSpaces = includeBranch?._config?.valueSpaces; @@ -91,6 +95,7 @@ export const getRelationTypeIds = ( listField, includeNodes, includeTotalCount, + ...(isProposalBacklink ? { resolutionStrategy: 'proposalBacklink' as const } : {}), ...(targetTypeIds ? { targetTypeIds } : {}), }; const level1Info: RelationTypeIdInfo = @@ -137,6 +142,9 @@ export const getRelationTypeIds = ( const nestedIsBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)( nestedProp.type, ).pipe(Option.getOrElse(() => false)); + const nestedIsProposalBacklink = SchemaAST.getAnnotation(Constants.ProposalBacklinkSymbol)( + nestedProp.type, + ).pipe(Option.getOrElse(() => false)); const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations'; const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces; const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces; @@ -148,6 +156,7 @@ export const getRelationTypeIds = ( listField: nestedListField, includeNodes: nestedIncludeNodes, includeTotalCount: nestedIncludeTotalCount, + ...(nestedIsProposalBacklink ? { resolutionStrategy: 'proposalBacklink' as const } : {}), ...(nestedTargetTypeIds ? { targetTypeIds: nestedTargetTypeIds } : {}), }; const nestedInfo: RelationTypeIdInfo = diff --git a/packages/hypergraph/test/entity/find-one-public.test.ts b/packages/hypergraph/test/entity/find-one-public.test.ts index ca9fa63d..91f2342e 100644 --- a/packages/hypergraph/test/entity/find-one-public.test.ts +++ b/packages/hypergraph/test/entity/find-one-public.test.ts @@ -15,6 +15,8 @@ vi.mock('graphql-request', () => ({ const TITLE_PROPERTY_ID = Id('c6c9ad0ff3334f508e928d93bc38b63c'); const CHILDREN_RELATION_PROPERTY_ID = Id('1e8caeb93e644dd3b7a43d9cc714d4f2'); const CHILD_NAME_PROPERTY_ID = Id('7a9e63df80e34c44baf4844a6b9511cd'); +const BOUNTY_NAME_PROPERTY_ID = Id('5d07de37f0a349c98bcb664fba357f7e'); +const SUBMISSION_RELATION_PROPERTY_ID = Id('3b4c516ff3ac41e0a939374119a27d6e'); const Child = Entity.Schema( { @@ -42,6 +44,20 @@ const Parent = Entity.Schema( }, ); +const Bounty = Entity.Schema( + { + name: Type.String, + proposals: Type.ProposalBacklink(), + }, + { + types: [Id('4f08bcb188d04db0a455fa623bfc19aa')], + properties: { + name: BOUNTY_NAME_PROPERTY_ID, + proposals: SUBMISSION_RELATION_PROPERTY_ID, + }, + }, +); + const buildValueEntry = ( propertyId: string, value: Partial<{ @@ -131,4 +147,76 @@ describe('findOnePublic', () => { propertyName: 'children', }); }); + + it('hydrates proposal backlinks by querying proposals with collected ids', async () => { + const relationInfo = getRelationTypeIds(Bounty, { proposals: {} }); + const proposalsRelationInfo = relationInfo.find((info) => info.propertyName === 'proposals'); + const relationAlias = getRelationAlias(SUBMISSION_RELATION_PROPERTY_ID, proposalsRelationInfo?.targetTypeIds); + + const proposalId = '000afabf90cf42bbb1bb0fe77af20e56'; + const proposalPayload = { + id: proposalId, + proposedBy: '52c7ae149838b6d47ce0f3b2a5974546', + executedAt: '1771111365', + spaceId: '52c7ae149838b6d47ce0f3b2a5974546', + votingMode: 'FAST', + startTime: '1771082486', + endTime: '1771168886', + quorum: '1', + threshold: '0', + name: 'Add Member', + createdAt: '1771082486', + noCount: '0', + yesCount: '1', + createdAtBlock: '92716', + }; + + mockRequest.mockResolvedValueOnce({ + entity: { + id: 'bounty-1', + name: 'Bounty 1', + valuesList: [buildValueEntry(BOUNTY_NAME_PROPERTY_ID, { text: 'Bounty 1' })], + [relationAlias]: { + nodes: [ + { + id: 'submission-1', + entity: { valuesList: [] }, + toEntity: { + id: proposalId, + name: 'proposal', + valuesList: [], + }, + typeId: SUBMISSION_RELATION_PROPERTY_ID, + }, + ], + }, + }, + }); + mockRequest.mockResolvedValueOnce({ + proposals: [proposalPayload], + }); + + const result = await findOnePublic(Bounty, { + id: 'bounty-1', + space: 'space-1', + include: { + proposals: {}, + }, + }); + + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(mockRequest.mock.calls[1]?.[2]).toEqual({ + ids: [proposalId], + }); + expect(result.invalidEntity).toBeNull(); + expect(result.invalidRelationEntities).toEqual([]); + expect(result.entity?.proposals).toEqual([ + { + ...proposalPayload, + _relation: { + id: 'submission-1', + }, + }, + ]); + }); }); diff --git a/packages/hypergraph/test/utils/relation-config-overrides.test.ts b/packages/hypergraph/test/utils/relation-config-overrides.test.ts index 7d8db239..c5e52857 100644 --- a/packages/hypergraph/test/utils/relation-config-overrides.test.ts +++ b/packages/hypergraph/test/utils/relation-config-overrides.test.ts @@ -12,8 +12,10 @@ const CHILD_TYPES = [Id('cd9a2ae2831c4fa2b714ad3aa254db7d')]; const FRIEND_TYPES = [Id('35ac5c3e4f31466eb3da51fdfbb4b38e')]; const PODCAST_TYPES = [Id('f347d2a2cc184d45aa9a0df3ba40f4ad')]; const EPISODE_TYPES = [Id('b1fe2f9e1f6a4f07a0fb3f5d463f98f1')]; +const BOUNTY_TYPES = [Id('f5075753000049228b8ef2bc2a6e851d')]; const NAME_PROPERTY_ID = Id('9f5e7ea451bb4c9f87397fa0aa695d02'); const PODCAST_EPISODES_RELATION_PROPERTY_ID = Id('88f2461558b14d6ca45e81ab9582c282'); +const BOUNTY_PROPOSALS_RELATION_PROPERTY_ID = Id('3b4c516ff3ac41e0a939374119a27d6e'); const stringifyTypeIds = (typeIds: readonly string[]) => `[${typeIds.map((id) => JSON.stringify(id)).join(', ')}]`; @@ -83,6 +85,20 @@ const Podcast = Entity.Schema( }, ); +const Bounty = Entity.Schema( + { + name: Type.String, + proposals: Type.ProposalBacklink(), + }, + { + types: BOUNTY_TYPES, + properties: { + name: NAME_PROPERTY_ID, + proposals: BOUNTY_PROPOSALS_RELATION_PROPERTY_ID, + }, + }, +); + describe('relation include config overrides', () => { it('propagates spaces overrides to query fragments', () => { const include = { @@ -168,4 +184,22 @@ describe('relation include config overrides', () => { expect(selection).toContain(`fromEntity: { typeIds: { in: ${stringifyTypeIds(EPISODE_TYPES)} } }`); }); + + it('marks proposal backlinks with a dedicated resolution strategy', () => { + const include = { + proposals: {}, + } satisfies Entity.EntityInclude; + + const relationInfo = getRelationTypeIds(Bounty, include); + const selection = buildRelationsSelection(relationInfo, 'single'); + + expect(relationInfo).toContainEqual( + expect.objectContaining({ + propertyName: 'proposals', + listField: 'backlinks', + resolutionStrategy: 'proposalBacklink', + }), + ); + expect(selection).not.toContain('fromEntity: { typeIds:'); + }); });