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