diff --git a/.changeset/wise-mugs-swim.md b/.changeset/wise-mugs-swim.md
new file mode 100644
index 00000000..05cebea2
--- /dev/null
+++ b/.changeset/wise-mugs-swim.md
@@ -0,0 +1,6 @@
+---
+"@graphprotocol/hypergraph": patch
+---
+
+improve include type for fineOne
+
\ No newline at end of file
diff --git a/.changeset/yummy-beans-float.md b/.changeset/yummy-beans-float.md
new file mode 100644
index 00000000..46e5734b
--- /dev/null
+++ b/.changeset/yummy-beans-float.md
@@ -0,0 +1,6 @@
+---
+"@graphprotocol/hypergraph-react": minor
+---
+
+remove useQueryEntity and add useEntity hook
+
\ No newline at end of file
diff --git a/apps/events/src/components/event.tsx b/apps/events/src/components/event.tsx
new file mode 100644
index 00000000..5ba8bda4
--- /dev/null
+++ b/apps/events/src/components/event.tsx
@@ -0,0 +1,30 @@
+import { useEntity } from '@graphprotocol/hypergraph-react';
+import { Event as EventType } from '../schema';
+
+export const Event = ({ spaceId, entityId }: { spaceId: string; entityId: string }) => {
+ const { data, isPending, isError } = useEntity(EventType, {
+ mode: 'public',
+ include: {
+ sponsors: {
+ jobOffers: {},
+ },
+ },
+ id: entityId,
+ space: spaceId,
+ });
+
+ console.log({ component: 'Event', isPending, isError, data });
+
+ return (
+
diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx
index 84c49193..f912ae98 100644
--- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx
+++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Entity, store } from '@graphprotocol/hypergraph';
+import { Entity, type Id, store } from '@graphprotocol/hypergraph';
import { useSelector } from '@xstate/store/react';
import * as Schema from 'effect/Schema';
import {
@@ -14,6 +14,7 @@ import {
useSyncExternalStore,
} from 'react';
import { useHypergraphApp } from './HypergraphAppContext.js';
+import { useEntityPublic } from './internal/use-entity-public.js';
import { usePublicSpace } from './internal/use-public-space.js';
// TODO space can be undefined
@@ -181,19 +182,28 @@ export function useQueryLocal(type: S, para
return { entities, deletedEntities };
}
-export function useQueryEntity(
+function useEntityPrivate(
type: S,
- id: string,
- params?: { space?: string; include?: { [K in keyof Schema.Schema.Type]?: Record } },
+ params: {
+ id: string | Id;
+ enabled?: boolean;
+ space?: string;
+ include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined;
+ },
) {
const { space: spaceFromContext } = useHypergraphSpaceInternal();
- const { space: spaceFromParams, include } = params ?? {};
- const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled: true });
- const prevEntityRef = useRef | undefined>(undefined);
+ const { space: spaceFromParams, include, id, enabled = true } = params;
+ const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled });
+ const prevEntityRef = useRef<{
+ data: Entity.Entity | undefined;
+ invalidEntity: Record | undefined;
+ isPending: boolean;
+ isError: boolean;
+ }>({ data: undefined, invalidEntity: undefined, isPending: false, isError: false });
const equals = Schema.equivalence(type);
const subscribe = (callback: () => void) => {
- if (!handle) {
+ if (!handle || !enabled) {
return () => {};
}
const handleChange = () => {
@@ -214,7 +224,7 @@ export function useQueryEntity(
};
return useSyncExternalStore(subscribe, () => {
- if (!handle) {
+ if (!handle || !enabled) {
return prevEntityRef.current;
}
const doc = handle.doc();
@@ -223,16 +233,39 @@ export function useQueryEntity(
}
const found = Entity.findOne(handle, type, include)(id);
- if (found === undefined && prevEntityRef.current !== undefined) {
+ if (found === undefined && prevEntityRef.current.data !== undefined) {
// entity was maybe deleted, delete from the ref
- prevEntityRef.current = undefined;
- } else if (found !== undefined && prevEntityRef.current === undefined) {
- prevEntityRef.current = found;
- } else if (found !== undefined && prevEntityRef.current !== undefined && !equals(found, prevEntityRef.current)) {
+ prevEntityRef.current = { data: undefined, invalidEntity: undefined, isPending: false, isError: false };
+ } else if (found !== undefined && prevEntityRef.current.data === undefined) {
+ prevEntityRef.current = { data: found, invalidEntity: undefined, isPending: false, isError: false };
+ } else if (
+ found !== undefined &&
+ prevEntityRef.current.data !== undefined &&
+ !equals(found, prevEntityRef.current.data)
+ ) {
// found and ref have a value, compare for equality, if they are not equal, update the ref and return
- prevEntityRef.current = found;
+ prevEntityRef.current = { data: found, invalidEntity: undefined, isPending: false, isError: false };
}
return prevEntityRef.current;
});
}
+
+export function useEntity(
+ type: S,
+ params: {
+ id: string | Id;
+ space?: string;
+ mode: 'private' | 'public';
+ include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined;
+ },
+) {
+ const resultPublic = useEntityPublic(type, { ...params, enabled: params.mode === 'public' });
+ const resultPrivate = useEntityPrivate(type, { ...params, enabled: params.mode === 'private' });
+
+ if (params.mode === 'public') {
+ return resultPublic;
+ }
+
+ return resultPrivate;
+}
diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts
index f2f80723..26da47dc 100644
--- a/packages/hypergraph-react/src/index.ts
+++ b/packages/hypergraph-react/src/index.ts
@@ -9,8 +9,8 @@ export {
HypergraphSpaceProvider,
useCreateEntity,
useDeleteEntity,
+ useEntity,
useHardDeleteEntity,
- useQueryEntity,
useQueryLocal as _useQueryLocal,
useRemoveRelation,
useSpace,
@@ -26,6 +26,7 @@ export { usePublishToPublicSpace } from './hooks/usePublishToSpace.js';
export { generateDeleteOps as _generateDeleteOps } from './internal/generate-delete-ops.js';
export { useCreateEntityPublic as _useCreateEntityPublic } from './internal/use-create-entity-public.js';
export { useDeleteEntityPublic as _useDeleteEntityPublic } from './internal/use-delete-entity-public.js';
+export { useEntityPublic as _useEntityPublic } from './internal/use-entity-public.js';
export { useQueryPublic as _useQueryPublic } from './internal/use-query-public.js';
export { preparePublish } from './prepare-publish.js';
export { publishOps } from './publish-ops.js';
diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx
new file mode 100644
index 00000000..7ccd744d
--- /dev/null
+++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx
@@ -0,0 +1,398 @@
+import { Graph } from '@graphprotocol/grc-20';
+import { type Entity, type Mapping, store, TypeUtils } from '@graphprotocol/hypergraph';
+import { useQuery as useQueryTanstack } from '@tanstack/react-query';
+import { useSelector } from '@xstate/store/react';
+import * as Either from 'effect/Either';
+import * as Schema from 'effect/Schema';
+import { gql, request } from 'graphql-request';
+import { useMemo } from 'react';
+import { useHypergraphSpaceInternal } from '../HypergraphSpaceContext.js';
+
+const entityQueryDocumentLevel0 = gql`
+query entity($id: UUID!, $spaceId: UUID!) {
+ entity(
+ id: $id,
+ ) {
+ id
+ name
+ valuesList(filter: {spaceId: {is: $spaceId}}) {
+ propertyId
+ string
+ boolean
+ number
+ time
+ point
+ }
+ }
+}
+`;
+
+const entityQueryDocumentLevel1 = gql`
+query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!) {
+ entity(
+ id: $id,
+ ) {
+ id
+ name
+ valuesList(filter: {spaceId: {is: $spaceId}}) {
+ propertyId
+ string
+ boolean
+ number
+ time
+ point
+ }
+ relationsList(
+ filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}},
+ ) {
+ toEntity {
+ id
+ name
+ valuesList(filter: {spaceId: {is: $spaceId}}) {
+ propertyId
+ string
+ boolean
+ number
+ time
+ point
+ }
+ }
+ typeId
+ }
+ }
+}
+`;
+
+const entityQueryDocumentLevel2 = gql`
+query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!) {
+ entity(
+ id: $id,
+ ) {
+ id
+ name
+ valuesList(filter: {spaceId: {is: $spaceId}}) {
+ propertyId
+ string
+ boolean
+ number
+ time
+ point
+ }
+ relationsList(
+ filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}},
+ ) {
+ toEntity {
+ id
+ name
+ valuesList(filter: {spaceId: {is: $spaceId}}) {
+ propertyId
+ string
+ boolean
+ number
+ time
+ point
+ }
+ relationsList(
+ filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}},
+ ) {
+ toEntity {
+ id
+ name
+ valuesList(filter: {spaceId: {is: $spaceId}}) {
+ propertyId
+ string
+ boolean
+ number
+ time
+ point
+ }
+ }
+ typeId
+ }
+ }
+ typeId
+ }
+ }
+}
+`;
+
+type EntityQueryResult = {
+ entity: {
+ id: string;
+ name: string;
+ valuesList: {
+ propertyId: string;
+ string: string;
+ boolean: boolean;
+ number: number;
+ time: string;
+ point: string;
+ }[];
+ relationsList?: {
+ toEntity: {
+ id: string;
+ name: string;
+ valuesList: {
+ propertyId: string;
+ string: string;
+ boolean: boolean;
+ number: number;
+ time: string;
+ point: string;
+ }[];
+ relationsList?: {
+ toEntity: {
+ id: string;
+ name: string;
+ valuesList: {
+ propertyId: string;
+ string: string;
+ boolean: boolean;
+ number: number;
+ time: string;
+ point: string;
+ }[];
+ };
+ typeId: string;
+ }[];
+ };
+ typeId: string;
+ }[];
+ } | null;
+};
+
+// A recursive representation of the entity structure returned by the public GraphQL
+// endpoint. `values` and `relations` are optional because the nested `to` selections
+// get slimmer the deeper we traverse in the query. This type intentionally mirrors
+// only the fields we actually consume inside `convertRelations`.
+type RecursiveQueryEntity = {
+ id: string;
+ name: string;
+ valuesList?: {
+ propertyId: string;
+ string: string;
+ boolean: boolean;
+ number: number;
+ time: string;
+ point: string;
+ }[];
+ relationsList?: {
+ toEntity: RecursiveQueryEntity;
+ typeId: string;
+ }[];
+};
+
+const convertRelations = (
+ queryEntity: RecursiveQueryEntity,
+ type: S,
+ mappingEntry: Mapping.MappingEntry,
+ mapping: Mapping.Mapping,
+) => {
+ const rawEntity: Record = {};
+
+ for (const [key, relationId] of Object.entries(mappingEntry?.relations ?? {})) {
+ const properties = (queryEntity.relationsList ?? []).filter((a) => a.typeId === relationId);
+ if (properties.length === 0) {
+ rawEntity[key] = [] as unknown[];
+ continue;
+ }
+
+ const field = type.fields[key];
+ if (!field) {
+ // @ts-expect-error TODO: properly access the type.name
+ console.error(`Field ${key} not found in ${type.name}`);
+ continue;
+ }
+ // @ts-expect-error TODO: properly access the type.name
+ const annotations = field.ast.rest[0].type.to.annotations;
+
+ // TODO: fix this access using proper effect types
+ const relationTypeName =
+ annotations[
+ Object.getOwnPropertySymbols(annotations).find((sym) => sym.description === 'effect/annotation/Identifier')
+ ];
+
+ const relationMappingEntry = mapping[relationTypeName];
+ if (!relationMappingEntry) {
+ console.error(`Relation mapping entry for ${relationTypeName} not found`);
+ continue;
+ }
+
+ const newRelationEntities = properties.map((propertyEntry) => {
+ // @ts-expect-error TODO: properly access the type.name
+ const type = field.value;
+
+ let rawEntity: Record = {
+ id: propertyEntry.toEntity.id,
+ name: propertyEntry.toEntity.name,
+ // TODO: should be determined by the actual value
+ __deleted: false,
+ // TODO: should be determined by the actual value
+ __version: '',
+ };
+
+ // take the mappingEntry and assign the attributes to the rawEntity
+ for (const [key, value] of Object.entries(relationMappingEntry?.properties ?? {})) {
+ const property = propertyEntry.toEntity.valuesList?.find((a) => a.propertyId === value);
+ if (property) {
+ rawEntity[key] = convertPropertyValue(property, key, type);
+ }
+ }
+
+ rawEntity = {
+ ...rawEntity,
+ ...convertRelations(propertyEntry.toEntity, type, relationMappingEntry, mapping),
+ };
+
+ return rawEntity;
+ });
+
+ if (rawEntity[key]) {
+ rawEntity[key] = [
+ // @ts-expect-error TODO: properly access the type.name
+ ...rawEntity[key],
+ ...newRelationEntities,
+ ];
+ } else {
+ rawEntity[key] = newRelationEntities;
+ }
+ }
+
+ return rawEntity;
+};
+
+const convertPropertyValue = (
+ property: { propertyId: string; string: string; boolean: boolean; number: number; time: string; point: string },
+ key: string,
+ type: Entity.AnyNoContext,
+) => {
+ if (TypeUtils.isBooleanOrOptionalBooleanType(type.fields[key]) && property.boolean !== undefined) {
+ return Boolean(property.boolean);
+ }
+ if (TypeUtils.isPointOrOptionalPointType(type.fields[key]) && property.point !== undefined) {
+ return property.point;
+ }
+ if (TypeUtils.isDateOrOptionalDateType(type.fields[key]) && property.time !== undefined) {
+ return property.time;
+ }
+ if (TypeUtils.isNumberOrOptionalNumberType(type.fields[key]) && property.number !== undefined) {
+ return Number(property.number);
+ }
+ return property.string;
+};
+
+export const parseResult = (
+ queryData: EntityQueryResult,
+ type: S,
+ mappingEntry: Mapping.MappingEntry,
+ mapping: Mapping.Mapping,
+) => {
+ if (!queryData.entity) {
+ return { data: null, invalidEntity: null };
+ }
+
+ const decode = Schema.decodeUnknownEither(type);
+ const queryEntity = queryData.entity;
+ let rawEntity: Record = {
+ id: queryEntity.id,
+ };
+
+ // take the mappingEntry and assign the attributes to the rawEntity
+ for (const [key, value] of Object.entries(mappingEntry?.properties ?? {})) {
+ const property = queryEntity.valuesList.find((a) => a.propertyId === value);
+ if (property) {
+ rawEntity[key] = convertPropertyValue(property, key, type);
+ }
+ }
+
+ rawEntity = {
+ ...rawEntity,
+ ...convertRelations(queryEntity, type, mappingEntry, mapping),
+ };
+
+ const decodeResult = decode({
+ ...rawEntity,
+ __deleted: false,
+ __version: '',
+ });
+
+ if (Either.isRight(decodeResult)) {
+ return {
+ data: { ...decodeResult.right, __schema: type } as Entity.Entity,
+ invalidEntity: null,
+ };
+ }
+
+ return { data: null, invalidEntity: rawEntity };
+};
+
+type UseEntityPublicParams = {
+ id: string;
+ enabled?: boolean;
+ space?: string;
+ // TODO: for multi-level nesting it should only allow the allowed properties instead of Record>
+ include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined;
+};
+
+export const useEntityPublic = (type: S, params: UseEntityPublicParams) => {
+ const { id, enabled = true, space: spaceFromParams, include } = params;
+ const { space: spaceFromContext } = useHypergraphSpaceInternal();
+ const space = spaceFromParams ?? spaceFromContext;
+ const mapping = useSelector(store, (state) => state.context.mapping);
+
+ // @ts-expect-error TODO should use the actual type instead of the name in the mapping
+ const typeName = type.name;
+ const mappingEntry = mapping?.[typeName];
+ if (enabled && !mappingEntry) {
+ throw new Error(`Mapping entry for ${typeName} not found`);
+ }
+
+ // constructing the relation type ids for the query
+ const relationTypeIdsLevel1: string[] = [];
+ const relationTypeIdsLevel2: string[] = [];
+ for (const key in mappingEntry?.relations ?? {}) {
+ if (include?.[key] && mappingEntry?.relations?.[key]) {
+ relationTypeIdsLevel1.push(mappingEntry?.relations?.[key]);
+ const field = type.fields[key];
+ // @ts-expect-error TODO find a better way to access the relation type name
+ const typeName2 = field.value.name;
+ const mappingEntry2 = mapping[typeName2];
+ for (const key2 in mappingEntry2?.relations ?? {}) {
+ if (include?.[key][key2] && mappingEntry2?.relations?.[key2]) {
+ relationTypeIdsLevel2.push(mappingEntry2?.relations?.[key2]);
+ }
+ }
+ }
+ }
+
+ const result = useQueryTanstack({
+ queryKey: ['hypergraph-public-entity', typeName, id, space, relationTypeIdsLevel1, relationTypeIdsLevel2, include],
+ queryFn: async () => {
+ let queryDocument = entityQueryDocumentLevel0;
+ if (relationTypeIdsLevel1.length > 0) {
+ queryDocument = entityQueryDocumentLevel1;
+ }
+ if (relationTypeIdsLevel2.length > 0) {
+ queryDocument = entityQueryDocumentLevel2;
+ }
+
+ const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, {
+ id,
+ spaceId: space,
+ relationTypeIdsLevel1,
+ relationTypeIdsLevel2,
+ });
+ return result;
+ },
+ enabled: enabled && !!id && !!space,
+ });
+
+ const { data, invalidEntity } = useMemo(() => {
+ if (result.data && mappingEntry) {
+ return parseResult(result.data, type, mappingEntry, mapping);
+ }
+ return { data: null, invalidEntity: null };
+ }, [result.data, type, mappingEntry, mapping]);
+
+ return { ...result, data, invalidEntity };
+};
diff --git a/packages/hypergraph-react/src/internal/use-query-public.tsx b/packages/hypergraph-react/src/internal/use-query-public.tsx
index 95ac5f05..fe64a7ba 100644
--- a/packages/hypergraph-react/src/internal/use-query-public.tsx
+++ b/packages/hypergraph-react/src/internal/use-query-public.tsx
@@ -283,11 +283,7 @@ const convertRelations = (
});
if (rawEntity[key]) {
- rawEntity[key] = [
- // @ts-expect-error TODO: properly access the type.name
- ...rawEntity[key],
- ...newRelationEntities,
- ];
+ rawEntity[key] = [...rawEntity[key], ...newRelationEntities];
} else {
rawEntity[key] = newRelationEntities;
}
diff --git a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx
index 565ff4bd..4c8194fd 100644
--- a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx
+++ b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx
@@ -9,7 +9,7 @@ import {
HypergraphSpaceProvider,
useCreateEntity,
useDeleteEntity,
- useQueryEntity,
+ useEntity,
useQueryLocal,
useUpdateEntity,
} from '../src/HypergraphSpaceContext.js';
@@ -86,9 +86,12 @@ describe('HypergraphSpaceContext', () => {
});
if (createdEntity != null) {
- const { result: queryEntityResult } = renderHook(() => useQueryEntity(Event, createdEntity?.id || ''), {
- wrapper,
- });
+ const { result: queryEntityResult } = renderHook(
+ () => useEntity(Event, { id: createdEntity?.id || '', mode: 'private' }),
+ {
+ wrapper,
+ },
+ );
expect(queryEntityResult.current).toEqual(createdEntity);
}
@@ -137,7 +140,9 @@ describe('HypergraphSpaceContext', () => {
__schema: Person,
});
- const { result: queryEntityResult } = renderHook(() => useQueryEntity(Person, id), { wrapper });
+ const { result: queryEntityResult } = renderHook(() => useEntity(Person, { id: id, mode: 'private' }), {
+ wrapper,
+ });
expect(queryEntityResult.current).toEqual({
// @ts-expect-error - TODO: fix the types error
...createdEntity,
diff --git a/packages/hypergraph/src/entity/findOne.ts b/packages/hypergraph/src/entity/findOne.ts
index 1c092f75..e501667f 100644
--- a/packages/hypergraph/src/entity/findOne.ts
+++ b/packages/hypergraph/src/entity/findOne.ts
@@ -10,7 +10,7 @@ import type { AnyNoContext, DocumentContent, Entity } from './types.js';
export const findOne = (
handle: DocHandle,
type: S,
- include: { [K in keyof Schema.Schema.Type]?: Record } = {},
+ include: { [K in keyof Schema.Schema.Type]?: Record> } | undefined = undefined,
) => {
const decode = Schema.decodeUnknownSync(type);