Skip to content

Commit 3611711

Browse files
committed
add proposal backlinks
1 parent 4194dba commit 3611711

10 files changed

Lines changed: 333 additions & 4 deletions

File tree

packages/hypergraph/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export const PropertyTypeSymbol = Symbol.for('grc-20/property/type');
1111
export const RelationPropertiesSymbol = Symbol.for('grc-20/relation/properties');
1212

1313
export const RelationBacklinkSymbol = Symbol.for('grc-20/relation/backlink');
14+
15+
export const ProposalBacklinkSymbol = Symbol.for('grc-20/relation/proposal-backlink');

packages/hypergraph/src/entity/find-many-public.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { request } from 'graphql-request';
88
import type { InvalidRelationEntity, RelationsListWithNodes } from '../utils/convert-relations.js';
99
import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js';
1010
import { buildRelationsSelection } from '../utils/relation-query-helpers.js';
11+
import { hydrateProposalBacklinks } from './internal/hydrate-proposal-backlinks.js';
1112
import { normalizeSpaceIds } from './internal/normalize-space-ids.js';
1213
import type { SpaceSelection } from './internal/space-selection.js';
1314
import { normalizeSpaceSelection } from './internal/space-selection.js';
@@ -342,6 +343,9 @@ export const findManyPublic = async <
342343
relationTypeIds,
343344
includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam },
344345
);
346+
347+
await hydrateProposalBacklinks(result.entities, data, relationTypeIds);
348+
345349
if (logInvalidResults) {
346350
if (invalidEntities.length > 0) {
347351
console.warn('Entities where decoding failed were dropped', invalidEntities);

packages/hypergraph/src/entity/find-one-public.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { request } from 'graphql-request';
88
import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js';
99
import { buildRelationsSelection } from '../utils/relation-query-helpers.js';
1010
import type { EntityQueryResult as MultiEntityQueryResult } from './find-many-public.js';
11+
import { hydrateProposalBacklinks } from './internal/hydrate-proposal-backlinks.js';
1112
import { normalizeSpaceIds } from './internal/normalize-space-ids.js';
1213

1314
type EntityQueryResult = {
@@ -176,6 +177,11 @@ export const findOnePublic = async <
176177
relationTypeIds,
177178
includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam },
178179
);
180+
181+
if (result.entity && parsed.entity) {
182+
await hydrateProposalBacklinks([result.entity], [parsed.entity], relationTypeIds);
183+
}
184+
179185
if (logInvalidResults) {
180186
if (parsed.invalidEntity) {
181187
console.warn('Entity decoding failed', parsed.invalidEntity);
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Config } from '@graphprotocol/hypergraph';
2+
import { request } from 'graphql-request';
3+
import type { RelationsListWithNodes } from '../../utils/convert-relations.js';
4+
import type { RelationTypeIdInfo } from '../../utils/get-relation-type-ids.js';
5+
import { getRelationAlias } from '../../utils/relation-query-helpers.js';
6+
7+
type QueryEntityWithRelations = {
8+
id: string;
9+
} & Partial<Record<`relations_${string}`, RelationsListWithNodes | undefined>>;
10+
11+
type ProposalRelationRef = {
12+
proposalId: string;
13+
relationId: string;
14+
};
15+
16+
type ProposalQueryResult = {
17+
id: string;
18+
} & Record<string, unknown>;
19+
20+
type ProposalsByIdsQueryResult = {
21+
proposals: ProposalQueryResult[];
22+
};
23+
24+
const proposalsByIdsQueryDocument = `
25+
query proposalsByIds($ids: [UUID!]!) {
26+
proposals(filter: { id: { in: $ids } }) {
27+
id
28+
proposedBy
29+
executedAt
30+
spaceId
31+
votingMode
32+
startTime
33+
endTime
34+
quorum
35+
threshold
36+
name
37+
createdAt
38+
noCount
39+
yesCount
40+
createdAtBlock
41+
}
42+
}
43+
`;
44+
45+
const getProposalBacklinkInfo = (relationInfo: RelationTypeIdInfo[]) =>
46+
relationInfo.filter((info) => info.includeNodes && info.resolutionStrategy === 'proposalBacklink');
47+
48+
const collectProposalBacklinkRefs = (queryEntities: QueryEntityWithRelations[], relationInfo: RelationTypeIdInfo[]) => {
49+
const backlinkInfo = getProposalBacklinkInfo(relationInfo);
50+
const refsByParentId = new Map<string, Map<string, ProposalRelationRef[]>>();
51+
const proposalIds = new Set<string>();
52+
53+
for (const queryEntity of queryEntities) {
54+
const refsByPropertyName = new Map<string, ProposalRelationRef[]>();
55+
56+
for (const info of backlinkInfo) {
57+
const alias = getRelationAlias(info.typeId, info.targetTypeIds);
58+
const relationNodes = queryEntity[alias]?.nodes ?? [];
59+
const relationRefs: ProposalRelationRef[] = [];
60+
61+
for (const relationNode of relationNodes) {
62+
if (!relationNode?.toEntity?.id || !relationNode.id) {
63+
continue;
64+
}
65+
relationRefs.push({
66+
proposalId: relationNode.toEntity.id,
67+
relationId: relationNode.id,
68+
});
69+
proposalIds.add(relationNode.toEntity.id);
70+
}
71+
72+
refsByPropertyName.set(info.propertyName, relationRefs);
73+
}
74+
75+
refsByParentId.set(queryEntity.id, refsByPropertyName);
76+
}
77+
78+
return {
79+
refsByParentId,
80+
proposalIds: Array.from(proposalIds),
81+
};
82+
};
83+
84+
const fetchProposalsByIds = async (proposalIds: readonly string[]) => {
85+
if (proposalIds.length === 0) {
86+
return new Map<string, ProposalQueryResult>();
87+
}
88+
89+
const response = await request<ProposalsByIdsQueryResult>(
90+
`${Config.getApiOrigin()}/graphql`,
91+
proposalsByIdsQueryDocument,
92+
{
93+
ids: proposalIds,
94+
},
95+
);
96+
97+
return new Map(response.proposals.map((proposal) => [proposal.id, proposal]));
98+
};
99+
100+
export const hydrateProposalBacklinks = async <T extends { id: string }>(
101+
queryEntities: QueryEntityWithRelations[],
102+
entities: T[],
103+
relationInfo: RelationTypeIdInfo[],
104+
) => {
105+
const proposalBacklinkInfo = getProposalBacklinkInfo(relationInfo);
106+
if (proposalBacklinkInfo.length === 0 || entities.length === 0) {
107+
return;
108+
}
109+
110+
const { refsByParentId, proposalIds } = collectProposalBacklinkRefs(queryEntities, relationInfo);
111+
const proposalsById = await fetchProposalsByIds(proposalIds);
112+
113+
for (const entity of entities) {
114+
const refsByPropertyName = refsByParentId.get(entity.id);
115+
if (!refsByPropertyName) {
116+
continue;
117+
}
118+
119+
const entityRecord = entity as Record<string, unknown>;
120+
121+
for (const info of proposalBacklinkInfo) {
122+
const refs = refsByPropertyName.get(info.propertyName) ?? [];
123+
entityRecord[info.propertyName] = refs.flatMap((ref) => {
124+
const proposal = proposalsById.get(ref.proposalId);
125+
if (!proposal) {
126+
return [];
127+
}
128+
129+
return [
130+
{
131+
...proposal,
132+
_relation: {
133+
id: ref.relationId,
134+
},
135+
},
136+
];
137+
});
138+
}
139+
}
140+
};

packages/hypergraph/src/entity/search-many-public.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { request } from 'graphql-request';
66
import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js';
77
import { buildRelationsSelection } from '../utils/relation-query-helpers.js';
88
import { type EntityQueryResult, parseResult } from './find-many-public.js';
9+
import { hydrateProposalBacklinks } from './internal/hydrate-proposal-backlinks.js';
910

1011
export type SearchManyPublicParams<
1112
S extends Schema.Schema.AnyNoContext,
@@ -97,5 +98,8 @@ export const searchManyPublic = async <
9798
relationTypeIds,
9899
includeSpaceIdsParam === undefined ? undefined : { includeSpaceIds: includeSpaceIdsParam },
99100
);
101+
102+
await hydrateProposalBacklinks(result.entities, data, relationTypeIds);
103+
100104
return { data, invalidEntities, invalidRelationEntities };
101105
};

