From c128c891db960d148c67b529e02d0eb7cd744ade Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 18 Jan 2026 18:41:07 +0100 Subject: [PATCH 1/3] return type --- .changeset/add-space-type.md | 6 +++++ .claude/settings.local.json | 5 +++- .../hypergraph/src/space/find-many-public.ts | 11 +++++++++ .../test/space/find-many-public.test.ts | 23 +++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-space-type.md diff --git a/.changeset/add-space-type.md b/.changeset/add-space-type.md new file mode 100644 index 00000000..15405476 --- /dev/null +++ b/.changeset/add-space-type.md @@ -0,0 +1,6 @@ +--- +"@graphprotocol/hypergraph": patch +"@graphprotocol/hypergraph-react": patch +--- + +Add `type` field to `PublicSpace` type returned by `Space.findManyPublic()` and `usePublicSpaces()`. The type is either `"PERSONAL"` or `"DAO"`. diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 742604a0..921b695b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,10 @@ "Bash(pnpm typecheck:*)", "Bash(pnpm check:*)", "Bash(pnpm --filter events test:script:*)", - "Bash(pnpm test:*)" + "Bash(pnpm test:*)", + "Bash(pnpm vitest:*)", + "Bash(pnpm changeset:*)", + "Bash(npx tsc:*)" ], "deny": [], "ask": [] diff --git a/packages/hypergraph/src/space/find-many-public.ts b/packages/hypergraph/src/space/find-many-public.ts index 1acb7315..04e0e45d 100644 --- a/packages/hypergraph/src/space/find-many-public.ts +++ b/packages/hypergraph/src/space/find-many-public.ts @@ -6,6 +6,7 @@ import { request } from 'graphql-request'; const spaceFields = ` id + type page { name relationsList(filter: { @@ -53,8 +54,16 @@ query editorSpaces($accountId: UUID!) { } `; +export const SpaceTypeSchema = EffectSchema.Union( + EffectSchema.Literal('PERSONAL'), + EffectSchema.Literal('DAO'), +); + +export type SpaceType = typeof SpaceTypeSchema.Type; + export const PublicSpaceSchema = EffectSchema.Struct({ id: EffectSchema.String, + type: SpaceTypeSchema, name: EffectSchema.String, avatar: EffectSchema.optional(EffectSchema.String), editorIds: EffectSchema.Array(EffectSchema.String), @@ -66,6 +75,7 @@ export type PublicSpace = typeof PublicSpaceSchema.Type; type SpacesQueryResult = { spaces?: { id: string; + type: 'PERSONAL' | 'DAO'; page: { name?: string | null; relationsList?: { @@ -120,6 +130,7 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => { for (const space of spaces) { const rawSpace: Record = { id: space.id, + type: space.type, name: space.page?.name ?? undefined, avatar: getAvatarFromSpace(space), editorIds: getEditorIdsFromSpace(space), diff --git a/packages/hypergraph/test/space/find-many-public.test.ts b/packages/hypergraph/test/space/find-many-public.test.ts index 72173012..992085d7 100644 --- a/packages/hypergraph/test/space/find-many-public.test.ts +++ b/packages/hypergraph/test/space/find-many-public.test.ts @@ -3,12 +3,14 @@ import { parseSpacesQueryResult } from '../../src/space/find-many-public.js'; const buildQuerySpace = ({ id = 'space-id', + type = 'PERSONAL', name = 'Space name', avatar, editorsList = [], membersList = [], }: { id?: string; + type?: 'PERSONAL' | 'DAO'; name?: string | null; avatar?: string | null; editorsList?: { memberSpaceId: string }[]; @@ -16,6 +18,7 @@ const buildQuerySpace = ({ } = {}) => { return { id, + type, page: { name, relationsList: @@ -51,6 +54,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-1', + type: 'PERSONAL', name: 'Space 1', avatar: 'https://example.com/avatar.png', editorIds: [], @@ -68,6 +72,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-2', + type: 'PERSONAL', name: 'Space 2', editorIds: [], memberIds: [], @@ -75,6 +80,22 @@ describe('parseSpacesQueryResult', () => { ]); }); + it('parses DAO type', () => { + const { data } = parseSpacesQueryResult({ + spaces: [buildQuerySpace({ id: 'space-dao', type: 'DAO', name: 'DAO Space' })], + }); + + expect(data).toEqual([ + { + id: 'space-dao', + type: 'DAO', + name: 'DAO Space', + editorIds: [], + memberIds: [], + }, + ]); + }); + it('filters invalid data', () => { const { data, invalidSpaces } = parseSpacesQueryResult({ spaces: [ @@ -86,6 +107,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-valid', + type: 'PERSONAL', name: 'Space valid', avatar: 'https://example.com/a.png', editorIds: [], @@ -111,6 +133,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-with-members', + type: 'PERSONAL', name: 'Space with members', editorIds: ['editor-1', 'editor-2'], memberIds: ['member-1'], From dcf764c6d894841908457d901e9dfbd1dc3254f3 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 18 Jan 2026 18:56:10 +0100 Subject: [PATCH 2/3] add space type filtering --- .changeset/add-space-type.md | 10 ++ .../hypergraph/src/space/find-many-public.ts | 92 +++++++++---------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/.changeset/add-space-type.md b/.changeset/add-space-type.md index 15405476..f8ad13d3 100644 --- a/.changeset/add-space-type.md +++ b/.changeset/add-space-type.md @@ -4,3 +4,13 @@ --- Add `type` field to `PublicSpace` type returned by `Space.findManyPublic()` and `usePublicSpaces()`. The type is either `"PERSONAL"` or `"DAO"`. + +Add `spaceType` filter option to `Space.findManyPublic()` and `usePublicSpaces()` to filter spaces by type. Example usage: + +```typescript +// Filter for DAO spaces only +const { data } = usePublicSpaces({ filter: { spaceType: 'DAO' } }); + +// Combine with existing filters +const { data } = usePublicSpaces({ filter: { editorId: 'xxx', spaceType: 'PERSONAL' } }); +``` diff --git a/packages/hypergraph/src/space/find-many-public.ts b/packages/hypergraph/src/space/find-many-public.ts index 04e0e45d..4a2b5db1 100644 --- a/packages/hypergraph/src/space/find-many-public.ts +++ b/packages/hypergraph/src/space/find-many-public.ts @@ -30,34 +30,7 @@ const spaceFields = ` } `; -const spacesQueryDocument = ` -query spaces { - spaces { - ${spaceFields} - } -} -`; - -const memberSpacesQueryDocument = ` -query memberSpaces($accountId: UUID!) { - spaces(filter: {members: {some: {memberSpaceId: {is: $accountId}}}}) { - ${spaceFields} - } -} -`; - -const editorSpacesQueryDocument = ` -query editorSpaces($accountId: UUID!) { - spaces(filter: {editors: {some: {memberSpaceId: {is: $accountId}}}}) { - ${spaceFields} - } -} -`; - -export const SpaceTypeSchema = EffectSchema.Union( - EffectSchema.Literal('PERSONAL'), - EffectSchema.Literal('DAO'), -); +export const SpaceTypeSchema = EffectSchema.Union(EffectSchema.Literal('PERSONAL'), EffectSchema.Literal('DAO')); export type SpaceType = typeof SpaceTypeSchema.Type; @@ -96,10 +69,6 @@ type SpacesQueryResult = { }[]; }; -type SpacesQueryVariables = { - accountId: string; -}; - type SpaceQueryEntry = NonNullable[number]; const decodeSpace = EffectSchema.decodeUnknownEither(PublicSpaceSchema); @@ -150,14 +119,49 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => { }; export type FindManyPublicFilter = - | Readonly<{ memberId: string; editorId?: never }> - | Readonly<{ editorId: string; memberId?: never }> - | Readonly<{ memberId?: undefined; editorId?: undefined }>; + | Readonly<{ memberId: string; editorId?: never; spaceType?: SpaceType }> + | Readonly<{ editorId: string; memberId?: never; spaceType?: SpaceType }> + | Readonly<{ memberId?: undefined; editorId?: undefined; spaceType?: SpaceType }>; export type FindManyPublicParams = Readonly<{ filter?: FindManyPublicFilter; }>; +const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => { + const conditions: string[] = []; + + if (filter?.memberId) { + conditions.push(`members: {some: {memberSpaceId: {is: "${filter.memberId}"}}}`); + } + + if (filter?.editorId) { + conditions.push(`editors: {some: {memberSpaceId: {is: "${filter.editorId}"}}}`); + } + + if (filter?.spaceType) { + conditions.push(`type: {is: ${filter.spaceType}}`); + } + + if (conditions.length === 0) { + return undefined; + } + + return `filter: {${conditions.join(', ')}}`; +}; + +const buildSpacesQuery = (filter?: FindManyPublicFilter): string => { + const filterString = buildFilterString(filter); + const filterClause = filterString ? `(${filterString})` : ''; + + return ` +query spaces { + spaces${filterClause} { + ${spaceFields} + } +} +`; +}; + export const findManyPublic = async (params?: FindManyPublicParams) => { const filter = params?.filter; const memberId = filter?.memberId; @@ -168,21 +172,7 @@ export const findManyPublic = async (params?: FindManyPublicParams) => { } const endpoint = `${Config.getApiOrigin()}/v2/graphql`; - - if (memberId) { - const queryResult = await request(endpoint, memberSpacesQueryDocument, { - accountId: memberId, - }); - return parseSpacesQueryResult(queryResult); - } - - if (editorId) { - const queryResult = await request(endpoint, editorSpacesQueryDocument, { - accountId: editorId, - }); - return parseSpacesQueryResult(queryResult); - } - - const queryResult = await request(endpoint, spacesQueryDocument); + const queryDocument = buildSpacesQuery(filter); + const queryResult = await request(endpoint, queryDocument); return parseSpacesQueryResult(queryResult); }; From 37c2171fb5a3d256f623dfcea29ff0942f1c84d2 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 20 Jan 2026 09:20:12 +0100 Subject: [PATCH 3/3] improvements --- .../hypergraph/src/space/find-many-public.ts | 25 +++- .../test/space/find-many-public.test.ts | 108 +++++++++++++++++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/hypergraph/src/space/find-many-public.ts b/packages/hypergraph/src/space/find-many-public.ts index 4a2b5db1..ecaebf87 100644 --- a/packages/hypergraph/src/space/find-many-public.ts +++ b/packages/hypergraph/src/space/find-many-public.ts @@ -3,6 +3,7 @@ import { Config } from '@graphprotocol/hypergraph'; import * as Either from 'effect/Either'; import * as EffectSchema from 'effect/Schema'; import { request } from 'graphql-request'; +import { parseGeoId } from '../utils/geo-id.js'; const spaceFields = ` id @@ -127,19 +128,33 @@ export type FindManyPublicParams = Readonly<{ filter?: FindManyPublicFilter; }>; -const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => { +const validateSpaceType = (spaceType: SpaceType): SpaceType => { + const result = EffectSchema.decodeUnknownEither(SpaceTypeSchema)(spaceType); + if (Either.isLeft(result)) { + throw new Error(`Invalid spaceType: ${spaceType}. Must be 'PERSONAL' or 'DAO'.`); + } + return result.right; +}; + +export const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => { const conditions: string[] = []; if (filter?.memberId) { - conditions.push(`members: {some: {memberSpaceId: {is: "${filter.memberId}"}}}`); + // Validate memberId is a valid GeoId to prevent injection attacks + const validatedMemberId = parseGeoId(filter.memberId); + conditions.push(`members: {some: {memberSpaceId: {is: "${validatedMemberId}"}}}`); } if (filter?.editorId) { - conditions.push(`editors: {some: {memberSpaceId: {is: "${filter.editorId}"}}}`); + // Validate editorId is a valid GeoId to prevent injection attacks + const validatedEditorId = parseGeoId(filter.editorId); + conditions.push(`editors: {some: {memberSpaceId: {is: "${validatedEditorId}"}}}`); } if (filter?.spaceType) { - conditions.push(`type: {is: ${filter.spaceType}}`); + // Validate spaceType at runtime to ensure it's a valid value + const validatedSpaceType = validateSpaceType(filter.spaceType); + conditions.push(`type: {is: ${validatedSpaceType}}`); } if (conditions.length === 0) { @@ -149,7 +164,7 @@ const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => return `filter: {${conditions.join(', ')}}`; }; -const buildSpacesQuery = (filter?: FindManyPublicFilter): string => { +export const buildSpacesQuery = (filter?: FindManyPublicFilter): string => { const filterString = buildFilterString(filter); const filterClause = filterString ? `(${filterString})` : ''; diff --git a/packages/hypergraph/test/space/find-many-public.test.ts b/packages/hypergraph/test/space/find-many-public.test.ts index 992085d7..29004744 100644 --- a/packages/hypergraph/test/space/find-many-public.test.ts +++ b/packages/hypergraph/test/space/find-many-public.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseSpacesQueryResult } from '../../src/space/find-many-public.js'; +import { buildFilterString, buildSpacesQuery, parseSpacesQueryResult } from '../../src/space/find-many-public.js'; const buildQuerySpace = ({ id = 'space-id', @@ -141,3 +141,109 @@ describe('parseSpacesQueryResult', () => { ]); }); }); + +describe('buildFilterString', () => { + it('returns undefined when no filter is provided', () => { + expect(buildFilterString()).toBeUndefined(); + expect(buildFilterString({})).toBeUndefined(); + }); + + it('builds filter string with memberId', () => { + const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}'); + }); + + it('builds filter string with editorId', () => { + const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(result).toBe('filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}'); + }); + + it('builds filter string with spaceType PERSONAL', () => { + const result = buildFilterString({ spaceType: 'PERSONAL' }); + expect(result).toBe('filter: {type: {is: PERSONAL}}'); + }); + + it('builds filter string with spaceType DAO', () => { + const result = buildFilterString({ spaceType: 'DAO' }); + expect(result).toBe('filter: {type: {is: DAO}}'); + }); + + it('builds filter string with memberId and spaceType', () => { + const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' }); + expect(result).toBe( + 'filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}}', + ); + }); + + it('builds filter string with editorId and spaceType', () => { + const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'DAO' }); + expect(result).toBe( + 'filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: DAO}}', + ); + }); + + it('normalizes UUID with dashes to dashless format', () => { + const result = buildFilterString({ memberId: '1e5e39da-a00d-4fd8-b53b-98095337112f' }); + expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}'); + }); + + it('throws error for invalid memberId', () => { + expect(() => buildFilterString({ memberId: 'invalid-id' })).toThrow('Invalid Geo ID'); + }); + + it('throws error for invalid editorId', () => { + expect(() => buildFilterString({ editorId: 'invalid"; DROP TABLE spaces; --' })).toThrow('Invalid Geo ID'); + }); + + it('throws error for invalid spaceType', () => { + // @ts-expect-error - testing runtime validation with invalid value + expect(() => buildFilterString({ spaceType: 'INVALID' })).toThrow( + "Invalid spaceType: INVALID. Must be 'PERSONAL' or 'DAO'.", + ); + }); +}); + +describe('buildSpacesQuery', () => { + it('builds query without filter', () => { + const query = buildSpacesQuery(); + expect(query).toContain('query spaces {'); + // Check that the top-level spaces query doesn't have a filter (spaces { not spaces(filter:) + expect(query).toMatch(/spaces\s*\{/); + expect(query).not.toMatch(/spaces\s*\(filter:/); + }); + + it('builds query with memberId filter', () => { + const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(query).toContain( + 'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})', + ); + }); + + it('builds query with editorId filter', () => { + const query = buildSpacesQuery({ editorId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(query).toContain( + 'spaces(filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})', + ); + }); + + it('builds query with spaceType filter only', () => { + const query = buildSpacesQuery({ spaceType: 'DAO' }); + expect(query).toContain('spaces(filter: {type: {is: DAO}})'); + }); + + it('builds query with combined filters', () => { + const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' }); + expect(query).toContain( + 'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}})', + ); + }); + + it('includes required space fields in query', () => { + const query = buildSpacesQuery(); + expect(query).toContain('id'); + expect(query).toContain('type'); + expect(query).toContain('page {'); + expect(query).toContain('editorsList {'); + expect(query).toContain('membersList {'); + }); +});