Skip to content
13 changes: 13 additions & 0 deletions apps/connect/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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);
13 changes: 13 additions & 0 deletions apps/events/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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);
261 changes: 1 addition & 260 deletions packages/hypergraph-react/src/HypergraphSpaceContext.tsx
Original file line number Diff line number Diff line change
@@ -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<HypergraphContext | undefined>(undefined);

export function useHypergraphSpaceInternal() {
const context = useContext(HypergraphReactContext);
return (context as HypergraphContext) || { space: '' };
}

export function HypergraphSpaceProvider({ space, children }: { space: string; children: ReactNode }) {
return <HypergraphReactContext.Provider value={{ space }}>{children}</HypergraphReactContext.Provider>;
}

const subscribeToSpaceCache = new Map<string, boolean>();

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<const S extends Entity.AnyNoContext>(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<const S extends Entity.AnyNoContext>(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<S extends Entity.AnyNoContext> = {
space?: string | undefined;
enabled: boolean;
filter?: Entity.EntityFilter<Schema.Schema.Type<S>> | undefined;
include?: { [K in keyof Schema.Schema.Type<S>]?: Record<string, Record<string, never>> } | undefined;
};

export function useQueryLocal<const S extends Entity.AnyNoContext>(type: S, params?: QueryParams<S>) {
const { enabled = true, filter, include, space: spaceFromParams } = params ?? {};
const entitiesRef = useRef<Entity.Entity<S>[]>([]);
const subscriptionRef = useRef<Entity.FindManySubscription<S>>({
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<S>[] = [];
const deletedEntities: Entity.Entity<S>[] = [];
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<const S extends Entity.AnyNoContext>(
type: S,
params: {
id: string | Id;
enabled?: boolean;
space?: string;
include?: { [K in keyof Schema.Schema.Type<S>]?: Record<string, Record<string, never>> } | 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<S> | undefined;
invalidEntity: Record<string, string | boolean | number | Date> | 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<const S extends Entity.AnyNoContext>(
type: S,
params: {
id: string | Id;
space?: string;
mode: 'private' | 'public';
include?: { [K in keyof Schema.Schema.Type<S>]?: Record<string, Record<string, never>> } | 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;
}
18 changes: 18 additions & 0 deletions packages/hypergraph-react/src/hooks/use-create-entity.ts
Original file line number Diff line number Diff line change
@@ -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<const S extends Entity.AnyNoContext>(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);
}
17 changes: 17 additions & 0 deletions packages/hypergraph-react/src/hooks/use-delete-entity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
23 changes: 23 additions & 0 deletions packages/hypergraph-react/src/hooks/use-entity.tsx
Original file line number Diff line number Diff line change
@@ -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<const S extends Entity.AnyNoContext>(
type: S,
params: {
id: string | Id;
space?: string;
mode: 'private' | 'public';
include?: { [K in keyof Schema.Schema.Type<S>]?: Record<string, Record<string, never>> } | 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;
}
17 changes: 17 additions & 0 deletions packages/hypergraph-react/src/hooks/use-hard-delete-entity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<S extends Entity.AnyNoContext> = {
mode: 'public' | 'private';
Expand All @@ -17,7 +17,7 @@ const preparePublishDummy = () => undefined;
export function useQuery<const S extends Entity.AnyNoContext>(type: S, params: QueryParams<S>) {
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 {
Expand Down
Loading
Loading