@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.
Files changed (33) hide show
  1. package/dist/lib/browser/index.mjs +593 -794
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +585 -785
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +593 -794
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/experimental/graph-projections.test.d.ts +25 -0
  11. package/dist/types/src/experimental/graph-projections.test.d.ts.map +1 -0
  12. package/dist/types/src/graph-builder.d.ts +48 -91
  13. package/dist/types/src/graph-builder.d.ts.map +1 -1
  14. package/dist/types/src/graph.d.ts +191 -98
  15. package/dist/types/src/graph.d.ts.map +1 -1
  16. package/dist/types/src/node.d.ts +3 -3
  17. package/dist/types/src/node.d.ts.map +1 -1
  18. package/dist/types/src/signals-integration.test.d.ts +2 -0
  19. package/dist/types/src/signals-integration.test.d.ts.map +1 -0
  20. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  21. package/dist/types/src/testing.d.ts +5 -0
  22. package/dist/types/src/testing.d.ts.map +1 -0
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +24 -17
  25. package/src/experimental/graph-projections.test.ts +56 -0
  26. package/src/graph-builder.test.ts +293 -310
  27. package/src/graph-builder.ts +235 -318
  28. package/src/graph.test.ts +314 -463
  29. package/src/graph.ts +452 -455
  30. package/src/node.ts +4 -4
  31. package/src/signals-integration.test.ts +218 -0
  32. package/src/stories/EchoGraph.stories.tsx +67 -76
  33. 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 { batch, effect, untracked } from '@preact/signals-core';
5
+ import { Registry, Rx } from '@effect-rx/rx-react';
6
+ import { Option, pipe, Record } from 'effect';
6
7
 
7
- import { asyncTimeout, Trigger } from '@dxos/async';
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, isNonNullable, pick } from '@dxos/util';
12
+ import { isNonNullable, type MakeOptional } from '@dxos/util';
12
13
 
13
- import { type Node, type NodeArg, type NodeFilter, type Relation, actionGroupSymbol, isActionLike } from './node';
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 root
45
+ * @default ROOT_ID
52
46
  */
53
- node?: Node;
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, string[]>;
71
- onInitialNode?: Graph['_onInitialNode'];
72
- onInitialNodes?: Graph['_onInitialNodes'];
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
- * The Graph represents the structure of the application constructed via plugins.
78
- */
79
- export class Graph {
80
- private readonly _onInitialNode?: (id: string) => Promise<void>;
81
- private readonly _onInitialNodes?: (node: Node, relation: Relation, type?: string) => Promise<void>;
82
- private readonly _onRemoveNode?: (id: string) => Promise<void>;
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
- * @internal
72
+ * Event emitted when a node is changed.
89
73
  */
90
- readonly _nodes: Record<string, ReactiveObject<NodeInternal>> = {};
74
+ onNodeChanged: Event<{ id: string; node: Option.Option<Node> }>;
91
75
 
92
76
  /**
93
- * @internal
77
+ * Convert the graph to a JSON object.
94
78
  */
95
- readonly _edges: Record<string, ReactiveObject<{ inbound: string[]; outbound: string[] }>> = {};
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
- this._edges[ROOT_ID] = create({ inbound: [], outbound: [] });
123
- if (edges) {
124
- Object.entries(edges).forEach(([source, edges]) => {
125
- edges.forEach((target) => {
126
- this._addEdge({ source, target });
127
- });
128
- this._sortEdges(source, 'outbound', edges);
129
- });
130
- }
131
- }
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
- * Alias for `findNode('root')`.
84
+ * Get the rx key for the node with the given id.
140
85
  */
141
- get root() {
142
- return this.findNode(ROOT_ID)!;
143
- }
86
+ node(id: string): Rx.Rx<Option.Option<Node>>;
144
87
 
145
88
  /**
146
- * Convert the graph to a JSON object.
89
+ * Get the rx key for the node with the given id.
147
90
  */
