@dxos/app-graph 0.8.2-main.fbd8ed0 → 0.8.2-staging.7ac8446

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.
Files changed (33) hide show
  1. package/dist/lib/browser/index.mjs +789 -541
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +780 -533
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +789 -541
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/graph-builder.d.ts +91 -48
  11. package/dist/types/src/graph-builder.d.ts.map +1 -1
  12. package/dist/types/src/graph.d.ts +98 -191
  13. package/dist/types/src/graph.d.ts.map +1 -1
  14. package/dist/types/src/node.d.ts +3 -3
  15. package/dist/types/src/node.d.ts.map +1 -1
  16. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  17. package/dist/types/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +16 -23
  19. package/src/graph-builder.test.ts +310 -293
  20. package/src/graph-builder.ts +317 -209
  21. package/src/graph.test.ts +463 -314
  22. package/src/graph.ts +455 -452
  23. package/src/node.ts +4 -4
  24. package/src/stories/EchoGraph.stories.tsx +78 -57
  25. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  26. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  27. package/dist/types/src/signals-integration.test.d.ts +0 -2
  28. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  29. package/dist/types/src/testing.d.ts +0 -5
  30. package/dist/types/src/testing.d.ts.map +0 -1
  31. package/src/experimental/graph-projections.test.ts +0 -56
  32. package/src/signals-integration.test.ts +0 -218
  33. package/src/testing.ts +0 -20
package/src/graph.ts CHANGED
@@ -2,24 +2,20 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
6
- import { Option, pipe, Record } from 'effect';
5
+ import { batch, effect, untracked } from '@preact/signals-core';
7
6
 
8
- import { Event, Trigger } from '@dxos/async';
9
- import { todo } from '@dxos/debug';
7
+ import { asyncTimeout, Trigger } from '@dxos/async';
10
8
  import { invariant } from '@dxos/invariant';
9
+ import { type ReactiveObject, create } from '@dxos/live-object';
11
10
  import { log } from '@dxos/log';
12
- import { isNonNullable, type MakeOptional } from '@dxos/util';
11
+ import { type MakeOptional, isNonNullable, pick } from '@dxos/util';
13
12
 
14
- import { type NodeArg, type Node, type Relation, type Action, type ActionGroup } from './node';
13
+ import { type Node, type NodeArg, type NodeFilter, type Relation, actionGroupSymbol, isActionLike } from './node';
15
14
 
16
15
  const graphSymbol = Symbol('graph');
17
16
  type DeepWriteable<T> = { -readonly [K in keyof T]: T[K] extends object ? DeepWriteable<T[K]> : T[K] };
18
17
  type NodeInternal = DeepWriteable<Node> & { [graphSymbol]: Graph };
19
18
 
