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
18 changes: 18 additions & 0 deletions .changeset/warm-oranges-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@graphprotocol/hypergraph": patch
"@graphprotocol/hypergraph-react": patch
---

Allow relation includes to override nested relation and value space filters by adding _config: { relationSpaces, valueSpaces } to any include branch; GraphQL fragments now honor those overrides when building queries.

```
include: {
friends: {
_config: {
relationSpaces: ['space-a', 'space-b'],
valueSpaces: 'all',
},
},
}
```

Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove trailing whitespace at the end of the file.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +18
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace at the end of the file should be removed.

Suggested change
```

Copilot uses AI. Check for mistakes.
21 changes: 20 additions & 1 deletion apps/events/src/routes/podcasts.lazy.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEntities, useEntity, usePublicSpaces } from '@graphprotocol/hypergraph-react';
import { createLazyFileRoute } from '@tanstack/react-router';
import { Podcast, Topic } from '@/schema';
import { Person, Podcast, Topic } from '@/schema';

export const Route = createLazyFileRoute('/podcasts')({
component: RouteComponent,
Expand All @@ -22,6 +22,25 @@ function RouteComponent() {
// }, 1000);
// }, []);

const {
data: person,
invalidEntity: personInvalidEntity,
invalidRelationEntities: personInvalidRelationEntities,
} = useEntity(Person, {
id: '9800a4e8-8437-4310-9af6-ac91644f7c26',
mode: 'public',
space: '95a4a1cc-bfcc-4038-b7a1-02c513d27700',
include: {
skills: {
_config: {
relationSpaces: ['95a4a1cc-bfcc-4038-b7a1-02c513d27700'],
valueSpaces: ['021265e2-d839-47c3-8d03-0ee3dfb29ffc', '95a4a1cc-bfcc-4038-b7a1-02c513d27700'],
},
},
},
});
console.log({ person, personInvalidEntity, personInvalidRelationEntities });
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The console.log statement appears to be debug code that should likely be removed before merging to production.

Suggested change
console.log({ person, personInvalidEntity, personInvalidRelationEntities });

Copilot uses AI. Check for mistakes.

const {
data: podcast,
invalidEntity,
Expand Down
16 changes: 15 additions & 1 deletion apps/events/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SystemIds } from '@graphprotocol/grc-20';
import { ContentIds, SystemIds } from '@graphprotocol/grc-20';
import { Entity, Id, Type } from '@graphprotocol/hypergraph';

export const User = Entity.Schema(
Expand Down Expand Up @@ -119,18 +119,32 @@ export const Project = Entity.Schema(
},
);

export const Skill = Entity.Schema(
{
name: Type.String,
},
{
types: [ContentIds.SKILL_TYPE],
properties: {
name: SystemIds.NAME_PROPERTY,
},
},
);

export const Person = Entity.Schema(
{
name: Type.String,
description: Type.optional(Type.String),
avatar: Type.Relation(Image),
skills: Type.Relation(Skill),
},
{
types: [Id('7ed45f2b-c48b-419e-8e46-64d5ff680b0d')],
properties: {
name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'),
description: Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'),
avatar: Id('1155beff-fad5-49b7-a2e0-da4777b8792c'),
skills: Id(ContentIds.SKILLS_PROPERTY),
},
},
);
Expand Down
30 changes: 30 additions & 0 deletions docs/docs/query-public-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ const { data, isPending, isError } = useEntities(Event, {

For deeper relations you can use the `include` parameter multiple levels deep. Currently two levels of relations are supported for public data.

#### Controlling include scopes with `_config`

Each branch within `include` can optionally carry a `_config` object that lets you override which spaces Hypergraph will inspect for the relation edges and the related entity values. When you omit `_config`, the query automatically reuses the `space`/`spaces` selection you passed to `useEntities`, `useEntity`, `Entity.findOnePublic`, `Entity.findManyPublic` and `Entity.searchManyPublic` helpers.

```ts
const { data: project } = useEntity(Project, {
id: '9f130661-8c3f-4db7-9bdc-3ce69631c5ef',
mode: 'public',
space: '3f32353d-3b27-4a13-b71a-746f06e1f7db',
include: {
contributors: {
_config: {
relationSpaces: ['3f32353d-3b27-4a13-b71a-746f06e1f7db', '95a4a1cc-bfcc-4038-b7a1-02c513d27700'],
valueSpaces: 'all',
},
organizations: {
_config: {
valueSpaces: ['95a4a1cc-bfcc-4038-b7a1-02c513d27700'],
},
},
},
},
});
```

- `relationSpaces` controls which spaces are searched for the relation edges themselves (`relations`/`backlinks`). Pass an array to whitelist specific spaces, `'all'` to drop the filter entirely, or `[]` if you intentionally want the branch to match nothing.
- `valueSpaces` applies the same override to the `valuesList` lookups for the related entities. This lets you fetch relation edges from one space while trusting the canonical values that live in another.

Each nested branch can have its own `_config` settings,so you can attach `_config` anywhere within the two supported include levels. Mix and match the settings per branch to stitch together data that spans multiple public spaces without issuing separate queries.

### Querying from a specific space

You can also query from a specific space by passing in the `space` parameter.
Expand Down
11 changes: 10 additions & 1 deletion packages/hypergraph/src/entity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ import type * as Schema from 'effect/Schema';

type SchemaKey<S extends Schema.Schema.AnyNoContext> = Extract<keyof Schema.Schema.Type<S>, string>;

export type RelationSpacesOverride = 'all' | readonly string[];

export type RelationIncludeConfig = {
relationSpaces?: RelationSpacesOverride;
valueSpaces?: RelationSpacesOverride;
};

export type RelationIncludeBranch = {
[key: string]: RelationIncludeBranch | boolean | undefined;
_config?: RelationIncludeConfig;
} & {
[key: string]: RelationIncludeBranch | RelationIncludeConfig | boolean | undefined;
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type definition allows RelationIncludeConfig to appear anywhere in the branch structure, but _config is a special reserved key. The index signature [key: string]: RelationIncludeBranch | RelationIncludeConfig | boolean | undefined could allow a user to accidentally create a property called _config at any level, which may conflict with the intended special _config property defined in the intersection. Consider restricting the index signature to exclude the _config key explicitly, or documenting this behavior clearly.

Suggested change
[key: string]: RelationIncludeBranch | RelationIncludeConfig | boolean | undefined;
[K in Exclude<string, '_config'>]?: RelationIncludeBranch | RelationIncludeConfig | boolean;

Copilot uses AI. Check for mistakes.
};

export type EntityInclude<S extends Schema.Schema.AnyNoContext> = Partial<
Expand Down
41 changes: 34 additions & 7 deletions packages/hypergraph/src/utils/get-relation-type-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Constants, Utils } from '@graphprotocol/hypergraph';
import * as Option from 'effect/Option';
import type * as Schema from 'effect/Schema';
import * as SchemaAST from 'effect/SchemaAST';
import type { EntityInclude, RelationIncludeBranch } from '../entity/types.js';
import type { EntityInclude, RelationIncludeBranch, RelationSpacesOverride } from '../entity/types.js';

export type RelationListField = 'relations' | 'backlinks';

Expand All @@ -12,6 +12,8 @@ export type RelationTypeIdInfo = {
listField: RelationListField;
includeNodes: boolean;
includeTotalCount: boolean;
relationSpaces?: RelationSpacesOverride;
valueSpaces?: RelationSpacesOverride;
children?: RelationTypeIdInfo[];
};

Expand All @@ -35,8 +37,9 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
const result = SchemaAST.getAnnotation<string>(Constants.PropertyIdSymbol)(prop.type);
if (Option.isSome(result)) {
const propertyName = String(prop.name);
const includeBranch = include?.[propertyName as keyof EntityInclude<S>] as RelationIncludeBranch | undefined;
const includeNodes = isRelationIncludeBranch(includeBranch);
const includeBranchCandidate = include?.[propertyName as keyof EntityInclude<S>];
const includeBranch = isRelationIncludeBranch(includeBranchCandidate) ? includeBranchCandidate : undefined;
const includeNodes = Boolean(includeBranch);
const includeTotalCount = hasTotalCountFlag(include as Record<string, unknown> | undefined, propertyName);

if (!includeNodes && !includeTotalCount) {
Expand All @@ -47,13 +50,24 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
Option.getOrElse(() => false),
);
const listField: RelationListField = isBacklink ? 'backlinks' : 'relations';
const level1Info: RelationTypeIdInfo = {
const relationSpaces = includeBranch?._config?.relationSpaces;
const valueSpaces = includeBranch?._config?.valueSpaces;

const level1InfoBase: RelationTypeIdInfo = {
typeId: result.value,
propertyName,
listField,
includeNodes,
includeTotalCount,
};
const level1Info: RelationTypeIdInfo =
relationSpaces === undefined && valueSpaces === undefined
? level1InfoBase
: {
...level1InfoBase,
...(relationSpaces !== undefined ? { relationSpaces } : {}),
...(valueSpaces !== undefined ? { valueSpaces } : {}),
};
const nestedRelations: RelationTypeIdInfo[] = [];

if (!SchemaAST.isTupleType(prop.type)) {
Expand All @@ -78,8 +92,11 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(

const nestedResult = SchemaAST.getAnnotation<string>(Constants.PropertyIdSymbol)(nestedProp.type);
const nestedPropertyName = String(nestedProp.name);
const nestedIncludeBranch = includeBranch?.[nestedPropertyName];
const nestedIncludeNodes = isRelationIncludeBranch(nestedIncludeBranch);
const nestedIncludeBranchCandidate = includeBranch?.[nestedPropertyName];
const nestedIncludeBranch = isRelationIncludeBranch(nestedIncludeBranchCandidate)
? nestedIncludeBranchCandidate
: undefined;
const nestedIncludeNodes = Boolean(nestedIncludeBranch);
const nestedIncludeTotalCount = hasTotalCountFlag(
includeBranch as Record<string, unknown> | undefined,
nestedPropertyName,
Expand All @@ -90,13 +107,23 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
nestedProp.type,
).pipe(Option.getOrElse(() => false));
const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations';
const nestedInfo: RelationTypeIdInfo = {
const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces;
const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces;
const nestedInfoBase: RelationTypeIdInfo = {
typeId: nestedResult.value,
propertyName: nestedPropertyName,
listField: nestedListField,
includeNodes: nestedIncludeNodes,
includeTotalCount: nestedIncludeTotalCount,
};
const nestedInfo: RelationTypeIdInfo =
nestedRelationSpaces === undefined && nestedValueSpaces === undefined
? nestedInfoBase
: {
...nestedInfoBase,
...(nestedRelationSpaces !== undefined ? { relationSpaces: nestedRelationSpaces } : {}),
...(nestedValueSpaces !== undefined ? { valueSpaces: nestedValueSpaces } : {}),
};
nestedRelations.push(nestedInfo);
}
}
Expand Down
62 changes: 48 additions & 14 deletions packages/hypergraph/src/utils/relation-query-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,58 @@ import type { RelationTypeIdInfo } from './get-relation-type-ids.js';

type SpaceSelectionMode = 'single' | 'many' | 'all';

const buildValuesListFilter = (spaceSelectionMode: SpaceSelectionMode) => {
if (spaceSelectionMode === 'single') {
return '(filter: {spaceId: {is: $spaceId}})';
const formatGraphQLStringArray = (values: readonly string[]) =>
`[${values.map((value) => JSON.stringify(value)).join(', ')}]`;

const buildValuesListFilter = (
spaceSelectionMode: SpaceSelectionMode,
override?: RelationTypeIdInfo['valueSpaces'],
) => {
if (!override) {
if (spaceSelectionMode === 'single') {
return '(filter: {spaceId: {is: $spaceId}})';
}
if (spaceSelectionMode === 'many') {
return '(filter: {spaceId: {in: $spaceIds}})';
}
return '';
}
if (spaceSelectionMode === 'many') {
return '(filter: {spaceId: {in: $spaceIds}})';

if (override === 'all') {
return '';
}

if (override.length === 0) {
// Explicit empty overrides should produce a match-nothing filter.
return '(filter: {spaceId: {in: []}})';
}
return '';

return `(filter: {spaceId: {in: ${formatGraphQLStringArray(override)}}})`;
};

const buildRelationSpaceFilter = (spaceSelectionMode: SpaceSelectionMode) => {
if (spaceSelectionMode === 'single') {
return 'spaceId: {is: $spaceId}, ';
const buildRelationSpaceFilter = (
spaceSelectionMode: SpaceSelectionMode,
override?: RelationTypeIdInfo['relationSpaces'],
) => {
if (!override) {
if (spaceSelectionMode === 'single') {
return 'spaceId: {is: $spaceId}, ';
}
if (spaceSelectionMode === 'many') {
return 'spaceId: {in: $spaceIds}, ';
}
return '';
}

if (override === 'all') {
return '';
}
if (spaceSelectionMode === 'many') {
return 'spaceId: {in: $spaceIds}, ';

if (override.length === 0) {
return 'spaceId: {in: []}, ';
}
return '';

return `spaceId: {in: ${formatGraphQLStringArray(override)}}, `;
};

export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`;
Expand All @@ -31,8 +65,8 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spac
const connectionField = listField === 'backlinks' ? 'backlinks' : 'relations';
const toEntityField = listField === 'backlinks' ? 'fromEntity' : 'toEntity';
const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`;
const valuesListFilter = buildValuesListFilter(spaceSelectionMode);
const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode);
const valuesListFilter = buildValuesListFilter(spaceSelectionMode, info.valueSpaces);
const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode, info.relationSpaces);

if (!info.includeNodes && !info.includeTotalCount) {
return '';
Expand Down
Loading
Loading