Skip to content

Commit 51f5009

Browse files
committed
add first draft
1 parent ad11615 commit 51f5009

File tree

2 files changed

+216
-57
lines changed

2 files changed

+216
-57
lines changed

packages/hypergraph-react/src/HypergraphSpaceContext.tsx

Lines changed: 18 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
import type { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo';
44
import { useRepo } from '@automerge/automerge-repo-react-hooks';
55
import { Entity, Utils } from '@graphprotocol/hypergraph';
6+
import { type DocumentContent, registerChangeListener } from '@graphprotocol/hypergraph/Entity';
67
import * as Schema from 'effect/Schema';
7-
import { type ReactNode, createContext, useContext, useRef, useSyncExternalStore } from 'react';
8+
import { type ReactNode, createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react';
89

910
export type HypergraphContext = {
1011
space: string;
1112
repo: Repo;
1213
id: AnyDocumentId;
1314
handle: DocHandle<Entity.DocumentContent>;
15+
unsubscribeChangeListener: () => void;
1416
};
1517

1618
export const HypergraphReactContext = createContext<HypergraphContext | undefined>(undefined);
@@ -31,14 +33,25 @@ export function HypergraphProvider({ space, children }: { space: string; childre
3133
let current = ref.current;
3234
if (current === undefined || space !== current.space || repo !== current.repo) {
3335
const id = Utils.idToAutomergeId(space) as AnyDocumentId;
36+
const handle = repo.find<DocumentContent>(id);
37+
const unsubscribeChangeListener = registerChangeListener(handle);
38+
3439
current = ref.current = {
3540
space,
3641
repo,
3742
id,
38-
handle: repo.find(id),
43+
handle,
44+
unsubscribeChangeListener,
3945
};
4046
}
4147

48+
// biome-ignore lint/correctness/useExhaustiveDependencies: no need for dependencies as the unsubscribe is called from the ref
49+
useEffect(() => {
50+
return () => {
51+
current?.unsubscribeChangeListener();
52+
};
53+
}, []);
54+
4255
return <HypergraphReactContext.Provider value={current}>{children}</HypergraphReactContext.Provider>;
4356
}
4457

@@ -59,37 +72,10 @@ export function useDeleteEntity() {
5972

6073
export function useQueryEntities<const S extends Entity.AnyNoContext>(type: S) {
6174
const hypergraph = useHypergraph();
62-
const equal = isEqual(type);
63-
64-
// store as a map of type to array of entities of the type
65-
const prevEntitiesRef = useRef<Readonly<Array<Entity.Entity<S>>>>([]);
66-
67-
const subscribe = (callback: () => void) => {
68-
const handleChange = () => {
69-
callback();
70-
};
71-
72-
const handleDelete = () => {
73-
callback();
74-
};
75-
76-
hypergraph.handle.on('change', handleChange);
77-
hypergraph.handle.on('delete', handleDelete);
78-
79-
return () => {
80-
hypergraph.handle.off('change', handleChange);
81-
hypergraph.handle.off('delete', handleDelete);
82-
};
83-
};
84-
85-
return useSyncExternalStore<Readonly<Array<Entity.Entity<S>>>>(subscribe, () => {
86-
const filtered = Entity.findMany(hypergraph.handle, type);
87-
if (!equal(filtered, prevEntitiesRef.current)) {
88-
prevEntitiesRef.current = filtered;
89-
}
90-
91-
return prevEntitiesRef.current;
75+
const [subscription] = useState(() => {
76+
return Entity.subscribeToFindMany(hypergraph.handle, type);
9277
});
78+
return useSyncExternalStore(subscription.listener, subscription.getEntities, () => []);
9379
}
9480

9581
export function useQueryEntity<const S extends Entity.AnyNoContext>(type: S, id: string) {
@@ -135,22 +121,3 @@ export function useQueryEntity<const S extends Entity.AnyNoContext>(type: S, id:
135121
return prevEntityRef.current;
136122
});
137123
}
138-
139-
/** @internal */
140-
const isEqual = <A, E>(type: Schema.Schema<A, E, never>) => {
141-
const equals = Schema.equivalence(type);
142-
143-
return (a: ReadonlyArray<A>, b: ReadonlyArray<A>) => {
144-
if (a.length !== b.length) {
145-
return false;
146-
}
147-
148-
for (let i = 0; i < a.length; i++) {
149-
if (!equals(a[i], b[i])) {
150-
return false;
151-
}
152-
}
153-
154-
return true;
155-
};
156-
};

packages/hypergraph/src/Entity.ts

Lines changed: 198 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DocHandle } from '@automerge/automerge-repo';
1+
import type { DocHandle, Patch } from '@automerge/automerge-repo';
22
import * as VariantSchema from '@effect/experimental/VariantSchema';
33
import * as Data from 'effect/Data';
44
import * as Schema from 'effect/Schema';
@@ -66,6 +66,132 @@ export class EntityNotFoundError extends Data.TaggedError('EntityNotFoundError')
6666

6767
export type Entity<S extends AnyNoContext> = Schema.Schema.Type<S> & { type: string };
6868

69+
/*
70+
* Note: Currently we only use one global cache for all entities.
71+
* In the future we probably want a build function that creates a cache and returns the
72+
* functions (create, update, findMany, …) that use this specific cache.
73+
*
74+
* How does it work?
75+
*
76+
* We store all decoded entities in a cache and for each query we reference the entities relevant to this query.
77+
* Whenever a query is registered we add it to the cache and set the reference count to 1. Whenever a query is unregistered
78+
* we decrease the reference count. Whenever the reference count reaches 0 we remove the query from the cache and remove the
79+
* entities from the cache.
80+
*
81+
* Handling filters is relatively straight forward as they are uniquely identified by their params.
82+
*
83+
* Questions:
84+
* How do we handle findOne?
85+
* Thoughts: Could be just a special case of findMany limited to a specific id or a separate mechanism.
86+
* How do we handle relations?
87+
* 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.
88+
*/
89+
type DecodedEntitiesCache = Map<
90+
string, // type name
91+
{
92+
decoder: (data: unknown) => unknown;
93+
entities: Map<string, Entity<AnyNoContext>>; // holds all entities of this type
94+
queries: Map<
95+
string, // instead of serializedQueryKey we could also have the actual params
96+
{
97+
data: Array<Entity<AnyNoContext>>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array
98+
listeners: Array<() => void>; // listeners to this query
99+
}
100+
>;
101+
}
102+
>;
103+
104+
const decodedEntitiesCache: DecodedEntitiesCache = new Map();
105+
106+
export const registerChangeListener = (handle: DocHandle<DocumentContent>) => {
107+
const onChange = ({ patches, doc }: { patches: Array<Patch>; doc: DocumentContent }) => {
108+
const changedEntities = new Set<string>();
109+
const deletedEntities = new Set<string>();
110+
111+
for (const patch of patches) {
112+
switch (patch.action) {
113+
case 'put':
114+
case 'insert':
115+
case 'splice': {
116+
if (patch.path.length > 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') {
117+
changedEntities.add(patch.path[1]);
118+
}
119+
break;
120+
}
121+
case 'del': {
122+
if (patch.path.length === 2 && patch.path[0] === 'entities' && typeof patch.path[1] === 'string') {
123+
deletedEntities.add(patch.path[1]);
124+
}
125+
break;
126+
}
127+
}
128+
}
129+
130+
const entityTypes = new Set<string>();
131+
132+
// loop over all changed entities and update the cache
133+
for (const entityId of changedEntities) {
134+
const entity = doc.entities?.[entityId];
135+
if (!entity || typeof entity !== 'object' || !('@@types@@' in entity) || !Array.isArray(entity['@@types@@']))
136+
return;
137+
for (const typeName of entity['@@types@@']) {
138+
const cacheEntry = decodedEntitiesCache.get(typeName);
139+
if (!cacheEntry) continue;
140+
141+
const decoded = cacheEntry.decoder({ ...entity, id: entityId });
142+
cacheEntry.entities.set(entityId, decoded);
143+
144+
const query = cacheEntry.queries.get('all');
145+
if (query) {
146+
const index = query.data.findIndex((entity) => entity.id === entityId);
147+
if (index !== -1) {
148+
query.data[index] = decoded;
149+
} else {
150+
query.data.push(decoded);
151+
}
152+
}
153+
154+
entityTypes.add(typeName);
155+
}
156+
}
157+
158+
// loop over all deleted entities and remove them from the cache
159+
for (const entityId of deletedEntities) {
160+
for (const [affectedTypeName, cacheEntry] of decodedEntitiesCache) {
161+
if (cacheEntry.entities.has(entityId)) {
162+
entityTypes.add(affectedTypeName);
163+
cacheEntry.entities.delete(entityId);
164+
165+
for (const [, query] of cacheEntry.queries) {
166+
// find the entity in the query and remove it using splice
167+
const index = query.data.findIndex((entity) => entity.id === entityId);
168+
if (index !== -1) {
169+
query.data.splice(index, 1);
170+
}
171+
}
172+
}
173+
}
174+
}
175+
176+
for (const typeName of entityTypes) {
177+
const cacheEntry = decodedEntitiesCache.get(typeName);
178+
if (!cacheEntry) continue;
179+
180+
for (const query of cacheEntry.queries.values()) {
181+
for (const listener of query.listeners) {
182+
listener();
183+
}
184+
}
185+
}
186+
};
187+
188+
handle.on('change', onChange);
189+
190+
return () => {
191+
handle.off('change', onChange);
192+
};
193+
};
194+
69195
/**
70196
* Creates an entity model of given type and stores it in the repo.
71197
*/
@@ -78,7 +204,7 @@ export const create = <const S extends AnyNoContext>(handle: DocHandle<DocumentC
78204

79205
return (data: Readonly<Schema.Schema.Type<Insert<S>>>): Entity<S> => {
80206
const encoded = encode(data);
81-
// apply changes to the repo -> adds the entity to the repo entites document
207+
// apply changes to the repo -> adds the entity to the repo entities document
82208
handle.change((doc) => {
83209
doc.entities ??= {};
84210
doc.entities[entityId] = { ...encoded, '@@types@@': [typeName] };
@@ -103,7 +229,7 @@ export const update = <const S extends AnyNoContext>(handle: DocHandle<DocumentC
103229
return (id: string, data: Schema.Simplify<Partial<Schema.Schema.Type<Update<S>>>>): Entity<S> => {
104230
validate(data);
105231

106-
// apply changes to the repo -> updates the existing entity to the repo entites document
232+
// apply changes to the repo -> updates the existing entity to the repo entities document
107233
let updated: Schema.Schema.Type<S> | undefined = undefined;
108234
handle.change((doc) => {
109235
if (doc.entities === undefined) {
@@ -116,7 +242,7 @@ export const update = <const S extends AnyNoContext>(handle: DocHandle<DocumentC
116242
return;
117243
}
118244

119-
// TODO: Try to get a diff of the entitiy properties and only override the changed ones.
245+
// TODO: Try to get a diff of the entity properties and only override the changed ones.
120246
updated = { ...decode(entity), ...data };
121247
doc.entities[id] = { ...encode(updated), '@@types@@': [typeName] };
122248
});
@@ -163,7 +289,7 @@ export function findMany<const S extends AnyNoContext>(
163289
const typeName = type.name;
164290

165291
// TODO: Instead of this insane filtering logic, we should be keeping track of the entities in
166-
// an index and store the decoded valeus instead of re-decoding over and over again.
292+
// an index and store the decoded values instead of re-decoding over and over again.
167293
const entities = handle.docSync()?.entities ?? {};
168294
const filtered: Array<Entity<S>> = [];
169295
for (const id in entities) {
@@ -179,6 +305,72 @@ export function findMany<const S extends AnyNoContext>(
179305
return filtered;
180306
}
181307

308+
export function subscribeToFindMany<const S extends AnyNoContext>(
309+
handle: DocHandle<DocumentContent>,
310+
type: S,
311+
): { listener: () => () => void; getEntities: () => Readonly<Array<Entity<S>>> } {
312+
const decode = Schema.decodeUnknownSync(type);
313+
// TODO: what's the right way to get the name of the type?
314+
// @ts-expect-error name is defined
315+
const typeName = type.name;
316+
317+
const getEntities = () => {
318+
const entities = decodedEntitiesCache.get(typeName)?.queries.get('all')?.data ?? [];
319+
return entities;
320+
};
321+
322+
const listener = () => {
323+
return () => undefined;
324+
};
325+
326+
const entities = findMany(handle, type);
327+
328+
if (decodedEntitiesCache.has(typeName)) {
329+
// add a listener to the existing query
330+
const cacheEntry = decodedEntitiesCache.get(typeName);
331+
const query = cacheEntry?.queries.get('all');
332+
333+
for (const entity of entities) {
334+
cacheEntry?.entities.set(entity.id, entity);
335+
336+
if (!query) continue;
337+
338+
const index = query.data.findIndex((e) => e.id === entity.id);
339+
if (index !== -1) {
340+
query.data[index] = entity;
341+
} else {
342+
query.data.push(entity);
343+
}
344+
}
345+
346+
if (query?.listeners) {
347+
query.listeners.push(listener);
348+
}
349+
} else {
350+
const entitiesMap = new Map();
351+
for (const entity of entities) {
352+
entitiesMap.set(entity.id, entity);
353+
}
354+
355+
const queries = new Map();
356+
357+
queries.set('all', {
358+
data: entities,
359+
listeners: [listener],
360+
});
361+
362+
decodedEntitiesCache.set(typeName, {
363+
decoder: decode,
364+
entities: entitiesMap,
365+
queries,
366+
});
367+
}
368+
369+
// TODO when switching from one to another space the cache must be wiped
370+
// TODO also return an unsubscribe function to remove the listener from the cache
371+
return { listener, getEntities };
372+
}
373+
182374
/**
183375
* Find the entity of the given type, with the given id, from the repo.
184376
*/
@@ -192,7 +384,7 @@ export const findOne =
192384
const typeName = type.name;
193385

194386
// TODO: Instead of this insane filtering logic, we should be keeping track of the entities in
195-
// an index and store the decoded valeus instead of re-decoding over and over again.
387+
// an index and store the decoded values instead of re-decoding over and over again.
196388
const entity = handle.docSync()?.entities?.[id];
197389
if (typeof entity === 'object' && entity != null && '@@types@@' in entity) {
198390
const types = entity['@@types@@'];

0 commit comments

Comments
 (0)