20
- /**
21
- * Get the Graph a Node is currently associated with.
22
- */
23
19
  export const getGraph = (node: Node): Graph => {
24
20
  const graph = (node as NodeInternal)[graphSymbol];
25
21
  invariant(graph, 'Node is not associated with a graph.');
@@ -31,6 +27,16 @@ export const ROOT_TYPE = 'dxos.org/type/GraphRoot';
31
27
  export const ACTION_TYPE = 'dxos.org/type/GraphAction';
32
28
  export const ACTION_GROUP_TYPE = 'dxos.org/type/GraphActionGroup';
33
29
 
30
+ export type NodesOptions<T = any, U extends Record<string, any> = Record<string, any>> = {
31
+ relation?: Relation;
32
+ filter?: NodeFilter<T, U>;
33
+ expansion?: boolean;
34
+ type?: string;
35
+ };
36
+
37
+ // TODO(wittjosiah): Consider having default be undefined. This is current default for backwards compatibility.
38
+ const DEFAULT_FILTER = (node: Node) => untracked(() => !isActionLike(node));
39
+
34
40
  export type GraphTraversalOptions = {
35
41
  /**
36
42
  * A callback which is called for each node visited during traversal.
@@ -42,9 +48,9 @@ export type GraphTraversalOptions = {
42
48
  /**
43
49
  * The node to start traversing from.
44
50
  *
45
- * @default ROOT_ID
51
+ * @default root
46
52
  */
47
- source?: string;
53
+ node?: Node;
48
54
 
49
55
  /**
50
56
  * The relation to traverse graph edges.
@@ -52,552 +58,549 @@ export type GraphTraversalOptions = {
52
58
  * @default 'outbound'
53
59
  */
54
60
  relation?: Relation;
61
+
62
+ /**
63
+ * Allow traversal to trigger expansion of the graph via `onInitialNodes`.
64
+ */
65
+ expansion?: boolean;
55
66
  };
56
67
 
57
68
  export type GraphParams = {
58
- registry?: Registry.Registry;
59
69
  nodes?: MakeOptional<Node, 'data' | 'cacheable'>[];
60
- edges?: Record<string, Edges>;
61
- onExpand?: Graph['_onExpand'];
62
- // TODO(wittjosiah): On initialize to restore state from cache.
63
- // onInitialize?: Graph['_onInitialize'];
70
+ edges?: Record<string, string[]>;
71
+ onInitialNode?: Graph['_onInitialNode'];
72
+ onInitialNodes?: Graph['_onInitialNodes'];
64
73
  onRemoveNode?: Graph['_onRemoveNode'];
65
74
  };
66
75
 
67
- export type Edge = { source: string; target: string };
68
- export type Edges = { inbound: string[]; outbound: string[] };
76
+ /**
77
+ * The Graph represents the structure of the application constructed via plugins.
78
+ */
79
+ export class Graph {
80
+ private readonly _onInitialNode?: (id: string) => Promise<void>;
81
+ private readonly _onInitialNodes?: (node: Node, relation: Relation, type?: string) => Promise<void>;
82
+ private readonly _onRemoveNode?: (id: string) => Promise<void>;
69
83
 
70
- export interface ReadableGraph {
71
- /**
72
- * Event emitted when a node is changed.
73
- */
74
- onNodeChanged: Event<{ id: string; node: Option.Option<Node> }>;
84
+ private readonly _waitingForNodes: Record<string, Trigger<Node>> = {};
85
+ private readonly _initialized: Record<string, boolean> = {};
75
86
 
76
87
  /**
77
- * Convert the graph to a JSON object.
88
+ * @internal
78
89
  */
79
- toJSON(id?: string): object;
80
-
81
- json(id?: string): Rx.Rx<any>;
90
+ readonly _nodes: Record<string, ReactiveObject<NodeInternal>> = {};
82
91
 
83
92
  /**
84
- * Get the rx key for the node with the given id.
93
+ * @internal
85
94
  */
86
- node(id: string): Rx.Rx<Option.Option<Node>>;
95
+ readonly _edges: Record<string, ReactiveObject<{ inbound: string[]; outbound: string[] }>> = {};
87
96
 
88
- /**
89
- * Get the rx key for the node with the given id.
90
- */
91
- nodeOrThrow(id: string): Rx.Rx<Node>;
97
+ constructor({ nodes, edges, onInitialNode, onInitialNodes, onRemoveNode }: GraphParams = {}) {
98
+ this._onInitialNode = onInitialNode;
99
+ this._onInitialNodes = onInitialNodes;
100
+ this._onRemoveNode = onRemoveNode;
92
101
 
93
- /**
94
- * Get the rx key for the connections of the node with the given id.
95
- */
96
- connections(id: string, relation?: Relation): Rx.Rx<Node[]>;
102
+ this._nodes[ROOT_ID] = this._constructNode({
103
+ id: ROOT_ID,
104
+ type: ROOT_TYPE,
105
+ cacheable: [],
106
+ properties: {},
107
+ data: null,
108
+ });
109
+ if (nodes) {
110
+ nodes.forEach((node) => {
111
+ const cacheable = Object.keys(node.properties ?? {});
112
+ if (node.type === ACTION_TYPE) {
113
+ this._addNode({ cacheable, data: () => log.warn('Pickled action invocation'), ...node });
114
+ } else if (node.type === ACTION_GROUP_TYPE) {
115
+ this._addNode({ cacheable, data: actionGroupSymbol, ...node });
116
+ } else {
117
+ this._addNode({ cacheable, ...node });
118
+ }
119
+ });
120
+ }
97
121
 
98
- /**
99
- * Get the rx key for the actions of the node with the given id.
100
- */
101
- actions(id: string): Rx.Rx<(Action | ActionGroup)[]>;
122
+ this._edges[ROOT_ID] = create({ inbound: [], outbound: [] });
123
+ if (edges) {
124
+ Object.entries(edges).forEach(([source, edges]) => {
125
+ edges.forEach((target) => {
126
+ this._addEdge({ source, target });
127
+ });
128
+ this._sortEdges(source, 'outbound', edges);
129
+ });
130
+ }
131
+ }
102
132
 
103
- /**
104
- * Get the rx key for the edges of the node with the given id.
105
- */
106
- edges(id: string): Rx.Rx<Edges>;
133
+ static from(pickle: string, options: Omit<GraphParams, 'nodes' | 'edges'> = {}) {
134
+ const { nodes, edges } = JSON.parse(pickle);
135
+ return new Graph({ nodes, edges, ...options });
136
+ }
107
137
 
108
138
  /**
109
- * Alias for `getNodeOrThrow(ROOT_ID)`.
139
+ * Alias for `findNode('root')`.
110
140
  */
111
- get root(): Node;
141
+ get root() {
142
+ return this.findNode(ROOT_ID)!;
143
+ }
112
144
 
113
145
  /**
114
- * Get the node with the given id from the graph's registry.
146
+ * Convert the graph to a JSON object.
115
147
  */
116
- getNode(id: string): Option.Option<Node>;
148
+ toJSON({ id = ROOT_ID, maxLength = 32 }: { id?: string; maxLength?: number } = {}) {
149
+ const toJSON = (node: Node, seen: string[] = []): any => {
150
+ const nodes = this.nodes(node);
151
+ const obj: Record<string, any> = {
152
+ id: node.id.length > maxLength ? `${node.id.slice(0, maxLength - 3)}...` : node.id,
153
+ type: node.type,
154
+ };
155
+ if (node.properties.label) {
156
+ obj.label = node.properties.label;
157
+ }
158
+ if (nodes.length) {
159
+ obj.nodes = nodes
160
+ .map((n) => {
161
+ // Break cycles.
162
+ const nextSeen = [...seen, node.id];
163
+ return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
164
+ })
165
+ .filter(isNonNullable);
166
+ }
167
+ return obj;
168
+ };
117
169
 
118
- /**
119
- * Get the node with the given id from the graph's registry.
120
- *
121
- * @throws If the node is Option.none().
122
- */
123
- getNodeOrThrow(id: string): Node;
170
+ const root = this.findNode(id);
171
+ invariant(root, `Node not found: ${id}`);
172
+ return toJSON(root);
173
+ }
124
174
 
125
- /**
126
- * Get all nodes connected to the node with the given id by the given relation from the graph's registry.
127
- */
128
- getConnections(id: string, relation?: Relation): Node[];
175
+ pickle() {
176
+ const nodes = Object.values(this._nodes)
177
+ .filter((node) => !!node.cacheable)
178
+ .map((node) => {
179
+ return {
180
+ id: node.id,
181
+ type: node.type,
182
+ properties: pick(node.properties, node.cacheable!),
183
+ };
184
+ });
129
185
 
130
- /**
131
- * Get all actions connected to the node with the given id from the graph's registry.
132
- */
133
- getActions(id: string): Node[];
186
+ const cacheable = new Set(nodes.map((node) => node.id));
134
187
 
135
- /**
136
- * Get the edges from the node with the given id from the graph's registry.
137
- */
138
- getEdges(id: string): Edges;
188
+ const edges = Object.fromEntries(
189
+ Object.entries(this._edges)
190
+ .filter(([id]) => cacheable.has(id))
191
+ .map(([id, { outbound }]): [string, string[]] => [id, outbound.filter((nodeId) => cacheable.has(nodeId))])
192
+ // TODO(wittjosiah): Why sort?
193
+ .toSorted(([a], [b]) => a.localeCompare(b)),
194
+ );
139
195
 
140
- /**
141
- * Recursive depth-first traversal of the graph.
142
- *
143
- * @param options.node The node to start traversing from.
144
- * @param options.relation The relation to traverse graph edges.
145
- * @param options.visitor A callback which is called for each node visited during traversal.
146
- */
147
- traverse(options: GraphTraversalOptions, path?: string[]): void;
196
+ return JSON.stringify({ nodes, edges });
197
+ }
148
198
 
149
199
  /**
150
- * Get the path between two nodes in the graph.
200
+ * Find the node with the given id in the graph.
201
+ *
202
+ * If a node is not found within the graph and an `onInitialNode` callback is provided,
203
+ * it is called with the id and type of the node, potentially initializing the node.
151
204
  */
152
- getPath(params: { source?: string; target: string }): Option.Option<string[]>;
205
+ findNode(id: string, expansion = true): Node | undefined {
206
+ const existingNode = this._nodes[id];
207
+ if (!existingNode && expansion) {
208
+ void this._onInitialNode?.(id);
209
+ }
153
210
 
154
- /**
155
- * Wait for the path between two nodes in the graph to be established.
156
- */
157
- waitForPath(
158
- params: { source?: string; target: string },
159
- options?: { timeout?: number; interval?: number },
160
- ): Promise<string[]>;
161
- }
211
+ return existingNode;
212
+ }
162
213
 
163
- export interface ExpandableGraph extends ReadableGraph {
164
214
  /**
165
- * Initialize a node in the graph.
215
+ * Wait for a node to be added to the graph.
166
216
  *
167
- * Fires the `onInitialize` callback to provide initial data for a node.
168
- */
169
- // initialize(id: string): Promise<void>;
170
-
171
- /**
172
- * Expand a node in the graph.
217
+ * If the node is already present in the graph, the promise resolves immediately.
173
218
  *
174
- * Fires the `onExpand` callback to add connections to the node.
219
+ * @param id The id of the node to wait for.
220
+ * @param timeout The time in milliseconds to wait for the node to be added.
175
221
  */
176
- expand(id: string, relation?: Relation): void;
177
-
178
- /**
179
- * Sort the edges of the node with the given id.
180
- */
181
- sortEdges(id: string, relation: Relation, order: string[]): void;
182
- }
183
-
184
- export interface WritableGraph extends ExpandableGraph {
185
- /**
186
- * Add nodes to the graph.
187
- */
188
- addNodes(nodes: NodeArg<any, Record<string, any>>[]): void;
222
+ async waitForNode(id: string, timeout?: number): Promise<Node> {
223
+ const trigger = this._waitingForNodes[id] ?? (this._waitingForNodes[id] = new Trigger<Node>());
224
+ const node = this.findNode(id);
225
+ if (node) {
226
+ delete this._waitingForNodes[id];
227
+ return node;
228
+ }
189
229
 
190
- /**
191
- * Add a node to the graph.
192
- */
193
- addNode(node: NodeArg<any, Record<string, any>>): void;
230
+ if (timeout === undefined) {
231
+ return trigger.wait();
232
+ } else {
233
+ return asyncTimeout(trigger.wait(), timeout, `Node not found: ${id}`);
234
+ }
235
+ }
194
236
 
195
237
  /**
196
- * Remove nodes from the graph.
238
+ * Nodes that this node is connected to in default order.
197
239
  */
198
- removeNodes(ids: string[], edges?: boolean): void;
240
+ nodes<T = any, U extends Record<string, any> = Record<string, any>>(node: Node, options: NodesOptions<T, U> = {}) {
241
+ const { relation, expansion, filter = DEFAULT_FILTER, type } = options;
242
+ const nodes = this._getNodes({ node, relation, expansion, type });
243
+ return nodes.filter((n) => filter(n, node));
244
+ }
199
245
 
200
246
  /**
201
- * Remove a node from the graph.
247
+ * Edges that this node is connected to in default order.
202
248
  */
203
- removeNode(id: string, edges?: boolean): void;
249
+ edges(node: Node, { relation = 'outbound' }: { relation?: Relation } = {}) {
250
+ return this._edges[node.id]?.[relation] ?? [];
251
+ }
204
252
 
205
253
  /**
206
- * Add edges to the graph.
254
+ * Actions or action groups that this node is connected to in default order.
207
255
  */
208
- addEdges(edges: Edge[]): void;
256
+ actions(node: Node, { expansion }: { expansion?: boolean } = {}) {
257
+ return [
258
+ ...this._getNodes({ node, expansion, type: ACTION_GROUP_TYPE }),
259
+ ...this._getNodes({ node, expansion, type: ACTION_TYPE }),
260
+ ];
261
+ }
209
262
 
210
- /**
211
- * Add an edge to the graph.
212
- */
213
- addEdge(edge: Edge): void;
263
+ async expand(node: Node, relation: Relation = 'outbound', type?: string) {
264
+ const key = this._key(node, relation, type);
265
+ const initialized = this._initialized[key];
266
+ if (!initialized && this._onInitialNodes) {
267
+ await this._onInitialNodes(node, relation, type);
268
+ this._initialized[key] = true;
269
+ }
270
+ }
214
271
 
215
- /**
216
- * Remove edges from the graph.
217
- */
218
- removeEdges(edges: Edge[], removeOrphans?: boolean): void;
272
+ private _key(node: Node, relation: Relation, type?: string) {
273
+ return `${node.id}-${relation}-${type}`;
274
+ }
219
275
 
220
276
  /**
221
- * Remove an edge from the graph.
277
+ * Recursive depth-first traversal of the graph.
278
+ *
279
+ * @param options.node The node to start traversing from.
280
+ * @param options.relation The relation to traverse graph edges.
281
+ * @param options.visitor A callback which is called for each node visited during traversal.
222
282
  */
223
- removeEdge(edge: Edge, removeOrphans?: boolean): void;
224
- }
225
-
226
- /**
227
- * The Graph represents the user interface information architecture of the application constructed via plugins.
228
- */
229
- export class Graph implements WritableGraph {
230
- readonly onNodeChanged = new Event<{ id: string; node: Option.Option<Node> }>();
231
-
232
- private readonly _onExpand?: (id: string, relation: Relation) => void;
233
- // private readonly _onInitialize?: (id: string) => Promise<void>;
234
- private readonly _onRemoveNode?: (id: string) => void;
235
-
236
- private readonly _registry: Registry.Registry;
237
- private readonly _expanded = Record.empty<string, boolean>();
238
- private readonly _initialized = Record.empty<string, boolean>();
239
- private readonly _initialEdges = Record.empty<string, Edges>();
240
- private readonly _initialNodes = Record.fromEntries([
241
- [ROOT_ID, this._constructNode({ id: ROOT_ID, type: ROOT_TYPE, data: null, properties: {} })],
242
- ]);
243
-
244
- /** @internal */
245
- readonly _node = Rx.family<string, Rx.Writable<Option.Option<Node>>>((id) => {
246
- const initial = Option.flatten(Record.get(this._initialNodes, id));
247
- return Rx.make<Option.Option<Node>>(initial).pipe(Rx.keepAlive, Rx.withLabel(`graph:node:${id}`));
248
- });
249
-
250
- private readonly _nodeOrThrow = Rx.family<string, Rx.Rx<Node>>((id) => {
251
- return Rx.make((get) => {
252
- const node = get(this._node(id));
253
- invariant(Option.isSome(node), `Node not available: ${id}`);
254
- return node.value;
255
- });
256
- });
257
-
258
- private readonly _edges = Rx.family<string, Rx.Writable<Edges>>((id) => {
259
- const initial = Record.get(this._initialEdges, id).pipe(Option.getOrElse(() => ({ inbound: [], outbound: [] })));
260
- return Rx.make<Edges>(initial).pipe(Rx.keepAlive, Rx.withLabel(`graph:edges:${id}`));
261
- });
262
-
263
- // NOTE: Currently the argument to the family needs to be referentially stable for the rx to be referentially stable.
264
- // TODO(wittjosiah): Rx feature request, support for something akin to `ComplexMap` to allow for complex arguments.
265
- private readonly _connections = Rx.family<string, Rx.Rx<Node[]>>((key) => {
266
- return Rx.make((get) => {
267
- const [id, relation] = key.split('$');
268
- const edges = get(this._edges(id));
269
- return edges[relation as Relation]
270
- .map((id) => get(this._node(id)))
271
- .filter(Option.isSome)
272
- .map((o) => o.value);
273
- }).pipe(Rx.withLabel(`graph:connections:${key}`));
274
- });
275
-
276
- private readonly _actions = Rx.family<string, Rx.Rx<(Action | ActionGroup)[]>>((id) => {
277
- return Rx.make((get) => {
278
- return get(this._connections(`${id}$outbound`)).filter(
279
- (node) => node.type === ACTION_TYPE || node.type === ACTION_GROUP_TYPE,
280
- );
281
- }).pipe(Rx.withLabel(`graph:actions:${id}`));
282
- });
283
-
284
- private readonly _json = Rx.family<string, Rx.Rx<any>>((id) => {
285
- return Rx.make((get) => {
286
- const toJSON = (node: Node, seen: string[] = []): any => {
287
- const nodes = get(this.connections(node.id));
288
- const obj: Record<string, any> = {
289
- id: node.id.length > 32 ? `${node.id.slice(0, 32)}...` : node.id,
290
- type: node.type,
291
- };
292
- if (node.properties.label) {
293
- obj.label = node.properties.label;
294
- }
295
- if (nodes.length) {
296
- obj.nodes = nodes
297
- .map((n) => {
298
- // Break cycles.
299
- const nextSeen = [...seen, node.id];
300
- return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
301
- })
302
- .filter(isNonNullable);
303
- }
304
- return obj;
305
- };
306
-
307
- const root = get(this.nodeOrThrow(id));
308
- return toJSON(root);
309
- }).pipe(Rx.withLabel(`graph:json:${id}`));
310
- });
311
-
312
- constructor({ registry, nodes, edges, onExpand, onRemoveNode }: GraphParams = {}) {
313
- this._registry = registry ?? Registry.make();
314
- this._onExpand = onExpand;
315
- this._onRemoveNode = onRemoveNode;
316
-
317
- if (nodes) {
318
- nodes.forEach((node) => {
319
- Record.set(this._initialNodes, node.id, this._constructNode(node));
320
- });
283
+ traverse(
284
+ { visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
285
+ path: string[] = [],
286
+ ): void {
287
+ // Break cycles.
288
+ if (path.includes(node.id)) {
289
+ return;
321
290
  }
322
291
 
323
- if (edges) {
324
- Object.entries(edges).forEach(([source, edges]) => {
325
- Record.set(this._initialEdges, source, edges);
326
- });
292
+ const shouldContinue = visitor(node, [...path, node.id]);
293
+ if (shouldContinue === false) {
294
+ return;
327
295
  }
328
- }
329
-
330
- toJSON(id = ROOT_ID) {
331
- return this._registry.get(this._json(id));
332
- }
333
-
334
- json(id = ROOT_ID) {
335
- return this._json(id);
336
- }
337
296
 
338
- node(id: string): Rx.Rx<Option.Option<Node>> {
339
- return this._node(id);
297
+ Object.values(this._getNodes({ node, relation, expansion })).forEach((child) =>
298
+ this.traverse({ node: child, relation, visitor, expansion }, [...path, node.id]),
299
+ );
340
300
  }
341
301
 
342
- nodeOrThrow(id: string): Rx.Rx<Node> {
343
- return this._nodeOrThrow(id);
344
- }
302
+ /**
303
+ * Recursive depth-first traversal of the graph wrapping each visitor call in an effect.
304
+ *
305
+ * @param options.node The node to start traversing from.
306
+ * @param options.relation The relation to traverse graph edges.
307
+ * @param options.visitor A callback which is called for each node visited during traversal.
308
+ */
309
+ subscribeTraverse(
310
+ { visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
311
+ currentPath: string[] = [],
312
+ ) {
313
+ return effect(() => {
314
+ const path = [...currentPath, node.id];
315
+ const result = visitor(node, path);
316
+ if (result === false) {
317
+ return;
318
+ }
345
319
 
346
- connections(id: string, relation: Relation = 'outbound'): Rx.Rx<Node[]> {
347
- return this._connections(`${id}$${relation}`);
320
+ const nodes = this._getNodes({ node, relation, expansion });
321
+ const nodeSubscriptions = nodes.map((n) => this.subscribeTraverse({ node: n, visitor, expansion }, path));
322
+ return () => {
323
+ nodeSubscriptions.forEach((unsubscribe) => unsubscribe());
324
+ };
325
+ });
348
326
  }
349
327
 
350
- actions(id: string) {
351
- return this._actions(id);
352
- }
328
+ /**
329
+ * Get the path between two nodes in the graph.
330
+ */
331
+ getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
332
+ const start = this.findNode(source);
333
+ if (!start) {
334
+ return undefined;
335
+ }
353
336
 
354
- edges(id: string): Rx.Rx<Edges> {
355
- return this._edges(id);
356
- }
337
+ let found: string[] | undefined;
338
+ this.traverse({
339
+ node: start,
340
+ visitor: (node, path) => {
341
+ if (found) {
342
+ return false;
343
+ }
357
344
 
358
- get root() {
359
- return this.getNodeOrThrow(ROOT_ID);
360
- }
345
+ if (node.id === target) {
346
+ found = path;
347
+ }
348
+ },
349
+ });
361
350
 
362
- getNode(id: string): Option.Option<Node> {
363
- return this._registry.get(this.node(id));
351
+ return found;
364
352
  }
365
353
 
366
- getNodeOrThrow(id: string): Node {
367
- return this._registry.get(this.nodeOrThrow(id));
368
- }
354
+ /**
355
+ * Wait for the path between two nodes in the graph to be established.
356
+ */
357
+ async waitForPath(
358
+ params: { source?: string; target: string },
359
+ { timeout = 5_000, interval = 500 }: { timeout?: number; interval?: number } = {},
360
+ ) {
361
+ const path = this.getPath(params);
362
+ if (path) {
363
+ return path;
364
+ }
369
365
 
370
- getConnections(id: string, relation: Relation = 'outbound'): Node[] {
371
- return this._registry.get(this.connections(id, relation));
372
- }
366
+ const trigger = new Trigger<string[]>();
367
+ const i = setInterval(() => {
368
+ const path = this.getPath(params);
369
+ if (path) {
370
+ trigger.wake(path);
371
+ }
372
+ }, interval);
373
373
 
374
- getActions(id: string): Node[] {
375
- return this._registry.get(this.actions(id));
374
+ return trigger.wait({ timeout }).finally(() => clearInterval(i));
376
375
  }
377
376
 
378
- getEdges(id: string): Edges {
379
- return this._registry.get(this.edges(id));
377
+ /**
378
+ * Add nodes to the graph.
379
+ *
380
+ * @internal
381
+ */
382
+ _addNodes<TData = null, TProperties extends Record<string, any> = Record<string, any>>(
383
+ nodes: NodeArg<TData, TProperties>[],
384
+ ): Node<TData, TProperties>[] {
385
+ return batch(() => nodes.map((node) => this._addNode(node)));
380
386
  }
381
387
 
382
- // TODO(wittjosiah): On initialize to restore state from cache.
383
- // async initialize(id: string) {
384
- // const initialized = Record.get(this._initialized, id).pipe(Option.getOrElse(() => false));
385
- // log('initialize', { id, initialized });
386
- // if (!initialized) {
387
- // await this._onInitialize?.(id);
388
- // Record.set(this._initialized, id, true);
389
- // }
390
- // }
391
-
392
- expand(id: string, relation: Relation = 'outbound') {
393
- const key = `${id}$${relation}`;
394
- const expanded = Record.get(this._expanded, key).pipe(Option.getOrElse(() => false));
395
- log('expand', { key, expanded });
396
- if (!expanded) {
397
- this._onExpand?.(id, relation);
398
- Record.set(this._expanded, key, true);
399
- }
400
- }
388
+ private _addNode<TData, TProperties extends Record<string, any> = Record<string, any>>({
389
+ nodes,
390
+ edges,
391
+ ..._node
392
+ }: NodeArg<TData, TProperties>): Node<TData, TProperties> {
393
+ return untracked(() => {
394
+ const existingNode = this._nodes[_node.id];
395
+ const node = existingNode ?? this._constructNode({ data: null, properties: {}, ..._node });
396
+ if (existingNode) {
397
+ const { data = null, properties, type } = _node;
398
+ if (data !== node.data) {
399
+ node.data = data;
400
+ }
401
401
 
402
- addNodes(nodes: NodeArg<any, Record<string, any>>[]) {
403
- Rx.batch(() => {
404
- nodes.map((node) => this.addNode(node));
405
- });
406
- }
402
+ if (type !== node.type) {
403
+ node.type = type;
404
+ }
407
405
 
408
- addNode({ nodes, edges, ...nodeArg }: NodeArg<any, Record<string, any>>) {
409
- const { id, type, data = null, properties = {} } = nodeArg;
410
- const nodeRx = this._node(id);
411
- const node = this._registry.get(nodeRx);
412
- Option.match(node, {
413
- onSome: (node) => {
414
- const typeChanged = node.type !== type;
415
- const dataChanged = node.data !== data;
416
- const propertiesChanged = Object.keys(properties).some((key) => node.properties[key] !== properties[key]);
417
- log('existing node', { typeChanged, dataChanged, propertiesChanged });
418
- if (typeChanged || dataChanged || propertiesChanged) {
419
- log('updating node', { id, type, data, properties });
420
- const newNode = Option.some({ ...node, type, data, properties: { ...node.properties, ...properties } });
421
- this._registry.set(nodeRx, newNode);
422
- this.onNodeChanged.emit({ id, node: newNode });
406
+ for (const key in properties) {
407
+ if (properties[key] !== node.properties[key]) {
408
+ node.properties[key] = properties[key];
409
+ }
423
410
  }
424
- },
425
- onNone: () => {
426
- log('new node', { id, type, data, properties });
427
- const newNode = this._constructNode({ id, type, data, properties });
428
- this._registry.set(nodeRx, newNode);
429
- this.onNodeChanged.emit({ id, node: newNode });
430
- },
431
- });
411
+ } else {
412
+ this._nodes[node.id] = node;
413
+ this._edges[node.id] = create({ inbound: [], outbound: [] });
414
+ }
432
415
 
433
- if (nodes) {
434
- // Rx.batch(() => {
435
- this.addNodes(nodes);
436
- const _edges = nodes.map((node) => ({ source: id, target: node.id }));
437
- this.addEdges(_edges);
438
- // });
439
- }
416
+ const trigger = this._waitingForNodes[node.id];
417
+ if (trigger) {
418
+ trigger.wake(node);
419
+ delete this._waitingForNodes[node.id];
420
+ }
440
421
 
441
- if (edges) {
442
- todo();
443
- }
444
- }
422
+ if (nodes) {
423
+ nodes.forEach((subNode) => {
424
+ this._addNode(subNode);
425
+ this._addEdge({ source: node.id, target: subNode.id });
426
+ });
427
+ }
428
+
429
+ if (edges) {
430
+ edges.forEach(([id, relation]) =>
431
+ relation === 'outbound'
432
+ ? this._addEdge({ source: node.id, target: id })
433
+ : this._addEdge({ source: id, target: node.id }),
434
+ );
435
+ }
445
436
 
446
- removeNodes(ids: string[], edges = false) {
447
- Rx.batch(() => {
448
- ids.map((id) => this.removeNode(id, edges));
437
+ return node as unknown as Node<TData, TProperties>;
449
438
  });
450
439
  }
451
440
 
452
- removeNode(id: string, edges = false) {
453
- const nodeRx = this._node(id);
454
- // TODO(wittjosiah): Is there a way to mark these rx values for garbage collection?
455
- this._registry.set(nodeRx, Option.none());
456
- this.onNodeChanged.emit({ id, node: Option.none() });
457
- // TODO(wittjosiah): Reset expanded and initialized flags?
458
-
459
- if (edges) {
460
- const { inbound, outbound } = this._registry.get(this._edges(id));
461
- const edges = [
462
- ...inbound.map((source) => ({ source, target: id })),
463
- ...outbound.map((target) => ({ source: id, target })),
464
- ];
465
- this.removeEdges(edges);
466
- }
467
-
468
- this._onRemoveNode?.(id);
441
+ /**
442
+ * Remove nodes from the graph.
443
+ *
444
+ * @param ids The id of the node to remove.
445
+ * @param edges Whether to remove edges connected to the node from the graph as well.
446
+ * @internal
447
+ */
448
+ _removeNodes(ids: string[], edges = false) {
449
+ batch(() => ids.forEach((id) => this._removeNode(id, edges)));
469
450
  }
470
451
 
471
- addEdges(edges: Edge[]) {
472
- Rx.batch(() => {
473
- edges.map((edge) => this.addEdge(edge));
474
- });
475
- }
452
+ private _removeNode(id: string, edges = false) {
453
+ untracked(() => {
454
+ const node = this.findNode(id, false);
455
+ if (!node) {
456
+ return;
457
+ }
476
458
 
477
- addEdge(edgeArg: Edge) {
478
- const sourceRx = this._edges(edgeArg.source);
479
- const source = this._registry.get(sourceRx);
480
- if (!source.outbound.includes(edgeArg.target)) {
481
- log('add outbound edge', { source: edgeArg.source, target: edgeArg.target });
482
- this._registry.set(sourceRx, { inbound: source.inbound, outbound: [...source.outbound, edgeArg.target] });
483
- }
459
+ if (edges) {
460
+ // Remove edges from connected nodes.
461
+ this._getNodes({ node }).forEach((node) => {
462
+ this._removeEdge({ source: id, target: node.id });
463
+ });
464
+ this._getNodes({ node, relation: 'inbound' }).forEach((node) => {
465
+ this._removeEdge({ source: node.id, target: id });
466
+ });
484
467
 
485
- const targetRx = this._edges(edgeArg.target);
486
- const target = this._registry.get(targetRx);
487
- if (!target.inbound.includes(edgeArg.source)) {
488
- log('add inbound edge', { source: edgeArg.source, target: edgeArg.target });
489
- this._registry.set(targetRx, { inbound: [...target.inbound, edgeArg.source], outbound: target.outbound });
490
- }
491
- }
468
+ // Remove edges from node.
469
+ delete this._edges[id];
470
+ }
492
471
 
493
- removeEdges(edges: Edge[], removeOrphans = false) {
494
- Rx.batch(() => {
495
- edges.map((edge) => this.removeEdge(edge, removeOrphans));
472
+ // Remove node.
473
+ delete this._nodes[id];
474
+ Object.keys(this._initialized)
475
+ .filter((key) => key.startsWith(id))
476
+ .forEach((key) => {
477
+ delete this._initialized[key];
478
+ });
479
+ void this._onRemoveNode?.(id);
496
480
  });
497
481
  }
498
482
 
499
- removeEdge(edgeArg: Edge, removeOrphans = false) {
500
- const sourceRx = this._edges(edgeArg.source);
501
- const source = this._registry.get(sourceRx);
502
- if (source.outbound.includes(edgeArg.target)) {
503
- this._registry.set(sourceRx, {
504
- inbound: source.inbound,
505
- outbound: source.outbound.filter((id) => id !== edgeArg.target),
506
- });
507
- }
483
+ /**
484
+ * Add edges to the graph.
485
+ *
486
+ * @internal
487
+ */
488
+ _addEdges(edges: { source: string; target: string }[]) {
489
+ batch(() => edges.forEach((edge) => this._addEdge(edge)));
490
+ }
508
491
 
509
- const targetRx = this._edges(edgeArg.target);
510
- const target = this._registry.get(targetRx);
511
- if (target.inbound.includes(edgeArg.source)) {
512
- this._registry.set(targetRx, {
513
- inbound: target.inbound.filter((id) => id !== edgeArg.source),
514
- outbound: target.outbound,
515
- });
516
- }
492
+ private _addEdge({ source, target }: { source: string; target: string }) {
493
+ untracked(() => {
494
+ if (!this._edges[source]) {
495
+ this._edges[source] = create({ inbound: [], outbound: [] });
496
+ }
497
+ if (!this._edges[target]) {
498
+ this._edges[target] = create({ inbound: [], outbound: [] });
499
+ }
517
500
 
518
- if (removeOrphans) {
519
- const source = this._registry.get(sourceRx);
520
- const target = this._registry.get(targetRx);
521
- if (source.outbound.length === 0 && source.inbound.length === 0 && edgeArg.source !== ROOT_ID) {
522
- this.removeNodes([edgeArg.source]);
501
+ const sourceEdges = this._edges[source];
502
+ if (!sourceEdges.outbound.includes(target)) {
503
+ sourceEdges.outbound.push(target);
523
504
  }
524
- if (target.outbound.length === 0 && target.inbound.length === 0 && edgeArg.target !== ROOT_ID) {
525
- this.removeNodes([edgeArg.target]);
505
+
506
+ const targetEdges = this._edges[target];
507
+ if (!targetEdges.inbound.includes(source)) {
508
+ targetEdges.inbound.push(source);
526
509
  }
527
- }
510
+ });
528
511
  }
529
512
 
530
- sortEdges(id: string, relation: Relation, order: string[]) {
531
- const edgesRx = this._edges(id);
532
- const edges = this._registry.get(edgesRx);
533
- const unsorted = edges[relation].filter((id) => !order.includes(id)) ?? [];
534
- const sorted = order.filter((id) => edges[relation].includes(id)) ?? [];
535
- edges[relation].splice(0, edges[relation].length, ...[...sorted, ...unsorted]);
536
- this._registry.set(edgesRx, edges);
513
+ /**
514
+ * Remove edges from the graph.
515
+ * @internal
516
+ */
517
+ _removeEdges(edges: { source: string; target: string }[], removeOrphans = false) {
518
+ batch(() => edges.forEach((edge) => this._removeEdge(edge, removeOrphans)));
537
519
  }
538
520
 
539
- traverse({ visitor, source = ROOT_ID, relation = 'outbound' }: GraphTraversalOptions, path: string[] = []): void {
540
- // Break cycles.
541
- if (path.includes(source)) {
542
- return;
543
- }
521
+ private _removeEdge({ source, target }: { source: string; target: string }, removeOrphans = false) {
522
+ untracked(() => {
523
+ batch(() => {
524
+ const outboundIndex = this._edges[source]?.outbound.findIndex((id) => id === target);
525
+ if (outboundIndex !== undefined && outboundIndex !== -1) {
526
+ this._edges[source].outbound.splice(outboundIndex, 1);
527
+ }
544
528
 
545
- const node = this.getNodeOrThrow(source);
546
- const shouldContinue = visitor(node, [...path, source]);
547
- if (shouldContinue === false) {
548
- return;
549
- }
529
+ const inboundIndex = this._edges[target]?.inbound.findIndex((id) => id === source);
530
+ if (inboundIndex !== undefined && inboundIndex !== -1) {
531
+ this._edges[target].inbound.splice(inboundIndex, 1);
532
+ }
550
533
 
551
- Object.values(this.getConnections(source, relation)).forEach((child) =>
552
- this.traverse({ source: child.id, relation, visitor }, [...path, source]),
553
- );
534
+ if (removeOrphans) {
535
+ if (
536
+ this._edges[source]?.outbound.length === 0 &&
537
+ this._edges[source]?.inbound.length === 0 &&
538
+ source !== ROOT_ID
539
+ ) {
540
+ this._removeNode(source, true);
541
+ }
542
+ if (
543
+ this._edges[target]?.outbound.length === 0 &&
544
+ this._edges[target]?.inbound.length === 0 &&
545
+ target !== ROOT_ID
546
+ ) {
547
+ this._removeNode(target, true);
548
+ }
549
+ }
550
+ });
551
+ });
554
552
  }
555
553
 
556
- getPath({ source = 'root', target }: { source?: string; target: string }): Option.Option<string[]> {
557
- return pipe(
558
- this.getNode(source),
559
- Option.flatMap((node) => {
560
- let found: Option.Option<string[]> = Option.none();
561
- this.traverse({
562
- source: node.id,
563
- visitor: (node, path) => {
564
- if (Option.isSome(found)) {
565
- return false;
566
- }
567
-
568
- if (node.id === target) {
569
- found = Option.some(path);
570
- }
571
- },
572
- });
573
-
574
- return found;
575
- }),
576
- );
554
+ /**
555
+ * Sort edges for a node.
556
+ *
557
+ * Edges not included in the sorted list are appended to the end of the list.
558
+ *
559
+ * @param nodeId The id of the node to sort edges for.
560
+ * @param relation The relation of the edges from the node to sort.
561
+ * @param edges The ordered list of edges.
562
+ * @ignore
563
+ */
564
+ _sortEdges(nodeId: string, relation: Relation, edges: string[]) {
565
+ untracked(() => {
566
+ batch(() => {
567
+ const current = this._edges[nodeId];
568
+ if (current) {
569
+ const unsorted = current[relation].filter((id) => !edges.includes(id)) ?? [];
570
+ const sorted = edges.filter((id) => current[relation].includes(id)) ?? [];
571
+ current[relation].splice(0, current[relation].length, ...[...sorted, ...unsorted]);
572
+ }
573
+ });
574
+ });
577
575
  }
578
576
 
579
- async waitForPath(
580
- params: { source?: string; target: string },
581
- { timeout = 5_000, interval = 500 }: { timeout?: number; interval?: number } = {},
582
- ) {
583
- const path = this.getPath(params);
584
- if (Option.isSome(path)) {
585
- return path.value;
577
+ private _constructNode = (node: Omit<Node, typeof graphSymbol>) => {
578
+ return create<NodeInternal>({ ...node, [graphSymbol]: this });
579
+ };
580
+
581
+ private _getNodes({
582
+ node,
583
+ relation = 'outbound',
584
+ type,
585
+ expansion,
586
+ }: {
587
+ node: Node;
588
+ relation?: Relation;
589
+ type?: string;
590
+ expansion?: boolean;
591
+ }): Node[] {
592
+ if (expansion) {
593
+ void this.expand(node, relation, type);
586
594
  }
587
595
 
588
- const trigger = new Trigger<string[]>();
589
- const i = setInterval(() => {
590
- const path = this.getPath(params);
591
- if (Option.isSome(path)) {
592
- trigger.wake(path.value);
593
- }
594
- }, interval);
595
-
596
- return trigger.wait({ timeout }).finally(() => clearInterval(i));
597
- }
598
-
599
- /** @internal */
600
- _constructNode(node: NodeArg<any>): Option.Option<Node> {
601
- return Option.some({ [graphSymbol]: this, data: null, properties: {}, ...node });
596
+ const edges = this._edges[node.id];
597
+ if (!edges) {
598
+ return [];
599
+ } else {
600
+ return edges[relation]
601
+ .map((id) => this._nodes[id])
602
+ .filter(isNonNullable)
603
+ .filter((n) => !type || n.type === type);
604
+ }
602
605
  }
603
606
  }