Skip to content

Commit f19b827

Browse files
nikgraf0xJagger
andauthored
feat(ranks): createRank/updateRank for ranking submissions (#89) (#90)
* feat(ranks): update rankings sdk * feat(ranks): delete orphaned vote entities on update, reuse FILTER property * refactor(ranks): one public path per workflow + address review feedback Co-authored-by: Jagger <194556652+0xJagger@users.noreply.github.com>
1 parent 976407a commit f19b827

17 files changed

Lines changed: 940 additions & 102 deletions

.changeset/ranks-create-update.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@geoprotocol/geo-sdk": patch
3+
---
4+
5+
Ranking submissions: extend the ranks module with per-item space, block links, and updates.
6+
7+
- Rank creation moves to `Ops.ranks.create(...)` (pure op generation), alongside `Ops.properties`/`Ops.types`. **Breaking:** the `Rank.createRank(...)` namespace export is removed.
8+
- Every vote now requires a `spaceId` (set as `to_space_id` on the vote relation), so a rank can include the same entity across multiple space perspectives. Item uniqueness is keyed on `(entityId, spaceId)`. **Breaking:** votes previously only took `entityId`.
9+
- `Ops.ranks.create(...)` accepts an optional `blockId` to link the rank to a `Ranking Block` via a `Rank → Ranking Block` relation.
10+
- Each vote relation now carries a fractional-index `position`, so clients can order votes natively by the relation's `position` field.
11+
- New `geo.ranks.update(...)` re-submits a rank: it fetches the rank's current vote relations from the configured Geo API, deletes them and their reified vote entities, then re-emits the new ordered votes — no indexer involvement required. The lower-level `updateRank` op-builder is internal.

examples/ranks/create-ordinal-rank.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
11
/**
22
* Example: Creating an Ordinal Rank with grc-20-ts
33
*
4-
* This example demonstrates how to use the `createRank` function to create
4+
* This example demonstrates how to use `Ops.ranks.create` to create
55
* an ordinal (ordered) rank in the Knowledge Graph.
66
*/
77

8-
import { IdUtils, Rank } from '@geoprotocol/geo-sdk';
8+
import { IdUtils, Ops } from '@geoprotocol/geo-sdk';
99

1010
// For this example, we'll generate some entity IDs to represent items we want to rank.
1111
// In a real application, these would be existing entity IDs from your Knowledge Graph.
1212
const movie1Id = IdUtils.generate();
1313
const movie2Id = IdUtils.generate();
1414
const movie3Id = IdUtils.generate();
1515

16+
// Each ranked entity is scoped to a space perspective via `spaceId`. In a real
17+
// application this is the space the ranked entity lives in.
18+
const spaceId = IdUtils.generate();
19+
1620
// =============================================================================
1721
// Example 1: Creating an Ordinal Rank (Ordered List)
1822
// =============================================================================
1923
// Ordinal ranks are used when you want to rank items by position (1st, 2nd, 3rd, etc.)
2024
// The position is derived from the array order - no need to specify position values!
2125

22-
const ordinalRankResult = Rank.createRank({
26+
const ordinalRankResult = Ops.ranks.create({
2327
name: 'My Favorite Movies of 2024',
2428
description: 'A ranked list of my top movies this year',
2529
rankType: 'ORDINAL',
2630
votes: [
27-
{ entityId: movie1Id }, // 1st place
28-
{ entityId: movie2Id }, // 2nd place
29-
{ entityId: movie3Id }, // 3rd place
31+
{ entityId: movie1Id, spaceId }, // 1st place
32+
{ entityId: movie2Id, spaceId }, // 2nd place
33+
{ entityId: movie3Id, spaceId }, // 3rd place
3034
],
3135
});
3236

examples/ranks/create-weighted-rank.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
11
/**
22
* Example: Creating a Weighted Rank with grc-20-ts
33
*
4-
* This example demonstrates how to use the `createRank` function to create
4+
* This example demonstrates how to use `Ops.ranks.create` to create
55
* a weighted (scored) rank in the Knowledge Graph.
66
*/
77

8-
import { IdUtils, Rank } from '@geoprotocol/geo-sdk';
8+
import { IdUtils, Ops } from '@geoprotocol/geo-sdk';
99

1010
// For this example, we'll generate some entity IDs to represent items we want to rank.
1111
// In a real application, these would be existing entity IDs from your Knowledge Graph.
1212
const restaurant1Id = IdUtils.generate();
1313
const restaurant2Id = IdUtils.generate();
1414
const restaurant3Id = IdUtils.generate();
1515

16+
// Each ranked entity is scoped to a space perspective via `spaceId`. In a real
17+
// application this is the space the ranked entity lives in.
18+
const spaceId = IdUtils.generate();
19+
1620
// =============================================================================
1721
// Example 1: Creating a Weighted Rank (Scored List)
1822
// =============================================================================
1923
// Weighted ranks are used when you want to assign numeric scores to items.
2024
// Useful for ratings, reviews, or any scenario where magnitude matters.
2125

22-
const weightedRankResult = Rank.createRank({
26+
const weightedRankResult = Ops.ranks.create({
2327
name: 'Restaurant Ratings',
2428
description: 'My restaurant reviews',
2529
rankType: 'WEIGHTED',
2630
votes: [
27-
{ entityId: restaurant1Id, value: 90 }, // Can use any number and scale as needed
28-
{ entityId: restaurant2Id, value: 65 },
29-
{ entityId: restaurant3Id, value: 50 },
31+
{ entityId: restaurant1Id, spaceId, value: 90 }, // Can use any number and scale as needed
32+
{ entityId: restaurant2Id, spaceId, value: 65 },
33+
{ entityId: restaurant3Id, spaceId, value: 50 },
3034
],
3135
});
3236

src/client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import * as DaoSpaces from './client/dao-spaces.js';
77
import { deleteEntity } from './client/entities.js';
88
import * as EntityVotes from './client/entity-votes.js';
99
import * as PersonalSpaces from './client/personal-spaces.js';
10+
import type { UpdateRankClientParams } from './client/ranks.js';
11+
import * as Ranks from './client/ranks.js';
1012
import type { VotingSettingsInput } from './encodings/get-create-dao-space-calldata.js';
1113
import type { Id } from './id.js';
1214
import { defineGeoNetworkConfig } from './networks.js';
15+
import type { UpdateRankResult } from './ranks/types.js';
1316
import type { GeoNetworkConfig } from './types.js';
1417

1518
export type FetchLike = typeof fetch;
@@ -242,6 +245,9 @@ export type Client = {
242245
create(params: CreateCommentParams): Promise<CreateResult>;
243246
update(params: UpdateCommentParams): CreateResult;
244247
};
248+
ranks: {
249+
update(params: UpdateRankClientParams): Promise<UpdateRankResult>;
250+
};
245251
personalSpaces: {
246252
create(params: CreatePersonalSpaceParams): CreatePersonalSpaceResult;
247253
setTopic(params: SetPersonalSpaceTopicParams): CalldataResult;
@@ -511,6 +517,26 @@ export function createGeoClient(params: CreateGeoClientParams): Client {
511517
*/
512518
update: Comments.update,
513519
},
520+
/** Rank operation helpers. */
521+
ranks: {
522+
/**
523+
* Fetches the rank's current votes and builds ops that supersede them with
524+
* the new ordered votes.
525+
*
526+
* @example
527+
* ```ts
528+
* const { ops } = await geo.ranks.update({
529+
* rankId,
530+
* rankType: 'ORDINAL',
531+
* votes: [
532+
* { entityId: movie2Id, spaceId },
533+
* { entityId: movie1Id, spaceId },
534+
* ],
535+
* });
536+
* ```
537+
*/
538+
update: (params: UpdateRankClientParams) => Ranks.update(context, params),
539+
},
514540
/** Personal-space transaction helpers. */
515541
personalSpaces: {
516542
/**

src/client/ranks.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { CreateEntity, CreateRelation, DeleteEntity, DeleteRelation } from '@geoprotocol/grc-20';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { createGeoClient } from '../client.js';
4+
import { RANK_VOTES_RELATION_TYPE } from '../core/ids/system.js';
5+
import { toGrcId } from '../id-utils.js';
6+
import { defineGeoNetworkConfig } from '../networks.js';
7+
8+
const RANK_ID = 'b1dc6e5c63e143bab3d4755b251a4ea1';
9+
const MOVIE_1 = 'f47ac10b58cc4372a5670e02b2c3d479';
10+
const MOVIE_2 = '550e8400e29b41d4a716446655440000';
11+
const SPACE_ID = 'd4bc2f205e2d415e971eb0b9fbf6b6fc';
12+
const EXISTING_REL_1 = 'aaaaaaaa11114111811aaaaaaaaaaaaa';
13+
const EXISTING_REL_2 = 'bbbbbbbb22224222822bbbbbbbbbbbbb';
14+
const EXISTING_VOTE_1 = 'cccccccc33334333833ccccccccccccc';
15+
const EXISTING_VOTE_2 = 'dddddddd44444444844ddddddddddddd';
16+
17+
function customNetwork() {
18+
return defineGeoNetworkConfig({
19+
id: 'LOCAL',
20+
name: 'Local Geo',
21+
apiOrigin: 'http://localhost:3000',
22+
});
23+
}
24+
25+
function voteRelations(ops: Array<{ type: string }>) {
26+
return ops.filter(
27+
(op): op is CreateRelation =>
28+
op.type === 'createRelation' &&
29+
(op as CreateRelation).relationType.every((byte, index) => byte === toGrcId(RANK_VOTES_RELATION_TYPE)[index]),
30+
);
31+
}
32+
33+
describe('geo.ranks', () => {
34+
describe('update', () => {
35+
it('fetches existing vote relations then supersedes them with new votes', async () => {
36+
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
37+
new Response(
38+
JSON.stringify({
39+
data: {
40+
entity: {
41+
relationsList: [
42+
{ id: EXISTING_REL_1, entityId: EXISTING_VOTE_1 },
43+
{ id: EXISTING_REL_2, entityId: EXISTING_VOTE_2 },
44+
],
45+
},
46+
},
47+
}),
48+
),
49+
);
50+
const geo = createGeoClient({ network: customNetwork(), fetch });
51+
52+
const result = await geo.ranks.update({
53+
rankId: RANK_ID,
54+
rankType: 'ORDINAL',
55+
votes: [{ entityId: MOVIE_2, spaceId: SPACE_ID }],
56+
});
57+
58+
expect(fetch).toHaveBeenCalledWith('http://localhost:3000/graphql', expect.objectContaining({ method: 'POST' }));
59+
60+
// Two relation deletes for the fetched relations
61+
const deletes = result.ops.filter((op): op is DeleteRelation => op.type === 'deleteRelation');
62+
expect(deletes).toHaveLength(2);
63+
expect(deletes[0]?.id).toEqual(toGrcId(EXISTING_REL_1));
64+
expect(deletes[1]?.id).toEqual(toGrcId(EXISTING_REL_2));
65+
66+
// Each superseded vote also deletes its reified vote entity
67+
const entityDeletes = result.ops.filter((op): op is DeleteEntity => op.type === 'deleteEntity');
68+
expect(entityDeletes).toHaveLength(2);
69+
expect(entityDeletes[0]?.id).toEqual(toGrcId(EXISTING_VOTE_1));
70+
expect(entityDeletes[1]?.id).toEqual(toGrcId(EXISTING_VOTE_2));
71+
72+
// One new vote relation + entity
73+
expect(voteRelations(result.ops)).toHaveLength(1);
74+
const voteEntity = result.ops.find((op): op is CreateEntity => op.type === 'createEntity');
75+
expect(voteEntity).toBeDefined();
76+
expect(result.voteIds).toHaveLength(1);
77+
});
78+
79+
it('emits only new votes when the rank has no existing votes', async () => {
80+
const fetch = vi
81+
.fn<typeof globalThis.fetch>()
82+
.mockResolvedValue(new Response(JSON.stringify({ data: { entity: { relationsList: [] } } })));
83+
const geo = createGeoClient({ network: customNetwork(), fetch });
84+
85+
const result = await geo.ranks.update({
86+
rankId: RANK_ID,
87+
rankType: 'ORDINAL',
88+
votes: [{ entityId: MOVIE_1, spaceId: SPACE_ID }],
89+
});
90+
91+
expect(result.ops.some(op => op.type === 'deleteRelation')).toBe(false);
92+
expect(voteRelations(result.ops)).toHaveLength(1);
93+
});
94+
95+
it('throws when the rank is not found', async () => {
96+
const fetch = vi
97+
.fn<typeof globalThis.fetch>()
98+
.mockResolvedValue(new Response(JSON.stringify({ data: { entity: null } })));
99+
const geo = createGeoClient({ network: customNetwork(), fetch });
100+
101+
await expect(
102+
geo.ranks.update({
103+
rankId: RANK_ID,
104+
rankType: 'ORDINAL',
105+
votes: [{ entityId: MOVIE_1, spaceId: SPACE_ID }],
106+
}),
107+
).rejects.toThrow(`Rank ${RANK_ID} not found`);
108+
});
109+
110+
it('throws when the rankId is invalid', async () => {
111+
const fetch = vi.fn<typeof globalThis.fetch>();
112+
const geo = createGeoClient({ network: customNetwork(), fetch });
113+
114+
await expect(
115+
geo.ranks.update({
116+
rankId: 'invalid',
117+
rankType: 'ORDINAL',
118+
votes: [{ entityId: MOVIE_1, spaceId: SPACE_ID }],
119+
}),
120+
).rejects.toThrow('Invalid id: "invalid" for `rankId` in `updateRank`');
121+
});
122+
});
123+
});

src/client/ranks.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { RANK_VOTES_RELATION_TYPE } from '../core/ids/system.js';
2+
import type { Id } from '../id.js';
3+
import { assertValid } from '../id-utils.js';
4+
import type { RankType, UpdateRankResult, Vote } from '../ranks/types.js';
5+
import { updateRank } from '../ranks/update-rank.js';
6+
import { graphqlData } from './api.js';
7+
import type { GeoClientContext } from './context.js';
8+
9+
class UpdateRankError extends Error {
10+
readonly _tag = 'UpdateRankError';
11+
}
12+
13+
type RankRelationsResponse = {
14+
entity: {
15+
relationsList: Array<{ id: string; entityId: string }>;
16+
} | null;
17+
};
18+
19+
export type UpdateRankClientParams = {
20+
/** The `Rank` entity to update. */
21+
rankId: Id | string;
22+
/** Whether the rank stores ordinal positions or weighted scores. */
23+
rankType: RankType;
24+
/** The new, ordered list of votes that replaces the rank's current votes. */
25+
votes: Vote[];
26+
};
27+
28+
/**
29+
* Fetches the rank's current `RANK_VOTES` relations from the configured Geo API,
30+
* then builds ops that delete them (and their reified vote entities) and re-emit
31+
* the new ordered votes.
32+
*
33+
* @example
34+
* ```ts
35+
* const { ops } = await geo.ranks.update({
36+
* rankId,
37+
* rankType: 'ORDINAL',
38+
* votes: [
39+
* { entityId: movie2Id, spaceId }, // reordered submission
40+
* { entityId: movie1Id, spaceId },
41+
* ],
42+
* });
43+
* ```
44+
*
45+
* @param context Client context containing API origin and fetch configuration.
46+
* @param params Rank ID, type, and the new ordered votes.
47+
* @returns Rank entity ID, ops, and the created vote entity IDs.
48+
* @throws When IDs are invalid, fetch is unavailable, GraphQL fails, the response
49+
* is malformed, or the rank is not found.
50+
*/
51+
export async function update(
52+
context: GeoClientContext,
53+
{ rankId, rankType, votes }: UpdateRankClientParams,
54+
): Promise<UpdateRankResult> {
55+
assertValid(rankId, '`rankId` in `updateRank`');
56+
57+
const query = `query entity {
58+
entity(id: "${rankId}") {
59+
relationsList(filter: { typeId: { in: ["${RANK_VOTES_RELATION_TYPE}"] } }) {
60+
id
61+
entityId
62+
}
63+
}
64+
}`;
65+
66+
let response: RankRelationsResponse;
67+
try {
68+
response = await graphqlData<RankRelationsResponse>(context, query);
69+
} catch (error) {
70+
throw new UpdateRankError(`Could not fetch existing votes for rank ${rankId}: ${error}`);
71+
}
72+
73+
if (!response.entity) {
74+
throw new UpdateRankError(`Rank ${rankId} not found`);
75+
}
76+
77+
const existingVotes = response.entity.relationsList.map(relation => ({
78+
relationId: relation.id,
79+
voteEntityId: relation.entityId,
80+
}));
81+
82+
return updateRank({ rankId, rankType, votes, existingVotes });
83+
}

src/core/ids/system.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,15 @@ export const RANK_VOTES_RELATION_TYPE = Id('19a4cfff45f24150abf2af0f43eb2eec');
282282
export const VOTE_ORDINAL_VALUE_PROPERTY = Id('49ee1b8918204e75a1ae38a2dcaad4a5');
283283
export const VOTE_WEIGHTED_VALUE_PROPERTY = Id('103701ddcabe4a8e835b10345327b647');
284284

285+
/** Ranking Block */
286+
export const RANKING_BLOCK_TYPE = Id('150db6defe2344f0805afa57502e2c32');
287+
export const RANK_FILTER_PROPERTY = FILTER;
288+
export const RANK_START_DATE_PROPERTY = Id('eed03a040acd4a9e81e08272ed70a817');
289+
export const RANK_END_DATE_PROPERTY = Id('b08b8f63dc1e41568b0819946f2b011c');
290+
export const RANK_AGGREGATION_RESTRICTION_PROPERTY = Id('1e4caa2de3314efa8ac24e8d9d3e9fe9');
291+
export const RANK_RESTRICTION_MEMBERS_AND_EDITORS = Id('10a7b10390f94a728087935052ffaa69');
292+
export const RANK_BLOCK_RELATION_TYPE = Id('09c219c103d14d2aa5c78edbf2d0182a');
293+
285294
export const WORKS_AT_PROPERTY = Id('dac6e89e76be4f7788e10f556ceb6869');
286295

287296
// bounty

src/ops/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * as comments from './comments.js';
22
export * as entities from './entities.js';
33
export * as properties from './properties.js';
44
export * as proposalReviews from './proposal-reviews.js';
5+
export * as ranks from './ranks.js';
56
export * as relations from './relations.js';
67
export * as types from './types.js';

0 commit comments

Comments
 (0)