@dxos/app-graph 0.8.2-main.f11618f → 0.8.2-main.fbd8ed0

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