From 528c1d8b761882c4484aad26494641dce9cb0fc6 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 22 Jan 2025 17:27:50 +0100 Subject: [PATCH 01/18] add first draft --- apps/events/src/routes/space/$spaceId.tsx | 7 +- .../src/HypergraphSpaceContext.tsx | 72 ++---- packages/hypergraph/src/Entity.ts | 211 +++++++++++++++++- 3 files changed, 234 insertions(+), 56 deletions(-) diff --git a/apps/events/src/routes/space/$spaceId.tsx b/apps/events/src/routes/space/$spaceId.tsx index 9cc26434..b9869b5c 100644 --- a/apps/events/src/routes/space/$spaceId.tsx +++ b/apps/events/src/routes/space/$spaceId.tsx @@ -7,7 +7,7 @@ import { DevTool } from '@/components/dev-tool'; import { Todos } from '@/components/todos'; import { Button } from '@/components/ui/button'; import { availableAccounts } from '@/lib/availableAccounts'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { getAddress } from 'viem'; export const Route = createFileRoute('/space/$spaceId')({ @@ -23,6 +23,7 @@ function Space() { subscribeToSpace({ spaceId }); } }, [loading, subscribeToSpace, spaceId]); + const [show2ndTodos, setShow2ndTodos] = useState(false); const space = spaces.find((space) => space.id === spaceId); @@ -38,6 +39,7 @@ function Space() {
+ {show2ndTodos && }

Invite people

{availableAccounts.map((invitee) => { @@ -56,8 +58,9 @@ function Space() { ); })}
-
+
+
diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx index 2fc6b7da..99efdf61 100644 --- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx +++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx @@ -3,14 +3,16 @@ import type { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo'; import { useRepo } from '@automerge/automerge-repo-react-hooks'; import { Entity, Utils } from '@graphprotocol/hypergraph'; +import { type DocumentContent, subscribeToDocumentChanges } from '@graphprotocol/hypergraph/Entity'; import * as Schema from 'effect/Schema'; -import { type ReactNode, createContext, useContext, useRef, useSyncExternalStore } from 'react'; +import { type ReactNode, createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react'; export type HypergraphContext = { space: string; repo: Repo; id: AnyDocumentId; handle: DocHandle; + unsubscribeChangeListener: () => void; }; export const HypergraphReactContext = createContext(undefined); @@ -30,15 +32,28 @@ export function HypergraphSpaceProvider({ space, children }: { space: string; ch let current = ref.current; if (current === undefined || space !== current.space || repo !== current.repo) { + current?.unsubscribeChangeListener(); // unsubscribe from the previous space when switching to a new space + const id = Utils.idToAutomergeId(space) as AnyDocumentId; + const handle = repo.find(id); + const unsubscribeChangeListener = subscribeToDocumentChanges(handle); + current = ref.current = { space, repo, id, - handle: repo.find(id), + handle, + unsubscribeChangeListener, }; } + // biome-ignore lint/correctness/useExhaustiveDependencies: no need for dependencies as the unsubscribe is called from the ref + useEffect(() => { + return () => { + current?.unsubscribeChangeListener(); // unsubscribe from the previous space when the component unmounts + }; + }, []); + return {children}; } @@ -59,37 +74,17 @@ export function useDeleteEntity() { export function useQueryEntities(type: S) { const hypergraph = useHypergraph(); - const equal = isEqual(type); - - // store as a map of type to array of entities of the type - const prevEntitiesRef = useRef>>>([]); - - const subscribe = (callback: () => void) => { - const handleChange = () => { - callback(); - }; - - const handleDelete = () => { - callback(); - }; - - hypergraph.handle.on('change', handleChange); - hypergraph.handle.on('delete', handleDelete); + const [subscription] = useState(() => { + return Entity.subscribeToFindMany(hypergraph.handle, type); + }); + useEffect(() => { return () => { - hypergraph.handle.off('change', handleChange); - hypergraph.handle.off('delete', handleDelete); + subscription.unsubscribe(); }; - }; + }, [subscription]); - return useSyncExternalStore>>>(subscribe, () => { - const filtered = Entity.findMany(hypergraph.handle, type); - if (!equal(filtered, prevEntitiesRef.current)) { - prevEntitiesRef.current = filtered; - } - - return prevEntitiesRef.current; - }); + return useSyncExternalStore(subscription.listener, subscription.getEntities, () => []); } export function useQueryEntity(type: S, id: string) { @@ -135,22 +130,3 @@ export function useQueryEntity(type: S, id: return prevEntityRef.current; }); } - -/** @internal */ -const isEqual = (type: Schema.Schema) => { - const equals = Schema.equivalence(type); - - return (a: ReadonlyArray, b: ReadonlyArray) => { - if (a.length !== b.length) { - return false; - } - - for (let i = 0; i < a.length; i++) { - if (!equals(a[i], b[i])) { - return false; - } - } - - return true; - }; -}; diff --git a/packages/hypergraph/src/Entity.ts b/packages/hypergraph/src/Entity.ts index bf241ff8..783705d8 100644 --- a/packages/hypergraph/src/Entity.ts +++ b/packages/hypergraph/src/Entity.ts @@ -1,4 +1,4 @@ -import type { DocHandle } from '@automerge/automerge-repo'; +import type { DocHandle, Patch } from '@automerge/automerge-repo'; import * as VariantSchema from '@effect/experimental/VariantSchema'; import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; @@ -66,6 +66,133 @@ export class EntityNotFoundError extends Data.TaggedError('EntityNotFoundError') export type Entity = Schema.Schema.Type & { type: string }; +/* + * Note: Currently we only use one global cache for all entities. + * In the future we probably want a build function that creates a cache and returns the + * functions (create, update, findMany, …) that use this specific cache. + * + * How does it work? + * + * We store all decoded entities in a cache and for each query we reference the entities relevant to this query. + * Whenever a query is registered we add it to the cache and add a listener to the query. Whenever a query is unregistered we remove the listener from the query. + * + * Handling filters is relatively straight forward as they are uniquely identified by their params. + * + * Questions: + * How do we handle findOne? + * Thoughts: Could be just a special case of findMany limited to a specific id or a separate mechanism. + * How do we handle relations? + * Thoughts: We could have a separate query entry for each relation, but when requesting a lot of entities e.g. 1000 only for a nesting one level deep it would result in a lot of cached queries. Not sure this is a good idea. + */ +type DecodedEntitiesCache = Map< + string, // type name + { + decoder: (data: unknown) => unknown; + entities: Map>; // holds all entities of this type + queries: Map< + string, // instead of serializedQueryKey as string we could also have the actual params + { + data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array + listeners: Array<() => void>; // listeners to this query + } + >; + } +>; + +const decodedEntitiesCache: DecodedEntitiesCache = new Map(); + +export const subscribeToDocumentChanges = (handle: DocHandle) => { + const onChange = ({ patches, doc }: { patches: Array; doc: DocumentContent }) => { + const changedEntities = new Set(); + const deletedEntities = new Set(); + + for (const patch of patches) { + switch (patch.action) { + case 'put': + case 'insert': + case 'splice': { + if (patch.path.length > 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') { + changedEntities.add(patch.path[1]); + } + break; + } + case 'del': { + if (patch.path.length === 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') { + deletedEntities.add(patch.path[1]); + } + break; + } + } + } + + const entityTypes = new Set(); + + // loop over all changed entities and update the cache + for (const entityId of changedEntities) { + const entity = doc.entities?.[entityId]; + if (!entity || typeof entity !== 'object' || !('@@types@@' in entity) || !Array.isArray(entity['@@types@@'])) + return; + for (const typeName of entity['@@types@@']) { + const cacheEntry = decodedEntitiesCache.get(typeName); + if (!cacheEntry) continue; + + const decoded = cacheEntry.decoder({ ...entity, id: entityId }); + cacheEntry.entities.set(entityId, decoded); + + const query = cacheEntry.queries.get('all'); + if (query) { + const index = query.data.findIndex((entity) => entity.id === entityId); + if (index !== -1) { + query.data[index] = decoded; + } else { + query.data.push(decoded); + } + } + + entityTypes.add(typeName); + } + } + + // loop over all deleted entities and remove them from the cache + for (const entityId of deletedEntities) { + for (const [affectedTypeName, cacheEntry] of decodedEntitiesCache) { + if (cacheEntry.entities.has(entityId)) { + entityTypes.add(affectedTypeName); + cacheEntry.entities.delete(entityId); + + for (const [, query] of cacheEntry.queries) { + // find the entity in the query and remove it using splice + const index = query.data.findIndex((entity) => entity.id === entityId); + if (index !== -1) { + query.data.splice(index, 1); + } + } + } + } + } + + // invoke all the listeners per type + for (const typeName of entityTypes) { + const cacheEntry = decodedEntitiesCache.get(typeName); + if (!cacheEntry) continue; + + for (const query of cacheEntry.queries.values()) { + for (const listener of query.listeners) { + listener(); + } + } + } + }; + + handle.on('change', onChange); + + return () => { + console.log('unsubscribe document changes'); + handle.off('change', onChange); + decodedEntitiesCache.clear(); // currently we only support exactly one space + }; +}; + /** * Creates an entity model of given type and stores it in the repo. */ @@ -78,7 +205,7 @@ export const create = (handle: DocHandle>>): Entity => { const encoded = encode(data); - // apply changes to the repo -> adds the entity to the repo entites document + // apply changes to the repo -> adds the entity to the repo entities document handle.change((doc) => { doc.entities ??= {}; doc.entities[entityId] = { ...encoded, '@@types@@': [typeName] }; @@ -103,7 +230,7 @@ export const update = (handle: DocHandle>>>): Entity => { validate(data); - // apply changes to the repo -> updates the existing entity to the repo entites document + // apply changes to the repo -> updates the existing entity to the repo entities document let updated: Schema.Schema.Type | undefined = undefined; handle.change((doc) => { if (doc.entities === undefined) { @@ -116,7 +243,7 @@ export const update = (handle: DocHandle( const typeName = type.name; // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in - // an index and store the decoded valeus instead of re-decoding over and over again. + // an index and store the decoded values instead of re-decoding over and over again. const entities = handle.docSync()?.entities ?? {}; const filtered: Array> = []; for (const id in entities) { @@ -179,6 +306,78 @@ export function findMany( return filtered; } +export function subscribeToFindMany( + handle: DocHandle, + type: S, +): { listener: () => () => void; getEntities: () => Readonly>>; unsubscribe: () => void } { + const queryKey = 'all'; + const decode = Schema.decodeUnknownSync(type); + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + + const getEntities = () => { + const entities = decodedEntitiesCache.get(typeName)?.queries.get(queryKey)?.data ?? []; + return entities; + }; + + const listener = () => { + return () => undefined; + }; + + const unsubscribe = () => { + const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); + if (!query || !query.listeners) return; + query.listeners = query.listeners.filter((cachedListener) => cachedListener !== listener); + console.log('unsubscribe query', query.listeners); + }; + + const entities = findMany(handle, type); + + if (decodedEntitiesCache.has(typeName)) { + // add a listener to the existing query + const cacheEntry = decodedEntitiesCache.get(typeName); + const query = cacheEntry?.queries.get(queryKey); + + for (const entity of entities) { + cacheEntry?.entities.set(entity.id, entity); + + if (!query) continue; + + const index = query.data.findIndex((e) => e.id === entity.id); + if (index !== -1) { + query.data[index] = entity; + } else { + query.data.push(entity); + } + } + + if (query?.listeners) { + query.listeners.push(listener); + } + } else { + const entitiesMap = new Map(); + for (const entity of entities) { + entitiesMap.set(entity.id, entity); + } + + const queries = new Map(); + + queries.set(queryKey, { + data: entities, + listeners: [listener], + }); + + decodedEntitiesCache.set(typeName, { + decoder: decode, + entities: entitiesMap, + queries, + }); + } + + return { listener, getEntities, unsubscribe }; +} + /** * Find the entity of the given type, with the given id, from the repo. */ @@ -192,7 +391,7 @@ export const findOne = const typeName = type.name; // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in - // an index and store the decoded valeus instead of re-decoding over and over again. + // an index and store the decoded values instead of re-decoding over and over again. const entity = handle.docSync()?.entities?.[id]; if (typeof entity === 'object' && entity != null && '@@types@@' in entity) { const types = entity['@@types@@']; From 9ba7c45ad3db3b3c6f8db822abd7dfbb8f02c214 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 27 Jan 2025 12:04:57 +0100 Subject: [PATCH 02/18] move document subscription to be managed by subscribeToFindMany --- .../src/HypergraphSpaceContext.tsx | 14 +--------- packages/hypergraph/src/Entity.ts | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx index 99efdf61..42cd3a95 100644 --- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx +++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx @@ -3,7 +3,7 @@ import type { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo'; import { useRepo } from '@automerge/automerge-repo-react-hooks'; import { Entity, Utils } from '@graphprotocol/hypergraph'; -import { type DocumentContent, subscribeToDocumentChanges } from '@graphprotocol/hypergraph/Entity'; +import type { DocumentContent } from '@graphprotocol/hypergraph/Entity'; import * as Schema from 'effect/Schema'; import { type ReactNode, createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react'; @@ -12,7 +12,6 @@ export type HypergraphContext = { repo: Repo; id: AnyDocumentId; handle: DocHandle; - unsubscribeChangeListener: () => void; }; export const HypergraphReactContext = createContext(undefined); @@ -32,28 +31,17 @@ export function HypergraphSpaceProvider({ space, children }: { space: string; ch let current = ref.current; if (current === undefined || space !== current.space || repo !== current.repo) { - current?.unsubscribeChangeListener(); // unsubscribe from the previous space when switching to a new space - const id = Utils.idToAutomergeId(space) as AnyDocumentId; const handle = repo.find(id); - const unsubscribeChangeListener = subscribeToDocumentChanges(handle); current = ref.current = { space, repo, id, handle, - unsubscribeChangeListener, }; } - // biome-ignore lint/correctness/useExhaustiveDependencies: no need for dependencies as the unsubscribe is called from the ref - useEffect(() => { - return () => { - current?.unsubscribeChangeListener(); // unsubscribe from the previous space when the component unmounts - }; - }, []); - return {children}; } diff --git a/packages/hypergraph/src/Entity.ts b/packages/hypergraph/src/Entity.ts index 783705d8..823941dd 100644 --- a/packages/hypergraph/src/Entity.ts +++ b/packages/hypergraph/src/Entity.ts @@ -101,6 +101,14 @@ type DecodedEntitiesCache = Map< const decodedEntitiesCache: DecodedEntitiesCache = new Map(); +const documentChangeListener: { + subscribedQueriesCount: number; + unsubscribe: undefined | (() => void); +} = { + subscribedQueriesCount: 0, + unsubscribe: undefined, +}; + export const subscribeToDocumentChanges = (handle: DocHandle) => { const onChange = ({ patches, doc }: { patches: Array; doc: DocumentContent }) => { const changedEntities = new Set(); @@ -327,9 +335,16 @@ export function subscribeToFindMany( const unsubscribe = () => { const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); - if (!query || !query.listeners) return; - query.listeners = query.listeners.filter((cachedListener) => cachedListener !== listener); - console.log('unsubscribe query', query.listeners); + if (query?.listeners) { + query.listeners = query.listeners.filter((cachedListener) => cachedListener !== listener); + console.log('unsubscribe query', query.listeners); + } + + documentChangeListener.subscribedQueriesCount--; + if (documentChangeListener.subscribedQueriesCount === 0) { + documentChangeListener.unsubscribe?.(); + documentChangeListener.unsubscribe = undefined; + } }; const entities = findMany(handle, type); @@ -375,6 +390,11 @@ export function subscribeToFindMany( }); } + if (documentChangeListener.subscribedQueriesCount === 0) { + documentChangeListener.unsubscribe = subscribeToDocumentChanges(handle); + } + documentChangeListener.subscribedQueriesCount++; + return { listener, getEntities, unsubscribe }; } From 2333f52c310e68b548628b67fb5f878e39758498 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 9 Feb 2025 10:03:08 +0100 Subject: [PATCH 03/18] fix change subscription --- .../events/src/components/todos-read-only.tsx | 18 +++++ apps/events/src/routes/space/$spaceId.tsx | 2 + .../src/HypergraphSpaceContext.tsx | 10 +-- packages/hypergraph/src/Entity.ts | 69 +++++++++++-------- 4 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 apps/events/src/components/todos-read-only.tsx diff --git a/apps/events/src/components/todos-read-only.tsx b/apps/events/src/components/todos-read-only.tsx new file mode 100644 index 00000000..411887cb --- /dev/null +++ b/apps/events/src/components/todos-read-only.tsx @@ -0,0 +1,18 @@ +import { useQueryEntities } from '@graphprotocol/hypergraph-react'; +import { Todo } from '../schema'; + +export const TodosReadOnly = () => { + const todos = useQueryEntities(Todo); + + return ( + <> +

Todos (read only)

+ {todos.map((todo) => ( +
+

{todo.name}

+ +
+ ))} + + ); +}; diff --git a/apps/events/src/routes/space/$spaceId.tsx b/apps/events/src/routes/space/$spaceId.tsx index b9869b5c..2be5f05f 100644 --- a/apps/events/src/routes/space/$spaceId.tsx +++ b/apps/events/src/routes/space/$spaceId.tsx @@ -5,6 +5,7 @@ import { useSelector } from '@xstate/store/react'; import { DevTool } from '@/components/dev-tool'; import { Todos } from '@/components/todos'; +import { TodosReadOnly } from '@/components/todos-read-only'; import { Button } from '@/components/ui/button'; import { availableAccounts } from '@/lib/availableAccounts'; import { useEffect, useState } from 'react'; @@ -39,6 +40,7 @@ function Space() {
+ {show2ndTodos && }

Invite people

diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx index 42cd3a95..0aa6feda 100644 --- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx +++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx @@ -5,7 +5,7 @@ import { useRepo } from '@automerge/automerge-repo-react-hooks'; import { Entity, Utils } from '@graphprotocol/hypergraph'; import type { DocumentContent } from '@graphprotocol/hypergraph/Entity'; import * as Schema from 'effect/Schema'; -import { type ReactNode, createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { type ReactNode, createContext, useContext, useRef, useState, useSyncExternalStore } from 'react'; export type HypergraphContext = { space: string; @@ -66,13 +66,7 @@ export function useQueryEntities(type: S) { return Entity.subscribeToFindMany(hypergraph.handle, type); }); - useEffect(() => { - return () => { - subscription.unsubscribe(); - }; - }, [subscription]); - - return useSyncExternalStore(subscription.listener, subscription.getEntities, () => []); + return useSyncExternalStore(subscription.subscribe, subscription.getEntities, () => []); } export function useQueryEntity(type: S, id: string) { diff --git a/packages/hypergraph/src/Entity.ts b/packages/hypergraph/src/Entity.ts index 823941dd..ba495bd7 100644 --- a/packages/hypergraph/src/Entity.ts +++ b/packages/hypergraph/src/Entity.ts @@ -134,6 +134,9 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = } const entityTypes = new Set(); + // collect all query entries that changed and only at the end make one copy to change the + // reference to reduce the amount of O(n) operations per query to 1 + const touchedQueries = new Set>(); // loop over all changed entities and update the cache for (const entityId of changedEntities) { @@ -155,6 +158,7 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = } else { query.data.push(decoded); } + touchedQueries.add([typeName, 'all']); } entityTypes.add(typeName); @@ -173,12 +177,23 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = const index = query.data.findIndex((entity) => entity.id === entityId); if (index !== -1) { query.data.splice(index, 1); + touchedQueries.add([affectedTypeName, 'all']); } } } } } + for (const [typeName, queryKey] of touchedQueries) { + const cacheEntry = decodedEntitiesCache.get(typeName); + if (!cacheEntry) continue; + + const query = cacheEntry.queries.get(queryKey); + if (!query) continue; + + query.data = [...query.data]; // must be a new reference for React.useSyncExternalStore + } + // invoke all the listeners per type for (const typeName of entityTypes) { const cacheEntry = decodedEntitiesCache.get(typeName); @@ -195,7 +210,6 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = handle.on('change', onChange); return () => { - console.log('unsubscribe document changes'); handle.off('change', onChange); decodedEntitiesCache.clear(); // currently we only support exactly one space }; @@ -317,7 +331,10 @@ export function findMany( export function subscribeToFindMany( handle: DocHandle, type: S, -): { listener: () => () => void; getEntities: () => Readonly>>; unsubscribe: () => void } { +): { + subscribe: (callback: () => void) => () => void; + getEntities: () => Readonly>>; +} { const queryKey = 'all'; const decode = Schema.decodeUnknownSync(type); // TODO: what's the right way to get the name of the type? @@ -325,26 +342,7 @@ export function subscribeToFindMany( const typeName = type.name; const getEntities = () => { - const entities = decodedEntitiesCache.get(typeName)?.queries.get(queryKey)?.data ?? []; - return entities; - }; - - const listener = () => { - return () => undefined; - }; - - const unsubscribe = () => { - const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); - if (query?.listeners) { - query.listeners = query.listeners.filter((cachedListener) => cachedListener !== listener); - console.log('unsubscribe query', query.listeners); - } - - documentChangeListener.subscribedQueriesCount--; - if (documentChangeListener.subscribedQueriesCount === 0) { - documentChangeListener.unsubscribe?.(); - documentChangeListener.unsubscribe = undefined; - } + return decodedEntitiesCache.get(typeName)?.queries.get(queryKey)?.data ?? []; }; const entities = findMany(handle, type); @@ -366,10 +364,6 @@ export function subscribeToFindMany( query.data.push(entity); } } - - if (query?.listeners) { - query.listeners.push(listener); - } } else { const entitiesMap = new Map(); for (const entity of entities) { @@ -380,7 +374,7 @@ export function subscribeToFindMany( queries.set(queryKey, { data: entities, - listeners: [listener], + listeners: [], }); decodedEntitiesCache.set(typeName, { @@ -390,12 +384,31 @@ export function subscribeToFindMany( }); } + const subscribe = (callback: () => void) => { + const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); + if (query?.listeners) { + query.listeners.push(callback); + } + return () => { + const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); + if (query?.listeners) { + query.listeners = query?.listeners?.filter((cachedListener) => cachedListener !== callback); + } + + documentChangeListener.subscribedQueriesCount--; + if (documentChangeListener.subscribedQueriesCount === 0) { + documentChangeListener.unsubscribe?.(); + documentChangeListener.unsubscribe = undefined; + } + }; + }; + if (documentChangeListener.subscribedQueriesCount === 0) { documentChangeListener.unsubscribe = subscribeToDocumentChanges(handle); } documentChangeListener.subscribedQueriesCount++; - return { listener, getEntities, unsubscribe }; + return { subscribe, getEntities }; } /** From 5494d14f94ab064251f211ae0f542bdfcdc7e1f7 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 10 Feb 2025 11:48:55 +0100 Subject: [PATCH 04/18] first iteration for relations --- apps/events/package.json | 1 + apps/events/src/components/todos.tsx | 26 +- apps/events/src/components/users.tsx | 37 ++ apps/events/src/routes/space/$spaceId.tsx | 2 + apps/events/src/schema.ts | 6 + packages/hypergraph/src/Entity.ts | 89 ++++- .../hypergraph/src/utils/hasArrayField.ts | 4 + pnpm-lock.yaml | 329 ++++++++++++++++++ 8 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 apps/events/src/components/users.tsx create mode 100644 packages/hypergraph/src/utils/hasArrayField.ts diff --git a/apps/events/package.json b/apps/events/package.json index 1942b5fc..20dde2b7 100644 --- a/apps/events/package.json +++ b/apps/events/package.json @@ -27,6 +27,7 @@ "lucide-react": "^0.471.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-select": "^5.10.0", "siwe": "^2.3.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/apps/events/src/components/todos.tsx b/apps/events/src/components/todos.tsx index 50bfa0ef..626e7b88 100644 --- a/apps/events/src/components/todos.tsx +++ b/apps/events/src/components/todos.tsx @@ -1,25 +1,34 @@ import { useCreateEntity, useDeleteEntity, useQueryEntities, useUpdateEntity } from '@graphprotocol/hypergraph-react'; import { useState } from 'react'; -import { Todo } from '../schema'; +import Select from 'react-select'; +import { Todo, User } from '../schema'; import { Button } from './ui/button'; import { Input } from './ui/input'; export const Todos = () => { const todos = useQueryEntities(Todo); + const users = useQueryEntities(User); const createEntity = useCreateEntity(Todo); const updateEntity = useUpdateEntity(Todo); const deleteEntity = useDeleteEntity(); - const [newTodoTitle, setNewTodoTitle] = useState(''); + const [newTodoName, setNewTodoName] = useState(''); + const [assignees, setAssignees] = useState<{ value: string; label: string }[]>([]); + const userOptions = users.map((user) => ({ value: user.id, label: user.name })); return ( <>

Todos

-
- setNewTodoTitle(e.target.value)} /> +
+ setNewTodoName(e.target.value)} /> + { + const users = useQueryEntities(User); + const createEntity = useCreateEntity(User); + const deleteEntity = useDeleteEntity(); + const [newUserName, setNewUserName] = useState(''); + + return ( + <> +

Users

+
+ setNewUserName(e.target.value)} /> + +
+ {users.map((user) => ( +
+

+ {user.name} ({user.id}) +

+ +
+ ))} + + ); +}; diff --git a/apps/events/src/routes/space/$spaceId.tsx b/apps/events/src/routes/space/$spaceId.tsx index 2be5f05f..7e5551ec 100644 --- a/apps/events/src/routes/space/$spaceId.tsx +++ b/apps/events/src/routes/space/$spaceId.tsx @@ -7,6 +7,7 @@ import { DevTool } from '@/components/dev-tool'; import { Todos } from '@/components/todos'; import { TodosReadOnly } from '@/components/todos-read-only'; import { Button } from '@/components/ui/button'; +import { Users } from '@/components/users'; import { availableAccounts } from '@/lib/availableAccounts'; import { useEffect, useState } from 'react'; import { getAddress } from 'viem'; @@ -39,6 +40,7 @@ function Space() { return (
+ {show2ndTodos && } diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index fe408045..c6397ead 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -1,7 +1,13 @@ import { Entity } from '@graphprotocol/hypergraph'; +export class User extends Entity.Class('User')({ + id: Entity.Generated(Entity.Text), + name: Entity.Text, +}) {} + export class Todo extends Entity.Class('Todo')({ id: Entity.Generated(Entity.Text), name: Entity.Text, completed: Entity.Checkbox, + assignees: Entity.Reference(Entity.ReferenceArray(User)), }) {} diff --git a/packages/hypergraph/src/Entity.ts b/packages/hypergraph/src/Entity.ts index ba495bd7..ebfd9caf 100644 --- a/packages/hypergraph/src/Entity.ts +++ b/packages/hypergraph/src/Entity.ts @@ -3,6 +3,7 @@ import * as VariantSchema from '@effect/experimental/VariantSchema'; import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; import { generateId } from './utils/generateId.js'; +import { hasArrayField } from './utils/hasArrayField.js'; const { Class, @@ -35,7 +36,6 @@ export type AnyNoContext = Schema.Schema.AnyNoContext & { export type Update = S['update']; export type Insert = S['insert']; - export interface Generated extends VariantSchema.Field<{ readonly select: S; @@ -88,6 +88,7 @@ type DecodedEntitiesCache = Map< string, // type name { decoder: (data: unknown) => unknown; + type: Any; // TODO should be the type of the entity entities: Map>; // holds all entities of this type queries: Map< string, // instead of serializedQueryKey as string we could also have the actual params @@ -147,7 +148,39 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = const cacheEntry = decodedEntitiesCache.get(typeName); if (!cacheEntry) continue; - const decoded = cacheEntry.decoder({ ...entity, id: entityId }); + const relations: Record> = {}; + for (const [fieldName, field] of Object.entries(cacheEntry.type.fields)) { + // check if the type exists in the cach and is a proper relation + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const fieldCacheEntry = decodedEntitiesCache.get(field.name); + if (!fieldCacheEntry) continue; + + const relationEntities: Array> = []; + + if (hasArrayField(entity, fieldName)) { + for (const relationEntityId of entity[fieldName]) { + const relationEntity = doc.entities?.[relationEntityId]; + if ( + !relationEntity || + typeof relationEntity !== 'object' || + !('@@types@@' in relationEntity) || + !Array.isArray(relationEntity['@@types@@']) + ) + continue; + + relationEntities.push({ ...relationEntity, id: relationEntityId }); + } + } + + relations[fieldName] = relationEntities; + } + + const decoded = cacheEntry.decoder({ + ...entity, + ...relations, + id: entityId, + }); cacheEntry.entities.set(entityId, decoded); const query = cacheEntry.queries.get('all'); @@ -379,11 +412,54 @@ export function subscribeToFindMany( decodedEntitiesCache.set(typeName, { decoder: decode, + type, entities: entitiesMap, queries, }); } + const allTypes = new Set(); + for (const [_key, field] of Object.entries(type.fields)) { + // TODO check if it is a class instead of specific name + // TODO: what's the right way to extract the name from the ast + // @ts-expect-error rest is defined + if (field.ast.rest) { + // @ts-expect-error name is defined + const typeName = field.ast.rest[0].type.to.toString(); + if (typeName === 'User') { + allTypes.add(field as S); + } + } + } + + for (const type of allTypes) { + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + const entities = findMany(handle, type); + + if (decodedEntitiesCache.has(typeName)) { + // add a listener to the existing query + const cacheEntry = decodedEntitiesCache.get(typeName); + + for (const entity of entities) { + cacheEntry?.entities.set(entity.id, entity); + } + } else { + const entitiesMap = new Map(); + for (const entity of entities) { + entitiesMap.set(entity.id, entity); + } + + decodedEntitiesCache.set(typeName, { + decoder: decode, + type, + entities: entitiesMap, + queries: new Map(), + }); + } + } + const subscribe = (callback: () => void) => { const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); if (query?.listeners) { @@ -435,3 +511,12 @@ export const findOne = return undefined; }; + +export const Reference = (schema: S) => + Field({ + select: schema, + insert: Schema.optional(Schema.Array(Schema.String)), + update: Schema.optional(Schema.Array(Schema.String)), + }); + +export const ReferenceArray = Schema.Array; diff --git a/packages/hypergraph/src/utils/hasArrayField.ts b/packages/hypergraph/src/utils/hasArrayField.ts new file mode 100644 index 00000000..be78e221 --- /dev/null +++ b/packages/hypergraph/src/utils/hasArrayField.ts @@ -0,0 +1,4 @@ +export const hasArrayField = (obj: unknown, key: string): obj is { [K in string]: string[] } => { + // biome-ignore lint/suspicious/noExplicitAny: any is fine here + return obj !== null && typeof obj === 'object' && key in obj && Array.isArray((obj as any)[key]); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff8056ad..3f71ad73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + react-select: + specifier: ^5.10.0 + version: 5.10.0(@types/react@19.0.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) siwe: specifier: ^2.3.2 version: 2.3.2(ethers@6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -648,15 +651,56 @@ packages: peerDependencies: effect: ^3.12.2 + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + '@emotion/is-prop-valid@1.2.2': resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} '@emotion/memoize@0.8.1': resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + '@emotion/unitless@0.8.1': resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild/aix-ppc64@0.23.1': resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} engines: {node: '>=18'} @@ -1970,6 +2014,9 @@ packages: '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/pg@8.11.10': resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} @@ -1984,6 +2031,11 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + '@types/react@19.0.7': resolution: {integrity: sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==} @@ -2295,6 +2347,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2441,6 +2497,10 @@ packages: call-me-maybe@1.0.2: resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -2569,6 +2629,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2590,6 +2653,10 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -2795,6 +2862,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + domain-browser@4.23.0: resolution: {integrity: sha512-ArzcM/II1wCCujdCNyQjXrAFwS4mrLh4C7DZWlaI8mdh7h3BfKdNd3bKXITfl2PT9FtfQqaGvhi1vPRQPimjGA==} engines: {node: '>=10'} @@ -2858,6 +2928,9 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-define-property@1.0.0: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} @@ -2892,6 +2965,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -3020,6 +3097,9 @@ packages: find-my-way-ts@0.1.5: resolution: {integrity: sha512-4GOTMrpGQVzsCH2ruUn2vmwzV/02zF4q+ybhCIrw/Rkt3L8KWcycdC6aJMctJzwN4fXD4SD5F/4B9Sksh5rE0A==} + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3181,6 +3261,9 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -3228,6 +3311,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -3250,6 +3337,9 @@ packages: resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -3395,6 +3485,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -3463,6 +3556,10 @@ packages: lokijs@1.5.12: resolution: {integrity: sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} @@ -3512,6 +3609,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -3783,10 +3883,18 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse-asn1@5.1.7: resolution: {integrity: sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==} engines: {node: '>= 0.10'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse5@7.2.1: resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} @@ -4038,6 +4146,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -4129,6 +4240,9 @@ packages: peerDependencies: react: ^19.0.0 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -4136,6 +4250,18 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-select@5.10.0: + resolution: {integrity: sha512-k96gw+i6N3ExgDwPIg0lUPmexl1ygPe6u5BdQFNBhkpbwroIgCNXdubtIzHfThYXYYTubwOBafoMnn7ruEP1xA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react-usestateref@1.0.9: resolution: {integrity: sha512-t8KLsI7oje0HzfzGhxFXzuwbf1z9vhBM1ptHLUIHhYqZDKFuI5tzdhEVxSNzUkYxwF8XdpOErzHlKxvP7sTERw==} peerDependencies: @@ -4189,6 +4315,10 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -4345,6 +4475,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -4439,6 +4573,9 @@ packages: react: '>= 16.8.0' react-dom: '>= 16.8.0' + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + stylis@4.3.2: resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} @@ -4782,6 +4919,15 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} + use-isomorphic-layout-effect@1.2.0: + resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -5079,6 +5225,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + yaml@2.7.0: resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} engines: {node: '>= 14'} @@ -5574,14 +5724,78 @@ snapshots: find-my-way-ts: 0.1.5 multipasta: 0.2.5 + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.25.9 + '@babel/runtime': 7.26.0 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + '@emotion/is-prop-valid@1.2.2': dependencies: '@emotion/memoize': 0.8.1 '@emotion/memoize@0.8.1': {} + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.0.7)(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.0.0) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.7 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + '@emotion/unitless@0.8.1': {} + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.0.0)': + dependencies: + react: 19.0.0 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.23.1': optional: true @@ -7080,6 +7294,8 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/parse-json@4.0.2': {} + '@types/pg@8.11.10': dependencies: '@types/node': 22.10.7 @@ -7094,6 +7310,10 @@ snapshots: dependencies: '@types/react': 19.0.7 + '@types/react-transition-group@4.4.12(@types/react@19.0.7)': + dependencies: + '@types/react': 19.0.7 + '@types/react@19.0.7': dependencies: csstype: 3.1.3 @@ -7671,6 +7891,12 @@ snapshots: dependencies: '@babel/core': 7.26.0 + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.26.0 + cosmiconfig: 7.1.0 + resolve: 1.22.10 + balanced-match@1.0.2: {} base-x@3.0.10: @@ -7863,6 +8089,8 @@ snapshots: call-me-maybe@1.0.2: {} + callsites@3.1.0: {} + camelcase-css@2.0.1: {} camelcase@5.3.1: {} @@ -7986,6 +8214,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -8001,6 +8231,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + crc-32@1.2.2: {} create-ecdh@4.0.4: @@ -8179,6 +8417,11 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.26.0 + csstype: 3.1.3 + domain-browser@4.23.0: {} dotenv@16.4.7: {} @@ -8247,6 +8490,10 @@ snapshots: entities@4.5.0: {} + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + es-define-property@1.0.0: dependencies: get-intrinsic: 1.2.4 @@ -8320,6 +8567,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + esprima@4.0.1: {} estree-walker@2.0.2: {} @@ -8511,6 +8760,8 @@ snapshots: find-my-way-ts@0.1.5: {} + find-root@1.1.0: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -8696,6 +8947,10 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -8748,6 +9003,11 @@ snapshots: ignore@5.3.2: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + indent-string@4.0.0: {} inflight@1.0.6: @@ -8766,6 +9026,8 @@ snapshots: call-bind: 1.0.7 has-tostringtag: 1.0.2 + is-arrayish@0.2.1: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -8912,6 +9174,8 @@ snapshots: jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json-stringify-safe@5.0.1: {} json5@2.2.3: {} @@ -8972,6 +9236,10 @@ snapshots: lokijs@1.5.12: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.1.2: {} lru-cache@10.4.3: {} @@ -9019,6 +9287,8 @@ snapshots: media-typer@1.1.0: {} + memoize-one@6.0.0: {} + merge-descriptors@2.0.0: {} merge2@1.4.1: {} @@ -9283,6 +9553,10 @@ snapshots: pako@1.0.11: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse-asn1@5.1.7: dependencies: asn1.js: 4.10.1 @@ -9292,6 +9566,13 @@ snapshots: pbkdf2: 3.1.2 safe-buffer: 5.2.1 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.26.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse5@7.2.1: dependencies: entities: 4.5.0 @@ -9527,6 +9808,12 @@ snapshots: process@0.11.10: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -9632,10 +9919,38 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-is@16.13.1: {} + react-is@17.0.2: {} react-refresh@0.14.2: {} + react-select@5.10.0(@types/react@19.0.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/runtime': 7.26.0 + '@emotion/cache': 11.14.0 + '@emotion/react': 11.14.0(@types/react@19.0.7)(react@19.0.0) + '@floating-ui/dom': 1.6.13 + '@types/react-transition-group': 4.4.12(@types/react@19.0.7) + memoize-one: 6.0.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + use-isomorphic-layout-effect: 1.2.0(@types/react@19.0.7)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - supports-color + + react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/runtime': 7.26.0 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-usestateref@1.0.9(react@19.0.0): dependencies: react: 19.0.0 @@ -9696,6 +10011,8 @@ snapshots: require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -9888,6 +10205,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.5.7: {} + source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 @@ -9983,6 +10302,8 @@ snapshots: stylis: 4.3.2 tslib: 2.6.2 + stylis@4.2.0: {} + stylis@4.3.2: {} stylis@4.3.5: {} @@ -10278,6 +10599,12 @@ snapshots: punycode: 1.4.1 qs: 6.13.0 + use-isomorphic-layout-effect@1.2.0(@types/react@19.0.7)(react@19.0.0): + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.7 + use-sync-external-store@1.2.0(react@19.0.0): dependencies: react: 19.0.0 @@ -10545,6 +10872,8 @@ snapshots: yallist@3.1.1: {} + yaml@1.10.2: {} + yaml@2.7.0: {} yargs-parser@18.1.3: From e77d41b1e81ae24612242de2f99e3767d46e37de Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 11 Feb 2025 08:23:43 +0100 Subject: [PATCH 05/18] move entity to separate folder and cleanup imports --- packages/hypergraph-react/src/HypergraphSpaceContext.tsx | 3 +-- packages/hypergraph/src/{Entity.ts => entity/entity.ts} | 4 ++-- packages/hypergraph/src/entity/index.ts | 1 + packages/hypergraph/src/index.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename packages/hypergraph/src/{Entity.ts => entity/entity.ts} (99%) create mode 100644 packages/hypergraph/src/entity/index.ts diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx index 0aa6feda..2eb2dd6a 100644 --- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx +++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx @@ -3,7 +3,6 @@ import type { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo'; import { useRepo } from '@automerge/automerge-repo-react-hooks'; import { Entity, Utils } from '@graphprotocol/hypergraph'; -import type { DocumentContent } from '@graphprotocol/hypergraph/Entity'; import * as Schema from 'effect/Schema'; import { type ReactNode, createContext, useContext, useRef, useState, useSyncExternalStore } from 'react'; @@ -32,7 +31,7 @@ export function HypergraphSpaceProvider({ space, children }: { space: string; ch let current = ref.current; if (current === undefined || space !== current.space || repo !== current.repo) { const id = Utils.idToAutomergeId(space) as AnyDocumentId; - const handle = repo.find(id); + const handle = repo.find(id); current = ref.current = { space, diff --git a/packages/hypergraph/src/Entity.ts b/packages/hypergraph/src/entity/entity.ts similarity index 99% rename from packages/hypergraph/src/Entity.ts rename to packages/hypergraph/src/entity/entity.ts index ebfd9caf..be43316b 100644 --- a/packages/hypergraph/src/Entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -2,8 +2,8 @@ import type { DocHandle, Patch } from '@automerge/automerge-repo'; import * as VariantSchema from '@effect/experimental/VariantSchema'; import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; -import { generateId } from './utils/generateId.js'; -import { hasArrayField } from './utils/hasArrayField.js'; +import { generateId } from '../utils/generateId.js'; +import { hasArrayField } from '../utils/hasArrayField.js'; const { Class, diff --git a/packages/hypergraph/src/entity/index.ts b/packages/hypergraph/src/entity/index.ts new file mode 100644 index 00000000..dab908bb --- /dev/null +++ b/packages/hypergraph/src/entity/index.ts @@ -0,0 +1 @@ +export * from './entity.js'; diff --git a/packages/hypergraph/src/index.ts b/packages/hypergraph/src/index.ts index fe73c09f..9a995eca 100644 --- a/packages/hypergraph/src/index.ts +++ b/packages/hypergraph/src/index.ts @@ -1,7 +1,7 @@ +export * as Entity from './entity/index.js'; export * as Identity from './identity/index.js'; export * as Key from './key/index.js'; export * as Messages from './messages/index.js'; -export * as Entity from './Entity.js'; export * as SpaceEvents from './space-events/index.js'; export * from './store.js'; export * as Utils from './utils/index.js'; From 39f82624419d5149df01211eae6c6db7cc5fec23 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 11 Feb 2025 08:48:59 +0100 Subject: [PATCH 06/18] split up entity into multiple files --- .../src/entity/decodedEntitiesCache.ts | 30 +++++++++++ packages/hypergraph/src/entity/entity.ts | 54 +------------------ packages/hypergraph/src/entity/index.ts | 1 + packages/hypergraph/src/entity/types.ts | 18 +++++++ 4 files changed, 51 insertions(+), 52 deletions(-) create mode 100644 packages/hypergraph/src/entity/decodedEntitiesCache.ts create mode 100644 packages/hypergraph/src/entity/types.ts diff --git a/packages/hypergraph/src/entity/decodedEntitiesCache.ts b/packages/hypergraph/src/entity/decodedEntitiesCache.ts new file mode 100644 index 00000000..2a5a06b5 --- /dev/null +++ b/packages/hypergraph/src/entity/decodedEntitiesCache.ts @@ -0,0 +1,30 @@ +import type { Any, AnyNoContext, Entity } from './types.js'; + +/* +/* + * Note: Currently we only use one global cache for all entities. + * In the future we probably want a build function that creates a cache and returns the + * functions (create, update, findMany, …) that use this specific cache. + * + * How does it work? + * + * We store all decoded entities in a cache and for each query we reference the entities relevant to this query. + * Whenever a query is registered we add it to the cache and add a listener to the query. Whenever a query is unregistered we remove the listener from the query. + */ +type DecodedEntitiesCache = Map< + string, // type name + { + decoder: (data: unknown) => unknown; + type: Any; // TODO should be the type of the entity + entities: Map>; // holds all entities of this type + queries: Map< + string, // instead of serializedQueryKey as string we could also have the actual params + { + data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array + listeners: Array<() => void>; // listeners to this query + } + >; + } +>; + +export const decodedEntitiesCache: DecodedEntitiesCache = new Map(); diff --git a/packages/hypergraph/src/entity/entity.ts b/packages/hypergraph/src/entity/entity.ts index be43316b..c9a7d4ba 100644 --- a/packages/hypergraph/src/entity/entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -4,6 +4,8 @@ import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; import { generateId } from '../utils/generateId.js'; import { hasArrayField } from '../utils/hasArrayField.js'; +import { decodedEntitiesCache } from './decodedEntitiesCache.js'; +import type { AnyNoContext, Entity, Insert, Update } from './types.js'; const { Class, @@ -22,20 +24,6 @@ const { export { Class }; -export type Any = Schema.Schema.Any & { - readonly fields: Schema.Struct.Fields; - readonly insert: Schema.Schema.Any; - readonly update: Schema.Schema.Any; -}; - -export type AnyNoContext = Schema.Schema.AnyNoContext & { - readonly fields: Schema.Struct.Fields; - readonly insert: Schema.Schema.AnyNoContext; - readonly update: Schema.Schema.AnyNoContext; -}; - -export type Update = S['update']; -export type Insert = S['insert']; export interface Generated extends VariantSchema.Field<{ readonly select: S; @@ -64,44 +52,6 @@ export class EntityNotFoundError extends Data.TaggedError('EntityNotFoundError') cause?: unknown; }> {} -export type Entity = Schema.Schema.Type & { type: string }; - -/* - * Note: Currently we only use one global cache for all entities. - * In the future we probably want a build function that creates a cache and returns the - * functions (create, update, findMany, …) that use this specific cache. - * - * How does it work? - * - * We store all decoded entities in a cache and for each query we reference the entities relevant to this query. - * Whenever a query is registered we add it to the cache and add a listener to the query. Whenever a query is unregistered we remove the listener from the query. - * - * Handling filters is relatively straight forward as they are uniquely identified by their params. - * - * Questions: - * How do we handle findOne? - * Thoughts: Could be just a special case of findMany limited to a specific id or a separate mechanism. - * How do we handle relations? - * Thoughts: We could have a separate query entry for each relation, but when requesting a lot of entities e.g. 1000 only for a nesting one level deep it would result in a lot of cached queries. Not sure this is a good idea. - */ -type DecodedEntitiesCache = Map< - string, // type name - { - decoder: (data: unknown) => unknown; - type: Any; // TODO should be the type of the entity - entities: Map>; // holds all entities of this type - queries: Map< - string, // instead of serializedQueryKey as string we could also have the actual params - { - data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array - listeners: Array<() => void>; // listeners to this query - } - >; - } ->; - -const decodedEntitiesCache: DecodedEntitiesCache = new Map(); - const documentChangeListener: { subscribedQueriesCount: number; unsubscribe: undefined | (() => void); diff --git a/packages/hypergraph/src/entity/index.ts b/packages/hypergraph/src/entity/index.ts index dab908bb..311f5b67 100644 --- a/packages/hypergraph/src/entity/index.ts +++ b/packages/hypergraph/src/entity/index.ts @@ -1 +1,2 @@ export * from './entity.js'; +export * from './types.js'; diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts new file mode 100644 index 00000000..17806f27 --- /dev/null +++ b/packages/hypergraph/src/entity/types.ts @@ -0,0 +1,18 @@ +import type * as Schema from 'effect/Schema'; + +export type Any = Schema.Schema.Any & { + readonly fields: Schema.Struct.Fields; + readonly insert: Schema.Schema.Any; + readonly update: Schema.Schema.Any; +}; + +export type AnyNoContext = Schema.Schema.AnyNoContext & { + readonly fields: Schema.Struct.Fields; + readonly insert: Schema.Schema.AnyNoContext; + readonly update: Schema.Schema.AnyNoContext; +}; + +export type Update = S['update']; +export type Insert = S['insert']; + +export type Entity = Schema.Schema.Type & { type: string }; From 045fdcceb8b1895b4098ad3e54b8652dcfc8fbb8 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 11 Feb 2025 09:32:10 +0100 Subject: [PATCH 07/18] extract getEntityRelations and use it in findMany to fix hot-reloading --- .../src/entity/decodedEntitiesCache.ts | 28 +++++++------ packages/hypergraph/src/entity/entity.ts | 42 ++++--------------- .../src/entity/getEntityRelations.ts | 38 +++++++++++++++++ .../hypergraph/test/entity/entity.test.ts | 2 +- 4 files changed, 63 insertions(+), 47 deletions(-) create mode 100644 packages/hypergraph/src/entity/getEntityRelations.ts diff --git a/packages/hypergraph/src/entity/decodedEntitiesCache.ts b/packages/hypergraph/src/entity/decodedEntitiesCache.ts index 2a5a06b5..4f06db7b 100644 --- a/packages/hypergraph/src/entity/decodedEntitiesCache.ts +++ b/packages/hypergraph/src/entity/decodedEntitiesCache.ts @@ -1,4 +1,17 @@ -import type { Any, AnyNoContext, Entity } from './types.js'; +import type { AnyNoContext, Entity } from './types.js'; + +export type DecodedEntitiesCacheEntry = { + decoder: (data: unknown) => unknown; + type: AnyNoContext; // TODO should be the type of the entity + entities: Map>; // holds all entities of this type + queries: Map< + string, // instead of serializedQueryKey as string we could also have the actual params + { + data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array + listeners: Array<() => void>; // listeners to this query + } + >; +}; /* /* @@ -13,18 +26,7 @@ import type { Any, AnyNoContext, Entity } from './types.js'; */ type DecodedEntitiesCache = Map< string, // type name - { - decoder: (data: unknown) => unknown; - type: Any; // TODO should be the type of the entity - entities: Map>; // holds all entities of this type - queries: Map< - string, // instead of serializedQueryKey as string we could also have the actual params - { - data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array - listeners: Array<() => void>; // listeners to this query - } - >; - } + DecodedEntitiesCacheEntry >; export const decodedEntitiesCache: DecodedEntitiesCache = new Map(); diff --git a/packages/hypergraph/src/entity/entity.ts b/packages/hypergraph/src/entity/entity.ts index c9a7d4ba..87351510 100644 --- a/packages/hypergraph/src/entity/entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -3,8 +3,8 @@ import * as VariantSchema from '@effect/experimental/VariantSchema'; import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; import { generateId } from '../utils/generateId.js'; -import { hasArrayField } from '../utils/hasArrayField.js'; import { decodedEntitiesCache } from './decodedEntitiesCache.js'; +import { getEntityRelations } from './getEntityRelations.js'; import type { AnyNoContext, Entity, Insert, Update } from './types.js'; const { @@ -98,34 +98,7 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = const cacheEntry = decodedEntitiesCache.get(typeName); if (!cacheEntry) continue; - const relations: Record> = {}; - for (const [fieldName, field] of Object.entries(cacheEntry.type.fields)) { - // check if the type exists in the cach and is a proper relation - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const fieldCacheEntry = decodedEntitiesCache.get(field.name); - if (!fieldCacheEntry) continue; - - const relationEntities: Array> = []; - - if (hasArrayField(entity, fieldName)) { - for (const relationEntityId of entity[fieldName]) { - const relationEntity = doc.entities?.[relationEntityId]; - if ( - !relationEntity || - typeof relationEntity !== 'object' || - !('@@types@@' in relationEntity) || - !Array.isArray(relationEntity['@@types@@']) - ) - continue; - - relationEntities.push({ ...relationEntity, id: relationEntityId }); - } - } - - relations[fieldName] = relationEntities; - } - + const relations = getEntityRelations(entity, cacheEntry.type, doc); const decoded = cacheEntry.decoder({ ...entity, ...relations, @@ -294,16 +267,19 @@ export function findMany( // @ts-expect-error name is defined const typeName = type.name; - // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in - // an index and store the decoded values instead of re-decoding over and over again. - const entities = handle.docSync()?.entities ?? {}; + const doc = handle.docSync(); + if (!doc) { + return []; + } + const entities = doc.entities ?? {}; const filtered: Array> = []; for (const id in entities) { const entity = entities[id]; if (typeof entity === 'object' && entity != null && '@@types@@' in entity) { const types = entity['@@types@@']; if (Array.isArray(types) && types.includes(typeName)) { - filtered.push({ ...decode({ ...entity, id }), type: typeName }); + const relations = getEntityRelations(entity, type, doc); + filtered.push({ ...decode({ ...entity, ...relations, id }), type: typeName }); } } } diff --git a/packages/hypergraph/src/entity/getEntityRelations.ts b/packages/hypergraph/src/entity/getEntityRelations.ts new file mode 100644 index 00000000..11fb95fd --- /dev/null +++ b/packages/hypergraph/src/entity/getEntityRelations.ts @@ -0,0 +1,38 @@ +import { hasArrayField } from '../utils/hasArrayField.js'; +import type { DocumentContent } from './index.js'; +import type { AnyNoContext, Entity } from './types.js'; + +export const getEntityRelations = ( + entity: Entity, + type: S, + doc: DocumentContent, +) => { + const relations: Record> = {}; + for (const [fieldName, field] of Object.entries(type.fields)) { + // TODO: this check is a hack atm, instead check if it is a class instead of specific name + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + if (field.name !== 'ArrayClass') continue; + + const relationEntities: Array> = []; + + if (hasArrayField(entity, fieldName)) { + for (const relationEntityId of entity[fieldName]) { + const relationEntity = doc.entities?.[relationEntityId]; + if ( + !relationEntity || + typeof relationEntity !== 'object' || + !('@@types@@' in relationEntity) || + !Array.isArray(relationEntity['@@types@@']) + ) + continue; + + relationEntities.push({ ...relationEntity, id: relationEntityId }); + } + } + + relations[fieldName] = relationEntities; + } + + return relations; +}; diff --git a/packages/hypergraph/test/entity/entity.test.ts b/packages/hypergraph/test/entity/entity.test.ts index e487148e..23a1ad60 100644 --- a/packages/hypergraph/test/entity/entity.test.ts +++ b/packages/hypergraph/test/entity/entity.test.ts @@ -2,7 +2,7 @@ import type { AnyDocumentId, DocHandle } from '@automerge/automerge-repo'; import { Repo } from '@automerge/automerge-repo'; import { beforeEach, describe, expect, it } from 'vitest'; -import * as Entity from '../../src/Entity.js'; +import * as Entity from '../../src/entity/index.js'; import { idToAutomergeId } from '../../src/utils/automergeId.js'; describe('Entity', () => { From 74e812f95e4e5f634eb89fbc49dabb90c478038b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 11 Feb 2025 10:48:55 +0100 Subject: [PATCH 08/18] extract utils isReferenceField --- packages/hypergraph/src/entity/entity.ts | 12 +++--------- packages/hypergraph/src/entity/getEntityRelations.ts | 6 ++---- packages/hypergraph/src/entity/isReferenceField.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 packages/hypergraph/src/entity/isReferenceField.ts diff --git a/packages/hypergraph/src/entity/entity.ts b/packages/hypergraph/src/entity/entity.ts index 87351510..f8fe61d9 100644 --- a/packages/hypergraph/src/entity/entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -5,6 +5,7 @@ import * as Schema from 'effect/Schema'; import { generateId } from '../utils/generateId.js'; import { decodedEntitiesCache } from './decodedEntitiesCache.js'; import { getEntityRelations } from './getEntityRelations.js'; +import { isReferenceField } from './isReferenceField.js'; import type { AnyNoContext, Entity, Insert, Update } from './types.js'; const { @@ -346,15 +347,8 @@ export function subscribeToFindMany( const allTypes = new Set(); for (const [_key, field] of Object.entries(type.fields)) { - // TODO check if it is a class instead of specific name - // TODO: what's the right way to extract the name from the ast - // @ts-expect-error rest is defined - if (field.ast.rest) { - // @ts-expect-error name is defined - const typeName = field.ast.rest[0].type.to.toString(); - if (typeName === 'User') { - allTypes.add(field as S); - } + if (isReferenceField(field)) { + allTypes.add(field as S); } } diff --git a/packages/hypergraph/src/entity/getEntityRelations.ts b/packages/hypergraph/src/entity/getEntityRelations.ts index 11fb95fd..bdbcdf39 100644 --- a/packages/hypergraph/src/entity/getEntityRelations.ts +++ b/packages/hypergraph/src/entity/getEntityRelations.ts @@ -1,5 +1,6 @@ import { hasArrayField } from '../utils/hasArrayField.js'; import type { DocumentContent } from './index.js'; +import { isReferenceField } from './isReferenceField.js'; import type { AnyNoContext, Entity } from './types.js'; export const getEntityRelations = ( @@ -9,10 +10,7 @@ export const getEntityRelations = ( ) => { const relations: Record> = {}; for (const [fieldName, field] of Object.entries(type.fields)) { - // TODO: this check is a hack atm, instead check if it is a class instead of specific name - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - if (field.name !== 'ArrayClass') continue; + if (!isReferenceField(field)) continue; const relationEntities: Array> = []; diff --git a/packages/hypergraph/src/entity/isReferenceField.ts b/packages/hypergraph/src/entity/isReferenceField.ts new file mode 100644 index 00000000..b84c03e2 --- /dev/null +++ b/packages/hypergraph/src/entity/isReferenceField.ts @@ -0,0 +1,10 @@ +import type * as Schema from 'effect/Schema'; + +export const isReferenceField = (field: Schema.Schema.All | Schema.PropertySignature.All) => { + // TODO: instead we should check that the field in the array is an Entity.Class + // @ts-expect-error name is defined + if (field.name === 'ArrayClass') { + return true; + } + return false; +}; From 0691b12a1b31f823bbcdfbc458f142ba9728bff0 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 11 Feb 2025 11:06:22 +0100 Subject: [PATCH 09/18] cleanup by extracting utilities --- packages/hypergraph/src/entity/entity.ts | 21 +++++++------------ .../src/entity/getEntityRelations.ts | 9 ++------ .../src/entity/hasValidTypesProperty.ts | 8 +++++++ 3 files changed, 18 insertions(+), 20 deletions(-) create mode 100644 packages/hypergraph/src/entity/hasValidTypesProperty.ts diff --git a/packages/hypergraph/src/entity/entity.ts b/packages/hypergraph/src/entity/entity.ts index f8fe61d9..af7027a2 100644 --- a/packages/hypergraph/src/entity/entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -5,6 +5,7 @@ import * as Schema from 'effect/Schema'; import { generateId } from '../utils/generateId.js'; import { decodedEntitiesCache } from './decodedEntitiesCache.js'; import { getEntityRelations } from './getEntityRelations.js'; +import { hasValidTypesProperty } from './hasValidTypesProperty.js'; import { isReferenceField } from './isReferenceField.js'; import type { AnyNoContext, Entity, Insert, Update } from './types.js'; @@ -93,9 +94,9 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = // loop over all changed entities and update the cache for (const entityId of changedEntities) { const entity = doc.entities?.[entityId]; - if (!entity || typeof entity !== 'object' || !('@@types@@' in entity) || !Array.isArray(entity['@@types@@'])) - return; + if (!hasValidTypesProperty(entity)) continue; for (const typeName of entity['@@types@@']) { + if (typeof typeName !== 'string') continue; const cacheEntry = decodedEntitiesCache.get(typeName); if (!cacheEntry) continue; @@ -276,12 +277,9 @@ export function findMany( const filtered: Array> = []; for (const id in entities) { const entity = entities[id]; - if (typeof entity === 'object' && entity != null && '@@types@@' in entity) { - const types = entity['@@types@@']; - if (Array.isArray(types) && types.includes(typeName)) { - const relations = getEntityRelations(entity, type, doc); - filtered.push({ ...decode({ ...entity, ...relations, id }), type: typeName }); - } + if (hasValidTypesProperty(entity) && entity['@@types@@'].includes(typeName)) { + const relations = getEntityRelations(entity, type, doc); + filtered.push({ ...decode({ ...entity, ...relations, id }), type: typeName }); } } @@ -422,11 +420,8 @@ export const findOne = // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in // an index and store the decoded values instead of re-decoding over and over again. const entity = handle.docSync()?.entities?.[id]; - if (typeof entity === 'object' && entity != null && '@@types@@' in entity) { - const types = entity['@@types@@']; - if (Array.isArray(types) && types.includes(typeName)) { - return { ...decode({ ...entity, id }), type: typeName }; - } + if (hasValidTypesProperty(entity) && entity['@@types@@'].includes(typeName)) { + return { ...decode({ ...entity, id }), type: typeName }; } return undefined; diff --git a/packages/hypergraph/src/entity/getEntityRelations.ts b/packages/hypergraph/src/entity/getEntityRelations.ts index bdbcdf39..5c661038 100644 --- a/packages/hypergraph/src/entity/getEntityRelations.ts +++ b/packages/hypergraph/src/entity/getEntityRelations.ts @@ -1,4 +1,5 @@ import { hasArrayField } from '../utils/hasArrayField.js'; +import { hasValidTypesProperty } from './hasValidTypesProperty.js'; import type { DocumentContent } from './index.js'; import { isReferenceField } from './isReferenceField.js'; import type { AnyNoContext, Entity } from './types.js'; @@ -17,13 +18,7 @@ export const getEntityRelations = ( if (hasArrayField(entity, fieldName)) { for (const relationEntityId of entity[fieldName]) { const relationEntity = doc.entities?.[relationEntityId]; - if ( - !relationEntity || - typeof relationEntity !== 'object' || - !('@@types@@' in relationEntity) || - !Array.isArray(relationEntity['@@types@@']) - ) - continue; + if (!hasValidTypesProperty(relationEntity)) continue; relationEntities.push({ ...relationEntity, id: relationEntityId }); } diff --git a/packages/hypergraph/src/entity/hasValidTypesProperty.ts b/packages/hypergraph/src/entity/hasValidTypesProperty.ts new file mode 100644 index 00000000..8aa4ed63 --- /dev/null +++ b/packages/hypergraph/src/entity/hasValidTypesProperty.ts @@ -0,0 +1,8 @@ +export const hasValidTypesProperty = (value: unknown): value is Record<'@@types@@', unknown[]> => { + return ( + value !== null && + typeof value === 'object' && + '@@types@@' in value && + Array.isArray((value as { '@@types@@': unknown })['@@types@@']) + ); +}; From b2b1189865651042f76ef0c20b5f55b5991ce617 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 12 Feb 2025 16:20:02 +0100 Subject: [PATCH 10/18] first draft of relation entities invalidation parent queries --- apps/events/src/components/todos.tsx | 9 ++- .../src/entity/decodedEntitiesCache.ts | 16 +++- packages/hypergraph/src/entity/entity.ts | 81 ++++++++++++++++--- .../src/entity/relationParentQueries.ts | 6 ++ 4 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 packages/hypergraph/src/entity/relationParentQueries.ts diff --git a/apps/events/src/components/todos.tsx b/apps/events/src/components/todos.tsx index 626e7b88..541025ea 100644 --- a/apps/events/src/components/todos.tsx +++ b/apps/events/src/components/todos.tsx @@ -1,5 +1,5 @@ import { useCreateEntity, useDeleteEntity, useQueryEntities, useUpdateEntity } from '@graphprotocol/hypergraph-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Select from 'react-select'; import { Todo, User } from '../schema'; import { Button } from './ui/button'; @@ -14,6 +14,13 @@ export const Todos = () => { const [newTodoName, setNewTodoName] = useState(''); const [assignees, setAssignees] = useState<{ value: string; label: string }[]>([]); + useEffect(() => { + // filter out assignees that are not in the users array + setAssignees((prevFilteredAssignees) => { + return prevFilteredAssignees.filter((assignee) => users.some((user) => user.id === assignee.value)); + }); + }, [users]); + const userOptions = users.map((user) => ({ value: user.id, label: user.name })); return ( <> diff --git a/packages/hypergraph/src/entity/decodedEntitiesCache.ts b/packages/hypergraph/src/entity/decodedEntitiesCache.ts index 4f06db7b..1e6156dc 100644 --- a/packages/hypergraph/src/entity/decodedEntitiesCache.ts +++ b/packages/hypergraph/src/entity/decodedEntitiesCache.ts @@ -1,15 +1,18 @@ import type { AnyNoContext, Entity } from './types.js'; +export type QueryEntry = { + data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array + listeners: Array<() => void>; // listeners to this query + isInvalidated: boolean; +}; + export type DecodedEntitiesCacheEntry = { decoder: (data: unknown) => unknown; type: AnyNoContext; // TODO should be the type of the entity entities: Map>; // holds all entities of this type queries: Map< string, // instead of serializedQueryKey as string we could also have the actual params - { - data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array - listeners: Array<() => void>; // listeners to this query - } + QueryEntry >; }; @@ -30,3 +33,8 @@ type DecodedEntitiesCache = Map< >; export const decodedEntitiesCache: DecodedEntitiesCache = new Map(); + +export const relationParentQueries: Map< + string, // entity ID + Array +> = new Map(); diff --git a/packages/hypergraph/src/entity/entity.ts b/packages/hypergraph/src/entity/entity.ts index af7027a2..b400e73b 100644 --- a/packages/hypergraph/src/entity/entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -3,10 +3,11 @@ import * as VariantSchema from '@effect/experimental/VariantSchema'; import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; import { generateId } from '../utils/generateId.js'; -import { decodedEntitiesCache } from './decodedEntitiesCache.js'; +import { type QueryEntry, decodedEntitiesCache } from './decodedEntitiesCache.js'; import { getEntityRelations } from './getEntityRelations.js'; import { hasValidTypesProperty } from './hasValidTypesProperty.js'; import { isReferenceField } from './isReferenceField.js'; +import { relationParentQueries } from './relationParentQueries.js'; import type { AnyNoContext, Entity, Insert, Update } from './types.js'; const { @@ -91,6 +92,8 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = // reference to reduce the amount of O(n) operations per query to 1 const touchedQueries = new Set>(); + const touchedRelationParentQueries = new Set(); + // loop over all changed entities and update the cache for (const entityId of changedEntities) { const entity = doc.entities?.[entityId]; @@ -117,9 +120,34 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = query.data.push(decoded); } touchedQueries.add([typeName, 'all']); + + // @ts-expect-error decoded is a valid object + for (const [key, value] of Object.entries(decoded)) { + if (Array.isArray(value)) { + for (const relationEntity of value) { + let relationParentQueriesEntry = relationParentQueries.get(relationEntity.id); + if (!relationParentQueriesEntry) { + relationParentQueriesEntry = []; + relationParentQueries.set(relationEntity.id, relationParentQueriesEntry); + } + + relationParentQueriesEntry.push(query); + } + } + } } entityTypes.add(typeName); + + // gather all the queries of impacted parent relation queries + if (relationParentQueries.has(entityId)) { + const queries = relationParentQueries.get(entityId); + if (!queries) return; + + for (const query of queries) { + touchedRelationParentQueries.add(query); + } + } } } @@ -140,6 +168,16 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = } } } + + // gather all the queries of impacted parent relation queries + if (relationParentQueries.has(entityId)) { + const queries = relationParentQueries.get(entityId); + if (!queries) return; + + for (const query of queries) { + touchedRelationParentQueries.add(query); + } + } } for (const [typeName, queryKey] of touchedQueries) { @@ -163,6 +201,15 @@ export const subscribeToDocumentChanges = (handle: DocHandle) = } } } + + // trigger all the listeners of the parent relation queries + // TODO: align with the touchedQueries to avoid unnecessary trigger calls + for (const query of touchedRelationParentQueries) { + query.isInvalidated = true; + for (const listener of query.listeners) { + listener(); + } + } }; handle.on('change', onChange); @@ -286,6 +333,8 @@ export function findMany( return filtered; } +const stableEmptyArray: Array = []; + export function subscribeToFindMany( handle: DocHandle, type: S, @@ -300,16 +349,15 @@ export function subscribeToFindMany( const typeName = type.name; const getEntities = () => { - return decodedEntitiesCache.get(typeName)?.queries.get(queryKey)?.data ?? []; - }; - - const entities = findMany(handle, type); - - if (decodedEntitiesCache.has(typeName)) { - // add a listener to the existing query const cacheEntry = decodedEntitiesCache.get(typeName); const query = cacheEntry?.queries.get(queryKey); + if (!query) return stableEmptyArray; + if (!query.isInvalidated) { + return query.data; + } + + const entities = findMany(handle, type); for (const entity of entities) { cacheEntry?.entities.set(entity.id, entity); @@ -322,17 +370,26 @@ export function subscribeToFindMany( query.data.push(entity); } } - } else { + + query.isInvalidated = false; + return query.data; + }; + + if (!decodedEntitiesCache.has(typeName)) { + const entities = findMany(handle, type); const entitiesMap = new Map(); + const relationParentQueries = new Map(); for (const entity of entities) { entitiesMap.set(entity.id, entity); + relationParentQueries.set(entity.id, new Map()); } - const queries = new Map(); + const queries = new Map(); queries.set(queryKey, { - data: entities, + data: [...entities], listeners: [], + isInvalidated: false, }); decodedEntitiesCache.set(typeName, { @@ -365,8 +422,10 @@ export function subscribeToFindMany( } } else { const entitiesMap = new Map(); + const relationParentQueries = new Map(); for (const entity of entities) { entitiesMap.set(entity.id, entity); + relationParentQueries.set(entity.id, new Map()); } decodedEntitiesCache.set(typeName, { diff --git a/packages/hypergraph/src/entity/relationParentQueries.ts b/packages/hypergraph/src/entity/relationParentQueries.ts new file mode 100644 index 00000000..cec42797 --- /dev/null +++ b/packages/hypergraph/src/entity/relationParentQueries.ts @@ -0,0 +1,6 @@ +import type { QueryEntry } from './decodedEntitiesCache.js'; + +export const relationParentQueries: Map< + string, // entity ID + Array +> = new Map(); From 5085d977520c25366cefeb99082cf6391a2ed237 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 12 Feb 2025 17:01:49 +0100 Subject: [PATCH 11/18] split up entity.ts --- apps/events/src/components/todos.tsx | 2 +- packages/hypergraph/src/entity/create.ts | 26 + .../src/entity/decodedEntitiesCache.ts | 5 - packages/hypergraph/src/entity/delete.ts | 23 + packages/hypergraph/src/entity/entity.ts | 444 +----------------- packages/hypergraph/src/entity/findMany.ts | 333 +++++++++++++ packages/hypergraph/src/entity/findOne.ts | 26 + .../src/entity/getEntityRelations.ts | 3 +- packages/hypergraph/src/entity/index.ts | 5 + packages/hypergraph/src/entity/types.ts | 4 + packages/hypergraph/src/entity/update.ts | 45 ++ 11 files changed, 465 insertions(+), 451 deletions(-) create mode 100644 packages/hypergraph/src/entity/create.ts create mode 100644 packages/hypergraph/src/entity/delete.ts create mode 100644 packages/hypergraph/src/entity/findMany.ts create mode 100644 packages/hypergraph/src/entity/findOne.ts create mode 100644 packages/hypergraph/src/entity/update.ts diff --git a/apps/events/src/components/todos.tsx b/apps/events/src/components/todos.tsx index 541025ea..3bc61f04 100644 --- a/apps/events/src/components/todos.tsx +++ b/apps/events/src/components/todos.tsx @@ -15,8 +15,8 @@ export const Todos = () => { const [assignees, setAssignees] = useState<{ value: string; label: string }[]>([]); useEffect(() => { - // filter out assignees that are not in the users array setAssignees((prevFilteredAssignees) => { + // filter out assignees that are not in the users array whenever users change return prevFilteredAssignees.filter((assignee) => users.some((user) => user.id === assignee.value)); }); }, [users]); diff --git a/packages/hypergraph/src/entity/create.ts b/packages/hypergraph/src/entity/create.ts new file mode 100644 index 00000000..6c25355e --- /dev/null +++ b/packages/hypergraph/src/entity/create.ts @@ -0,0 +1,26 @@ +import type { DocHandle } from '@automerge/automerge-repo'; +import * as Schema from 'effect/Schema'; +import { generateId } from '../utils/generateId.js'; +import type { AnyNoContext, DocumentContent, Entity, Insert } from './types.js'; + +/** + * Creates an entity model of given type and stores it in the repo. + */ +export const create = (handle: DocHandle, type: S) => { + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + const entityId = generateId(); + const encode = Schema.encodeSync(type.insert); + + return (data: Readonly>>): Entity => { + const encoded = encode(data); + // apply changes to the repo -> adds the entity to the repo entities document + handle.change((doc) => { + doc.entities ??= {}; + doc.entities[entityId] = { ...encoded, '@@types@@': [typeName] }; + }); + + return { id: entityId, ...encoded, type: typeName }; + }; +}; diff --git a/packages/hypergraph/src/entity/decodedEntitiesCache.ts b/packages/hypergraph/src/entity/decodedEntitiesCache.ts index 1e6156dc..faf41f3d 100644 --- a/packages/hypergraph/src/entity/decodedEntitiesCache.ts +++ b/packages/hypergraph/src/entity/decodedEntitiesCache.ts @@ -33,8 +33,3 @@ type DecodedEntitiesCache = Map< >; export const decodedEntitiesCache: DecodedEntitiesCache = new Map(); - -export const relationParentQueries: Map< - string, // entity ID - Array -> = new Map(); diff --git a/packages/hypergraph/src/entity/delete.ts b/packages/hypergraph/src/entity/delete.ts new file mode 100644 index 00000000..d51610c5 --- /dev/null +++ b/packages/hypergraph/src/entity/delete.ts @@ -0,0 +1,23 @@ +import type { DocHandle } from '@automerge/automerge-repo'; +import type { DocumentContent } from './types.js'; + +/** + * Deletes the exiting entity from the repo. + */ +export const delete$ = (handle: DocHandle) => { + return (id: string): boolean => { + let result = false; + + // apply changes to the repo -> removes the existing entity by its id + handle.change((doc) => { + if (doc.entities?.[id] !== undefined) { + delete doc.entities[id]; + result = true; + } + }); + + return result; + }; +}; + +export { delete$ as delete }; diff --git a/packages/hypergraph/src/entity/entity.ts b/packages/hypergraph/src/entity/entity.ts index b400e73b..be63afa9 100644 --- a/packages/hypergraph/src/entity/entity.ts +++ b/packages/hypergraph/src/entity/entity.ts @@ -1,14 +1,7 @@ -import type { DocHandle, Patch } from '@automerge/automerge-repo'; import * as VariantSchema from '@effect/experimental/VariantSchema'; import * as Data from 'effect/Data'; import * as Schema from 'effect/Schema'; -import { generateId } from '../utils/generateId.js'; -import { type QueryEntry, decodedEntitiesCache } from './decodedEntitiesCache.js'; -import { getEntityRelations } from './getEntityRelations.js'; -import { hasValidTypesProperty } from './hasValidTypesProperty.js'; -import { isReferenceField } from './isReferenceField.js'; -import { relationParentQueries } from './relationParentQueries.js'; -import type { AnyNoContext, Entity, Insert, Update } from './types.js'; +import type { AnyNoContext } from './types.js'; const { Class, @@ -45,447 +38,12 @@ export const Text = Schema.String; export const Number = Schema.Number; export const Checkbox = Schema.Boolean; -export type DocumentContent = { - entities?: Record; -}; - export class EntityNotFoundError extends Data.TaggedError('EntityNotFoundError')<{ id: string; type: AnyNoContext; cause?: unknown; }> {} -const documentChangeListener: { - subscribedQueriesCount: number; - unsubscribe: undefined | (() => void); -} = { - subscribedQueriesCount: 0, - unsubscribe: undefined, -}; - -export const subscribeToDocumentChanges = (handle: DocHandle) => { - const onChange = ({ patches, doc }: { patches: Array; doc: DocumentContent }) => { - const changedEntities = new Set(); - const deletedEntities = new Set(); - - for (const patch of patches) { - switch (patch.action) { - case 'put': - case 'insert': - case 'splice': { - if (patch.path.length > 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') { - changedEntities.add(patch.path[1]); - } - break; - } - case 'del': { - if (patch.path.length === 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') { - deletedEntities.add(patch.path[1]); - } - break; - } - } - } - - const entityTypes = new Set(); - // collect all query entries that changed and only at the end make one copy to change the - // reference to reduce the amount of O(n) operations per query to 1 - const touchedQueries = new Set>(); - - const touchedRelationParentQueries = new Set(); - - // loop over all changed entities and update the cache - for (const entityId of changedEntities) { - const entity = doc.entities?.[entityId]; - if (!hasValidTypesProperty(entity)) continue; - for (const typeName of entity['@@types@@']) { - if (typeof typeName !== 'string') continue; - const cacheEntry = decodedEntitiesCache.get(typeName); - if (!cacheEntry) continue; - - const relations = getEntityRelations(entity, cacheEntry.type, doc); - const decoded = cacheEntry.decoder({ - ...entity, - ...relations, - id: entityId, - }); - cacheEntry.entities.set(entityId, decoded); - - const query = cacheEntry.queries.get('all'); - if (query) { - const index = query.data.findIndex((entity) => entity.id === entityId); - if (index !== -1) { - query.data[index] = decoded; - } else { - query.data.push(decoded); - } - touchedQueries.add([typeName, 'all']); - - // @ts-expect-error decoded is a valid object - for (const [key, value] of Object.entries(decoded)) { - if (Array.isArray(value)) { - for (const relationEntity of value) { - let relationParentQueriesEntry = relationParentQueries.get(relationEntity.id); - if (!relationParentQueriesEntry) { - relationParentQueriesEntry = []; - relationParentQueries.set(relationEntity.id, relationParentQueriesEntry); - } - - relationParentQueriesEntry.push(query); - } - } - } - } - - entityTypes.add(typeName); - - // gather all the queries of impacted parent relation queries - if (relationParentQueries.has(entityId)) { - const queries = relationParentQueries.get(entityId); - if (!queries) return; - - for (const query of queries) { - touchedRelationParentQueries.add(query); - } - } - } - } - - // loop over all deleted entities and remove them from the cache - for (const entityId of deletedEntities) { - for (const [affectedTypeName, cacheEntry] of decodedEntitiesCache) { - if (cacheEntry.entities.has(entityId)) { - entityTypes.add(affectedTypeName); - cacheEntry.entities.delete(entityId); - - for (const [, query] of cacheEntry.queries) { - // find the entity in the query and remove it using splice - const index = query.data.findIndex((entity) => entity.id === entityId); - if (index !== -1) { - query.data.splice(index, 1); - touchedQueries.add([affectedTypeName, 'all']); - } - } - } - } - - // gather all the queries of impacted parent relation queries - if (relationParentQueries.has(entityId)) { - const queries = relationParentQueries.get(entityId); - if (!queries) return; - - for (const query of queries) { - touchedRelationParentQueries.add(query); - } - } - } - - for (const [typeName, queryKey] of touchedQueries) { - const cacheEntry = decodedEntitiesCache.get(typeName); - if (!cacheEntry) continue; - - const query = cacheEntry.queries.get(queryKey); - if (!query) continue; - - query.data = [...query.data]; // must be a new reference for React.useSyncExternalStore - } - - // invoke all the listeners per type - for (const typeName of entityTypes) { - const cacheEntry = decodedEntitiesCache.get(typeName); - if (!cacheEntry) continue; - - for (const query of cacheEntry.queries.values()) { - for (const listener of query.listeners) { - listener(); - } - } - } - - // trigger all the listeners of the parent relation queries - // TODO: align with the touchedQueries to avoid unnecessary trigger calls - for (const query of touchedRelationParentQueries) { - query.isInvalidated = true; - for (const listener of query.listeners) { - listener(); - } - } - }; - - handle.on('change', onChange); - - return () => { - handle.off('change', onChange); - decodedEntitiesCache.clear(); // currently we only support exactly one space - }; -}; - -/** - * Creates an entity model of given type and stores it in the repo. - */ -export const create = (handle: DocHandle, type: S) => { - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - const entityId = generateId(); - const encode = Schema.encodeSync(type.insert); - - return (data: Readonly>>): Entity => { - const encoded = encode(data); - // apply changes to the repo -> adds the entity to the repo entities document - handle.change((doc) => { - doc.entities ??= {}; - doc.entities[entityId] = { ...encoded, '@@types@@': [typeName] }; - }); - - return { id: entityId, ...encoded, type: typeName }; - }; -}; - -/** - * Update an existing entity model of given type in the repo. - */ -export const update = (handle: DocHandle, type: S) => { - const validate = Schema.validateSync(Schema.partial(type.update)); - const encode = Schema.encodeSync(type.update); - const decode = Schema.decodeUnknownSync(type.update); - - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - - return (id: string, data: Schema.Simplify>>>): Entity => { - validate(data); - - // apply changes to the repo -> updates the existing entity to the repo entities document - let updated: Schema.Schema.Type | undefined = undefined; - handle.change((doc) => { - if (doc.entities === undefined) { - return; - } - - // TODO: Fetch the pre-decoded value from the local cache. - const entity = doc.entities[id] ?? undefined; - if (entity === undefined || typeof entity !== 'object') { - return; - } - - // TODO: Try to get a diff of the entity properties and only override the changed ones. - updated = { ...decode(entity), ...data }; - doc.entities[id] = { ...encode(updated), '@@types@@': [typeName] }; - }); - - if (updated === undefined) { - throw new EntityNotFoundError({ id, type }); - } - - return { id, type: typeName, ...(updated as Schema.Schema.Type) }; - }; -}; - -/** - * Deletes the exiting entity from the repo. - */ -const delete$ = (handle: DocHandle) => { - return (id: string): boolean => { - let result = false; - - // apply changes to the repo -> removes the existing entity by its id - handle.change((doc) => { - if (doc.entities?.[id] !== undefined) { - delete doc.entities[id]; - result = true; - } - }); - - return result; - }; -}; - -export { delete$ as delete }; - -/** - * Queries for a list of entities of the given type from the repo. - */ -export function findMany( - handle: DocHandle, - type: S, -): Readonly>> { - const decode = Schema.decodeUnknownSync(type); - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - - const doc = handle.docSync(); - if (!doc) { - return []; - } - const entities = doc.entities ?? {}; - const filtered: Array> = []; - for (const id in entities) { - const entity = entities[id]; - if (hasValidTypesProperty(entity) && entity['@@types@@'].includes(typeName)) { - const relations = getEntityRelations(entity, type, doc); - filtered.push({ ...decode({ ...entity, ...relations, id }), type: typeName }); - } - } - - return filtered; -} - -const stableEmptyArray: Array = []; - -export function subscribeToFindMany( - handle: DocHandle, - type: S, -): { - subscribe: (callback: () => void) => () => void; - getEntities: () => Readonly>>; -} { - const queryKey = 'all'; - const decode = Schema.decodeUnknownSync(type); - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - - const getEntities = () => { - const cacheEntry = decodedEntitiesCache.get(typeName); - const query = cacheEntry?.queries.get(queryKey); - if (!query) return stableEmptyArray; - - if (!query.isInvalidated) { - return query.data; - } - - const entities = findMany(handle, type); - for (const entity of entities) { - cacheEntry?.entities.set(entity.id, entity); - - if (!query) continue; - - const index = query.data.findIndex((e) => e.id === entity.id); - if (index !== -1) { - query.data[index] = entity; - } else { - query.data.push(entity); - } - } - - query.isInvalidated = false; - return query.data; - }; - - if (!decodedEntitiesCache.has(typeName)) { - const entities = findMany(handle, type); - const entitiesMap = new Map(); - const relationParentQueries = new Map(); - for (const entity of entities) { - entitiesMap.set(entity.id, entity); - relationParentQueries.set(entity.id, new Map()); - } - - const queries = new Map(); - - queries.set(queryKey, { - data: [...entities], - listeners: [], - isInvalidated: false, - }); - - decodedEntitiesCache.set(typeName, { - decoder: decode, - type, - entities: entitiesMap, - queries, - }); - } - - const allTypes = new Set(); - for (const [_key, field] of Object.entries(type.fields)) { - if (isReferenceField(field)) { - allTypes.add(field as S); - } - } - - for (const type of allTypes) { - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - const entities = findMany(handle, type); - - if (decodedEntitiesCache.has(typeName)) { - // add a listener to the existing query - const cacheEntry = decodedEntitiesCache.get(typeName); - - for (const entity of entities) { - cacheEntry?.entities.set(entity.id, entity); - } - } else { - const entitiesMap = new Map(); - const relationParentQueries = new Map(); - for (const entity of entities) { - entitiesMap.set(entity.id, entity); - relationParentQueries.set(entity.id, new Map()); - } - - decodedEntitiesCache.set(typeName, { - decoder: decode, - type, - entities: entitiesMap, - queries: new Map(), - }); - } - } - - const subscribe = (callback: () => void) => { - const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); - if (query?.listeners) { - query.listeners.push(callback); - } - return () => { - const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); - if (query?.listeners) { - query.listeners = query?.listeners?.filter((cachedListener) => cachedListener !== callback); - } - - documentChangeListener.subscribedQueriesCount--; - if (documentChangeListener.subscribedQueriesCount === 0) { - documentChangeListener.unsubscribe?.(); - documentChangeListener.unsubscribe = undefined; - } - }; - }; - - if (documentChangeListener.subscribedQueriesCount === 0) { - documentChangeListener.unsubscribe = subscribeToDocumentChanges(handle); - } - documentChangeListener.subscribedQueriesCount++; - - return { subscribe, getEntities }; -} - -/** - * Find the entity of the given type, with the given id, from the repo. - */ -export const findOne = - (handle: DocHandle, type: S) => - (id: string): Entity | undefined => { - const decode = Schema.decodeUnknownSync(type); - - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - - // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in - // an index and store the decoded values instead of re-decoding over and over again. - const entity = handle.docSync()?.entities?.[id]; - if (hasValidTypesProperty(entity) && entity['@@types@@'].includes(typeName)) { - return { ...decode({ ...entity, id }), type: typeName }; - } - - return undefined; - }; - export const Reference = (schema: S) => Field({ select: schema, diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts new file mode 100644 index 00000000..aaddeb56 --- /dev/null +++ b/packages/hypergraph/src/entity/findMany.ts @@ -0,0 +1,333 @@ +import type { DocHandle, Patch } from '@automerge/automerge-repo'; +import * as Schema from 'effect/Schema'; +import { type QueryEntry, decodedEntitiesCache } from './decodedEntitiesCache.js'; +import { getEntityRelations } from './getEntityRelations.js'; +import { hasValidTypesProperty } from './hasValidTypesProperty.js'; +import { isReferenceField } from './isReferenceField.js'; +import { relationParentQueries } from './relationParentQueries.js'; +import type { AnyNoContext, DocumentContent, Entity } from './types.js'; + +const documentChangeListener: { + subscribedQueriesCount: number; + unsubscribe: undefined | (() => void); +} = { + subscribedQueriesCount: 0, + unsubscribe: undefined, +}; + +const subscribeToDocumentChanges = (handle: DocHandle) => { + const onChange = ({ patches, doc }: { patches: Array; doc: DocumentContent }) => { + const changedEntities = new Set(); + const deletedEntities = new Set(); + + for (const patch of patches) { + switch (patch.action) { + case 'put': + case 'insert': + case 'splice': { + if (patch.path.length > 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') { + changedEntities.add(patch.path[1]); + } + break; + } + case 'del': { + if (patch.path.length === 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') { + deletedEntities.add(patch.path[1]); + } + break; + } + } + } + + const entityTypes = new Set(); + // collect all query entries that changed and only at the end make one copy to change the + // reference to reduce the amount of O(n) operations per query to 1 + const touchedQueries = new Set>(); + + const touchedRelationParentQueries = new Set(); + + // loop over all changed entities and update the cache + for (const entityId of changedEntities) { + const entity = doc.entities?.[entityId]; + if (!hasValidTypesProperty(entity)) continue; + for (const typeName of entity['@@types@@']) { + if (typeof typeName !== 'string') continue; + const cacheEntry = decodedEntitiesCache.get(typeName); + if (!cacheEntry) continue; + + const relations = getEntityRelations(entity, cacheEntry.type, doc); + const decoded = cacheEntry.decoder({ + ...entity, + ...relations, + id: entityId, + }); + cacheEntry.entities.set(entityId, decoded); + + const query = cacheEntry.queries.get('all'); + if (query) { + const index = query.data.findIndex((entity) => entity.id === entityId); + if (index !== -1) { + query.data[index] = decoded; + } else { + query.data.push(decoded); + } + touchedQueries.add([typeName, 'all']); + + // @ts-expect-error decoded is a valid object + for (const [key, value] of Object.entries(decoded)) { + if (Array.isArray(value)) { + for (const relationEntity of value) { + let relationParentQueriesEntry = relationParentQueries.get(relationEntity.id); + if (!relationParentQueriesEntry) { + relationParentQueriesEntry = []; + relationParentQueries.set(relationEntity.id, relationParentQueriesEntry); + } + + relationParentQueriesEntry.push(query); + } + } + } + } + + entityTypes.add(typeName); + + // gather all the queries of impacted parent relation queries + if (relationParentQueries.has(entityId)) { + const queries = relationParentQueries.get(entityId); + if (!queries) return; + + for (const query of queries) { + touchedRelationParentQueries.add(query); + } + } + } + } + + // loop over all deleted entities and remove them from the cache + for (const entityId of deletedEntities) { + for (const [affectedTypeName, cacheEntry] of decodedEntitiesCache) { + if (cacheEntry.entities.has(entityId)) { + entityTypes.add(affectedTypeName); + cacheEntry.entities.delete(entityId); + + for (const [, query] of cacheEntry.queries) { + // find the entity in the query and remove it using splice + const index = query.data.findIndex((entity) => entity.id === entityId); + if (index !== -1) { + query.data.splice(index, 1); + touchedQueries.add([affectedTypeName, 'all']); + } + } + } + } + + // gather all the queries of impacted parent relation queries + if (relationParentQueries.has(entityId)) { + const queries = relationParentQueries.get(entityId); + if (!queries) return; + + for (const query of queries) { + touchedRelationParentQueries.add(query); + } + } + } + + for (const [typeName, queryKey] of touchedQueries) { + const cacheEntry = decodedEntitiesCache.get(typeName); + if (!cacheEntry) continue; + + const query = cacheEntry.queries.get(queryKey); + if (!query) continue; + + query.data = [...query.data]; // must be a new reference for React.useSyncExternalStore + } + + // invoke all the listeners per type + for (const typeName of entityTypes) { + const cacheEntry = decodedEntitiesCache.get(typeName); + if (!cacheEntry) continue; + + for (const query of cacheEntry.queries.values()) { + for (const listener of query.listeners) { + listener(); + } + } + } + + // trigger all the listeners of the parent relation queries + // TODO: align with the touchedQueries to avoid unnecessary trigger calls + for (const query of touchedRelationParentQueries) { + query.isInvalidated = true; + for (const listener of query.listeners) { + listener(); + } + } + }; + + handle.on('change', onChange); + + return () => { + handle.off('change', onChange); + decodedEntitiesCache.clear(); // currently we only support exactly one space + }; +}; + +/** + * Queries for a list of entities of the given type from the repo. + */ +export function findMany( + handle: DocHandle, + type: S, +): Readonly>> { + const decode = Schema.decodeUnknownSync(type); + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + + const doc = handle.docSync(); + if (!doc) { + return []; + } + const entities = doc.entities ?? {}; + const filtered: Array> = []; + for (const id in entities) { + const entity = entities[id]; + if (hasValidTypesProperty(entity) && entity['@@types@@'].includes(typeName)) { + const relations = getEntityRelations(entity, type, doc); + filtered.push({ ...decode({ ...entity, ...relations, id }), type: typeName }); + } + } + + return filtered; +} + +const stableEmptyArray: Array = []; + +export function subscribeToFindMany( + handle: DocHandle, + type: S, +): { + subscribe: (callback: () => void) => () => void; + getEntities: () => Readonly>>; +} { + const queryKey = 'all'; + const decode = Schema.decodeUnknownSync(type); + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + + const getEntities = () => { + const cacheEntry = decodedEntitiesCache.get(typeName); + const query = cacheEntry?.queries.get(queryKey); + if (!query) return stableEmptyArray; + + if (!query.isInvalidated) { + return query.data; + } + + const entities = findMany(handle, type); + for (const entity of entities) { + cacheEntry?.entities.set(entity.id, entity); + + if (!query) continue; + + const index = query.data.findIndex((e) => e.id === entity.id); + if (index !== -1) { + query.data[index] = entity; + } else { + query.data.push(entity); + } + } + + query.isInvalidated = false; + return query.data; + }; + + if (!decodedEntitiesCache.has(typeName)) { + const entities = findMany(handle, type); + const entitiesMap = new Map(); + const relationParentQueries = new Map(); + for (const entity of entities) { + entitiesMap.set(entity.id, entity); + relationParentQueries.set(entity.id, new Map()); + } + + const queries = new Map(); + + queries.set(queryKey, { + data: [...entities], + listeners: [], + isInvalidated: false, + }); + + decodedEntitiesCache.set(typeName, { + decoder: decode, + type, + entities: entitiesMap, + queries, + }); + } + + const allTypes = new Set(); + for (const [_key, field] of Object.entries(type.fields)) { + if (isReferenceField(field)) { + allTypes.add(field as S); + } + } + + for (const type of allTypes) { + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + const entities = findMany(handle, type); + + if (decodedEntitiesCache.has(typeName)) { + // add a listener to the existing query + const cacheEntry = decodedEntitiesCache.get(typeName); + + for (const entity of entities) { + cacheEntry?.entities.set(entity.id, entity); + } + } else { + const entitiesMap = new Map(); + const relationParentQueries = new Map(); + for (const entity of entities) { + entitiesMap.set(entity.id, entity); + relationParentQueries.set(entity.id, new Map()); + } + + decodedEntitiesCache.set(typeName, { + decoder: decode, + type, + entities: entitiesMap, + queries: new Map(), + }); + } + } + + const subscribe = (callback: () => void) => { + const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); + if (query?.listeners) { + query.listeners.push(callback); + } + return () => { + const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); + if (query?.listeners) { + query.listeners = query?.listeners?.filter((cachedListener) => cachedListener !== callback); + } + + documentChangeListener.subscribedQueriesCount--; + if (documentChangeListener.subscribedQueriesCount === 0) { + documentChangeListener.unsubscribe?.(); + documentChangeListener.unsubscribe = undefined; + } + }; + }; + + if (documentChangeListener.subscribedQueriesCount === 0) { + documentChangeListener.unsubscribe = subscribeToDocumentChanges(handle); + } + documentChangeListener.subscribedQueriesCount++; + + return { subscribe, getEntities }; +} diff --git a/packages/hypergraph/src/entity/findOne.ts b/packages/hypergraph/src/entity/findOne.ts new file mode 100644 index 00000000..f2c3d2ba --- /dev/null +++ b/packages/hypergraph/src/entity/findOne.ts @@ -0,0 +1,26 @@ +import type { DocHandle } from '@automerge/automerge-repo'; +import * as Schema from 'effect/Schema'; +import { hasValidTypesProperty } from './hasValidTypesProperty.js'; +import type { AnyNoContext, DocumentContent, Entity } from './types.js'; + +/** + * Find the entity of the given type, with the given id, from the repo. + */ +export const findOne = + (handle: DocHandle, type: S) => + (id: string): Entity | undefined => { + const decode = Schema.decodeUnknownSync(type); + + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + + // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in + // an index and store the decoded values instead of re-decoding over and over again. + const entity = handle.docSync()?.entities?.[id]; + if (hasValidTypesProperty(entity) && entity['@@types@@'].includes(typeName)) { + return { ...decode({ ...entity, id }), type: typeName }; + } + + return undefined; + }; diff --git a/packages/hypergraph/src/entity/getEntityRelations.ts b/packages/hypergraph/src/entity/getEntityRelations.ts index 5c661038..7c71a681 100644 --- a/packages/hypergraph/src/entity/getEntityRelations.ts +++ b/packages/hypergraph/src/entity/getEntityRelations.ts @@ -1,8 +1,7 @@ import { hasArrayField } from '../utils/hasArrayField.js'; import { hasValidTypesProperty } from './hasValidTypesProperty.js'; -import type { DocumentContent } from './index.js'; import { isReferenceField } from './isReferenceField.js'; -import type { AnyNoContext, Entity } from './types.js'; +import type { AnyNoContext, DocumentContent, Entity } from './types.js'; export const getEntityRelations = ( entity: Entity, diff --git a/packages/hypergraph/src/entity/index.ts b/packages/hypergraph/src/entity/index.ts index 311f5b67..92f4dd64 100644 --- a/packages/hypergraph/src/entity/index.ts +++ b/packages/hypergraph/src/entity/index.ts @@ -1,2 +1,7 @@ +export * from './create.js'; +export * from './delete.js'; export * from './entity.js'; +export * from './findMany.js'; +export * from './findOne.js'; export * from './types.js'; +export * from './update.js'; diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts index 17806f27..bde6c249 100644 --- a/packages/hypergraph/src/entity/types.ts +++ b/packages/hypergraph/src/entity/types.ts @@ -16,3 +16,7 @@ export type Update = S['update']; export type Insert = S['insert']; export type Entity = Schema.Schema.Type & { type: string }; + +export type DocumentContent = { + entities?: Record; +}; diff --git a/packages/hypergraph/src/entity/update.ts b/packages/hypergraph/src/entity/update.ts new file mode 100644 index 00000000..d8b2ff5f --- /dev/null +++ b/packages/hypergraph/src/entity/update.ts @@ -0,0 +1,45 @@ +import type { DocHandle } from '@automerge/automerge-repo'; +import * as Schema from 'effect/Schema'; +import { EntityNotFoundError } from './entity.js'; +import type { AnyNoContext, DocumentContent, Entity, Update } from './types.js'; + +/** + * Update an existing entity model of given type in the repo. + */ +export const update = (handle: DocHandle, type: S) => { + const validate = Schema.validateSync(Schema.partial(type.update)); + const encode = Schema.encodeSync(type.update); + const decode = Schema.decodeUnknownSync(type.update); + + // TODO: what's the right way to get the name of the type? + // @ts-expect-error name is defined + const typeName = type.name; + + return (id: string, data: Schema.Simplify>>>): Entity => { + validate(data); + + // apply changes to the repo -> updates the existing entity to the repo entities document + let updated: Schema.Schema.Type | undefined = undefined; + handle.change((doc) => { + if (doc.entities === undefined) { + return; + } + + // TODO: Fetch the pre-decoded value from the local cache. + const entity = doc.entities[id] ?? undefined; + if (entity === undefined || typeof entity !== 'object') { + return; + } + + // TODO: Try to get a diff of the entity properties and only override the changed ones. + updated = { ...decode(entity), ...data }; + doc.entities[id] = { ...encode(updated), '@@types@@': [typeName] }; + }); + + if (updated === undefined) { + throw new EntityNotFoundError({ id, type }); + } + + return { id, type: typeName, ...(updated as Schema.Schema.Type) }; + }; +}; From 8417d3b4ee0884d8529f632a4033a49ac29af10b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 12 Feb 2025 17:16:09 +0100 Subject: [PATCH 12/18] add user editing for todos example --- apps/events/src/components/user-entry.tsx | 25 +++++++++++++++++++++++ apps/events/src/components/users.tsx | 17 ++++++--------- 2 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 apps/events/src/components/user-entry.tsx diff --git a/apps/events/src/components/user-entry.tsx b/apps/events/src/components/user-entry.tsx new file mode 100644 index 00000000..61457ffd --- /dev/null +++ b/apps/events/src/components/user-entry.tsx @@ -0,0 +1,25 @@ +import { useDeleteEntity, useUpdateEntity } from '@graphprotocol/hypergraph-react'; +import { useState } from 'react'; +import { User } from '../schema.js'; +import { Button } from './ui/button'; +import { Input } from './ui/input.js'; + +export const UserEntry = (user: User) => { + const deleteEntity = useDeleteEntity(); + const updateEntity = useUpdateEntity(User); + const [editMode, setEditMode] = useState(false); + + return ( +
+

+ {user.name} ({user.id}) +

+ + + + {editMode && ( + updateEntity(user.id, { name: e.target.value })} /> + )} +
+ ); +}; diff --git a/apps/events/src/components/users.tsx b/apps/events/src/components/users.tsx index 24c9da64..5f892526 100644 --- a/apps/events/src/components/users.tsx +++ b/apps/events/src/components/users.tsx @@ -1,13 +1,13 @@ -import { useCreateEntity, useDeleteEntity, useQueryEntities } from '@graphprotocol/hypergraph-react'; +import { useCreateEntity, useQueryEntities } from '@graphprotocol/hypergraph-react'; import { useState } from 'react'; -import { User } from '../schema'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; +import { User } from '../schema.js'; +import { Button } from './ui/button.js'; +import { Input } from './ui/input.js'; +import { UserEntry } from './user-entry.js'; export const Users = () => { const users = useQueryEntities(User); const createEntity = useCreateEntity(User); - const deleteEntity = useDeleteEntity(); const [newUserName, setNewUserName] = useState(''); return ( @@ -25,12 +25,7 @@ export const Users = () => {
{users.map((user) => ( -
-

- {user.name} ({user.id}) -

- -
+ ))} ); From db8ef62e24b98e724e2f37dc2dcd54169c9ee01e Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 13 Feb 2025 12:37:15 +0100 Subject: [PATCH 13/18] invalidate the decodedEntitiesEntry instead of the query directly --- .../src/entity/decodedEntitiesCache.ts | 1 + .../src/entity/entityRelationParentsMap.ts | 6 ++ packages/hypergraph/src/entity/findMany.ts | 74 +++++++++---------- .../src/entity/relationParentQueries.ts | 6 -- 4 files changed, 44 insertions(+), 43 deletions(-) create mode 100644 packages/hypergraph/src/entity/entityRelationParentsMap.ts delete mode 100644 packages/hypergraph/src/entity/relationParentQueries.ts diff --git a/packages/hypergraph/src/entity/decodedEntitiesCache.ts b/packages/hypergraph/src/entity/decodedEntitiesCache.ts index faf41f3d..734fd633 100644 --- a/packages/hypergraph/src/entity/decodedEntitiesCache.ts +++ b/packages/hypergraph/src/entity/decodedEntitiesCache.ts @@ -14,6 +14,7 @@ export type DecodedEntitiesCacheEntry = { string, // instead of serializedQueryKey as string we could also have the actual params QueryEntry >; + isInvalidated: boolean; }; /* diff --git a/packages/hypergraph/src/entity/entityRelationParentsMap.ts b/packages/hypergraph/src/entity/entityRelationParentsMap.ts new file mode 100644 index 00000000..595fc4d6 --- /dev/null +++ b/packages/hypergraph/src/entity/entityRelationParentsMap.ts @@ -0,0 +1,6 @@ +import type { DecodedEntitiesCacheEntry } from './decodedEntitiesCache.js'; + +export const entityRelationParentsMap: Map< + string, // entity ID + Array +> = new Map(); diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts index aaddeb56..6f7ecf69 100644 --- a/packages/hypergraph/src/entity/findMany.ts +++ b/packages/hypergraph/src/entity/findMany.ts @@ -1,10 +1,10 @@ import type { DocHandle, Patch } from '@automerge/automerge-repo'; import * as Schema from 'effect/Schema'; -import { type QueryEntry, decodedEntitiesCache } from './decodedEntitiesCache.js'; +import { type DecodedEntitiesCacheEntry, type QueryEntry, decodedEntitiesCache } from './decodedEntitiesCache.js'; +import { entityRelationParentsMap } from './entityRelationParentsMap.js'; import { getEntityRelations } from './getEntityRelations.js'; import { hasValidTypesProperty } from './hasValidTypesProperty.js'; import { isReferenceField } from './isReferenceField.js'; -import { relationParentQueries } from './relationParentQueries.js'; import type { AnyNoContext, DocumentContent, Entity } from './types.js'; const documentChangeListener: { @@ -44,7 +44,7 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { // reference to reduce the amount of O(n) operations per query to 1 const touchedQueries = new Set>(); - const touchedRelationParentQueries = new Set(); + const touchedRelationParents = new Set(); // loop over all changed entities and update the cache for (const entityId of changedEntities) { @@ -77,13 +77,13 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { for (const [key, value] of Object.entries(decoded)) { if (Array.isArray(value)) { for (const relationEntity of value) { - let relationParentQueriesEntry = relationParentQueries.get(relationEntity.id); - if (!relationParentQueriesEntry) { - relationParentQueriesEntry = []; - relationParentQueries.set(relationEntity.id, relationParentQueriesEntry); + let relationParentEntry = entityRelationParentsMap.get(relationEntity.id); + if (!relationParentEntry) { + relationParentEntry = []; + entityRelationParentsMap.set(relationEntity.id, relationParentEntry); } - relationParentQueriesEntry.push(query); + relationParentEntry.push(cacheEntry); } } } @@ -91,13 +91,13 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { entityTypes.add(typeName); - // gather all the queries of impacted parent relation queries - if (relationParentQueries.has(entityId)) { - const queries = relationParentQueries.get(entityId); - if (!queries) return; + // gather all the decodedEntitiesCacheEntries + if (entityRelationParentsMap.has(entityId)) { + const decodedEntitiesCacheEntries = entityRelationParentsMap.get(entityId); + if (!decodedEntitiesCacheEntries) return; - for (const query of queries) { - touchedRelationParentQueries.add(query); + for (const entry of decodedEntitiesCacheEntries) { + touchedRelationParents.add(entry); } } } @@ -122,12 +122,12 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { } // gather all the queries of impacted parent relation queries - if (relationParentQueries.has(entityId)) { - const queries = relationParentQueries.get(entityId); - if (!queries) return; + if (entityRelationParentsMap.has(entityId)) { + const decodedEntitiesCacheEntries = entityRelationParentsMap.get(entityId); + if (!decodedEntitiesCacheEntries) return; - for (const query of queries) { - touchedRelationParentQueries.add(query); + for (const entry of decodedEntitiesCacheEntries) { + touchedRelationParents.add(entry); } } } @@ -156,10 +156,13 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { // trigger all the listeners of the parent relation queries // TODO: align with the touchedQueries to avoid unnecessary trigger calls - for (const query of touchedRelationParentQueries) { - query.isInvalidated = true; - for (const listener of query.listeners) { - listener(); + for (const decodedEntitiesCacheEntry of touchedRelationParents) { + decodedEntitiesCacheEntry.isInvalidated = true; + for (const query of decodedEntitiesCacheEntry.queries.values()) { + query.isInvalidated = true; + for (const listener of query.listeners) { + listener(); + } } } }; @@ -218,10 +221,11 @@ export function subscribeToFindMany( const getEntities = () => { const cacheEntry = decodedEntitiesCache.get(typeName); - const query = cacheEntry?.queries.get(queryKey); + if (!cacheEntry) return stableEmptyArray; + const query = cacheEntry.queries.get(queryKey); if (!query) return stableEmptyArray; - if (!query.isInvalidated) { + if (!cacheEntry.isInvalidated && !query.isInvalidated) { return query.data; } @@ -239,6 +243,7 @@ export function subscribeToFindMany( } } + cacheEntry.isInvalidated = false; query.isInvalidated = false; return query.data; }; @@ -246,10 +251,10 @@ export function subscribeToFindMany( if (!decodedEntitiesCache.has(typeName)) { const entities = findMany(handle, type); const entitiesMap = new Map(); - const relationParentQueries = new Map(); + const relationParent = new Map(); for (const entity of entities) { entitiesMap.set(entity.id, entity); - relationParentQueries.set(entity.id, new Map()); + relationParent.set(entity.id, new Map()); } const queries = new Map(); @@ -265,6 +270,7 @@ export function subscribeToFindMany( type, entities: entitiesMap, queries, + isInvalidated: false, }); } @@ -281,19 +287,12 @@ export function subscribeToFindMany( const typeName = type.name; const entities = findMany(handle, type); - if (decodedEntitiesCache.has(typeName)) { - // add a listener to the existing query - const cacheEntry = decodedEntitiesCache.get(typeName); - - for (const entity of entities) { - cacheEntry?.entities.set(entity.id, entity); - } - } else { + if (!decodedEntitiesCache.has(typeName)) { const entitiesMap = new Map(); - const relationParentQueries = new Map(); + const relationParent = new Map(); for (const entity of entities) { entitiesMap.set(entity.id, entity); - relationParentQueries.set(entity.id, new Map()); + relationParent.set(entity.id, new Map()); } decodedEntitiesCache.set(typeName, { @@ -301,6 +300,7 @@ export function subscribeToFindMany( type, entities: entitiesMap, queries: new Map(), + isInvalidated: false, }); } } diff --git a/packages/hypergraph/src/entity/relationParentQueries.ts b/packages/hypergraph/src/entity/relationParentQueries.ts deleted file mode 100644 index cec42797..00000000 --- a/packages/hypergraph/src/entity/relationParentQueries.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { QueryEntry } from './decodedEntitiesCache.js'; - -export const relationParentQueries: Map< - string, // entity ID - Array -> = new Map(); From 5c94cea04d0ca519501e2960cbd00bc96ea354ef Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 16 Feb 2025 08:12:58 +0100 Subject: [PATCH 14/18] properly cleanup the entity cache and entityRelationParentsMap --- packages/hypergraph/src/entity/findMany.ts | 56 +++++++++++----------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts index 6f7ecf69..1ad19baa 100644 --- a/packages/hypergraph/src/entity/findMany.ts +++ b/packages/hypergraph/src/entity/findMany.ts @@ -281,39 +281,41 @@ export function subscribeToFindMany( } } - for (const type of allTypes) { - // TODO: what's the right way to get the name of the type? - // @ts-expect-error name is defined - const typeName = type.name; - const entities = findMany(handle, type); - - if (!decodedEntitiesCache.has(typeName)) { - const entitiesMap = new Map(); - const relationParent = new Map(); - for (const entity of entities) { - entitiesMap.set(entity.id, entity); - relationParent.set(entity.id, new Map()); - } - - decodedEntitiesCache.set(typeName, { - decoder: decode, - type, - entities: entitiesMap, - queries: new Map(), - isInvalidated: false, - }); - } - } - const subscribe = (callback: () => void) => { const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); if (query?.listeners) { query.listeners.push(callback); } + return () => { - const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey); - if (query?.listeners) { - query.listeners = query?.listeners?.filter((cachedListener) => cachedListener !== callback); + const cacheEntry = decodedEntitiesCache.get(typeName); + if (cacheEntry) { + // first cleanup the queries + const query = cacheEntry.queries.get(queryKey); + if (query) { + query.listeners = query?.listeners?.filter((cachedListener) => cachedListener !== callback); + if (query.listeners.length === 0) { + cacheEntry.queries.delete(queryKey); + } + } + // if the last query is removed, cleanup the entityRelationParentsMap and remove the decodedEntitiesCacheEntry + if (cacheEntry.queries.size === 0) { + entityRelationParentsMap.forEach((relationCacheEntries, key) => { + for (const relationCacheEntry of relationCacheEntries) { + if (relationCacheEntry === cacheEntry) { + entityRelationParentsMap.set( + key, + relationCacheEntries.filter((entry) => entry !== cacheEntry), + ); + } + } + const updatedRelationCacheEntries = entityRelationParentsMap.get(key); + if (updatedRelationCacheEntries && updatedRelationCacheEntries.length === 0) { + entityRelationParentsMap.delete(key); + } + }); + decodedEntitiesCache.delete(typeName); + } } documentChangeListener.subscribedQueriesCount--; From 8775c067d3f8e222462fbf812f54ffc3f8ba6d23 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 16 Feb 2025 08:14:38 +0100 Subject: [PATCH 15/18] correctly initialize the entityRelationParentsMap --- packages/hypergraph/src/entity/findMany.ts | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts index 1ad19baa..0f85159f 100644 --- a/packages/hypergraph/src/entity/findMany.ts +++ b/packages/hypergraph/src/entity/findMany.ts @@ -251,10 +251,8 @@ export function subscribeToFindMany( if (!decodedEntitiesCache.has(typeName)) { const entities = findMany(handle, type); const entitiesMap = new Map(); - const relationParent = new Map(); for (const entity of entities) { entitiesMap.set(entity.id, entity); - relationParent.set(entity.id, new Map()); } const queries = new Map(); @@ -265,13 +263,31 @@ export function subscribeToFindMany( isInvalidated: false, }); - decodedEntitiesCache.set(typeName, { + const cacheEntry: DecodedEntitiesCacheEntry = { decoder: decode, type, entities: entitiesMap, queries, isInvalidated: false, - }); + }; + + decodedEntitiesCache.set(typeName, cacheEntry); + + for (const entity of entities) { + for (const [, value] of Object.entries(entity)) { + if (Array.isArray(value)) { + for (const relationEntity of value) { + let relationParentEntry = entityRelationParentsMap.get(relationEntity.id); + if (!relationParentEntry) { + relationParentEntry = []; + entityRelationParentsMap.set(relationEntity.id, relationParentEntry); + } + + relationParentEntry.push(cacheEntry); + } + } + } + } } const allTypes = new Set(); From 00154b60843d2aa5b8b238fa5755344536864506 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 16 Feb 2025 08:49:16 +0100 Subject: [PATCH 16/18] allow to remove assignee --- apps/events/src/components/todos.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/events/src/components/todos.tsx b/apps/events/src/components/todos.tsx index 3bc61f04..94c2ae49 100644 --- a/apps/events/src/components/todos.tsx +++ b/apps/events/src/components/todos.tsx @@ -46,7 +46,23 @@ export const Todos = () => {

{todo.name}

{todo.assignees.length > 0 && ( - Assigned to: {todo.assignees.map((assignee) => assignee.name).join(', ')} + Assigned to:{' '} + {todo.assignees.map((assignee) => ( + + {assignee.name} + + + ))} )} Date: Sun, 16 Feb 2025 10:27:38 +0100 Subject: [PATCH 17/18] properly handle case where a relation gets removed from an entity --- .../src/entity/entityRelationParentsMap.ts | 2 +- packages/hypergraph/src/entity/findMany.ts | 96 ++++++++++++------- 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/packages/hypergraph/src/entity/entityRelationParentsMap.ts b/packages/hypergraph/src/entity/entityRelationParentsMap.ts index 595fc4d6..f4738092 100644 --- a/packages/hypergraph/src/entity/entityRelationParentsMap.ts +++ b/packages/hypergraph/src/entity/entityRelationParentsMap.ts @@ -2,5 +2,5 @@ import type { DecodedEntitiesCacheEntry } from './decodedEntitiesCache.js'; export const entityRelationParentsMap: Map< string, // entity ID - Array + Map > = new Map(); diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts index 0f85159f..5b30d5fd 100644 --- a/packages/hypergraph/src/entity/findMany.ts +++ b/packages/hypergraph/src/entity/findMany.ts @@ -44,6 +44,7 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { // reference to reduce the amount of O(n) operations per query to 1 const touchedQueries = new Set>(); + // collect all entities that used this entity as a entry in on of their relation fields const touchedRelationParents = new Set(); // loop over all changed entities and update the cache @@ -55,6 +56,7 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { const cacheEntry = decodedEntitiesCache.get(typeName); if (!cacheEntry) continue; + const oldDecodedEntry = cacheEntry.entities.get(entityId); const relations = getEntityRelations(entity, cacheEntry.type, doc); const decoded = cacheEntry.decoder({ ...entity, @@ -63,6 +65,51 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { }); cacheEntry.entities.set(entityId, decoded); + if (oldDecodedEntry) { + // collect all the Ids for relation entries that don't exist in the `decoded` entry, but did in the `oldDecodedEntry` + const deletedRelationIds = new Set(); + for (const [fieldName, value] of Object.entries(oldDecodedEntry)) { + if (Array.isArray(value)) { + for (const relationEntity of value) { + // @ts-expect-error decoded is a valid object + if (!decoded[fieldName]?.includes(relationEntity.id)) { + deletedRelationIds.add(relationEntity.id); + } + } + } + } + + // it's fine to remove all of them since they are re-added below + for (const deletedRelationId of deletedRelationIds) { + const deletedRelationEntry = entityRelationParentsMap.get(deletedRelationId); + if (deletedRelationEntry) { + deletedRelationEntry.set(cacheEntry, (deletedRelationEntry.get(cacheEntry) ?? 0) - 1); + if (deletedRelationEntry.get(cacheEntry) === 0) { + deletedRelationEntry.delete(cacheEntry); + } + if (deletedRelationEntry.size === 0) { + entityRelationParentsMap.delete(deletedRelationId); + } + } + } + } + + // @ts-expect-error decoded is a valid object + for (const [key, value] of Object.entries(decoded)) { + if (Array.isArray(value)) { + for (const relationEntity of value) { + let relationParentEntry = entityRelationParentsMap.get(relationEntity.id); + if (relationParentEntry) { + relationParentEntry.set(cacheEntry, (relationParentEntry.get(cacheEntry) ?? 0) + 1); + } else { + relationParentEntry = new Map(); + entityRelationParentsMap.set(relationEntity.id, relationParentEntry); + relationParentEntry.set(cacheEntry, 1); + } + } + } + } + const query = cacheEntry.queries.get('all'); if (query) { const index = query.data.findIndex((entity) => entity.id === entityId); @@ -72,31 +119,17 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { query.data.push(decoded); } touchedQueries.add([typeName, 'all']); - - // @ts-expect-error decoded is a valid object - for (const [key, value] of Object.entries(decoded)) { - if (Array.isArray(value)) { - for (const relationEntity of value) { - let relationParentEntry = entityRelationParentsMap.get(relationEntity.id); - if (!relationParentEntry) { - relationParentEntry = []; - entityRelationParentsMap.set(relationEntity.id, relationParentEntry); - } - - relationParentEntry.push(cacheEntry); - } - } - } } entityTypes.add(typeName); - // gather all the decodedEntitiesCacheEntries + // gather all the decodedEntitiesCacheEntries that have a relation to this entity to + // invoke their query listeners below if (entityRelationParentsMap.has(entityId)) { const decodedEntitiesCacheEntries = entityRelationParentsMap.get(entityId); if (!decodedEntitiesCacheEntries) return; - for (const entry of decodedEntitiesCacheEntries) { + for (const [entry] of decodedEntitiesCacheEntries) { touchedRelationParents.add(entry); } } @@ -121,17 +154,20 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { } } - // gather all the queries of impacted parent relation queries + // gather all the queries of impacted parent relation queries and then remove the cacheEntry if (entityRelationParentsMap.has(entityId)) { const decodedEntitiesCacheEntries = entityRelationParentsMap.get(entityId); if (!decodedEntitiesCacheEntries) return; - for (const entry of decodedEntitiesCacheEntries) { + for (const [entry] of decodedEntitiesCacheEntries) { touchedRelationParents.add(entry); } + + entityRelationParentsMap.delete(entityId); } } + // update the queries affected queries for (const [typeName, queryKey] of touchedQueries) { const cacheEntry = decodedEntitiesCache.get(typeName); if (!cacheEntry) continue; @@ -155,7 +191,6 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { } // trigger all the listeners of the parent relation queries - // TODO: align with the touchedQueries to avoid unnecessary trigger calls for (const decodedEntitiesCacheEntry of touchedRelationParents) { decodedEntitiesCacheEntry.isInvalidated = true; for (const query of decodedEntitiesCacheEntry.queries.values()) { @@ -278,12 +313,13 @@ export function subscribeToFindMany( if (Array.isArray(value)) { for (const relationEntity of value) { let relationParentEntry = entityRelationParentsMap.get(relationEntity.id); - if (!relationParentEntry) { - relationParentEntry = []; + if (relationParentEntry) { + relationParentEntry.set(cacheEntry, (relationParentEntry.get(cacheEntry) ?? 0) + 1); + } else { + relationParentEntry = new Map(); entityRelationParentsMap.set(relationEntity.id, relationParentEntry); + relationParentEntry.set(cacheEntry, 1); } - - relationParentEntry.push(cacheEntry); } } } @@ -317,16 +353,12 @@ export function subscribeToFindMany( // if the last query is removed, cleanup the entityRelationParentsMap and remove the decodedEntitiesCacheEntry if (cacheEntry.queries.size === 0) { entityRelationParentsMap.forEach((relationCacheEntries, key) => { - for (const relationCacheEntry of relationCacheEntries) { - if (relationCacheEntry === cacheEntry) { - entityRelationParentsMap.set( - key, - relationCacheEntries.filter((entry) => entry !== cacheEntry), - ); + for (const [relationCacheEntry, counter] of relationCacheEntries) { + if (relationCacheEntry === cacheEntry && counter === 0) { + relationCacheEntries.delete(cacheEntry); } } - const updatedRelationCacheEntries = entityRelationParentsMap.get(key); - if (updatedRelationCacheEntries && updatedRelationCacheEntries.length === 0) { + if (relationCacheEntries.size === 0) { entityRelationParentsMap.delete(key); } }); From 4f7c98a78c744f7f7c4d151fd43f7003113d4fe9 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 17 Feb 2025 11:58:30 +0100 Subject: [PATCH 18/18] remove all references since they are re-added later --- packages/hypergraph/src/entity/findMany.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts index 5b30d5fd..973382a6 100644 --- a/packages/hypergraph/src/entity/findMany.ts +++ b/packages/hypergraph/src/entity/findMany.ts @@ -66,15 +66,12 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { cacheEntry.entities.set(entityId, decoded); if (oldDecodedEntry) { - // collect all the Ids for relation entries that don't exist in the `decoded` entry, but did in the `oldDecodedEntry` + // collect all the Ids for relation entries in the `oldDecodedEntry` const deletedRelationIds = new Set(); - for (const [fieldName, value] of Object.entries(oldDecodedEntry)) { + for (const [, value] of Object.entries(oldDecodedEntry)) { if (Array.isArray(value)) { for (const relationEntity of value) { - // @ts-expect-error decoded is a valid object - if (!decoded[fieldName]?.includes(relationEntity.id)) { - deletedRelationIds.add(relationEntity.id); - } + deletedRelationIds.add(relationEntity.id); } } }