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

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/src/graph.ts CHANGED
@@ -2,17 +2,45 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { untracked } from '@preact/signals-core';
5
+ import { batch, effect, untracked } from '@preact/signals-core';
6
6
 
7
- import { create } from '@dxos/echo-schema';
7
+ import { asyncTimeout, Trigger } from '@dxos/async';
8
+ import { type ReactiveObject, create } from '@dxos/echo-schema';
8
9
  import { invariant } from '@dxos/invariant';
9
10
  import { nonNullable } from '@dxos/util';
10
11
 
11
- import { isActionLike, type EdgeDirection, type Node, type NodeArg, type NodeBase } from './node';
12
+ import { type Relation, type Node, type NodeArg, type NodeFilter, isActionLike } from './node';
13
+
14
+ const graphSymbol = Symbol('graph');
15
+ type DeepWriteable<T> = { -readonly [K in keyof T]: DeepWriteable<T[K]> };
16
+ type NodeInternal = DeepWriteable<Node> & { [graphSymbol]: Graph };
17
+
18
+ export const getGraph = (node: Node): Graph => {
19
+ const graph = (node as NodeInternal)[graphSymbol];
20
+ invariant(graph, 'Node is not associated with a graph.');
21
+ return graph;
22
+ };
12
23
 
13
24
  export const ROOT_ID = 'root';
