@dxos/app-graph 0.6.3-main.d007b87 → 0.6.3-main.daaea86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,24 +2,219 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { EventSubscriptions, type UnsubscribeCallback } from '@dxos/async';
5
+ import { type Signal, effect, signal } from '@preact/signals-core';
6
+ // import { yieldOrContinue } from 'main-thread-scheduling';
6
7
 
7
- import { Graph } from './graph';
8
+ import { type UnsubscribeCallback } from '@dxos/async';
9
+ import { create } from '@dxos/echo-schema';
10
+ import { invariant } from '@dxos/invariant';
11
+ import { nonNullable } from '@dxos/util';
8
12
 
9
- export type BuilderExtension = (graph: Graph) => UnsubscribeCallback | void;
13
+ import { ACTION_GROUP_TYPE, ACTION_TYPE, Graph } from './graph';
14
+ import { type Relation, type NodeArg, type Node, type ActionData, actionGroupSymbol } from './node';
15
+
16
+ /**
17
+ * Graph builder extension for adding nodes to the graph based on just the node id.
18
+ * This is useful for creating the first node in a graph or for hydrating cached nodes with data.
19
+ *
20
+ * @param params.id The id of the node to resolve.
21
+ */
22
+ export type ResolverExtension = (params: { id: string }) => NodeArg<any> | undefined;
23
+
24
+ /**
25
+ * Graph builder extension for adding nodes to the graph based on a connection to an existing node.
26
+ *
27
+ * @param params.node The existing node the returned nodes will be connected to.
28
+ */
29
+ export type ConnectorExtension<T = any> = (params: { node: Node<T> }) => NodeArg<any>[] | undefined;
30
+
31
+ /**
32
+ * Constrained case of the connector extension for more easily adding actions to the graph.
33
+ */
34
+ export type ActionsExtension<T = any> = (params: {
35
+ node: Node<T>;
36
+ }) => Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[] | undefined;
37
+
38
+ /**
39
+ * Constrained case of the connector extension for more easily adding action groups to the graph.
40
+ */
41
+ export type ActionGroupsExtension<T = any> = (params: {
42
+ node: Node<T>;
43
+ }) => Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[] | undefined;
44
+
45
+ type GuardedNodeType<T> = T extends (value: any) => value is infer N ? (N extends Node<infer D> ? D : unknown) : never;
46
+
47
+ /**
48
+ * A graph builder extension is used to add nodes to the graph.
49
+ *
50
+ * @param params.id The unique id of the extension.
51
+ * @param params.relation The relation the graph is being expanded from the existing node.
52
+ * @param params.type If provided, all nodes returned are expected to have this type.
53
+ * @param params.filter A filter function to determine if an extension should act on a node.
54
+ * @param params.resolver A function to add nodes to the graph based on just the node id.
55
+ * @param params.connector A function to add nodes to the graph based on a connection to an existing node.
56
+ * @param params.actions A function to add actions to the graph based on a connection to an existing node.
57
+ * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
58
+ */
59
+ export type CreateExtensionOptions<T = any> = {
60
+ id: string;
61
+ relation?: Relation;
62
+ type?: string;
63
+ filter?: (node: Node) => node is Node<T>;
64
+ resolver?: ResolverExtension;
65
+ connector?: ConnectorExtension<GuardedNodeType<CreateExtensionOptions<T>['filter']>>;
66
+ actions?: ActionsExtension<GuardedNodeType<CreateExtensionOptions<T>['filter']>>;
67
+ actionGroups?: ActionGroupsExtension<GuardedNodeType<CreateExtensionOptions<T>['filter']>>;
68
+ };
69
+
70
+ /**
71
+ * Create a graph builder extension.
72
+ */
73
+ export const createExtension = <T = any>(extension: CreateExtensionOptions<T>): BuilderExtension[] => {
74
+ const { id, resolver, connector, actions, actionGroups, ...rest } = extension;
75
+ const getId = (key: string) => `${id}/${key}`;
76
+ return [
77
+ resolver ? { id: getId('resolver'), resolver } : undefined,
78
+ connector ? { ...rest, id: getId('connector'), connector } : undefined,
79
+ actionGroups
80
+ ? ({
81
+ ...rest,
82
+ id: getId('actionGroups'),
83
+ type: ACTION_GROUP_TYPE,
84
+ relation: 'outbound',
85
+ connector: ({ node }) =>
86
+ actionGroups({ node })?.map((arg) => ({ ...arg, data: actionGroupSymbol, type: ACTION_GROUP_TYPE })),
87
+ } satisfies BuilderExtension)
88
+ : undefined,
89
+ actions
90
+ ? ({
91
+ ...rest,
92
+ id: getId('actions'),
93
+ type: ACTION_TYPE,
94
+ relation: 'outbound',
95
+ connector: ({ node }) => actions({ node })?.map((arg) => ({ ...arg, type: ACTION_TYPE })),
96
+ } satisfies BuilderExtension)
97
+ : undefined,
98
+ ].filter(nonNullable);
99
+ };
100
+
101
+ export type GraphBuilderTraverseOptions = {
102
+ node: Node;
103
+ relation?: Relation;
104
+ visitor: (node: Node, path: string[]) => void;
105
+ };
106
+
107
+ /**
108
+ * The dispatcher is used to keep track of the current extension and state when memoizing functions.
109
+ */
110
+ class Dispatcher {
111
+ currentExtension?: string;
112
+ stateIndex = 0;
113
+ state: Record<string, any[]> = {};
114
+ cleanup: (() => void)[] = [];
115
+ }
116
+
117
+ class BuilderInternal {
118
+ // This must be static to avoid passing the dispatcher instance to every memoized function.
119
+ // If the dispatcher is not set that means that the memoized function is being called outside of the graph builder.
120
+ static currentDispatcher?: Dispatcher;
121
+ }
122
+
123
+ /**
124
+ * Allows code to be memoized within the context of a graph builder extension.
125
+ * This is useful for creating instances which should be subscribed to rather than recreated.
126
+ */
127
+ export const memoize = <T>(fn: () => T, key = 'result'): T => {
128
+ const dispatcher = BuilderInternal.currentDispatcher;
129
+ invariant(dispatcher?.currentExtension, 'memoize must be called within an extension');
130
+ const all = dispatcher.state[dispatcher.currentExtension][dispatcher.stateIndex] ?? {};
131
+ const current = all[key];
132
+ const result = current ? current.result : fn();
133
+ dispatcher.state[dispatcher.currentExtension][dispatcher.stateIndex] = { ...all, [key]: { result } };
134
+ dispatcher.stateIndex++;
135
+ return result;
136
+ };
137
+
138
+ /**
139
+ * Register a cleanup function to be called when the graph builder is destroyed.
140
+ */
141
+ export const cleanup = (fn: () => void): void => {
142
+ memoize(() => {
143
+ const dispatcher = BuilderInternal.currentDispatcher;
144
+ invariant(dispatcher, 'cleanup must be called within an extension');
145
+ dispatcher.cleanup.push(fn);
146
+ });
147
+ };
148
+
149
+ /**
150
+ * Convert a subscribe/get pair into a signal.
151
+ */
152
+ export const toSignal = <T>(
153
+ subscribe: (onChange: () => void) => () => void,
154
+ get: () => T | undefined,
155
+ key?: string,
156
+ ) => {
157
+ const thisSignal = memoize(() => {
158
+ return signal(get());
159
+ }, key);
160
+ const unsubscribe = memoize(() => {
161
+ return subscribe(() => (thisSignal.value = get()));
162
+ }, key);
163
+ cleanup(() => {
164
+ unsubscribe();
165
+ });
166
+ return thisSignal.value;
167
+ };
168
+
169
+ export type BuilderExtension = {
170
+ id: string;
171
+ resolver?: ResolverExtension;
172
+ connector?: ConnectorExtension;
173
+ // Only for connector.
174
+ relation?: Relation;
175
+ type?: string;
176
+ filter?: (node: Node) => boolean;
177
+ };
178
+
179
+ type ExtensionArg = BuilderExtension | BuilderExtension[] | ExtensionArg[];
10
180
 
