Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/hypergraph/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
4 changes: 4 additions & 0 deletions packages/hypergraph/src/entity/find-many-public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/hypergraph/src/entity/find-one-public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
140 changes: 140 additions & 0 deletions packages/hypergraph/src/entity/internal/hydrate-proposal-backlinks.ts
Original file line number Diff line number Diff line change
@@ -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<Record<`relations_${string}`, RelationsListWithNodes | undefined>>;

type ProposalRelationRef = {
proposalId: string;
relationId: string;
};

type ProposalQueryResult = {
id: string;
} & Record<string, unknown>;

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<string, Map<string, ProposalRelationRef[]>>();
const proposalIds = new Set<string>();

for (const queryEntity of queryEntities) {
const refsByPropertyName = new Map<string, ProposalRelationRef[]>();

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<string, ProposalQueryResult>();
}

const response = await request<ProposalsByIdsQueryResult>(
`${Config.getApiOrigin()}/graphql`,
proposalsByIdsQueryDocument,
{
ids: proposalIds,
},
);

return new Map(response.proposals.map((proposal) => [proposal.id, proposal]));
};

export const hydrateProposalBacklinks = async <T extends { id: string }>(
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<string, unknown>;

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,
},
},
];
});
}
}
};
4 changes: 4 additions & 0 deletions packages/hypergraph/src/entity/search-many-public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -97,5 +98,8 @@ export const searchManyPublic = async <
relationTypeIds,
includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam },
);

await hydrateProposalBacklinks(result.entities, data, relationTypeIds);

return { data, invalidEntities, invalidRelationEntities };
};
29 changes: 29 additions & 0 deletions packages/hypergraph/src/type/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as SchemaAST from 'effect/SchemaAST';
import {
PropertyIdSymbol,
PropertyTypeSymbol,
ProposalBacklinkSymbol,
RelationBacklinkSymbol,
RelationPropertiesSymbol,
RelationSchemaSymbol,
Expand All @@ -23,6 +24,7 @@ type RelationPropertiesDefinition = Record<string, SchemaBuilder>;

type RelationOptionsBase = {
backlink?: boolean;
proposalBacklink?: boolean;
};

type RelationOptions<RP extends RelationPropertiesDefinition> = RelationOptionsBase & {
Expand Down Expand Up @@ -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<S extends Schema.Schema.AnyNoContext>(
schema: S,
options?: RelationOptionsBase,
Expand Down Expand Up @@ -220,6 +238,7 @@ export function Relation<
>;

const isBacklinkRelation = !!normalizedOptions?.backlink;
const isProposalBacklinkRelation = !!normalizedOptions?.proposalBacklink;

const relationSchema = Schema.Array(schemaWithId).pipe(
Schema.annotations({
Expand All @@ -228,6 +247,7 @@ export function Relation<
[RelationSymbol]: true,
[PropertyTypeSymbol]: 'relation',
[RelationBacklinkSymbol]: isBacklinkRelation,
[ProposalBacklinkSymbol]: isProposalBacklinkRelation,
}),
);

Expand All @@ -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 =
<S extends Schema.Schema.AnyNoContext>(schemaFn: (propertyId: string) => S) =>
(propertyId: string) => {
Expand Down
21 changes: 17 additions & 4 deletions packages/hypergraph/src/utils/convert-relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,30 @@ 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<string[]>(Constants.TypeIdsSymbol)(relationTransformation).pipe(
Option.getOrElse(() => []),
);
if (typeIds.length === 0) {
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;
Expand Down
9 changes: 9 additions & 0 deletions packages/hypergraph/src/utils/get-relation-type-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type RelationTypeIdInfo = {
listField: RelationListField;
includeNodes: boolean;
includeTotalCount: boolean;
resolutionStrategy?: 'proposalBacklink';
targetTypeIds?: readonly string[];
relationSpaces?: RelationSpacesOverride;
valueSpaces?: RelationSpacesOverride;
Expand Down Expand Up @@ -79,6 +80,9 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
const isBacklink = SchemaAST.getAnnotation<boolean>(Constants.RelationBacklinkSymbol)(prop.type).pipe(
Option.getOrElse(() => false),
);
const isProposalBacklink = SchemaAST.getAnnotation<boolean>(Constants.ProposalBacklinkSymbol)(prop.type).pipe(
Option.getOrElse(() => false),
);
const listField: RelationListField = isBacklink ? 'backlinks' : 'relations';
const relationSpaces = includeBranch?._config?.relationSpaces;
const valueSpaces = includeBranch?._config?.valueSpaces;
Expand All @@ -91,6 +95,7 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
listField,
includeNodes,
includeTotalCount,
...(isProposalBacklink ? { resolutionStrategy: 'proposalBacklink' as const } : {}),
...(targetTypeIds ? { targetTypeIds } : {}),
};
const level1Info: RelationTypeIdInfo =
Expand Down Expand Up @@ -137,6 +142,9 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
const nestedIsBacklink = SchemaAST.getAnnotation<boolean>(Constants.RelationBacklinkSymbol)(
nestedProp.type,
).pipe(Option.getOrElse(() => false));
const nestedIsProposalBacklink = SchemaAST.getAnnotation<boolean>(Constants.ProposalBacklinkSymbol)(
nestedProp.type,
).pipe(Option.getOrElse(() => false));
const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations';
const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces;
const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces;
Expand All @@ -148,6 +156,7 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
listField: nestedListField,
includeNodes: nestedIncludeNodes,
includeTotalCount: nestedIncludeTotalCount,
...(nestedIsProposalBacklink ? { resolutionStrategy: 'proposalBacklink' as const } : {}),
...(nestedTargetTypeIds ? { targetTypeIds: nestedTargetTypeIds } : {}),
};
const nestedInfo: RelationTypeIdInfo =
Expand Down
Loading
Loading