@dxos/app-graph 0.8.2-staging.7ac8446 → 0.8.2
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 +593 -794
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +585 -785
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +593 -794
- 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 +3 -3
- 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 +24 -17
- package/src/experimental/graph-projections.test.ts +56 -0
- package/src/graph-builder.test.ts +293 -310
- package/src/graph-builder.ts +235 -318
- package/src/graph.test.ts +314 -463
- package/src/graph.ts +452 -455
- package/src/node.ts +4 -4
- package/src/signals-integration.test.ts +218 -0
- package/src/stories/EchoGraph.stories.tsx +67 -76
- 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 ReactiveObject, create } 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<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
|
-
|
|
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,220 +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
|
-
|
|
96
|
-
|
|
97
|
-
constructor({ nodes, edges, onInitialNode, onInitialNodes, onRemoveNode }: GraphParams = {}) {
|
|
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
|
-
}
|
|
79
|
+
toJSON(id?: string): object;
|
|
121
80
|
|
|
122
|
-
|
|
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
|
-
};
|
|
91
|
+
nodeOrThrow(id: string): Rx.Rx<Node>;
|
|
169
92
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
});
|
|
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
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* If the node is already present in the graph, the promise resolves immediately.
|
|
119
|
+
* Get the node with the given id from the graph's registry.
|
|
218
120
|
*
|
|
219
|
-
* @
|
|
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
|
-
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
|
-
}
|
|
128
|
+
getConnections(id: string, relation?: Relation): Node[];
|
|
245
129
|
|
|
246
130
|
/**
|
|
247
|
-
*
|
|
131
|
+
* Get all actions connected to the node with the given id from the graph's registry.
|
|
248
132
|
*/
|
|
249
|
-
|
|
250
|
-
return this._edges[node.id]?.[relation] ?? [];
|
|
251
|
-
}
|
|
133
|
+
getActions(id: string): Node[];
|
|
252
134
|
|
|
253
135
|
/**
|
|
254
|
-
*
|
|
136
|
+
* Get the edges from the node with the given id from the graph's registry.
|
|
255
137
|
*/
|
|
256
|
-
|
|
257
|
-
return [
|
|
258
|
-
...this._getNodes({ node, expansion, type: ACTION_GROUP_TYPE }),
|
|
259
|
-
...this._getNodes({ node, expansion, type: ACTION_TYPE }),
|
|
260
|
-
];
|
|
261
|
-
}
|
|
262
|
-
|
|
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
|
-
}
|
|
271
|
-
|
|
272
|
-
private _key(node: Node, relation: Relation, type?: string) {
|
|
273
|
-
return `${node.id}-${relation}-${type}`;
|
|
274
|
-
}
|
|
138
|
+
getEdges(id: string): Edges;
|
|
275
139
|
|
|
276
140
|
/**
|
|
277
141
|
* Recursive depth-first traversal of the graph.
|
|
@@ -280,327 +144,460 @@ export class Graph {
|
|
|
280
144
|
* @param options.relation The relation to traverse graph edges.
|
|
281
145
|
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
282
146
|
*/
|
|
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;
|
|
290
|
-
}
|
|
147
|
+
traverse(options: GraphTraversalOptions, path?: string[]): void;
|
|
291
148
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Get the path between two nodes in the graph.
|
|
151
|
+
*/
|
|
152
|
+
getPath(params: { source?: string; target: string }): Option.Option<string[]>;
|
|
296
153
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|
|
301
162
|
|
|
163
|
+
export interface ExpandableGraph extends ReadableGraph {
|
|
302
164
|
/**
|
|
303
|
-
*
|
|
165
|
+
* Initialize a node in the graph.
|
|
304
166
|
*
|
|
305
|
-
*
|
|
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.
|
|
167
|
+
* Fires the `onInitialize` callback to provide initial data for a node.
|
|
308
168
|
*/
|
|
309
|
-
|
|
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
|
-
}
|
|
169
|
+
// initialize(id: string): Promise<void>;
|
|
319
170
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
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;
|
|
327
177
|
|
|
328
178
|
/**
|
|
329
|
-
*
|
|
179
|
+
* Sort the edges of the node with the given id.
|
|
330
180
|
*/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (!start) {
|
|
334
|
-
return undefined;
|
|
335
|
-
}
|
|
181
|
+
sortEdges(id: string, relation: Relation, order: string[]): void;
|
|
182
|
+
}
|
|
336
183
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
184
|
+
export interface WritableGraph extends ExpandableGraph {
|
|
185
|
+
/**
|
|
186
|
+
* Add nodes to the graph.
|
|
187
|
+
*/
|
|
188
|
+
addNodes(nodes: NodeArg<any, Record<string, any>>[]): void;
|
|
344
189
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
});
|
|
190
|
+
/**
|
|
191
|
+
* Add a node to the graph.
|
|
192
|
+
*/
|
|
193
|
+
addNode(node: NodeArg<any, Record<string, any>>): void;
|
|
350
194
|
|
|
351
|
-
|
|
352
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Remove nodes from the graph.
|
|
197
|
+
*/
|
|
198
|
+
removeNodes(ids: string[], edges?: boolean): void;
|
|
353
199
|
|
|
354
200
|
/**
|
|
355
|
-
*
|
|
201
|
+
* Remove a node from the graph.
|
|
356
202
|
*/
|
|
357
|
-
|
|
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
|
-
}
|
|
203
|
+
removeNode(id: string, edges?: boolean): void;
|
|
365
204
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
trigger.wake(path);
|
|
371
|
-
}
|
|
372
|
-
}, interval);
|
|
205
|
+
/**
|
|
206
|
+
* Add edges to the graph.
|
|
207
|
+
*/
|
|
208
|
+
addEdges(edges: Edge[]): void;
|
|
373
209
|
|
|
374
|
-
|
|
375
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Add an edge to the graph.
|
|
212
|
+
*/
|
|
213
|
+
addEdge(edge: Edge): void;
|
|
376
214
|
|
|
377
215
|
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* @internal
|
|
216
|
+
* Remove edges from the graph.
|
|
381
217
|
*/
|
|
382
|
-
|
|
383
|
-
nodes: NodeArg<TData, TProperties>[],
|
|
384
|
-
): Node<TData, TProperties>[] {
|
|
385
|
-
return batch(() => nodes.map((node) => this._addNode(node)));
|
|
386
|
-
}
|
|
218
|
+
removeEdges(edges: Edge[], removeOrphans?: boolean): void;
|
|
387
219
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
}
|
|
220
|
+
/**
|
|
221
|
+
* Remove an edge from the graph.
|
|
222
|
+
*/
|
|
223
|
+
removeEdge(edge: Edge, removeOrphans?: boolean): void;
|
|
224
|
+
}
|
|
401
225
|
|
|
402
|
-
|
|
403
|
-
|
|
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;
|
|
404
294
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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);
|
|
410
303
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
this._edges[node.id] = create({ inbound: [], outbound: [] });
|
|
414
|
-
}
|
|
304
|
+
return obj;
|
|
305
|
+
};
|
|
415
306
|
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
307
|
+
const root = get(this.nodeOrThrow(id));
|
|
308
|
+
return toJSON(root);
|
|
309
|
+
}).pipe(Rx.withLabel(`graph:json:${id}`));
|
|
310
|
+
});
|
|
421
311
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
});
|
|
427
|
-
}
|
|
312
|
+
constructor({ registry, nodes, edges, onExpand, onRemoveNode }: GraphParams = {}) {
|
|
313
|
+
this._registry = registry ?? Registry.make();
|
|
314
|
+
this._onExpand = onExpand;
|
|
315
|
+
this._onRemoveNode = onRemoveNode;
|
|
428
316
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
);
|
|
435
|
-
}
|
|
317
|
+
if (nodes) {
|
|
318
|
+
nodes.forEach((node) => {
|
|
319
|
+
Record.set(this._initialNodes, node.id, this._constructNode(node));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
436
322
|
|
|
437
|
-
|
|
438
|
-
|
|
323
|
+
if (edges) {
|
|
324
|
+
Object.entries(edges).forEach(([source, edges]) => {
|
|
325
|
+
Record.set(this._initialEdges, source, edges);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
439
328
|
}
|
|
440
329
|
|
|
441
|
-
|
|
442
|
-
|
|
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)));
|
|
330
|
+
toJSON(id = ROOT_ID) {
|
|
331
|
+
return this._registry.get(this._json(id));
|
|
450
332
|
}
|
|
451
333
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (!node) {
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
334
|
+
json(id = ROOT_ID) {
|
|
335
|
+
return this._json(id);
|
|
336
|
+
}
|
|
458
337
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
});
|
|
338
|
+
node(id: string): Rx.Rx<Option.Option<Node>> {
|
|
339
|
+
return this._node(id);
|
|
340
|
+
}
|
|
467
341
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
342
|
+
nodeOrThrow(id: string): Rx.Rx<Node> {
|
|
343
|
+
return this._nodeOrThrow(id);
|
|
344
|
+
}
|
|
471
345
|
|
|
472
|
-
|
|
473
|
-
|
|
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);
|
|
480
|
-
});
|
|
346
|
+
connections(id: string, relation: Relation = 'outbound'): Rx.Rx<Node[]> {
|
|
347
|
+
return this._connections(`${id}$${relation}`);
|
|
481
348
|
}
|
|
482
349
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
*
|
|
486
|
-
* @internal
|
|
487
|
-
*/
|
|
488
|
-
_addEdges(edges: { source: string; target: string }[]) {
|
|
489
|
-
batch(() => edges.forEach((edge) => this._addEdge(edge)));
|
|
350
|
+
actions(id: string) {
|
|
351
|
+
return this._actions(id);
|
|
490
352
|
}
|
|
491
353
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
this._edges[source] = create({ inbound: [], outbound: [] });
|
|
496
|
-
}
|
|
497
|
-
if (!this._edges[target]) {
|
|
498
|
-
this._edges[target] = create({ inbound: [], outbound: [] });
|
|
499
|
-
}
|
|
354
|
+
edges(id: string): Rx.Rx<Edges> {
|
|
355
|
+
return this._edges(id);
|
|
356
|
+
}
|
|
500
357
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
358
|
+
get root() {
|
|
359
|
+
return this.getNodeOrThrow(ROOT_ID);
|
|
360
|
+
}
|
|
505
361
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
targetEdges.inbound.push(source);
|
|
509
|
-
}
|
|
510
|
-
});
|
|
362
|
+
getNode(id: string): Option.Option<Node> {
|
|
363
|
+
return this._registry.get(this.node(id));
|
|
511
364
|
}
|
|
512
365
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
* @internal
|
|
516
|
-
*/
|
|
517
|
-
_removeEdges(edges: { source: string; target: string }[], removeOrphans = false) {
|
|
518
|
-
batch(() => edges.forEach((edge) => this._removeEdge(edge, removeOrphans)));
|
|
366
|
+
getNodeOrThrow(id: string): Node {
|
|
367
|
+
return this._registry.get(this.nodeOrThrow(id));
|
|
519
368
|
}
|
|
520
369
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
}
|
|
370
|
+
getConnections(id: string, relation: Relation = 'outbound'): Node[] {
|
|
371
|
+
return this._registry.get(this.connections(id, relation));
|
|
372
|
+
}
|
|
528
373
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
374
|
+
getActions(id: string): Node[] {
|
|
375
|
+
return this._registry.get(this.actions(id));
|
|
376
|
+
}
|
|
533
377
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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'): void {
|
|
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>>[]): void {
|
|
403
|
+
Rx.batch(() => {
|
|
404
|
+
nodes.map((node) => this.addNode(node));
|
|
551
405
|
});
|
|
552
406
|
}
|
|
553
407
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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]);
|
|
408
|
+
addNode({ nodes, edges, ...nodeArg }: NodeArg<any, Record<string, any>>): void {
|
|
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', { id, 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 });
|
|
572
423
|
}
|
|
573
|
-
}
|
|
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
|
+
});
|
|
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): void {
|
|
447
|
+
Rx.batch(() => {
|
|
448
|
+
ids.map((id) => this.removeNode(id, edges));
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
removeNode(id: string, edges = false): void {
|
|
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[]): void {
|
|
472
|
+
Rx.batch(() => {
|
|
473
|
+
edges.map((edge) => this.addEdge(edge));
|
|
574
474
|
});
|
|
575
475
|
}
|
|
576
476
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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);
|
|
477
|
+
addEdge(edgeArg: Edge): void {
|
|
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] });
|
|
594
483
|
}
|
|
595
484
|
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
.map((id) => this._nodes[id])
|
|
602
|
-
.filter(isNonNullable)
|
|
603
|
-
.filter((n) => !type || n.type === type);
|
|
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 });
|
|
604
490
|
}
|
|
605
491
|
}
|
|
492
|
+
|
|
493
|
+
removeEdges(edges: Edge[], removeOrphans = false): void {
|
|
494
|
+
Rx.batch(() => {
|
|
495
|
+
edges.map((edge) => this.removeEdge(edge, removeOrphans));
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
removeEdge(edgeArg: Edge, removeOrphans = false): void {
|
|
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[]): void {
|
|
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);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
traverse({ visitor, source = ROOT_ID, relation = 'outbound' }: GraphTraversalOptions, path: string[] = []): void {
|
|
540
|
+
// Break cycles.
|
|
541
|
+
if (path.includes(source)) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
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
|
+
): Promise<string[]> {
|
|
583
|
+
const path = this.getPath(params);
|
|
584
|
+
if (Option.isSome(path)) {
|
|
585
|
+
return path.value;
|
|
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 });
|
|
602
|
+
}
|
|
606
603
|
}
|