Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/add-space-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@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"`.

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' } });
```
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
106 changes: 61 additions & 45 deletions packages/hypergraph/src/space/find-many-public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ 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
type
page {
name
relationsList(filter: {
Expand All @@ -29,32 +31,13 @@ const spaceFields = `
}
`;

const spacesQueryDocument = `
query spaces {
spaces {
${spaceFields}
}
}
`;
export const SpaceTypeSchema = EffectSchema.Union(EffectSchema.Literal('PERSONAL'), EffectSchema.Literal('DAO'));

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 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),
Expand All @@ -66,6 +49,7 @@ export type PublicSpace = typeof PublicSpaceSchema.Type;
type SpacesQueryResult = {
spaces?: {
id: string;
type: 'PERSONAL' | 'DAO';
page: {
name?: string | null;
relationsList?: {
Expand All @@ -86,10 +70,6 @@ type SpacesQueryResult = {
}[];
};

type SpacesQueryVariables = {
accountId: string;
};

type SpaceQueryEntry = NonNullable<SpacesQueryResult['spaces']>[number];

const decodeSpace = EffectSchema.decodeUnknownEither(PublicSpaceSchema);
Expand Down Expand Up @@ -120,6 +100,7 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => {
for (const space of spaces) {
const rawSpace: Record<string, unknown> = {
id: space.id,
type: space.type,
name: space.page?.name ?? undefined,
avatar: getAvatarFromSpace(space),
editorIds: getEditorIdsFromSpace(space),
Expand All @@ -139,14 +120,63 @@ 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 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) {
// 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) {
// 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) {
// 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) {
return undefined;
}

return `filter: {${conditions.join(', ')}}`;
};

export 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;
Expand All @@ -157,21 +187,7 @@ export const findManyPublic = async (params?: FindManyPublicParams) => {
}

const endpoint = `${Config.getApiOrigin()}/v2/graphql`;

if (memberId) {
const queryResult = await request<SpacesQueryResult, SpacesQueryVariables>(endpoint, memberSpacesQueryDocument, {
accountId: memberId,
});
return parseSpacesQueryResult(queryResult);
}

if (editorId) {
const queryResult = await request<SpacesQueryResult, SpacesQueryVariables>(endpoint, editorSpacesQueryDocument, {
accountId: editorId,
});
return parseSpacesQueryResult(queryResult);
}

const queryResult = await request<SpacesQueryResult>(endpoint, spacesQueryDocument);
const queryDocument = buildSpacesQuery(filter);
const queryResult = await request<SpacesQueryResult>(endpoint, queryDocument);
return parseSpacesQueryResult(queryResult);
};
131 changes: 130 additions & 1 deletion packages/hypergraph/test/space/find-many-public.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
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',
type = 'PERSONAL',
name = 'Space name',
avatar,
editorsList = [],
membersList = [],
}: {
id?: string;
type?: 'PERSONAL' | 'DAO';
name?: string | null;
avatar?: string | null;
editorsList?: { memberSpaceId: string }[];
membersList?: { memberSpaceId: string }[];
} = {}) => {
return {
id,
type,
page: {
name,
relationsList:
Expand Down Expand Up @@ -51,6 +54,7 @@ describe('parseSpacesQueryResult', () => {
expect(data).toEqual([
{
id: 'space-1',
type: 'PERSONAL',
name: 'Space 1',
avatar: 'https://example.com/avatar.png',
editorIds: [],
Expand All @@ -68,13 +72,30 @@ describe('parseSpacesQueryResult', () => {
expect(data).toEqual([
{
id: 'space-2',
type: 'PERSONAL',
name: 'Space 2',
editorIds: [],
memberIds: [],
},
]);
});

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: [
Expand All @@ -86,6 +107,7 @@ describe('parseSpacesQueryResult', () => {
expect(data).toEqual([
{
id: 'space-valid',
type: 'PERSONAL',
name: 'Space valid',
avatar: 'https://example.com/a.png',
editorIds: [],
Expand All @@ -111,10 +133,117 @@ describe('parseSpacesQueryResult', () => {
expect(data).toEqual([
{
id: 'space-with-members',
type: 'PERSONAL',
name: 'Space with members',
editorIds: ['editor-1', 'editor-2'],
memberIds: ['member-1'],
},
]);
});
});

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 {');
});
});
Loading