diff --git a/apps/connect/vitest.config.ts b/apps/connect/vitest.config.ts new file mode 100644 index 00000000..60f4dc1f --- /dev/null +++ b/apps/connect/vitest.config.ts @@ -0,0 +1,13 @@ +import react from '@vitejs/plugin-react'; +import { mergeConfig } from 'vitest/config'; + +import shared from '../../vitest.shared.js'; + +const config = { + plugins: [react()], + test: { + environment: 'jsdom', + }, +}; + +export default mergeConfig(shared, config); diff --git a/apps/events/vitest.config.ts b/apps/events/vitest.config.ts new file mode 100644 index 00000000..60f4dc1f --- /dev/null +++ b/apps/events/vitest.config.ts @@ -0,0 +1,13 @@ +import react from '@vitejs/plugin-react'; +import { mergeConfig } from 'vitest/config'; + +import shared from '../../vitest.shared.js'; + +const config = { + plugins: [react()], + test: { + environment: 'jsdom', + }, +}; + +export default mergeConfig(shared, config); diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx index f912ae98..21b3196c 100644 --- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx +++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx @@ -1,271 +1,12 @@ 'use client'; -import { Entity, type Id, store } from '@graphprotocol/hypergraph'; -import { useSelector } from '@xstate/store/react'; -import * as Schema from 'effect/Schema'; -import { - createContext, - type ReactNode, - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useSyncExternalStore, -} from 'react'; -import { useHypergraphApp } from './HypergraphAppContext.js'; -import { useEntityPublic } from './internal/use-entity-public.js'; -import { usePublicSpace } from './internal/use-public-space.js'; +import { createContext, type ReactNode } from 'react'; // TODO space can be undefined export type HypergraphContext = { space: string }; export const HypergraphReactContext = createContext(undefined); -export function useHypergraphSpaceInternal() { - const context = useContext(HypergraphReactContext); - return (context as HypergraphContext) || { space: '' }; -} - export function HypergraphSpaceProvider({ space, children }: { space: string; children: ReactNode }) { return {children}; } - -const subscribeToSpaceCache = new Map(); - -function useSubscribeToSpaceAndGetHandle({ spaceId, enabled }: { spaceId: string; enabled: boolean }) { - const handle = useSelector(store, (state) => { - const space = state.context.spaces.find((space) => space.id === spaceId); - if (!space) { - return undefined; - } - return space.automergeDocHandle; - }); - - const { subscribeToSpace, isConnecting } = useHypergraphApp(); - useEffect(() => { - if (!isConnecting && enabled) { - if (subscribeToSpaceCache.has(spaceId)) { - return; - } - subscribeToSpaceCache.set(spaceId, true); - subscribeToSpace({ spaceId }); - } - return () => { - // TODO: unsubscribe from space in case the space ID changes - subscribeToSpaceCache.delete(spaceId); - }; - }, [isConnecting, subscribeToSpace, spaceId, enabled]); - - return handle; -} - -export function useSpace(options: { space?: string; mode: 'private' | 'public' }) { - const { space: spaceIdFromContext } = useHypergraphSpaceInternal(); - const { space: spaceIdFromParams } = options ?? {}; - const spaceId = spaceIdFromParams ?? spaceIdFromContext; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId, enabled: options.mode === 'private' }); - const ready = options.mode === 'public' ? true : handle ? handle.isReady() : false; - const privateSpace = useSelector(store, (state) => state.context.spaces.find((space) => space.id === spaceId)); - const publicSpace = usePublicSpace({ spaceId, enabled: options.mode === 'public' }); - return { ready, name: options.mode === 'private' ? privateSpace?.name : publicSpace?.name, id: spaceId }; -} - -export function useCreateEntity(type: S, options?: { space?: string }) { - const { space: spaceIdFromParams } = options ?? {}; - const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const spaceId = spaceIdFromParams ?? spaceFromContext; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId, enabled: true }); - if (!handle) { - return () => { - throw new Error('Space not found or not ready'); - }; - } - return Entity.create(handle, type); -} - -export function useUpdateEntity(type: S, options?: { space?: string }) { - const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const { space } = options ?? {}; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); - if (!handle) { - return () => { - throw new Error('Space not found or not ready'); - }; - } - return Entity.update(handle, type); -} - -export function useDeleteEntity(options?: { space?: string }) { - const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const { space } = options ?? {}; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); - if (!handle) { - return () => { - throw new Error('Space not found or not ready'); - }; - } - return Entity.markAsDeleted(handle); -} - -export function useRemoveRelation(options?: { space?: string }) { - const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const { space } = options ?? {}; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); - if (!handle) { - return () => { - throw new Error('Space not found or not ready'); - }; - } - return Entity.removeRelation(handle); -} - -export function useHardDeleteEntity(options?: { space?: string }) { - const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const { space } = options ?? {}; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); - if (!handle) { - return () => { - throw new Error('Space not found or not ready'); - }; - } - return Entity.delete(handle); -} - -type QueryParams = { - space?: string | undefined; - enabled: boolean; - filter?: Entity.EntityFilter> | undefined; - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; -}; - -export function useQueryLocal(type: S, params?: QueryParams) { - const { enabled = true, filter, include, space: spaceFromParams } = params ?? {}; - const entitiesRef = useRef[]>([]); - const subscriptionRef = useRef>({ - subscribe: () => () => undefined, - getEntities: () => entitiesRef.current, - }); - const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled }); - const handleIsReady = handle ? handle.isReady() : false; - - // biome-ignore lint/correctness/useExhaustiveDependencies: allow to change filter and include - useLayoutEffect(() => { - if (enabled && handle && handleIsReady) { - const subscription = Entity.subscribeToFindMany(handle, type, filter, include); - subscriptionRef.current.subscribe = subscription.subscribe; - subscriptionRef.current.getEntities = subscription.getEntities; - } - }, [enabled, handleIsReady, handle, type]); - - // TODO: allow to change the enabled state - const allEntities = useSyncExternalStore( - subscriptionRef.current.subscribe, - subscriptionRef.current.getEntities, - () => entitiesRef.current, - ); - - const { entities, deletedEntities } = useMemo(() => { - const entities: Entity.Entity[] = []; - const deletedEntities: Entity.Entity[] = []; - for (const entity of allEntities) { - if (entity.__deleted === true) { - deletedEntities.push(entity); - } else { - entities.push(entity); - } - } - return { entities, deletedEntities }; - }, [allEntities]); - - return { entities, deletedEntities }; -} - -function useEntityPrivate( - type: S, - 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, 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 || !enabled) { - return () => {}; - } - const handleChange = () => { - callback(); - }; - - const handleDelete = () => { - callback(); - }; - - handle.on('change', handleChange); - handle.on('delete', handleDelete); - - return () => { - handle.off('change', handleChange); - handle.off('delete', handleDelete); - }; - }; - - return useSyncExternalStore(subscribe, () => { - if (!handle || !enabled) { - return prevEntityRef.current; - } - const doc = handle.doc(); - if (doc === undefined) { - return prevEntityRef.current; - } - - const found = Entity.findOne(handle, type, include)(id); - if (found === undefined && prevEntityRef.current.data !== undefined) { - // entity was maybe deleted, delete from the ref - 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 = { 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/hooks/use-create-entity.ts b/packages/hypergraph-react/src/hooks/use-create-entity.ts new file mode 100644 index 00000000..8f9d6a47 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-create-entity.ts @@ -0,0 +1,18 @@ +'use client'; + +import { Entity } from '@graphprotocol/hypergraph'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from '../internal/use-subscribe-to-space.js'; + +export function useCreateEntity(type: S, options?: { space?: string }) { + const { space: spaceIdFromParams } = options ?? {}; + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const spaceId = spaceIdFromParams ?? spaceFromContext; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId, enabled: true }); + if (!handle) { + return () => { + throw new Error('Space not found or not ready'); + }; + } + return Entity.create(handle, type); +} diff --git a/packages/hypergraph-react/src/hooks/use-delete-entity.ts b/packages/hypergraph-react/src/hooks/use-delete-entity.ts new file mode 100644 index 00000000..c7667ebc --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-delete-entity.ts @@ -0,0 +1,17 @@ +'use client'; + +import { Entity } from '@graphprotocol/hypergraph'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from '../internal/use-subscribe-to-space.js'; + +export function useDeleteEntity(options?: { space?: string }) { + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const { space } = options ?? {}; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); + if (!handle) { + return () => { + throw new Error('Space not found or not ready'); + }; + } + return Entity.markAsDeleted(handle); +} diff --git a/packages/hypergraph-react/src/hooks/use-entity.tsx b/packages/hypergraph-react/src/hooks/use-entity.tsx new file mode 100644 index 00000000..25d5d5eb --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-entity.tsx @@ -0,0 +1,23 @@ +import type { Entity, Id } from '@graphprotocol/hypergraph'; +import type * as Schema from 'effect/Schema'; +import { useEntityPrivate } from '../internal/use-entity-private.js'; +import { useEntityPublic } from '../internal/use-entity-public.js'; + +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/hooks/use-hard-delete-entity.ts b/packages/hypergraph-react/src/hooks/use-hard-delete-entity.ts new file mode 100644 index 00000000..df33d2e1 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-hard-delete-entity.ts @@ -0,0 +1,17 @@ +'use client'; + +import { Entity } from '@graphprotocol/hypergraph'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from '../internal/use-subscribe-to-space.js'; + +export function useHardDeleteEntity(options?: { space?: string }) { + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const { space } = options ?? {}; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); + if (!handle) { + return () => { + throw new Error('Space not found or not ready'); + }; + } + return Entity.delete(handle); +} diff --git a/packages/hypergraph-react/src/use-query.tsx b/packages/hypergraph-react/src/hooks/use-query.tsx similarity index 82% rename from packages/hypergraph-react/src/use-query.tsx rename to packages/hypergraph-react/src/hooks/use-query.tsx index 11e03548..0a866c94 100644 --- a/packages/hypergraph-react/src/use-query.tsx +++ b/packages/hypergraph-react/src/hooks/use-query.tsx @@ -1,7 +1,7 @@ import type { Entity } from '@graphprotocol/hypergraph'; import type * as Schema from 'effect/Schema'; -import { useQueryLocal } from './HypergraphSpaceContext.js'; -import { useQueryPublic } from './internal/use-query-public.js'; +import { useQueryPrivate } from '../internal/use-query-private.js'; +import { useQueryPublic } from '../internal/use-query-public.js'; type QueryParams = { mode: 'public' | 'private'; @@ -17,7 +17,7 @@ const preparePublishDummy = () => undefined; export function useQuery(type: S, params: QueryParams) { const { mode, filter, include, space, first } = params; const publicResult = useQueryPublic(type, { enabled: mode === 'public', filter, include, first, space }); - const localResult = useQueryLocal(type, { enabled: mode === 'private', filter, include, space }); + const localResult = useQueryPrivate(type, { enabled: mode === 'private', filter, include, space }); if (mode === 'public') { return { diff --git a/packages/hypergraph-react/src/hooks/use-remove-relation.ts b/packages/hypergraph-react/src/hooks/use-remove-relation.ts new file mode 100644 index 00000000..35841b81 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-remove-relation.ts @@ -0,0 +1,17 @@ +'use client'; + +import { Entity } from '@graphprotocol/hypergraph'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from '../internal/use-subscribe-to-space.js'; + +export function useRemoveRelation(options?: { space?: string }) { + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const { space } = options ?? {}; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); + if (!handle) { + return () => { + throw new Error('Space not found or not ready'); + }; + } + return Entity.removeRelation(handle); +} diff --git a/packages/hypergraph-react/src/hooks/use-space.ts b/packages/hypergraph-react/src/hooks/use-space.ts new file mode 100644 index 00000000..e994e158 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-space.ts @@ -0,0 +1,18 @@ +'use client'; + +import { store } from '@graphprotocol/hypergraph'; +import { useSelector } from '@xstate/store/react'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; +import { usePublicSpace } from '../internal/use-public-space.js'; +import { useSubscribeToSpaceAndGetHandle } from '../internal/use-subscribe-to-space.js'; + +export function useSpace(options: { space?: string; mode: 'private' | 'public' }) { + const { space: spaceIdFromContext } = useHypergraphSpaceInternal(); + const { space: spaceIdFromParams } = options ?? {}; + const spaceId = spaceIdFromParams ?? spaceIdFromContext; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId, enabled: options.mode === 'private' }); + const ready = options.mode === 'public' ? true : handle ? handle.isReady() : false; + const privateSpace = useSelector(store, (state) => state.context.spaces.find((space) => space.id === spaceId)); + const publicSpace = usePublicSpace({ spaceId, enabled: options.mode === 'public' }); + return { ready, name: options.mode === 'private' ? privateSpace?.name : publicSpace?.name, id: spaceId }; +} diff --git a/packages/hypergraph-react/src/hooks/use-update-entity.ts b/packages/hypergraph-react/src/hooks/use-update-entity.ts new file mode 100644 index 00000000..414f3d2f --- /dev/null +++ b/packages/hypergraph-react/src/hooks/use-update-entity.ts @@ -0,0 +1,17 @@ +'use client'; + +import { Entity } from '@graphprotocol/hypergraph'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from '../internal/use-subscribe-to-space.js'; + +export function useUpdateEntity(type: S, options?: { space?: string }) { + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const { space } = options ?? {}; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId: space ?? spaceFromContext, enabled: true }); + if (!handle) { + return () => { + throw new Error('Space not found or not ready'); + }; + } + return Entity.update(handle, type); +} diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts index 26da47dc..2987c20a 100644 --- a/packages/hypergraph-react/src/index.ts +++ b/packages/hypergraph-react/src/index.ts @@ -5,18 +5,16 @@ export { useHypergraphApp, useHypergraphAuth, } from './HypergraphAppContext.js'; -export { - HypergraphSpaceProvider, - useCreateEntity, - useDeleteEntity, - useEntity, - useHardDeleteEntity, - useQueryLocal as _useQueryLocal, - useRemoveRelation, - useSpace, - useUpdateEntity, -} from './HypergraphSpaceContext.js'; +export { HypergraphSpaceProvider } from './HypergraphSpaceContext.js'; +export { useCreateEntity } from './hooks/use-create-entity.js'; +export { useDeleteEntity } from './hooks/use-delete-entity.js'; +export { useEntity } from './hooks/use-entity.js'; +export { useHardDeleteEntity } from './hooks/use-hard-delete-entity.js'; +export { useQuery } from './hooks/use-query.js'; +export { useRemoveRelation } from './hooks/use-remove-relation.js'; +export { useSpace } from './hooks/use-space.js'; export { useSpaces } from './hooks/use-spaces.js'; +export { useUpdateEntity } from './hooks/use-update-entity.js'; export { useExternalAccountInbox } from './hooks/useExternalAccountInbox.js'; export { useExternalSpaceInbox } from './hooks/useExternalSpaceInbox.js'; export { useOwnAccountInbox } from './hooks/useOwnAccountInbox.js'; @@ -27,8 +25,8 @@ export { generateDeleteOps as _generateDeleteOps } from './internal/generate-del 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 { useQueryPrivate as _useQueryPrivate } from './internal/use-query-private.js'; export { useQueryPublic as _useQueryPublic } from './internal/use-query-public.js'; export { preparePublish } from './prepare-publish.js'; export { publishOps } from './publish-ops.js'; export type * from './types.js'; -export { useQuery } from './use-query.js'; diff --git a/packages/hypergraph-react/src/internal/convert-property-value.ts b/packages/hypergraph-react/src/internal/convert-property-value.ts new file mode 100644 index 00000000..9601a352 --- /dev/null +++ b/packages/hypergraph-react/src/internal/convert-property-value.ts @@ -0,0 +1,21 @@ +import { type Entity, TypeUtils } from '@graphprotocol/hypergraph'; + +export 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; +}; diff --git a/packages/hypergraph-react/src/internal/convert-relations.ts b/packages/hypergraph-react/src/internal/convert-relations.ts new file mode 100644 index 00000000..9a74a81d --- /dev/null +++ b/packages/hypergraph-react/src/internal/convert-relations.ts @@ -0,0 +1,98 @@ +import type { Entity, Mapping } from '@graphprotocol/hypergraph'; +import { convertPropertyValue } from './convert-property-value.js'; + +// 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; + }[]; +}; + +export 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] = [...(rawEntity[key] as unknown[]), ...newRelationEntities]; + } else { + rawEntity[key] = newRelationEntities; + } + } + + return rawEntity; +}; diff --git a/packages/hypergraph-react/src/internal/use-entity-private.tsx b/packages/hypergraph-react/src/internal/use-entity-private.tsx new file mode 100644 index 00000000..9dacf179 --- /dev/null +++ b/packages/hypergraph-react/src/internal/use-entity-private.tsx @@ -0,0 +1,74 @@ +import { Entity, type Id } from '@graphprotocol/hypergraph'; +import * as Schema from 'effect/Schema'; +import { useRef, useSyncExternalStore } from 'react'; +import { useHypergraphSpaceInternal } from './use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from './use-subscribe-to-space.js'; + +export function useEntityPrivate( + type: S, + 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, 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 || !enabled) { + return () => {}; + } + const handleChange = () => { + callback(); + }; + + const handleDelete = () => { + callback(); + }; + + handle.on('change', handleChange); + handle.on('delete', handleDelete); + + return () => { + handle.off('change', handleChange); + handle.off('delete', handleDelete); + }; + }; + + return useSyncExternalStore(subscribe, () => { + if (!handle || !enabled) { + return prevEntityRef.current; + } + const doc = handle.doc(); + if (doc === undefined) { + return prevEntityRef.current; + } + + const found = Entity.findOne(handle, type, include)(id); + if (found === undefined && prevEntityRef.current.data !== undefined) { + // entity was maybe deleted, delete from the ref + 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 = { data: found, invalidEntity: undefined, isPending: false, isError: false }; + } + + return prevEntityRef.current; + }); +} diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index 7ccd744d..3799edd1 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -1,12 +1,14 @@ import { Graph } from '@graphprotocol/grc-20'; -import { type Entity, type Mapping, store, TypeUtils } from '@graphprotocol/hypergraph'; +import { type Entity, type Mapping, store } 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'; +import { convertPropertyValue } from './convert-property-value.js'; +import { convertRelations } from './convert-relations.js'; +import { useHypergraphSpaceInternal } from './use-hypergraph-space-internal.js'; const entityQueryDocumentLevel0 = gql` query entity($id: UUID!, $spaceId: UUID!) { @@ -161,126 +163,6 @@ type EntityQueryResult = { } | 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, diff --git a/packages/hypergraph-react/src/internal/use-hypergraph-space-internal.tsx b/packages/hypergraph-react/src/internal/use-hypergraph-space-internal.tsx new file mode 100644 index 00000000..6f9c37ba --- /dev/null +++ b/packages/hypergraph-react/src/internal/use-hypergraph-space-internal.tsx @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { type HypergraphContext, HypergraphReactContext } from '../HypergraphSpaceContext.js'; + +export function useHypergraphSpaceInternal() { + const context = useContext(HypergraphReactContext); + return (context as HypergraphContext) || { space: '' }; +} diff --git a/packages/hypergraph-react/src/internal/use-query-private.tsx b/packages/hypergraph-react/src/internal/use-query-private.tsx new file mode 100644 index 00000000..7424a50d --- /dev/null +++ b/packages/hypergraph-react/src/internal/use-query-private.tsx @@ -0,0 +1,55 @@ +import { Entity } from '@graphprotocol/hypergraph'; +import type * as Schema from 'effect/Schema'; +import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import { useHypergraphSpaceInternal } from './use-hypergraph-space-internal.js'; +import { useSubscribeToSpaceAndGetHandle } from './use-subscribe-to-space.js'; + +type QueryParams = { + space?: string | undefined; + enabled: boolean; + filter?: Entity.EntityFilter> | undefined; + include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; +}; + +export function useQueryPrivate(type: S, params?: QueryParams) { + const { enabled = true, filter, include, space: spaceFromParams } = params ?? {}; + const entitiesRef = useRef[]>([]); + const subscriptionRef = useRef>({ + subscribe: () => () => undefined, + getEntities: () => entitiesRef.current, + }); + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled }); + const handleIsReady = handle ? handle.isReady() : false; + + // biome-ignore lint/correctness/useExhaustiveDependencies: allow to change filter and include + useLayoutEffect(() => { + if (enabled && handle && handleIsReady) { + const subscription = Entity.subscribeToFindMany(handle, type, filter, include); + subscriptionRef.current.subscribe = subscription.subscribe; + subscriptionRef.current.getEntities = subscription.getEntities; + } + }, [enabled, handleIsReady, handle, type]); + + // TODO: allow to change the enabled state + const allEntities = useSyncExternalStore( + subscriptionRef.current.subscribe, + subscriptionRef.current.getEntities, + () => entitiesRef.current, + ); + + const { entities, deletedEntities } = useMemo(() => { + const entities: Entity.Entity[] = []; + const deletedEntities: Entity.Entity[] = []; + for (const entity of allEntities) { + if (entity.__deleted === true) { + deletedEntities.push(entity); + } else { + entities.push(entity); + } + } + return { entities, deletedEntities }; + }, [allEntities]); + + return { entities, deletedEntities }; +} diff --git a/packages/hypergraph-react/src/internal/use-query-public.tsx b/packages/hypergraph-react/src/internal/use-query-public.tsx index fe64a7ba..14242d42 100644 --- a/packages/hypergraph-react/src/internal/use-query-public.tsx +++ b/packages/hypergraph-react/src/internal/use-query-public.tsx @@ -1,14 +1,16 @@ import { Graph } from '@graphprotocol/grc-20'; -import { type Entity, type Mapping, store, TypeUtils } from '@graphprotocol/hypergraph'; +import { type Entity, type Mapping, store } 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'; +import { convertPropertyValue } from './convert-property-value.js'; +import { convertRelations } from './convert-relations.js'; import { translateFilterToGraphql } from './translate-filter-to-graphql.js'; import type { QueryPublicParams } from './types.js'; +import { useHypergraphSpaceInternal } from './use-hypergraph-space-internal.js'; const entitiesQueryDocumentLevel0 = gql` query entities($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!) { @@ -176,122 +178,6 @@ type EntityQueryResult = { }[]; }; -// 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 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; -}; - -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] = [...rawEntity[key], ...newRelationEntities]; - } else { - rawEntity[key] = newRelationEntities; - } - } - - return rawEntity; -}; - export const parseResult = ( queryData: EntityQueryResult, type: S, diff --git a/packages/hypergraph-react/src/internal/use-subscribe-to-space.tsx b/packages/hypergraph-react/src/internal/use-subscribe-to-space.tsx new file mode 100644 index 00000000..b002a29b --- /dev/null +++ b/packages/hypergraph-react/src/internal/use-subscribe-to-space.tsx @@ -0,0 +1,33 @@ +import { store } from '@graphprotocol/hypergraph'; +import { useSelector } from '@xstate/store/react'; +import { useEffect } from 'react'; +import { useHypergraphApp } from '../HypergraphAppContext.js'; + +const subscribeToSpaceCache = new Map(); + +export function useSubscribeToSpaceAndGetHandle({ spaceId, enabled }: { spaceId: string; enabled: boolean }) { + const handle = useSelector(store, (state) => { + const space = state.context.spaces.find((space) => space.id === spaceId); + if (!space) { + return undefined; + } + return space.automergeDocHandle; + }); + + const { subscribeToSpace, isConnecting } = useHypergraphApp(); + useEffect(() => { + if (!isConnecting && enabled) { + if (subscribeToSpaceCache.has(spaceId)) { + return; + } + subscribeToSpaceCache.set(spaceId, true); + subscribeToSpace({ spaceId }); + } + return () => { + // TODO: unsubscribe from space in case the space ID changes + subscribeToSpaceCache.delete(spaceId); + }; + }, [isConnecting, subscribeToSpace, spaceId, enabled]); + + return handle; +} diff --git a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx index ced02a44..35cf1891 100644 --- a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx +++ b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx @@ -6,14 +6,12 @@ import '@testing-library/jest-dom/vitest'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - HypergraphSpaceProvider, - useCreateEntity, - useDeleteEntity, - useEntity, - useQueryLocal, - useUpdateEntity, -} from '../src/HypergraphSpaceContext.js'; +import { HypergraphSpaceProvider } from '../src/HypergraphSpaceContext.js'; +import { useCreateEntity } from '../src/hooks/use-create-entity.js'; +import { useDeleteEntity } from '../src/hooks/use-delete-entity.js'; +import { useEntity } from '../src/hooks/use-entity.js'; +import { useUpdateEntity } from '../src/hooks/use-update-entity.js'; +import { useQueryPrivate } from '../src/internal/use-query-private.js'; afterEach(() => { cleanup(); @@ -76,7 +74,7 @@ describe('HypergraphSpaceContext', () => { describe('useCreateEntity', () => { it('should be able to create an entity through the useCreateEntity Hook', async () => { - const { result: queryEntitiesResult, rerender } = renderHook(() => useQueryLocal(Event), { wrapper }); + const { result: queryEntitiesResult, rerender } = renderHook(() => useQueryPrivate(Event), { wrapper }); const { result: createEntityResult } = renderHook(() => useCreateEntity(Event), { wrapper }); let createdEntity: Entity.Entity | null = null; @@ -165,7 +163,7 @@ describe('HypergraphSpaceContext', () => { isError: false, }); - const { result: queryEntitiesResult, rerender } = renderHook(() => useQueryLocal(Person), { wrapper }); + const { result: queryEntitiesResult, rerender } = renderHook(() => useQueryPrivate(Person), { wrapper }); rerender(); @@ -194,7 +192,7 @@ describe('HypergraphSpaceContext', () => { ); }); - const { result: queryEntitiesResult, rerender: rerenderQueryEntities } = renderHook(() => useQueryLocal(User), { + const { result: queryEntitiesResult, rerender: rerenderQueryEntities } = renderHook(() => useQueryPrivate(User), { wrapper, }); rerenderQueryEntities(); diff --git a/packages/hypergraph-react/src/internal/translate-filter-to-graphql.test.ts b/packages/hypergraph-react/test/internal/translate-filter-to-graphql.test.ts similarity index 99% rename from packages/hypergraph-react/src/internal/translate-filter-to-graphql.test.ts rename to packages/hypergraph-react/test/internal/translate-filter-to-graphql.test.ts index 434102dd..59e35708 100644 --- a/packages/hypergraph-react/src/internal/translate-filter-to-graphql.test.ts +++ b/packages/hypergraph-react/test/internal/translate-filter-to-graphql.test.ts @@ -1,7 +1,7 @@ import { Graph, Id } from '@graphprotocol/grc-20'; import { Entity, type Mapping, Type } from '@graphprotocol/hypergraph'; import { describe, expect, it } from 'vitest'; -import { translateFilterToGraphql } from './translate-filter-to-graphql.js'; +import { translateFilterToGraphql } from '../../src/internal/translate-filter-to-graphql.js'; export class Todo extends Entity.Class('Todo')({ name: Type.String,