packages/hypergraph/src/type/type.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as SchemaAST from 'effect/SchemaAST';
44
import {
55
PropertyIdSymbol,
66
PropertyTypeSymbol,
7+
ProposalBacklinkSymbol,
78
RelationBacklinkSymbol,
89
RelationPropertiesSymbol,
910
RelationSchemaSymbol,
@@ -23,6 +24,7 @@ type RelationPropertiesDefinition = Record<string, SchemaBuilder>;
2324

2425
type RelationOptionsBase = {
2526
backlink?: boolean;
27+
proposalBacklink?: boolean;
2628
};
2729

2830
type RelationOptions<RP extends RelationPropertiesDefinition> = RelationOptionsBase & {
@@ -151,6 +153,22 @@ export const Point = (propertyId: string) =>
151153
encode: (points: readonly number[]) => points.join(','),
152154
}).pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'point' }));
153155

156+
export const Proposal = Schema.Struct({
157+
proposedBy: Schema.String,
158+
executedAt: Schema.String,
159+
spaceId: Schema.String,
160+
votingMode: Schema.String,
161+
startTime: Schema.String,
162+
endTime: Schema.String,
163+
quorum: Schema.String,
164+
threshold: Schema.String,
165+
name: Schema.String,
166+
createdAt: Schema.String,
167+
noCount: Schema.String,
168+
yesCount: Schema.String,
169+
createdAtBlock: Schema.String,
170+
});
171+
154172
export function Relation<S extends Schema.Schema.AnyNoContext>(
155173
schema: S,
156174
options?: RelationOptionsBase,
@@ -220,6 +238,7 @@ export function Relation<
220238
>;
221239

222240
const isBacklinkRelation = !!normalizedOptions?.backlink;
241+
const isProposalBacklinkRelation = !!normalizedOptions?.proposalBacklink;
223242

224243
const relationSchema = Schema.Array(schemaWithId).pipe(
225244
Schema.annotations({
@@ -228,6 +247,7 @@ export function Relation<
228247
[RelationSymbol]: true,
229248
[PropertyTypeSymbol]: 'relation',
230249
[RelationBacklinkSymbol]: isBacklinkRelation,
250+
[ProposalBacklinkSymbol]: isProposalBacklinkRelation,
231251
}),
232252
);
233253

@@ -252,6 +272,15 @@ export function Backlink<
252272
return Relation(schema, normalizedOptions);
253273
}
254274