25
+ export const ROOT_TYPE = 'dxos.org/type/GraphRoot';
26
+ export const ACTION_TYPE = 'dxos.org/type/GraphAction';
27
+ export const ACTION_GROUP_TYPE = 'dxos.org/type/GraphActionGroup';
28
+
29
+ export type NodesOptions<T = any, U extends Record<string, any> = Record<string, any>> = {
30
+ relation?: Relation;
31
+ filter?: NodeFilter<T, U>;
32
+ expansion?: boolean;
33
+ type?: string;
34
+ };
35
+
36
+ export type GraphTraversalOptions = {
37
+ /**
38
+ * A callback which is called for each node visited during traversal.
39
+ *
40
+ * If the callback returns `false`, traversal is stops recursing.
41
+ */
42
+ visitor: (node: Node, path: string[]) => boolean | void;
14
43
 
15
- export type TraversalOptions = {
16
44
  /**
17
45
  * The node to start traversing from.
18
46
  *
@@ -21,40 +49,54 @@ export type TraversalOptions = {
21
49
  node?: Node;
22
50
 
23
51
  /**
24
- * The direction to traverse graph edges.
52
+ * The relation to traverse graph edges.
25
53
  *
26
54
  * @default 'outbound'
27
55
  */
28
- direction?: EdgeDirection;
56
+ relation?: Relation;
29
57
 
30
58
  /**
31
- * A predicate to filter nodes which are passed to the `visitor` callback.
59
+ * Allow traversal to trigger expansion of the graph via `onInitialNodes`.
32
60
  */
33
- filter?: (node: Node) => boolean;
34
-
35
- /**
36
- * A callback which is called for each node visited during traversal.
37
- */
38
- visitor?: (node: Node, path: string[]) => void;
61
+ expansion?: boolean;
39
62
  };
40
63
 
41
64
  /**
42
65
  * The Graph represents the structure of the application constructed via plugins.
43
66
  */
44
67
  export class Graph {
68
+ private readonly _onInitialNode?: (id: string) => Promise<void>;
69
+ private readonly _onInitialNodes?: (node: Node, relation: Relation, type?: string) => Promise<void>;
70
+ private readonly _onRemoveNode?: (id: string) => Promise<void>;
71
+
72
+ private readonly _waitingForNodes: Record<string, Trigger<Node>> = {};
73
+ private readonly _initialized: Record<string, boolean> = {};
74
+
45
75
  /**
46
76
  * @internal
47
77
  */
48
- readonly _nodes = create<Record<string, NodeBase>>({
49
- [ROOT_ID]: { id: ROOT_ID, properties: {}, data: null },
50
- });
78
+ readonly _nodes: Record<string, ReactiveObject<NodeInternal>> = {};
51
79
 
52
80
  /**
53
81
  * @internal
54
82
  */
55
- // Key is the `${node.id}-${direction}` and value is an ordered list of node ids.
56
- // Explicit type required because TS says this is not portable.
57
- readonly _edges = create<Record<string, string[]>>({});
83
+ readonly _edges: Record<string, ReactiveObject<{ inbound: string[]; outbound: string[] }>> = {};
84
+
85
+ constructor({
86
+ onInitialNode,
87
+ onInitialNodes,
88
+ onRemoveNode,
89
+ }: {
90
+ onInitialNode?: Graph['_onInitialNode'];
91
+ onInitialNodes?: Graph['_onInitialNodes'];
92
+ onRemoveNode?: Graph['_onRemoveNode'];
93
+ } = {}) {
94
+ this._onInitialNode = onInitialNode;
95
+ this._onInitialNodes = onInitialNodes;
96
+ this._onRemoveNode = onRemoveNode;
97
+ this._nodes[ROOT_ID] = this._constructNode({ id: ROOT_ID, type: ROOT_TYPE, properties: {}, data: null });
98
+ this._edges[ROOT_ID] = create({ inbound: [], outbound: [] });
99
+ }
58
100
 
59
101
  /**
60
102
  * Alias for `findNode('root')`.
@@ -68,9 +110,10 @@ export class Graph {
68
110
  */
69
111
  toJSON({ id = ROOT_ID, maxLength = 32 }: { id?: string; maxLength?: number } = {}) {
70
112
  const toJSON = (node: Node, seen: string[] = []): any => {
71
- const nodes = node.nodes();
113
+ const nodes = this.nodes(node);
72
114
  const obj: Record<string, any> = {
73
115
  id: node.id.length > maxLength ? `${node.id.slice(0, maxLength - 3)}...` : node.id,
116
+ type: node.type,
74
117
  };
75
118
  if (node.properties.label) {
76
119
  obj.label = node.properties.label;
@@ -94,57 +137,166 @@ export class Graph {
94
137
 
95
138
  /**
96
139
  * Find the node with the given id in the graph.
140
+ *
141
+ * If a node is not found within the graph and an `onInitialNode` callback is provided,
142
+ * it is called with the id and type of the node, potentially initializing the node.
97
143
  */
98
144
  findNode(id: string): Node | undefined {
99
- const nodeBase = this._nodes[id];
100
- if (!nodeBase) {
101
- return undefined;
145
+ const existingNode = this._nodes[id];
146
+ if (!existingNode) {
147
+ void this._onInitialNode?.(id);
102
148
  }
103
149
 
104
- return this._constructNode(nodeBase);
150
+ return existingNode;
105
151
  }
106
152
 
107
- private _constructNode = (nodeBase: NodeBase): Node => {
108
- const node: Node = {
109
- ...nodeBase,
110
- edges: ({ direction = 'outbound' } = {}) => {
111
- return this._edges[this.getEdgeKey(node.id, direction)];
112
- },
113
- nodes: ({ direction, filter } = {}) => {
114
- const nodes = this._getNodes({ id: node.id, direction }).filter((n) => !isActionLike(n));
115
- return filter ? nodes.filter((n) => filter(n, node)) : nodes;
116
- },
117
- node: (id: string) => {
118
- return this._getNodes({ id }).find((node) => node.id === id);
119
- },
120
- actions: () => {
121
- return this._getNodes({ id: node.id }).filter(isActionLike);
122
- },
123
- };
153
+ /**
154
+ * Wait for a node to be added to the graph.
155
+ *
156
+ * If the node is already present in the graph, the promise resolves immediately.
157
+ *
158
+ * @param id The id of the node to wait for.
159
+ * @param timeout The time in milliseconds to wait for the node to be added.
160
+ */
161
+ async waitForNode(id: string, timeout?: number): Promise<Node> {
162
+ const trigger = this._waitingForNodes[id] ?? (this._waitingForNodes[id] = new Trigger<Node>());
163
+ const node = this.findNode(id);
164
+ if (node) {
165
+ delete this._waitingForNodes[id];
166
+ return node;
167
+ }
124
168
 
125
- return node;
126
- };
169
+ if (timeout === undefined) {
170
+ return trigger.wait();
171
+ } else {
172
+ return asyncTimeout(trigger.wait(), timeout, `Node not found: ${id}`);
173
+ }
174
+ }
127
175
 
128
- private _getNodes({ id, direction = 'outbound' }: { id: string; direction?: EdgeDirection }): Node[] {
129
- const edges = this._edges[this.getEdgeKey(id, direction)];
130
- if (!edges) {
131
- return [];
176
+ /**
177
+ * Nodes that this node is connected to in default order.
178
+ */
179
+ nodes<T = any, U extends Record<string, any> = Record<string, any>>(node: Node, options: NodesOptions<T, U> = {}) {
180
+ const { relation, expansion, filter, type } = options;
181
+ const nodes = this._getNodes({ node, relation, expansion, type });
182
+ return nodes.filter((n) => untracked(() => !isActionLike(n))).filter((n) => filter?.(n, node) ?? true);
183
+ }
184
+
185
+ /**
186
+ * Edges that this node is connected to in default order.
187
+ */
188
+ edges(node: Node, { relation = 'outbound' }: { relation?: Relation } = {}) {
189
+ return this._edges[node.id]?.[relation] ?? [];
190
+ }
191
+
192
+ /**
193
+ * Actions or action groups that this node is connected to in default order.
194
+ */
195
+ actions(node: Node, { expansion }: { expansion?: boolean } = {}) {
196
+ return [
197
+ ...this._getNodes({ node, expansion, type: ACTION_GROUP_TYPE }),
198
+ ...this._getNodes({ node, expansion, type: ACTION_TYPE }),
199
+ ];
200
+ }
201
+
202
+ async expand(node: Node, relation: Relation = 'outbound', type?: string) {
203
+ // TODO(wittjosiah): Factor out helper.
204
+ const key = `${node.id}-${relation}-${type}`;
205
+ const initialized = this._initialized[key];
206
+ if (!initialized && this._onInitialNodes) {
207
+ await this._onInitialNodes(node, relation, type);
208
+ this._initialized[key] = true;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Recursive depth-first traversal of the graph.
214
+ *
215
+ * @param options.node The node to start traversing from.
216
+ * @param options.relation The relation to traverse graph edges.
217
+ * @param options.visitor A callback which is called for each node visited during traversal.
218
+ */
219
+ traverse(
220
+ { visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
221
+ path: string[] = [],
222
+ ): void {
223
+ // Break cycles.
224
+ if (path.includes(node.id)) {
225
+ return;
132
226
  }
133
227
 
134
- return edges.map((id) => this.findNode(id)).filter(nonNullable);
228
+ const shouldContinue = visitor(node, [...path, node.id]);
229
+ if (shouldContinue === false) {
230
+ return;
231
+ }
232
+
233
+ Object.values(this._getNodes({ node, relation, expansion })).forEach((child) =>
234
+ this.traverse({ node: child, relation, visitor, expansion }, [...path, node.id]),
235
+ );
135
236
  }
136
237
 
137
- private getEdgeKey(id: string, direction: EdgeDirection) {
138
- return `${id}-${direction}`;
238
+ /**
239
+ * Recursive depth-first traversal of the graph wrapping each visitor call in an effect.
240
+ *
241
+ * @param options.node The node to start traversing from.
242
+ * @param options.relation The relation to traverse graph edges.
243
+ * @param options.visitor A callback which is called for each node visited during traversal.
244
+ */
245
+ subscribeTraverse(
246
+ { visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
247
+ currentPath: string[] = [],
248
+ ) {
249
+ return effect(() => {
250
+ const path = [...currentPath, node.id];
251
+ const result = visitor(node, path);
252
+ if (result === false) {
253
+ return;
254
+ }
255
+
256
+ const nodes = this._getNodes({ node, relation, expansion });
257
+ const nodeSubscriptions = nodes.map((n) => this.subscribeTraverse({ node: n, visitor, expansion }, path));
258
+
259
+ return () => {
260
+ nodeSubscriptions.forEach((unsubscribe) => unsubscribe());
261
+ };
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Get the path between two nodes in the graph.
267
+ */
268
+ getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
269
+ const start = this.findNode(source);
270
+ if (!start) {
271
+ return undefined;
272
+ }
273
+
274
+ let found: string[] | undefined;
275
+ this.traverse({
276
+ node: start,
277
+ visitor: (node, path) => {
278
+ if (found) {
279
+ return false;
280
+ }
281
+
282
+ if (node.id === target) {
283
+ found = path;
284
+ }
285
+ },
286
+ });
287
+
288
+ return found;
139
289
  }
140
290
 
141
291
  /**
142
292
  * Add nodes to the graph.
293
+ *
294
+ * @internal
143
295
  */
144
- addNodes<TData = null, TProperties extends Record<string, any> = Record<string, any>>(
145
- ...nodes: NodeArg<TData, TProperties>[]
296
+ _addNodes<TData = null, TProperties extends Record<string, any> = Record<string, any>>(
297
+ nodes: NodeArg<TData, TProperties>[],
146
298
  ): Node<TData, TProperties>[] {
147
- return nodes.map((node) => this._addNode(node));
299
+ return batch(() => nodes.map((node) => this._addNode(node)));
148
300
  }
149
301
 
150
302
  private _addNode<TData, TProperties extends Record<string, any> = Record<string, any>>({
@@ -153,35 +305,65 @@ export class Graph {
153
305
  ..._node
154
306
  }: NodeArg<TData, TProperties>): Node<TData, TProperties> {
155
307
  return untracked(() => {
156
- const node = { data: null, properties: {}, ..._node };
157
- this._nodes[node.id] = node;
308
+ const existingNode = this._nodes[_node.id];
309
+ const node = existingNode ?? this._constructNode({ data: null, properties: {}, ..._node });
310
+ if (existingNode) {
311
+ const { data, properties, type } = _node;
312
+ if (data && data !== node.data) {
313
+ node.data = data;
314
+ }
315
+
316
+ if (type !== node.type) {
317
+ node.type = type;
318
+ }
319
+
320
+ for (const key in properties) {
321
+ if (properties[key] !== node.properties[key]) {
322
+ node.properties[key] = properties[key];
323
+ }
324
+ }
325
+ } else {
326
+ this._nodes[node.id] = node;
327
+ this._edges[node.id] = create({ inbound: [], outbound: [] });
328
+ }
329
+
330
+ const trigger = this._waitingForNodes[node.id];
331
+ if (trigger) {
332
+ trigger.wake(node);
333
+ delete this._waitingForNodes[node.id];
334
+ }
158
335
 
159
336
  if (nodes) {
160
337
  nodes.forEach((subNode) => {
161
338
  this._addNode(subNode);
162
- this.addEdge({ source: node.id, target: subNode.id });
339
+ this._addEdge({ source: node.id, target: subNode.id });
163
340
  });
164
341
  }
165
342
 
166
343
  if (edges) {
167
- edges.forEach(([id, direction]) =>
168
- direction === 'outbound'
169
- ? this.addEdge({ source: node.id, target: id })
170
- : this.addEdge({ source: id, target: node.id }),
344
+ edges.forEach(([id, relation]) =>
345
+ relation === 'outbound'
346
+ ? this._addEdge({ source: node.id, target: id })
347
+ : this._addEdge({ source: id, target: node.id }),
171
348
  );
172
349
  }
173
350
 
174
- return this._constructNode(node) as Node<TData, TProperties>;
351
+ return node as unknown as Node<TData, TProperties>;
175
352
  });
176
353
  }
177
354
 
178
355
  /**
179
356
  * Remove nodes from the graph.
180
357
  *
181
- * @param id The id of the node to remove.
358
+ * @param ids The id of the node to remove.
182
359
  * @param edges Whether to remove edges connected to the node from the graph as well.
360
+ * @internal
183
361
  */
184
- removeNode(id: string, edges = false) {
362
+ _removeNodes(ids: string[], edges = false) {
363
+ batch(() => ids.forEach((id) => this._removeNode(id, edges)));
364
+ }
365
+
366
+ private _removeNode(id: string, edges = false) {
185
367
  untracked(() => {
186
368
  const node = this.findNode(id);
187
369
  if (!node) {
@@ -189,123 +371,128 @@ export class Graph {
189
371
  }
190
372
 
191
373
  if (edges) {
192
- // Remove edges from node.
193
- delete this._edges[this.getEdgeKey(id, 'outbound')];
194
- delete this._edges[this.getEdgeKey(id, 'inbound')];
195
-
196
374
  // Remove edges from connected nodes.
197
- this._getNodes({ id }).forEach((node) => this.removeEdge({ source: id, target: node.id }));
198
- this._getNodes({ id, direction: 'inbound' }).forEach((node) =>
199
- this.removeEdge({ source: node.id, target: id }),
200
- );
375
+ this._getNodes({ node }).forEach((node) => {
376
+ this._removeEdge({ source: id, target: node.id });
377
+ });
378
+ this._getNodes({ node, relation: 'inbound' }).forEach((node) => {
379
+ this._removeEdge({ source: node.id, target: id });
380
+ });
381
+
382
+ // Remove edges from node.
383
+ delete this._edges[id];
201
384
  }
202
385
 
203
386
  // Remove node.
204
387
  delete this._nodes[id];
388
+ void this._onRemoveNode?.(id);
205
389
  });
206
390
  }
207
391
 
208
392
  /**
209
- * Add an edge to the graph.
393
+ * Add edges to the graph.
394
+ *
395
+ * @internal
210
396
  */
211
- addEdge({ source, target }: { source: string; target: string }) {
397
+ _addEdges(edges: { source: string; target: string }[]) {
398
+ batch(() => edges.forEach((edge) => this._addEdge(edge)));
399
+ }
400
+
401
+ private _addEdge({ source, target }: { source: string; target: string }) {
212
402
  untracked(() => {
213
- const outbound = this._edges[this.getEdgeKey(source, 'outbound')];
214
- if (!outbound) {
215
- this._edges[this.getEdgeKey(source, 'outbound')] = [target];
216
- } else if (!outbound.includes(target)) {
217
- outbound.push(target);
403
+ if (!this._edges[source]) {
404
+ this._edges[source] = create({ inbound: [], outbound: [] });
405
+ }
406
+ if (!this._edges[target]) {
407
+ this._edges[target] = create({ inbound: [], outbound: [] });
218
408
  }
219
409
 
220
- const inbound = this._edges[this.getEdgeKey(target, 'inbound')];
221
- if (!inbound) {
222
- this._edges[this.getEdgeKey(target, 'inbound')] = [source];
223
- } else if (!inbound.includes(source)) {
224
- inbound.push(source);
410
+ const sourceEdges = this._edges[source];
411
+ if (!sourceEdges.outbound.includes(target)) {
412
+ sourceEdges.outbound.push(target);
225
413
  }
226
- });
227
- }
228
414
 
229
- /**
230
- * Sort edges for a node.
231
- *
232
- * Edges not included in the sorted list are appended to the end of the list.
233
- *
234
- * @param nodeId The id of the node to sort edges for.
235
- * @param direction The direction of the edges from the node to sort.
236
- * @param edges The ordered list of edges.
237
- */
238
- sortEdges(nodeId: string, direction: EdgeDirection, edges: string[]) {
239
- untracked(() => {
240
- const current = this._edges[this.getEdgeKey(nodeId, direction)];
241
- if (current) {
242
- const unsorted = current.filter((id) => !edges.includes(id)) ?? [];
243
- const sorted = edges.filter((id) => current.includes(id)) ?? [];
244
- current.splice(0, current.length, ...[...sorted, ...unsorted]);
415
+ const targetEdges = this._edges[target];
416
+ if (!targetEdges.inbound.includes(source)) {
417
+ targetEdges.inbound.push(source);
245
418
  }
246
419
  });
247
420
  }
248
421
 
249
422
  /**
250
- * Remove an edge from the graph.
423
+ * Remove edges from the graph.
424
+ * @internal
251
425
  */
252
- removeEdge({ source, target }: { source: string; target: string }) {
426
+ _removeEdges(edges: { source: string; target: string }[]) {
427
+ batch(() => edges.forEach((edge) => this._removeEdge(edge)));
428
+ }
429
+
430
+ private _removeEdge({ source, target }: { source: string; target: string }) {
253
431
  untracked(() => {
254
- const outboundIndex = this._edges[this.getEdgeKey(source, 'outbound')]?.findIndex((id) => id === target);
255
- if (outboundIndex !== -1) {
256
- this._edges[this.getEdgeKey(source, 'outbound')].splice(outboundIndex, 1);
257
- }
432
+ batch(() => {
433
+ const outboundIndex = this._edges[source]?.outbound.findIndex((id) => id === target);
434
+ if (outboundIndex !== undefined && outboundIndex !== -1) {
435
+ this._edges[source].outbound.splice(outboundIndex, 1);
436
+ }
258
437
 
259
- const inboundIndex = this._edges[this.getEdgeKey(target, 'inbound')]?.findIndex((id) => id === source);
260
- if (inboundIndex !== -1) {
261
- this._edges[this.getEdgeKey(target, 'inbound')].splice(inboundIndex, 1);
262
- }
438
+ const inboundIndex = this._edges[target]?.inbound.findIndex((id) => id === source);
439
+ if (inboundIndex !== undefined && inboundIndex !== -1) {
440
+ this._edges[target].inbound.splice(inboundIndex, 1);
441
+ }
442
+ });
263
443
  });
264
444
  }
265
445
 
266
446
  /**
267
- * Recursive depth-first traversal.
447
+ * Sort edges for a node.
268
448
  *
269
- * @param options.node The node to start traversing from.
270
- * @param options.direction The direction to traverse graph edges.
271
- * @param options.filter A predicate to filter nodes which are passed to the `visitor` callback.
272
- * @param options.visitor A callback which is called for each node visited during traversal.
449
+ * Edges not included in the sorted list are appended to the end of the list.
450
+ *
451
+ * @param nodeId The id of the node to sort edges for.
452
+ * @param relation The relation of the edges from the node to sort.
453
+ * @param edges The ordered list of edges.
454
+ * @ignore
273
455
  */
274
- traverse({ node = this.root, direction = 'outbound', filter, visitor }: TraversalOptions, path: string[] = []): void {
275
- // Break cycles.
276
- if (path.includes(node.id)) {
277
- return;
278
- }
279
-
280
- if (!filter || filter(node)) {
281
- visitor?.(node, [...path, node.id]);
282
- }
283
-
284
- Object.values(this._getNodes({ id: node.id, direction })).forEach((child) =>
285
- this.traverse({ node: child, direction, filter, visitor }, [...path, node.id]),
286
- );
456
+ _sortEdges(nodeId: string, relation: Relation, edges: string[]) {
457
+ untracked(() => {
458
+ batch(() => {
459
+ const current = this._edges[nodeId];
460
+ if (current) {
461
+ const unsorted = current[relation].filter((id) => !edges.includes(id)) ?? [];
462
+ const sorted = edges.filter((id) => current[relation].includes(id)) ?? [];
463
+ current[relation].splice(0, current[relation].length, ...[...sorted, ...unsorted]);
464
+ }
465
+ });
466
+ });
287
467
  }
288
468
 
289
- /**
290
- * Get the path between two nodes in the graph.
291
- */
292
- getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
293
- const start = this.findNode(source);
294
- if (!start) {
295
- return undefined;
296
- }
469
+ private _constructNode = (node: Omit<Node, typeof graphSymbol>) => {
470
+ return create<NodeInternal>({ ...node, [graphSymbol]: this });
471
+ };
297
472
 
298
- let found: string[] | undefined;
299
- this.traverse({
300
- node: start,
301
- filter: () => !found,
302
- visitor: (node, path) => {
303
- if (node.id === target) {
304
- found = path;
305
- }
306
- },
307
- });
473
+ private _getNodes({
474
+ node,
475
+ relation = 'outbound',
476
+ type,
477
+ expansion,
478
+ }: {
479
+ node: Node;
480
+ relation?: Relation;
481
+ type?: string;
482
+ expansion?: boolean;
483
+ }): Node[] {
484
+ if (expansion) {
485
+ void this.expand(node, relation, type);
486
+ }
308
487
 
309
- return found;
488
+ const edges = this._edges[node.id];
489
+ if (!edges) {
490
+ return [];
491
+ } else {
492
+ return edges[relation]
493
+ .map((id) => this._nodes[id])
494
+ .filter(nonNullable)
495
+ .filter((n) => !type || n.type === type);
496
+ }
310
497
  }
311
498
  }
package/src/index.ts CHANGED
@@ -4,5 +4,4 @@
4
4
 
5
5
  export * from './graph';
6
6
  export * from './graph-builder';
7
- export * from './helpers';
8
7
  export * from './node';