11
181
  /**
12
182
  * The builder provides an extensible way to compose the construction of the graph.
13
183
  */
184
+ // TODO(wittjosiah): Add api for setting subscription set and/or radius.
185
+ // Should unsubscribe from nodes that are not in the set/radius.
186
+ // Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
14
187
  export class GraphBuilder {
15
- private readonly _extensions = new Map<string, BuilderExtension>();
16
- private readonly _unsubscribe = new EventSubscriptions();
188
+ private readonly _dispatcher = new Dispatcher();
189
+ private readonly _extensions = create<Record<string, BuilderExtension>>({});
190
+ private readonly _resolverSubscriptions = new Map<string, UnsubscribeCallback>();
191
+ private readonly _connectorSubscriptions = new Map<string, UnsubscribeCallback>();
192
+ private readonly _nodeChanged: Record<string, Signal<{}>> = {};
193
+ private _graph: Graph;
194
+
195
+ constructor() {
196
+ this._graph = new Graph({
197
+ onInitialNode: (id, type) => this._onInitialNode(id, type),
198
+ onInitialNodes: (node, relation, type) => this._onInitialNodes(node, relation, type),
199
+ onRemoveNode: (id) => this._onRemoveNode(id),
200
+ });
201
+ }
202
+
203
+ get graph() {
204
+ return this._graph;
205
+ }
17
206
 
18
207
  /**
19
208
  * Register a node builder which will be called in order to construct the graph.
20
209
  */
21
- addExtension(id: string, extension: BuilderExtension): GraphBuilder {
22
- this._extensions.set(id, extension);
210
+ addExtension(extension: ExtensionArg): GraphBuilder {
211
+ if (Array.isArray(extension)) {
212
+ extension.forEach((ext) => this.addExtension(ext));
213
+ return this;
214
+ }
215
+
216
+ this._dispatcher.state[extension.id] = [];
217
+ this._extensions[extension.id] = extension;
23
218
  return this;
24
219
  }
25
220
 
@@ -27,25 +222,143 @@ export class GraphBuilder {
27
222
  * Remove a node builder from the graph builder.
28
223
  */
29
224
  removeExtension(id: string): GraphBuilder {
30
- this._extensions.delete(id);
225
+ delete this._extensions[id];
31
226
  return this;
32
227
  }
33
228
 
229
+ destroy() {
230
+ this._dispatcher.cleanup.forEach((fn) => fn());
231
+ this._resolverSubscriptions.forEach((unsubscribe) => unsubscribe());
232
+ this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
233
+ this._resolverSubscriptions.clear();
234
+ this._connectorSubscriptions.clear();
235
+ }
236
+
34
237
  /**
35
- * Construct the graph, starting by calling all registered extensions.
36
- * @param previousGraph If provided, the graph will be updated in place.
238
+ * Traverse a graph using just the connector extensions, without subscribing to any signals or persisting any nodes.
37
239
  */
38
- build(previousGraph?: Graph): Graph {
39
- // Clear previous extension subscriptions.
40
- this._unsubscribe.clear();
240
+ // TODO(wittjosiah): Rename? This is not traversing the graph proper.
241
+ async traverse({ node, relation = 'outbound', visitor }: GraphBuilderTraverseOptions, path: string[] = []) {
242
+ // Break cycles.
243
+ if (path.includes(node.id)) {
244
+ return;
245
+ }
41
246
 
42
- const graph: Graph = previousGraph ?? new Graph();
247
+ // TODO(wittjosiah): Failed in test environment. ESM only?
248
+ // await yieldOrContinue('idle');
249
+ visitor(node, [...path, node.id]);
43
250
 
44
- Array.from(this._extensions.values()).forEach((builder) => {
45
- const unsubscribe = builder(graph);
46
- unsubscribe && this._unsubscribe.add(unsubscribe);
47
- });
251
+ const nodes = Object.values(this._extensions)
252
+ .filter((extension) => relation === (extension.relation ?? 'outbound'))
253
+ .flatMap((extension) => extension.connector?.({ node }) ?? [])
254
+ .map(
255
+ (arg): Node => ({
256
+ id: arg.id,
257
+ type: arg.type,
258
+ data: arg.data ?? null,
259
+ properties: arg.properties ?? {},
260
+ }),
261
+ );
262
+
263
+ await Promise.all(nodes.map((n) => this.traverse({ node: n, relation, visitor }, [...path, node.id])));
264
+ }
265
+
266
+ private _onInitialNode(nodeId: string, nodeType?: string) {
267
+ this._nodeChanged[nodeId] = this._nodeChanged[nodeId] ?? signal({});
268
+ let initialized: NodeArg<any> | undefined;
269
+ for (const { id, type, resolver } of Object.values(this._extensions)) {
270
+ if (!resolver || (nodeType && type !== nodeType)) {
271
+ continue;
272
+ }
273
+
274
+ const unsubscribe = effect(() => {
275
+ this._dispatcher.currentExtension = id;
276
+ this._dispatcher.stateIndex = 0;
277
+ BuilderInternal.currentDispatcher = this._dispatcher;
278
+ const node = resolver({ id: nodeId });
279
+ BuilderInternal.currentDispatcher = undefined;
280
+ if (node && initialized) {
281
+ this.graph._addNodes([node]);
282
+ if (this._nodeChanged[initialized.id]) {
283
+ this._nodeChanged[initialized.id].value = {};
284
+ }
285
+ } else if (node) {
286
+ initialized = node;
287
+ }
288
+ });
289
+
290
+ if (initialized) {
291
+ this._resolverSubscriptions.set(nodeId, unsubscribe);
292
+ break;
293
+ } else {
294
+ unsubscribe();
295
+ }
296
+ }
297
+
298
+ return initialized;
299
+ }
300
+
301
+ private _onInitialNodes(node: Node, nodesRelation: Relation, nodesType?: string) {
302
+ this._nodeChanged[node.id] = this._nodeChanged[node.id] ?? signal({});
303
+ let initialized: NodeArg<any>[] | undefined;
304
+ let previous: string[] = [];
305
+ this._connectorSubscriptions.set(
306
+ node.id,
307
+ effect(() => {
308
+ // Subscribe to extensions being added.
309
+ Object.keys(this._extensions);
310
+ // Subscribe to connected node changes.
311
+ this._nodeChanged[node.id].value;
312
+
313
+ // TODO(wittjosiah): Consider allowing extensions to collaborate on the same node by merging their results.
314
+ const nodes: NodeArg<any>[] = [];
315
+ for (const { id, connector, filter, type, relation = 'outbound' } of Object.values(this._extensions)) {
316
+ if (
317
+ !connector ||
318
+ relation !== nodesRelation ||
319
+ (nodesType && type !== nodesType) ||
320
+ (filter && !filter(node))
321
+ ) {
322
+ continue;
323
+ }
324
+
325
+ this._dispatcher.currentExtension = id;
326
+ this._dispatcher.stateIndex = 0;
327
+ BuilderInternal.currentDispatcher = this._dispatcher;
328
+ nodes.push(...(connector({ node }) ?? []));
329
+ BuilderInternal.currentDispatcher = undefined;
330
+ }
331
+ const ids = nodes.map((n) => n.id);
332
+ const removed = previous.filter((id) => !ids.includes(id));
333
+ previous = ids;
334
+
335
+ if (initialized) {
336
+ this.graph._removeNodes(removed, true);
337
+ this.graph._addNodes(nodes);
338
+ this.graph._addEdges(nodes.map(({ id }) => ({ source: node.id, target: id })));
339
+ this.graph._sortEdges(
340
+ node.id,
341
+ 'outbound',
342
+ nodes.map(({ id }) => id),
343
+ );
344
+ nodes.forEach((n) => {
345
+ if (this._nodeChanged[n.id]) {
346
+ this._nodeChanged[n.id].value = {};
347
+ }
348
+ });
349
+ } else {
350
+ initialized = nodes;
351
+ }
352
+ }),
353
+ );
354
+
355
+ return initialized;
356
+ }
48
357
 
49
- return graph;
358
+ private _onRemoveNode(nodeId: string) {
359
+ this._resolverSubscriptions.get(nodeId)?.();
360
+ this._connectorSubscriptions.get(nodeId)?.();
361
+ this._resolverSubscriptions.delete(nodeId);
362
+ this._connectorSubscriptions.delete(nodeId);
50
363
  }
51
364
  }