275+
export function ProposalBacklink(options?: RelationOptionsBase) {
276+
const normalizedOptions = {
277+
...(options ?? {}),
278+
backlink: true,
279+
proposalBacklink: true,
280+
} as RelationOptionsBase;
281+
return Relation(Proposal, normalizedOptions);
282+
}
283+
255284
export const optional =
256285
<S extends Schema.Schema.AnyNoContext>(schemaFn: (propertyId: string) => S) =>
257286
(propertyId: string) => {

packages/hypergraph/src/utils/convert-relations.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,30 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>(
9090
continue;
9191
}
9292

93+
const relationMetadata = relationInfo.find(
94+
(info) => info.typeId === result.value && info.propertyName === String(prop.name),
95+
);
96+
97+
// ProposalBacklink relations are resolved via a second proposals query step.
98+
// Skip regular relation decoding here and leave the initialized empty array.
99+
if (relationMetadata?.resolutionStrategy === 'proposalBacklink') {
100+
if (relationMetadata.includeTotalCount) {
101+
const alias = getRelationAlias(result.value, relationMetadata.targetTypeIds);
102+
const relationConnection = queryEntity[alias as keyof RecursiveQueryEntity] as
103+
| RelationsListWithNodes
104+
| undefined;
105+
rawEntity[`${String(prop.name)}TotalCount`] = relationConnection?.totalCount ?? 0;
106+
}
107+
continue;
108+
}
109+
93110
const typeIds: string[] = SchemaAST.getAnnotation<string[]>(Constants.TypeIdsSymbol)(relationTransformation).pipe(
94111
Option.getOrElse(() => []),
95112
);
96113
if (typeIds.length === 0) {
97114
continue;
98115
}
99116

100-
const relationMetadata = relationInfo.find(
101-
(info) => info.typeId === result.value && info.propertyName === String(prop.name),
102-
);
103-
104117
// Get relations from aliased field if we have relationInfo for this property, otherwise fallback to old behavior
105118
let allRelationsWithTheCorrectPropertyTypeId: RelationsListItem[] | undefined;
106119
let relationConnection: RelationsListWithNodes | undefined;

packages/hypergraph/src/utils/get-relation-type-ids.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type RelationTypeIdInfo = {
1212
listField: RelationListField;
1313
includeNodes: boolean;
1414
includeTotalCount: boolean;
15+
resolutionStrategy?: 'proposalBacklink';
1516
targetTypeIds?: readonly string[];
1617
relationSpaces?: RelationSpacesOverride;
1718
valueSpaces?: RelationSpacesOverride;
@@ -79,6 +80,9 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
7980
const isBacklink = SchemaAST.getAnnotation<boolean>(Constants.RelationBacklinkSymbol)(prop.type).pipe(
8081
Option.getOrElse(() => false),
8182
);
83+
const isProposalBacklink = SchemaAST.getAnnotation<boolean>(Constants.ProposalBacklinkSymbol)(prop.type).pipe(
84+
Option.getOrElse(() => false),
85+
);
8286
const listField: RelationListField = isBacklink ? 'backlinks' : 'relations';
8387
const relationSpaces = includeBranch?._config?.relationSpaces;
8488
const valueSpaces = includeBranch?._config?.valueSpaces;
@@ -91,6 +95,7 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
9195
listField,
9296
includeNodes,
9397
includeTotalCount,
98+
...(isProposalBacklink ? { resolutionStrategy: 'proposalBacklink' as const } : {}),
9499
...(targetTypeIds ? { targetTypeIds } : {}),
95100
};
96101
const level1Info: RelationTypeIdInfo =
@@ -137,6 +142,9 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
137142
const nestedIsBacklink = SchemaAST.getAnnotation<boolean>(Constants.RelationBacklinkSymbol)(
138143
nestedProp.type,
139144
).pipe(Option.getOrElse(() => false));
145+
const nestedIsProposalBacklink = SchemaAST.getAnnotation<boolean>(Constants.ProposalBacklinkSymbol)(
146+
nestedProp.type,
147+
).pipe(Option.getOrElse(() => false));
140148
const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations';
141149
const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces;
142150
const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces;
@@ -148,6 +156,7 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
148156
listField: nestedListField,
149157
includeNodes: nestedIncludeNodes,
150158
includeTotalCount: nestedIncludeTotalCount,
159+
...(nestedIsProposalBacklink ? { resolutionStrategy: 'proposalBacklink' as const } : {}),
151160
...(nestedTargetTypeIds ? { targetTypeIds: nestedTargetTypeIds } : {}),
152161
};
153162
const nestedInfo: RelationTypeIdInfo =

0 commit comments

Comments
 (0)