148
- toJSON({ id = ROOT_ID, maxLength = 32 }: { id?: string; maxLength?: number } = {}) {
149
- const toJSON = (node: Node, seen: string[] = []): any => {
150
- const nodes = this.nodes(node);
151
- const obj: Record<string, any> = {
152
- id: node.id.length > maxLength ? `${node.id.slice(0, maxLength - 3)}...` : node.id,
153
- type: node.type,
154
- };
155
- if (node.properties.label) {
156
- obj.label = node.properties.label;
157
- }
158
- if (nodes.length) {
159
- obj.nodes = nodes
160
- .map((n) => {
161
- // Break cycles.
162
- const nextSeen = [...seen, node.id];
163
- return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
164
- })
165
- .filter(isNonNullable);
166
- }
167
- return obj;
168
- };
91
+ nodeOrThrow(id: string): Rx.Rx<Node>;
169
92
 
170
- const root = this.findNode(id);
171
- invariant(root, `Node not found: ${id}`);
172
- return toJSON(root);
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
- const edges = Object.fromEntries(
189
- Object.entries(this._edges)
190
- .filter(([id]) => cacheable.has(id))
191
- .map(([id, { outbound }]): [string, string[]] => [id, outbound.filter((nodeId) => cacheable.has(nodeId))])
192
- // TODO(wittjosiah): Why sort?
193
- .toSorted(([a], [b]) => a.localeCompare(b)),
194
- );
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
- return JSON.stringify({ nodes, edges });
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
- * Find the node with the given id in the graph.
201
- *
202
- * If a node is not found within the graph and an `onInitialNode` callback is provided,
203
- * it is called with the id and type of the node, potentially initializing the node.
109
+ * Alias for `getNodeOrThrow(ROOT_ID)`.
204
110
  */
205
- findNode(id: string, expansion = true): Node | undefined {
206
- const existingNode = this._nodes[id];
207
- if (!existingNode && expansion) {
208
- void this._onInitialNode?.(id);
209
- }
111
+ get root(): Node;
210
112
 
211
- return existingNode;
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
- * Wait for a node to be added to the graph.
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
- * @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
- async waitForNode(id: string, timeout?: number): Promise<Node> {
223
- const trigger = this._waitingForNodes[id] ?? (this._waitingForNodes[id] = new Trigger<Node>());
224
- const node = this.findNode(id);
225
- if (node) {
226
- delete this._waitingForNodes[id];
227
- return node;
228
- }
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
- * Nodes that this node is connected to in default order.
126
+ * Get all nodes connected to the node with the given id by the given relation from the graph's registry.
239
127
  */
240
- nodes<T = any, U extends Record<string, any> = Record<string, any>>(node: Node, options: NodesOptions<T, U> = {}) {
241
- const { relation, expansion, filter = DEFAULT_FILTER, type } = options;
242
- const nodes = this._getNodes({ node, relation, expansion, type });
243
- return nodes.filter((n) => filter(n, node));
244
- }
128
+ getConnections(id: string, relation?: Relation): Node[];
245
129
 
246
130
  /**
247
- * Edges that this node is connected to in default order.
131
+ * Get all actions connected to the node with the given id from the graph's registry.
248
132
  */
249
- edges(node: Node, { relation = 'outbound' }: { relation?: Relation } = {}) {
250
- return this._edges[node.id]?.[relation] ?? [];
251
- }
133
+ getActions(id: string): Node[];
252
134
 
253
135
  /**
254
- * Actions or action groups that this node is connected to in default order.
136
+ * Get the edges from the node with the given id from the graph's registry.
255
137
  */
256
- actions(node: Node, { expansion }: { expansion?: boolean } = {}) {
257
- return [
258
- ...this._getNodes({ node, expansion, type: ACTION_GROUP_TYPE }),
259
- ...this._getNodes({ node, expansion, type: ACTION_TYPE }),
260
- ];
261
- }
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
- const shouldContinue = visitor(node, [...path, node.id]);
293
- if (shouldContinue === false) {
294
- return;
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
- Object.values(this._getNodes({ node, relation, expansion })).forEach((child) =>
298
- this.traverse({ node: child, relation, visitor, expansion }, [...path, node.id]),
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
- * Recursive depth-first traversal of the graph wrapping each visitor call in an effect.
165
+ * Initialize a node in the graph.
304
166
  *
305
- * @param options.node The node to start traversing from.
306
- * @param options.relation The relation to traverse graph edges.
307
- * @param options.visitor A callback which is called for each node visited during traversal.
167
+ * Fires the `onInitialize` callback to provide initial data for a node.
308
168
  */
309
- subscribeTraverse(
310
- { visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
311
- currentPath: string[] = [],
312
- ) {
313
- return effect(() => {
314
- const path = [...currentPath, node.id];
315
- const result = visitor(node, path);
316
- if (result === false) {
317
- return;
318
- }
169
+ // initialize(id: string): Promise<void>;
319
170
 
320
- const nodes = this._getNodes({ node, relation, expansion });
321
- const nodeSubscriptions = nodes.map((n) => this.subscribeTraverse({ node: n, visitor, expansion }, path));
322
- return () => {
323
- nodeSubscriptions.forEach((unsubscribe) => unsubscribe());
324
- };
325
- });
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
- * Get the path between two nodes in the graph.
179
+ * Sort the edges of the node with the given id.
330
180
  */
331
- getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
332
- const start = this.findNode(source);
333
- if (!start) {
334
- return undefined;
335
- }
181
+ sortEdges(id: string, relation: Relation, order: string[]): void;
182
+ }
336
183
 
337
- let found: string[] | undefined;
338
- this.traverse({
339
- node: start,
340
- visitor: (node, path) => {
341
- if (found) {
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
- if (node.id === target) {
346
- found = path;
347
- }
348
- },
349
- });
190
+ /**
191
+ * Add a node to the graph.
192
+ */
193
+ addNode(node: NodeArg<any, Record<string, any>>): void;
350
194
 
351
- return found;
352
- }
195
+ /**
196
+ * Remove nodes from the graph.
197
+ */
198
+ removeNodes(ids: string[], edges?: boolean): void;
353
199
 
354
200
  /**
355
- * Wait for the path between two nodes in the graph to be established.
201
+ * Remove a node from the graph.
356
202
  */
357
- async waitForPath(
358
- params: { source?: string; target: string },
359
- { timeout = 5_000, interval = 500 }: { timeout?: number; interval?: number } = {},
360
- ) {
361
- const path = this.getPath(params);
362
- if (path) {
363
- return path;
364
- }
203
+ removeNode(id: string, edges?: boolean): void;
365
204
 
366
- const trigger = new Trigger<string[]>();
367
- const i = setInterval(() => {
368
- const path = this.getPath(params);
369
- if (path) {
370
- trigger.wake(path);
371
- }
372
- }, interval);
205
+ /**
206
+ * Add edges to the graph.
207
+ */
208
+ addEdges(edges: Edge[]): void;
373
209
 
374
- return trigger.wait({ timeout }).finally(() => clearInterval(i));
375
- }
210
+ /**
211
+ * Add an edge to the graph.
212
+ */
213
+ addEdge(edge: Edge): void;
376
214
 
377
215
  /**
378
- * Add nodes to the graph.
379
- *
380
- * @internal
216
+ * Remove edges from the graph.
381
217
  */
382
- _addNodes<TData = null, TProperties extends Record<string, any> = Record<string, any>>(
383
- nodes: NodeArg<TData, TProperties>[],
384
- ): Node<TData, TProperties>[] {
385
- return batch(() => nodes.map((node) => this._addNode(node)));
386
- }
218
+ removeEdges(edges: Edge[], removeOrphans?: boolean): void;
387
219
 
388
- private _addNode<TData, TProperties extends Record<string, any> = Record<string, any>>({
389
- nodes,
390
- edges,
391
- ..._node
392
- }: NodeArg<TData, TProperties>): Node<TData, TProperties> {
393
- return untracked(() => {
394
- const existingNode = this._nodes[_node.id];
395
- const node = existingNode ?? this._constructNode({ data: null, properties: {}, ..._node });
396
- if (existingNode) {
397
- const { data = null, properties, type } = _node;
398
- if (data !== node.data) {
399
- node.data = data;
400
- }
220
+ /**
221
+ * Remove an edge from the graph.
222
+ */
223
+ removeEdge(edge: Edge, removeOrphans?: boolean): void;
224
+ }
401
225
 
402
- if (type !== node.type) {
403
- node.type = type;
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
- for (const key in properties) {
407
- if (properties[key] !== node.properties[key]) {
408
- node.properties[key] = properties[key];
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
- } else {
412
- this._nodes[node.id] = node;
413
- this._edges[node.id] = create({ inbound: [], outbound: [] });
414
- }
304
+ return obj;
305
+ };
415
306
 
416
- const trigger = this._waitingForNodes[node.id];
417
- if (trigger) {
418
- trigger.wake(node);
419
- delete this._waitingForNodes[node.id];
420
- }
307
+ const root = get(this.nodeOrThrow(id));
308
+ return toJSON(root);
309
+ }).pipe(Rx.withLabel(`graph:json:${id}`));
310
+ });
421
311
 
422
- if (nodes) {
423
- nodes.forEach((subNode) => {
424
- this._addNode(subNode);
425
- this._addEdge({ source: node.id, target: subNode.id });
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
- if (edges) {
430
- edges.forEach(([id, relation]) =>
431
- relation === 'outbound'
432
- ? this._addEdge({ source: node.id, target: id })
433
- : this._addEdge({ source: id, target: node.id }),
434
- );
435
- }
317
+ if (nodes) {
318
+ nodes.forEach((node) => {
319
+ Record.set(this._initialNodes, node.id, this._constructNode(node));
320
+ });
321
+ }
436
322
 
437
- return node as unknown as Node<TData, TProperties>;
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
- * Remove nodes from the graph.
443
- *
444
- * @param ids The id of the node to remove.
445
- * @param edges Whether to remove edges connected to the node from the graph as well.
446
- * @internal
447
- */
448
- _removeNodes(ids: string[], edges = false) {
449
- batch(() => ids.forEach((id) => this._removeNode(id, edges)));
330
+ toJSON(id = ROOT_ID) {
331
+ return this._registry.get(this._json(id));
450
332
  }
451
333
 
452
- private _removeNode(id: string, edges = false) {
453
- untracked(() => {
454
- const node = this.findNode(id, false);
455
- if (!node) {
456
- return;
457
- }
334
+ json(id = ROOT_ID) {
335
+ return this._json(id);
336
+ }
458
337
 
459
- if (edges) {
460
- // Remove edges from connected nodes.
461
- this._getNodes({ node }).forEach((node) => {
462
- this._removeEdge({ source: id, target: node.id });
463
- });
464
- this._getNodes({ node, relation: 'inbound' }).forEach((node) => {
465
- this._removeEdge({ source: node.id, target: id });
466
- });
338
+ node(id: string): Rx.Rx<Option.Option<Node>> {
339
+ return this._node(id);
340
+ }
467
341
 
468
- // Remove edges from node.
469
- delete this._edges[id];
470
- }
342
+ nodeOrThrow(id: string): Rx.Rx<Node> {
343
+ return this._nodeOrThrow(id);
344
+ }
471
345
 
472
- // Remove node.
473
- delete this._nodes[id];
474
- Object.keys(this._initialized)
475
- .filter((key) => key.startsWith(id))
476
- .forEach((key) => {
477
- delete this._initialized[key];
478
- });
479
- void this._onRemoveNode?.(id);
480
- });
346
+ connections(id: string, relation: Relation = 'outbound'): Rx.Rx<Node[]> {
347
+ return this._connections(`${id}$${relation}`);
481
348
  }
482
349
 
483
- /**
484
- * Add edges to the graph.
485
- *
486
- * @internal
487
- */
488
- _addEdges(edges: { source: string; target: string }[]) {
489
- batch(() => edges.forEach((edge) => this._addEdge(edge)));
350
+ actions(id: string) {
351
+ return this._actions(id);
490
352
  }
491
353
 
492
- private _addEdge({ source, target }: { source: string; target: string }) {
493
- untracked(() => {
494
- if (!this._edges[source]) {
495
- this._edges[source] = create({ inbound: [], outbound: [] });
496
- }
497
- if (!this._edges[target]) {
498
- this._edges[target] = create({ inbound: [], outbound: [] });
499
- }
354
+ edges(id: string): Rx.Rx<Edges> {
355
+ return this._edges(id);
356
+ }
500
357
 
501
- const sourceEdges = this._edges[source];
502
- if (!sourceEdges.outbound.includes(target)) {
503
- sourceEdges.outbound.push(target);
504
- }
358
+ get root() {
359
+ return this.getNodeOrThrow(ROOT_ID);
360
+ }
505
361
 
506
- const targetEdges = this._edges[target];
507
- if (!targetEdges.inbound.includes(source)) {
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
- * Remove edges from the graph.
515
- * @internal
516
- */
517
- _removeEdges(edges: { source: string; target: string }[], removeOrphans = false) {
518
- batch(() => edges.forEach((edge) => this._removeEdge(edge, removeOrphans)));
366
+ getNodeOrThrow(id: string): Node {
367
+ return this._registry.get(this.nodeOrThrow(id));
519
368
  }
520
369
 
521
- private _removeEdge({ source, target }: { source: string; target: string }, removeOrphans = false) {
522
- untracked(() => {
523
- batch(() => {
524
- const outboundIndex = this._edges[source]?.outbound.findIndex((id) => id === target);
525
- if (outboundIndex !== undefined && outboundIndex !== -1) {
526
- this._edges[source].outbound.splice(outboundIndex, 1);
527
- }
370
+ getConnections(id: string, relation: Relation = 'outbound'): Node[] {
371
+ return this._registry.get(this.connections(id, relation));
372
+ }
528
373
 
529
- const inboundIndex = this._edges[target]?.inbound.findIndex((id) => id === source);
530
- if (inboundIndex !== undefined && inboundIndex !== -1) {
531
- this._edges[target].inbound.splice(inboundIndex, 1);
532
- }
374
+ getActions(id: string): Node[] {
375
+ return this._registry.get(this.actions(id));
376
+ }
533
377
 
534
- if (removeOrphans) {
535
- if (
536
- this._edges[source]?.outbound.length === 0 &&
537
- this._edges[source]?.inbound.length === 0 &&
538
- source !== ROOT_ID
539
- ) {
540
- this._removeNode(source, true);
541
- }
542
- if (
543
- this._edges[target]?.outbound.length === 0 &&
544
- this._edges[target]?.inbound.length === 0 &&
545
- target !== ROOT_ID
546
- ) {
547
- this._removeNode(target, true);
548
- }
549
- }
550
- });
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
- * Sort edges for a node.
556
- *
557
- * Edges not included in the sorted list are appended to the end of the list.
558
- *
559
- * @param nodeId The id of the node to sort edges for.
560
- * @param relation The relation of the edges from the node to sort.
561
- * @param edges The ordered list of edges.
562
- * @ignore
563
- */
564
- _sortEdges(nodeId: string, relation: Relation, edges: string[]) {
565
- untracked(() => {
566
- batch(() => {
567
- const current = this._edges[nodeId];
568
- if (current) {
569
- const unsorted = current[relation].filter((id) => !edges.includes(id)) ?? [];
570
- const sorted = edges.filter((id) => current[relation].includes(id)) ?? [];
571
- current[relation].splice(0, current[relation].length, ...[...sorted, ...unsorted]);
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
- private _constructNode = (node: Omit<Node, typeof graphSymbol>) => {
578
- return create<NodeInternal>({ ...node, [graphSymbol]: this });
579
- };
580
-
581
- private _getNodes({
582
- node,
583
- relation = 'outbound',
584
- type,
585
- expansion,
586
- }: {
587
- node: Node;
588
- relation?: Relation;
589
- type?: string;
590
- expansion?: boolean;
591
- }): Node[] {
592
- if (expansion) {
593
- void this.expand(node, relation, type);
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 edges = this._edges[node.id];
597
- if (!edges) {
598
- return [];
599
- } else {
600
- return edges[relation]
601
- .map((id) => this._nodes[id])
602
- .filter(isNonNullable)
603
- .filter((n) => !type || n.type === type);
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
  }