@dxos/app-graph 0.8.3 → 0.8.4-main.1068cf700f

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 (47) hide show
  1. package/dist/lib/browser/index.mjs +1135 -616
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1134 -616
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/atoms.d.ts +8 -0
  8. package/dist/types/src/atoms.d.ts.map +1 -0
  9. package/dist/types/src/graph-builder.d.ts +113 -60
  10. package/dist/types/src/graph-builder.d.ts.map +1 -1
  11. package/dist/types/src/graph.d.ts +183 -209
  12. package/dist/types/src/graph.d.ts.map +1 -1
  13. package/dist/types/src/index.d.ts +6 -3
  14. package/dist/types/src/index.d.ts.map +1 -1
  15. package/dist/types/src/node-matcher.d.ts +218 -0
  16. package/dist/types/src/node-matcher.d.ts.map +1 -0
  17. package/dist/types/src/node-matcher.test.d.ts +2 -0
  18. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  19. package/dist/types/src/node.d.ts +32 -3
  20. package/dist/types/src/node.d.ts.map +1 -1
  21. package/dist/types/src/stories/EchoGraph.stories.d.ts +6 -13
  22. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +37 -37
  25. package/src/atoms.ts +25 -0
  26. package/src/graph-builder.test.ts +571 -97
  27. package/src/graph-builder.ts +600 -258
  28. package/src/graph.test.ts +300 -107
  29. package/src/graph.ts +971 -400
  30. package/src/index.ts +9 -3
  31. package/src/node-matcher.test.ts +301 -0
  32. package/src/node-matcher.ts +284 -0
  33. package/src/node.ts +40 -5
  34. package/src/stories/EchoGraph.stories.tsx +128 -233
  35. package/src/stories/Tree.tsx +2 -2
  36. package/dist/lib/node/index.cjs +0 -816
  37. package/dist/lib/node/index.cjs.map +0 -7
  38. package/dist/lib/node/meta.json +0 -1
  39. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  40. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  41. package/dist/types/src/signals-integration.test.d.ts +0 -2
  42. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  43. package/dist/types/src/testing.d.ts +0 -5
  44. package/dist/types/src/testing.d.ts.map +0 -1
  45. package/src/experimental/graph-projections.test.ts +0 -56
  46. package/src/signals-integration.test.ts +0 -218
  47. package/src/testing.ts +0 -20
package/src/graph.ts CHANGED
@@ -2,42 +2,44 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
6
- import { Option, pipe, Record } from 'effect';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Function from 'effect/Function';
7
+ import * as Option from 'effect/Option';
8
+ import * as Pipeable from 'effect/Pipeable';
9
+ import * as Record from 'effect/Record';
7
10
 
8
11
  import { Event, Trigger } from '@dxos/async';
9
12
  import { todo } from '@dxos/debug';
10
13
  import { invariant } from '@dxos/invariant';
11
14
  import { log } from '@dxos/log';
12
- import { isNonNullable, type MakeOptional } from '@dxos/util';
15
+ import { type MakeOptional, isNonNullable } from '@dxos/util';
13
16
 
14
- import { type NodeArg, type Node, type Relation, type Action, type ActionGroup } from './node';
17
+ import * as Node from './node';
15
18
 
16
19
  const graphSymbol = Symbol('graph');
17
- type DeepWriteable<T> = { -readonly [K in keyof T]: T[K] extends object ? DeepWriteable<T[K]> : T[K] };
18
- type NodeInternal = DeepWriteable<Node> & { [graphSymbol]: Graph };
20
+
21
+ type DeepWriteable<T> = {
22
+ -readonly [K in keyof T]: T[K] extends object ? DeepWriteable<T[K]> : T[K];
23
+ };
24
+
25
+ type NodeInternal = DeepWriteable<Node.Node> & { [graphSymbol]: GraphImpl };
19
26
 
20
27
  /**
21
28
  * Get the Graph a Node is currently associated with.
22
29
  */
23
- export const getGraph = (node: Node): Graph => {
30
+ export const getGraph = (node: Node.Node): Graph => {
24
31
  const graph = (node as NodeInternal)[graphSymbol];
25
32
  invariant(graph, 'Node is not associated with a graph.');
26
- return graph;
33
+ return graph as Graph;
27
34
  };
28
35
 
29
- export const ROOT_ID = 'root';
30
- export const ROOT_TYPE = 'dxos.org/type/GraphRoot';
31
- export const ACTION_TYPE = 'dxos.org/type/GraphAction';
32
- export const ACTION_GROUP_TYPE = 'dxos.org/type/GraphActionGroup';
33
-
34
36
  export type GraphTraversalOptions = {
35
37
  /**
36
38
  * A callback which is called for each node visited during traversal.
37
39
  *
38
40
  * If the callback returns `false`, traversal is stops recursing.
39
41
  */
40
- visitor: (node: Node, path: string[]) => boolean | void;
42
+ visitor: (node: Node.Node, path: string[]) => boolean | void;
41
43
 
42
44
  /**
43
45
  * The node to start traversing from.
@@ -51,242 +53,161 @@ export type GraphTraversalOptions = {
51
53
  *
52
54
  * @default 'outbound'
53
55
  */
54
- relation?: Relation;
56
+ relation?: Node.Relation;
55
57
  };
56
58
 
57
- export type GraphParams = {
59
+ export type GraphProps = {
58
60
  registry?: Registry.Registry;
59
- nodes?: MakeOptional<Node, 'data' | 'cacheable'>[];
61
+ nodes?: MakeOptional<Node.Node, 'data' | 'cacheable'>[];
60
62
  edges?: Record<string, Edges>;
61
- onExpand?: Graph['_onExpand'];
62
- // TODO(wittjosiah): On initialize to restore state from cache.
63
- // onInitialize?: Graph['_onInitialize'];
64
- onRemoveNode?: Graph['_onRemoveNode'];
63
+ onExpand?: (id: string, relation: Node.Relation) => void;
64
+ onInitialize?: (id: string) => Promise<void>;
65
+ onRemoveNode?: (id: string) => void;
65
66
  };
66
67
 
67
68
  export type Edge = { source: string; target: string };
68
69
  export type Edges = { inbound: string[]; outbound: string[] };
69
70
 
70
- export interface ReadableGraph {
71
- /**
72
- * Event emitted when a node is changed.
73
- */
74
- onNodeChanged: Event<{ id: string; node: Option.Option<Node> }>;
75
-
76
- /**
77
- * Convert the graph to a JSON object.
78
- */
79
- toJSON(id?: string): object;
80
-
81
- json(id?: string): Rx.Rx<any>;
82
-
83
- /**
84
- * Get the rx key for the node with the given id.
85
- */
86
- node(id: string): Rx.Rx<Option.Option<Node>>;
87
-
88
- /**
89
- * Get the rx key for the node with the given id.
90
- */
91
- nodeOrThrow(id: string): Rx.Rx<Node>;
92
-
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[]>;
97
-
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)[]>;
102
-
103
- /**
104
- * Get the rx key for the edges of the node with the given id.
105
- */
106
- edges(id: string): Rx.Rx<Edges>;
107
-
108
- /**
109
- * Alias for `getNodeOrThrow(ROOT_ID)`.
110
- */
111
- get root(): Node;
112
-
113
- /**
114
- * Get the node with the given id from the graph's registry.
115
- */
116
- getNode(id: string): Option.Option<Node>;
117
-
118
- /**
119
- * Get the node with the given id from the graph's registry.
120
- *
121
- * @throws If the node is Option.none().
122
- */
123
- getNodeOrThrow(id: string): Node;
71
+ /**
72
+ * Identifier denoting a Graph.
73
+ */
74
+ export const GraphTypeId: unique symbol = Symbol.for('@dxos/app-graph/Graph');
75
+ export type GraphTypeId = typeof GraphTypeId;
124
76
 
125
- /**
126
- * Get all nodes connected to the node with the given id by the given relation from the graph's registry.
127
- */
128
- getConnections(id: string, relation?: Relation): Node[];
77
+ /**
78
+ * Identifier for the graph kind discriminator.
79
+ */
80
+ export const GraphKind: unique symbol = Symbol.for('@dxos/app-graph/GraphKind');
81
+ export type GraphKind = typeof GraphKind;
129
82
 
130
- /**
131
- * Get all actions connected to the node with the given id from the graph's registry.
132
- */
133
- getActions(id: string): Node[];
83
+ export type GraphKindType = 'readable' | 'expandable' | 'writable';
134
84
 
85
+ export interface BaseGraph extends Pipeable.Pipeable {
86
+ readonly [GraphTypeId]: GraphTypeId;
87
+ readonly [GraphKind]: GraphKindType;
135
88
  /**
136
- * Get the edges from the node with the given id from the graph's registry.
89
+ * Event emitted when a node is changed.
137
90
  */
138
- getEdges(id: string): Edges;
139
-
91
+ readonly onNodeChanged: Event<{ id: string; node: Option.Option<Node.Node> }>;
140
92
  /**
141
- * Recursive depth-first traversal of the graph.
142
- *
143
- * @param options.node The node to start traversing from.
144
- * @param options.relation The relation to traverse graph edges.
145
- * @param options.visitor A callback which is called for each node visited during traversal.
93
+ * Get the atom key for the JSON representation of the graph.
146
94
  */
147
- traverse(options: GraphTraversalOptions, path?: string[]): void;
148
-
95
+ json(id?: string): Atom.Atom<any>;
149
96
  /**
150
- * Get the path between two nodes in the graph.
97
+ * Get the atom key for the node with the given id.
151
98
  */
152
- getPath(params: { source?: string; target: string }): Option.Option<string[]>;
153
-
99
+ node(id: string): Atom.Atom<Option.Option<Node.Node>>;
154
100
  /**
155
- * Wait for the path between two nodes in the graph to be established.
101
+ * Get the atom key for the node with the given id.
156
102
  */
157
- waitForPath(
158
- params: { source?: string; target: string },
159
- options?: { timeout?: number; interval?: number },
160
- ): Promise<string[]>;
161
- }
162
-
163
- export interface ExpandableGraph extends ReadableGraph {
103
+ nodeOrThrow(id: string): Atom.Atom<Node.Node>;
164
104
  /**
165
- * Initialize a node in the graph.
166
- *
167
- * Fires the `onInitialize` callback to provide initial data for a node.
105
+ * Get the atom key for the connections of the node with the given id.
168
106
  */
169
- // initialize(id: string): Promise<void>;
170
-
107
+ connections(id: string, relation?: Node.Relation): Atom.Atom<Node.Node[]>;
171
108
  /**
172
- * Expand a node in the graph.
173
- *
174
- * Fires the `onExpand` callback to add connections to the node.
109
+ * Get the atom key for the actions of the node with the given id.
175
110
  */
176
- expand(id: string, relation?: Relation): void;
177
-
111
+ actions(id: string): Atom.Atom<(Node.Action | Node.ActionGroup)[]>;
178
112
  /**
179
- * Sort the edges of the node with the given id.
113
+ * Get the atom key for the edges of the node with the given id.
180
114
  */
181
- sortEdges(id: string, relation: Relation, order: string[]): void;
115
+ edges(id: string): Atom.Atom<Edges>;
182
116
  }
183
117
 
184
- export interface WritableGraph extends ExpandableGraph {
185
- /**
186
- * Add nodes to the graph.
187
- */
188
- addNodes(nodes: NodeArg<any, Record<string, any>>[]): void;
189
-
190
- /**
191
- * Add a node to the graph.
192
- */
193
- addNode(node: NodeArg<any, Record<string, any>>): void;
118
+ export type ReadableGraph = BaseGraph & { readonly [GraphKind]: 'readable' | 'expandable' | 'writable' };
119
+ export type ExpandableGraph = BaseGraph & { readonly [GraphKind]: 'expandable' | 'writable' };
120
+ export type WritableGraph = BaseGraph & { readonly [GraphKind]: 'writable' };
194
121
 
195
- /**
196
- * Remove nodes from the graph.
197
- */
198
- removeNodes(ids: string[], edges?: boolean): void;
199
-
200
- /**
201
- * Remove a node from the graph.
202
- */
203
- removeNode(id: string, edges?: boolean): void;
204
-
205
- /**
206
- * Add edges to the graph.
207
- */
208
- addEdges(edges: Edge[]): void;
209
-
210
- /**
211
- * Add an edge to the graph.
212
- */
213
- addEdge(edge: Edge): void;
214
-
215
- /**
216
- * Remove edges from the graph.
217
- */
218
- removeEdges(edges: Edge[], removeOrphans?: boolean): void;
219
-
220
- /**
221
- * Remove an edge from the graph.
222
- */
223
- removeEdge(edge: Edge, removeOrphans?: boolean): void;
224
- }
122
+ /**
123
+ * Graph interface.
124
+ */
125
+ export type Graph = WritableGraph;
225
126
 
226
127
  /**
227
128
  * The Graph represents the user interface information architecture of the application constructed via plugins.
129
+ * @internal
228
130
  */
229
- export class Graph implements WritableGraph {
230
- readonly onNodeChanged = new Event<{ id: string; node: Option.Option<Node> }>();
131
+ class GraphImpl implements WritableGraph {
132
+ readonly [GraphTypeId]: GraphTypeId = GraphTypeId;
133
+ readonly [GraphKind] = 'writable' as const;
231
134
 
232
- private readonly _onExpand?: (id: string, relation: Relation) => void;
233
- // private readonly _onInitialize?: (id: string) => Promise<void>;
234
- private readonly _onRemoveNode?: (id: string) => void;
135
+ pipe() {
136
+ // eslint-disable-next-line prefer-rest-params
137
+ return Pipeable.pipeArguments(this, arguments);
138
+ }
235
139
 
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: {} })],
140
+ readonly onNodeChanged = new Event<{
141
+ id: string;
142
+ node: Option.Option<Node.Node>;
143
+ }>();
144
+
145
+ readonly _onExpand?: GraphProps['onExpand'];
146
+ readonly _onInitialize?: GraphProps['onInitialize'];
147
+ readonly _onRemoveNode?: GraphProps['onRemoveNode'];
148
+
149
+ readonly _registry: Registry.Registry;
150
+ readonly _expanded = Record.empty<string, boolean>();
151
+ readonly _initialized = Record.empty<string, boolean>();
152
+ readonly _initialEdges = Record.empty<string, Edges>();
153
+ readonly _initialNodes = Record.fromEntries([
154
+ [
155
+ Node.RootId,
156
+ this._constructNode({
157
+ id: Node.RootId,
158
+ type: Node.RootType,
159
+ data: null,
160
+ properties: {},
161
+ }),
162
+ ],
242
163
  ]);
243
164
 
244
165
  /** @internal */
245
- readonly _node = Rx.family<string, Rx.Writable<Option.Option<Node>>>((id) => {
166
+ readonly _node = Atom.family<string, Atom.Writable<Option.Option<Node.Node>>>((id) => {
246
167
  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}`));
168
+ return Atom.make<Option.Option<Node.Node>>(initial).pipe(Atom.keepAlive, Atom.withLabel(`graph:node:${id}`));
248
169
  });
249
170
 
250
- private readonly _nodeOrThrow = Rx.family<string, Rx.Rx<Node>>((id) => {
251
- return Rx.make((get) => {
171
+ readonly _nodeOrThrow = Atom.family<string, Atom.Atom<Node.Node>>((id) => {
172
+ return Atom.make((get) => {
252
173
  const node = get(this._node(id));
253
174
  invariant(Option.isSome(node), `Node not available: ${id}`);
254
175
  return node.value;
255
176
  });
256
177
  });
257
178
 
258
- private readonly _edges = Rx.family<string, Rx.Writable<Edges>>((id) => {
179
+ readonly _edges = Atom.family<string, Atom.Writable<Edges>>((id) => {
259
180
  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}`));
181
+ return Atom.make<Edges>(initial).pipe(Atom.keepAlive, Atom.withLabel(`graph:edges:${id}`));
261
182
  });
262
183
 
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) => {
184
+ // NOTE: Currently the argument to the family needs to be referentially stable for the atom to be referentially stable.
185
+ // TODO(wittjosiah): Atom feature request, support for something akin to `ComplexMap` to allow for complex arguments.
186
+ readonly _connections = Atom.family<string, Atom.Atom<Node.Node[]>>((key) => {
187
+ return Atom.make((get) => {
267
188
  const [id, relation] = key.split('$');
268
189
  const edges = get(this._edges(id));
269
- return edges[relation as Relation]
190
+ return edges[relation as Node.Relation]
270
191
  .map((id) => get(this._node(id)))
271
192
  .filter(Option.isSome)
272
193
  .map((o) => o.value);
273
- }).pipe(Rx.withLabel(`graph:connections:${key}`));
194
+ }).pipe(Atom.withLabel(`graph:connections:${key}`));
274
195
  });
275
196
 
276
- private readonly _actions = Rx.family<string, Rx.Rx<(Action | ActionGroup)[]>>((id) => {
277
- return Rx.make((get) => {
197
+ readonly _actions = Atom.family<string, Atom.Atom<(Node.Action | Node.ActionGroup)[]>>((id) => {
198
+ return Atom.make((get) => {
278
199
  return get(this._connections(`${id}$outbound`)).filter(
279
- (node) => node.type === ACTION_TYPE || node.type === ACTION_GROUP_TYPE,
200
+ (node) => node.type === Node.ActionType || node.type === Node.ActionGroupType,
280
201
  );
281
- }).pipe(Rx.withLabel(`graph:actions:${id}`));
202
+ }).pipe(Atom.withLabel(`graph:actions:${id}`));
282
203
  });
283
204
 
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));
205
+ readonly _json = Atom.family<string, Atom.Atom<any>>((id) => {
206
+ return Atom.make((get) => {
207
+ const toJSON = (node: Node.Node, seen: string[] = []): any => {
208
+ const nodes = get(this._connections(`${node.id}$outbound`));
288
209
  const obj: Record<string, any> = {
289
- id: node.id.length > 32 ? `${node.id.slice(0, 32)}...` : node.id,
210
+ id: node.id,
290
211
  type: node.type,
291
212
  };
292
213
  if (node.properties.label) {
@@ -294,7 +215,7 @@ export class Graph implements WritableGraph {
294
215
  }
295
216
  if (nodes.length) {
296
217
  obj.nodes = nodes
297
- .map((n) => {
218
+ .map((n: Node.Node) => {
298
219
  // Break cycles.
299
220
  const nextSeen = [...seen, node.id];
300
221
  return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
@@ -304,13 +225,14 @@ export class Graph implements WritableGraph {
304
225
  return obj;
305
226
  };
306
227
 
307
- const root = get(this.nodeOrThrow(id));
228
+ const root = get(this._nodeOrThrow(id));
308
229
  return toJSON(root);
309
- }).pipe(Rx.withLabel(`graph:json:${id}`));
230
+ }).pipe(Atom.withLabel(`graph:json:${id}`));
310
231
  });
311
232
 
312
- constructor({ registry, nodes, edges, onExpand, onRemoveNode }: GraphParams = {}) {
233
+ constructor({ registry, nodes, edges, onInitialize, onExpand, onRemoveNode }: GraphProps = {}) {
313
234
  this._registry = registry ?? Registry.make();
235
+ this._onInitialize = onInitialize;
314
236
  this._onExpand = onExpand;
315
237
  this._onRemoveNode = onRemoveNode;
316
238
 
@@ -327,277 +249,926 @@ export class Graph implements WritableGraph {
327
249
  }
328
250
  }
329
251
 
330
- toJSON(id = ROOT_ID) {
331
- return this._registry.get(this._json(id));
252
+ json(id = Node.RootId): Atom.Atom<any> {
253
+ return jsonImpl(this, id);
332
254
  }
333
255
 
334
- json(id = ROOT_ID) {
335
- return this._json(id);
256
+ node(id: string): Atom.Atom<Option.Option<Node.Node>> {
257
+ return nodeImpl(this, id);
336
258
  }
337
259
 
338
- node(id: string): Rx.Rx<Option.Option<Node>> {
339
- return this._node(id);
260
+ nodeOrThrow(id: string): Atom.Atom<Node.Node> {
261
+ return nodeOrThrowImpl(this, id);
340
262
  }
341
263
 
342
- nodeOrThrow(id: string): Rx.Rx<Node> {
343
- return this._nodeOrThrow(id);
264
+ connections(id: string, relation: Node.Relation = 'outbound'): Atom.Atom<Node.Node[]> {
265
+ return connectionsImpl(this, id, relation);
344
266
  }
345
267
 
346
- connections(id: string, relation: Relation = 'outbound'): Rx.Rx<Node[]> {
347
- return this._connections(`${id}$${relation}`);
268
+ actions(id: string): Atom.Atom<(Node.Action | Node.ActionGroup)[]> {
269
+ return actionsImpl(this, id);
348
270
  }
349
271
 
350
- actions(id: string) {
351
- return this._actions(id);
272
+ edges(id: string): Atom.Atom<Edges> {
273
+ return edgesImpl(this, id);
352
274
  }
353
275
 
354
- edges(id: string): Rx.Rx<Edges> {
355
- return this._edges(id);
276
+ /** @internal */
277
+ _constructNode(node: Node.NodeArg<any>): Option.Option<Node.Node> {
278
+ return Option.some({
279
+ [graphSymbol]: this,
280
+ data: null,
281
+ properties: {},
282
+ ...node,
283
+ });
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Internal helper to access GraphImpl internals.
289
+ * @internal
290
+ */
291
+ const getInternal = (graph: BaseGraph): GraphImpl => {
292
+ return graph as unknown as GraphImpl;
293
+ };
294
+
295
+ /**
296
+ * Convert the graph to a JSON object.
297
+ */
298
+ export const toJSON = (graph: BaseGraph, id = Node.RootId): object => {
299
+ const internal = getInternal(graph);
300
+ return internal._registry.get(internal._json(id));
301
+ };
302
+
303
+ /**
304
+ * Implementation helper for json.
305
+ */
306
+ const jsonImpl = (graph: BaseGraph, id = Node.RootId): Atom.Atom<any> => {
307
+ const internal = getInternal(graph);
308
+ return internal._json(id);
309
+ };
310
+
311
+ /**
312
+ * Implementation helper for node.
313
+ */
314
+ const nodeImpl = (graph: BaseGraph, id: string): Atom.Atom<Option.Option<Node.Node>> => {
315
+ const internal = getInternal(graph);
316
+ return internal._node(id);
317
+ };
318
+
319
+ /**
320
+ * Implementation helper for nodeOrThrow.
321
+ */
322
+ const nodeOrThrowImpl = (graph: BaseGraph, id: string): Atom.Atom<Node.Node> => {
323
+ const internal = getInternal(graph);
324
+ return internal._nodeOrThrow(id);
325
+ };
326
+
327
+ /**
328
+ * Implementation helper for connections.
329
+ */
330
+ const connectionsImpl = (
331
+ graph: BaseGraph,
332
+ id: string,
333
+ relation: Node.Relation = 'outbound',
334
+ ): Atom.Atom<Node.Node[]> => {
335
+ const internal = getInternal(graph);
336
+ return internal._connections(`${id}$${relation}`);
337
+ };
338
+
339
+ /**
340
+ * Implementation helper for actions.
341
+ */
342
+ const actionsImpl = (graph: BaseGraph, id: string): Atom.Atom<(Node.Action | Node.ActionGroup)[]> => {
343
+ const internal = getInternal(graph);
344
+ return internal._actions(id);
345
+ };
346
+
347
+ /**
348
+ * Implementation helper for edges.
349
+ */
350
+ const edgesImpl = (graph: BaseGraph, id: string): Atom.Atom<Edges> => {
351
+ const internal = getInternal(graph);
352
+ return internal._edges(id);
353
+ };
354
+
355
+ /**
356
+ * Implementation helper for getNode.
357
+ */
358
+ const getNodeImpl = (graph: BaseGraph, id: string): Option.Option<Node.Node> => {
359
+ const internal = getInternal(graph);
360
+ return internal._registry.get(nodeImpl(graph, id));
361
+ };
362
+
363
+ /**
364
+ * Get the node with the given id from the graph's registry.
365
+ */
366
+ export function getNode(graph: BaseGraph, id: string): Option.Option<Node.Node>;
367
+ export function getNode(id: string): (graph: BaseGraph) => Option.Option<Node.Node>;
368
+ export function getNode(
369
+ graphOrId: BaseGraph | string,
370
+ id?: string,
371
+ ): Option.Option<Node.Node> | ((graph: BaseGraph) => Option.Option<Node.Node>) {
372
+ if (typeof graphOrId === 'string') {
373
+ // Curried: getNode(id)
374
+ const id = graphOrId;
375
+ return (graph: BaseGraph) => getNodeImpl(graph, id);
376
+ } else {
377
+ // Direct: getNode(graph, id)
378
+ const graph = graphOrId;
379
+ return getNodeImpl(graph, id!);
356
380
  }
381
+ }
357
382
 
358
- get root() {
359
- return this.getNodeOrThrow(ROOT_ID);
383
+ /**
384
+ * Implementation helper for getNodeOrThrow.
385
+ */
386
+ const getNodeOrThrowImpl = (graph: BaseGraph, id: string): Node.Node => {
387
+ const internal = getInternal(graph);
388
+ return internal._registry.get(nodeOrThrowImpl(graph, id));
389
+ };
390
+
391
+ /**
392
+ * Get the node with the given id from the graph's registry.
393
+ *
394
+ * @throws If the node is Option.none().
395
+ */
396
+ export function getNodeOrThrow(graph: BaseGraph, id: string): Node.Node;
397
+ export function getNodeOrThrow(id: string): (graph: BaseGraph) => Node.Node;
398
+ export function getNodeOrThrow(
399
+ graphOrId: BaseGraph | string,
400
+ id?: string,
401
+ ): Node.Node | ((graph: BaseGraph) => Node.Node) {
402
+ if (typeof graphOrId === 'string') {
403
+ // Curried: getNodeOrThrow(id)
404
+ const id = graphOrId;
405
+ return (graph: BaseGraph) => getNodeOrThrowImpl(graph, id);
406
+ } else {
407
+ // Direct: getNodeOrThrow(graph, id)
408
+ const graph = graphOrId;
409
+ return getNodeOrThrowImpl(graph, id!);
360
410
  }
411
+ }
361
412
 
362
- getNode(id: string): Option.Option<Node> {
363
- return this._registry.get(this.node(id));
413
+ /**
414
+ * Get the root node of the graph.
415
+ * This is an alias for `getNodeOrThrow(graph, ROOT_ID)`.
416
+ */
417
+ export function getRoot(graph: BaseGraph): Node.Node {
418
+ return getNodeOrThrowImpl(graph, Node.RootId);
419
+ }
420
+
421
+ /**
422
+ * Implementation helper for getConnections.
423
+ */
424
+ const getConnectionsImpl = (graph: BaseGraph, id: string, relation: Node.Relation = 'outbound'): Node.Node[] => {
425
+ const internal = getInternal(graph);
426
+ return internal._registry.get(connectionsImpl(graph, id, relation));
427
+ };
428
+
429
+ /**
430
+ * Get all nodes connected to the node with the given id by the given relation from the graph's registry.
431
+ */
432
+ export function getConnections(graph: BaseGraph, id: string, relation?: Node.Relation): Node.Node[];
433
+ export function getConnections(id: string, relation?: Node.Relation): (graph: BaseGraph) => Node.Node[];
434
+ export function getConnections(
435
+ graphOrId: BaseGraph | string,
436
+ idOrRelation?: string | Node.Relation,
437
+ relation?: Node.Relation,
438
+ ): Node.Node[] | ((graph: BaseGraph) => Node.Node[]) {
439
+ if (typeof graphOrId === 'string') {
440
+ // Curried: getConnections(id, relation?)
441
+ const id = graphOrId;
442
+ const rel = (typeof idOrRelation === 'string' ? 'outbound' : idOrRelation) ?? 'outbound';
443
+ return (graph: BaseGraph) => getConnectionsImpl(graph, id, rel);
444
+ } else {
445
+ // Direct: getConnections(graph, id, relation?)
446
+ const graph = graphOrId;
447
+ const id = idOrRelation as string;
448
+ const rel = relation ?? 'outbound';
449
+ return getConnectionsImpl(graph, id, rel);
364
450
  }
451
+ }
365
452
 
366
- getNodeOrThrow(id: string): Node {
367
- return this._registry.get(this.nodeOrThrow(id));
453
+ /**
454
+ * Implementation helper for getActions.
455
+ */
456
+ const getActionsImpl = (graph: BaseGraph, id: string): Node.Node[] => {
457
+ const internal = getInternal(graph);
458
+ return internal._registry.get(actionsImpl(graph, id));
459
+ };
460
+
461
+ /**
462
+ * Get all actions connected to the node with the given id from the graph's registry.
463
+ */
464
+ export function getActions(graph: BaseGraph, id: string): Node.Node[];
465
+ export function getActions(id: string): (graph: BaseGraph) => Node.Node[];
466
+ export function getActions(
467
+ graphOrId: BaseGraph | string,
468
+ id?: string,
469
+ ): Node.Node[] | ((graph: BaseGraph) => Node.Node[]) {
470
+ if (typeof graphOrId === 'string') {
471
+ // Curried: getActions(id)
472
+ const id = graphOrId;
473
+ return (graph: BaseGraph) => getActionsImpl(graph, id);
474
+ } else {
475
+ // Direct: getActions(graph, id)
476
+ const graph = graphOrId;
477
+ return getActionsImpl(graph, id!);
368
478
  }
479
+ }
480
+
481
+ /**
482
+ * Implementation helper for getEdges.
483
+ */
484
+ const getEdgesImpl = (graph: BaseGraph, id: string): Edges => {
485
+ const internal = getInternal(graph);
486
+ return internal._registry.get(edgesImpl(graph, id));
487
+ };
369
488
 
370
- getConnections(id: string, relation: Relation = 'outbound'): Node[] {
371
- return this._registry.get(this.connections(id, relation));
489
+ /**
490
+ * Get the edges from the node with the given id from the graph's registry.
491
+ */
492
+ export function getEdges(graph: BaseGraph, id: string): Edges;
493
+ export function getEdges(id: string): (graph: BaseGraph) => Edges;
494
+ export function getEdges(graphOrId: BaseGraph | string, id?: string): Edges | ((graph: BaseGraph) => Edges) {
495
+ if (typeof graphOrId === 'string') {
496
+ // Curried: getEdges(id)
497
+ const id = graphOrId;
498
+ return (graph: BaseGraph) => getEdgesImpl(graph, id);
499
+ } else {
500
+ // Direct: getEdges(graph, id)
501
+ const graph = graphOrId;
502
+ return getEdgesImpl(graph, id!);
372
503
  }
504
+ }
373
505
 
374
- getActions(id: string): Node[] {
375
- return this._registry.get(this.actions(id));
506
+ /**
507
+ * Recursive depth-first traversal of the graph.
508
+ */
509
+ /**
510
+ * Implementation helper for traverse.
511
+ */
512
+ const traverseImpl = (graph: BaseGraph, options: GraphTraversalOptions, path: string[] = []): void => {
513
+ const { visitor, source = Node.RootId, relation = 'outbound' } = options;
514
+ // Break cycles.
515
+ if (path.includes(source)) {
516
+ return;
376
517
  }
377
518
 
378
- getEdges(id: string): Edges {
379
- return this._registry.get(this.edges(id));
519
+ const node = getNodeOrThrow(graph, source);
520
+ const shouldContinue = visitor(node, [...path, source]);
521
+ if (shouldContinue === false) {
522
+ return;
380
523
  }
381
524
 
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
- // }
525
+ Object.values(getConnections(graph, source, relation)).forEach((child) =>
526
+ traverseImpl(graph, { source: child.id, relation, visitor }, [...path, source]),
527
+ );
528
+ };
391
529
 
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
- }
530
+ /**
531
+ * Traverse the graph with the given options.
532
+ */
533
+ export function traverse(graph: BaseGraph, options: GraphTraversalOptions, path?: string[]): void;
534
+ export function traverse(options: GraphTraversalOptions, path?: string[]): (graph: BaseGraph) => void;
535
+ export function traverse(
536
+ graphOrOptions: BaseGraph | GraphTraversalOptions,
537
+ optionsOrPath?: GraphTraversalOptions | string[],
538
+ path?: string[],
539
+ ): void | ((graph: BaseGraph) => void) {
540
+ if (typeof graphOrOptions === 'object' && 'visitor' in graphOrOptions) {
541
+ // Curried: traverse(options, path?)
542
+ const options = graphOrOptions as GraphTraversalOptions;
543
+ const pathArg = Array.isArray(optionsOrPath) ? optionsOrPath : undefined;
544
+ return (graph: BaseGraph) => traverseImpl(graph, options, pathArg);
545
+ } else {
546
+ // Direct: traverse(graph, options, path?)
547
+ const graph = graphOrOptions as BaseGraph;
548
+ const options = optionsOrPath as GraphTraversalOptions;
549
+ const pathArg = path ?? (Array.isArray(optionsOrPath) ? optionsOrPath : undefined);
550
+ return traverseImpl(graph, options, pathArg);
400
551
  }
552
+ }
401
553
 
402
- addNodes(nodes: NodeArg<any, Record<string, any>>[]): void {
403
- Rx.batch(() => {
404
- nodes.map((node) => this.addNode(node));
405
- });
554
+ /**
555
+ * Implementation helper for getPath.
556
+ */
557
+ const getPathImpl = (graph: BaseGraph, params: { source?: string; target: string }): Option.Option<string[]> => {
558
+ return Function.pipe(
559
+ getNode(graph, params.source ?? 'root'),
560
+ Option.flatMap((node) => {
561
+ let found: Option.Option<string[]> = Option.none();
562
+ traverseImpl(graph, {
563
+ source: node.id,
564
+ visitor: (node, path) => {
565
+ if (Option.isSome(found)) {
566
+ return false;
567
+ }
568
+
569
+ if (node.id === params.target) {
570
+ found = Option.some(path);
571
+ }
572
+ },
573
+ });
574
+
575
+ return found;
576
+ }),
577
+ );
578
+ };
579
+
580
+ /**
581
+ * Get the path between two nodes in the graph.
582
+ */
583
+ export function getPath(graph: BaseGraph, params: { source?: string; target: string }): Option.Option<string[]>;
584
+ export function getPath(params: { source?: string; target: string }): (graph: BaseGraph) => Option.Option<string[]>;
585
+ export function getPath(
586
+ graphOrParams: BaseGraph | { source?: string; target: string },
587
+ params?: { source?: string; target: string },
588
+ ): Option.Option<string[]> | ((graph: BaseGraph) => Option.Option<string[]>) {
589
+ if (params === undefined && typeof graphOrParams === 'object' && 'target' in graphOrParams) {
590
+ // Curried: getPath(params)
591
+ const params = graphOrParams as { source?: string; target: string };
592
+ return (graph: BaseGraph) => getPathImpl(graph, params);
593
+ } else {
594
+ // Direct: getPath(graph, params)
595
+ const graph = graphOrParams as BaseGraph;
596
+ return getPathImpl(graph, params!);
406
597
  }
598
+ }
407
599
 
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 });
423
- }
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
- });
600
+ /**
601
+ * Implementation helper for waitForPath.
602
+ */
603
+ const waitForPathImpl = (
604
+ graph: BaseGraph,
605
+ params: { source?: string; target: string },
606
+ options?: { timeout?: number; interval?: number },
607
+ ): Promise<string[]> => {
608
+ const { timeout = 5_000, interval = 500 } = options ?? {};
609
+ const path = getPathImpl(graph, params);
610
+ if (Option.isSome(path)) {
611
+ return Promise.resolve(path.value);
612
+ }
432
613
 
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
- // });
614
+ const trigger = new Trigger<string[]>();
615
+ const i = setInterval(() => {
616
+ const path = getPathImpl(graph, params);
617
+ if (Option.isSome(path)) {
618
+ trigger.wake(path.value);
439
619
  }
620
+ }, interval);
440
621
 
441
- if (edges) {
442
- todo();
443
- }
444
- }
622
+ return trigger.wait({ timeout }).finally(() => clearInterval(i));
623
+ };
445
624
 
446
- removeNodes(ids: string[], edges = false): void {
447
- Rx.batch(() => {
448
- ids.map((id) => this.removeNode(id, edges));
449
- });
625
+ /**
626
+ * Wait for the path between two nodes in the graph to be established.
627
+ */
628
+ export function waitForPath(
629
+ graph: BaseGraph,
630
+ params: { source?: string; target: string },
631
+ options?: { timeout?: number; interval?: number },
632
+ ): Promise<string[]>;
633
+ export function waitForPath(
634
+ params: { source?: string; target: string },
635
+ options?: { timeout?: number; interval?: number },
636
+ ): (graph: BaseGraph) => Promise<string[]>;
637
+ export function waitForPath(
638
+ graphOrParams: BaseGraph | { source?: string; target: string },
639
+ paramsOrOptions?: { source?: string; target: string } | { timeout?: number; interval?: number },
640
+ options?: { timeout?: number; interval?: number },
641
+ ): Promise<string[]> | ((graph: BaseGraph) => Promise<string[]>) {
642
+ if (typeof graphOrParams === 'object' && 'target' in graphOrParams) {
643
+ // Curried: waitForPath(params, options?)
644
+ const params = graphOrParams as { source?: string; target: string };
645
+ const opts = typeof paramsOrOptions === 'object' && !('target' in paramsOrOptions) ? paramsOrOptions : undefined;
646
+ return (graph: BaseGraph) => waitForPathImpl(graph, params, opts);
647
+ } else {
648
+ // Direct: waitForPath(graph, params, options?)
649
+ const graph = graphOrParams as BaseGraph;
650
+ const params = paramsOrOptions as { source?: string; target: string };
651
+ return waitForPathImpl(graph, params, options);
450
652
  }
653
+ }
451
654
 
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?
655
+ /**
656
+ * Implementation helper for initialize.
657
+ */
658
+ const initializeImpl = async <T extends ExpandableGraph | WritableGraph>(graph: T, id: string): Promise<T> => {
659
+ const internal = getInternal(graph);
660
+ const initialized = Record.get(internal._initialized, id).pipe(Option.getOrElse(() => false));
661
+ log('initialize', { id, initialized });
662
+ if (!initialized) {
663
+ await internal._onInitialize?.(id);
664
+ Record.set(internal._initialized, id, true);
665
+ }
666
+ return graph;
667
+ };
458
668
 
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
- }
669
+ /**
670
+ * Initialize a node in the graph.
671
+ *
672
+ * Fires the `onInitialize` callback to provide initial data for a node.
673
+ */
674
+ export function initialize<T extends ExpandableGraph | WritableGraph>(graph: T, id: string): Promise<T>;
675
+ export function initialize(id: string): <T extends ExpandableGraph | WritableGraph>(graph: T) => Promise<T>;
676
+ export function initialize<T extends ExpandableGraph | WritableGraph>(
677
+ graphOrId: T | string,
678
+ id?: string,
679
+ ): Promise<T> | (<T extends ExpandableGraph | WritableGraph>(graph: T) => Promise<T>) {
680
+ if (typeof graphOrId === 'string') {
681
+ // Curried: initialize(id)
682
+ const id = graphOrId;
683
+ return <T extends ExpandableGraph | WritableGraph>(graph: T) => initializeImpl(graph, id);
684
+ } else {
685
+ // Direct: initialize(graph, id)
686
+ const graph = graphOrId;
687
+ return initializeImpl(graph, id!);
688
+ }
689
+ }
467
690
 
468
- this._onRemoveNode?.(id);
691
+ /**
692
+ * Implementation helper for expand.
693
+ */
694
+ const expandImpl = <T extends ExpandableGraph | WritableGraph>(
695
+ graph: T,
696
+ id: string,
697
+ relation: Node.Relation = 'outbound',
698
+ ): T => {
699
+ const internal = getInternal(graph);
700
+ const key = `${id}$${relation}`;
701
+ const expanded = Record.get(internal._expanded, key).pipe(Option.getOrElse(() => false));
702
+ log('expand', { key, expanded });
703
+ if (!expanded) {
704
+ internal._onExpand?.(id, relation);
705
+ Record.set(internal._expanded, key, true);
469
706
  }
707
+ return graph;
708
+ };
470
709
 
471
- addEdges(edges: Edge[]): void {
472
- Rx.batch(() => {
473
- edges.map((edge) => this.addEdge(edge));
474
- });
710
+ /**
711
+ * Expand a node in the graph.
712
+ *
713
+ * Fires the `onExpand` callback to add connections to the node.
714
+ */
715
+ export function expand<T extends ExpandableGraph | WritableGraph>(graph: T, id: string, relation?: Node.Relation): T;
716
+ export function expand(
717
+ id: string,
718
+ relation?: Node.Relation,
719
+ ): <T extends ExpandableGraph | WritableGraph>(graph: T) => T;
720
+ export function expand<T extends ExpandableGraph | WritableGraph>(
721
+ graphOrId: T | string,
722
+ idOrRelation?: string | Node.Relation,
723
+ relation?: Node.Relation,
724
+ ): T | (<T extends ExpandableGraph | WritableGraph>(graph: T) => T) {
725
+ if (typeof graphOrId === 'string') {
726
+ // Curried: expand(id, relation?)
727
+ const id = graphOrId;
728
+ const rel = (typeof idOrRelation === 'string' ? 'outbound' : idOrRelation) ?? 'outbound';
729
+ return <T extends ExpandableGraph | WritableGraph>(graph: T) => expandImpl(graph, id, rel);
730
+ } else {
731
+ // Direct: expand(graph, id, relation?)
732
+ const graph = graphOrId;
733
+ const id = idOrRelation as string;
734
+ const rel = relation ?? 'outbound';
735
+ return expandImpl(graph, id, rel);
475
736
  }
737
+ }
476
738
 
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] });
483
- }
739
+ /**
740
+ * Implementation helper for sortEdges.
741
+ */
742
+ const sortEdgesImpl = <T extends ExpandableGraph | WritableGraph>(
743
+ graph: T,
744
+ id: string,
745
+ relation: Node.Relation,
746
+ order: string[],
747
+ ): T => {
748
+ const internal = getInternal(graph);
749
+ const edgesAtom = internal._edges(id);
750
+ const edges = internal._registry.get(edgesAtom);
751
+ const unsorted = edges[relation].filter((id) => !order.includes(id)) ?? [];
752
+ const sorted = order.filter((id) => edges[relation].includes(id)) ?? [];
753
+ edges[relation].splice(0, edges[relation].length, ...[...sorted, ...unsorted]);
754
+ internal._registry.set(edgesAtom, edges);
755
+ return graph;
756
+ };
484
757
 
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
- }
758
+ /**
759
+ * Sort the edges of the node with the given id.
760
+ */
761
+ export function sortEdges<T extends ExpandableGraph | WritableGraph>(
762
+ graph: T,
763
+ id: string,
764
+ relation: Node.Relation,
765
+ order: string[],
766
+ ): T;
767
+ export function sortEdges(
768
+ id: string,
769
+ relation: Node.Relation,
770
+ order: string[],
771
+ ): <T extends ExpandableGraph | WritableGraph>(graph: T) => T;
772
+ export function sortEdges<T extends ExpandableGraph | WritableGraph>(
773
+ graphOrId: T | string,
774
+ idOrRelation?: string | Node.Relation,
775
+ relationOrOrder?: Node.Relation | string[],
776
+ order?: string[],
777
+ ): T | (<T extends ExpandableGraph | WritableGraph>(graph: T) => T) {
778
+ if (typeof graphOrId === 'string') {
779
+ // Curried: sortEdges(id, relation, order)
780
+ const id = graphOrId;
781
+ const relation = idOrRelation as Node.Relation;
782
+ const order = relationOrOrder as string[];
783
+ return <T extends ExpandableGraph | WritableGraph>(graph: T) => sortEdgesImpl(graph, id, relation, order);
784
+ } else {
785
+ // Direct: sortEdges(graph, id, relation, order)
786
+ const graph = graphOrId;
787
+ const id = idOrRelation as string;
788
+ const relation = relationOrOrder as Node.Relation;
789
+ return sortEdgesImpl(graph, id, relation, order!);
491
790
  }
791
+ }
492
792
 
493
- removeEdges(edges: Edge[], removeOrphans = false): void {
494
- Rx.batch(() => {
495
- edges.map((edge) => this.removeEdge(edge, removeOrphans));
496
- });
793
+ /**
794
+ * Implementation helper for addNodes.
795
+ */
796
+ const addNodesImpl = <T extends WritableGraph>(graph: T, nodes: Node.NodeArg<any, Record<string, any>>[]): T => {
797
+ Atom.batch(() => {
798
+ nodes.map((node) => addNodeImpl(graph, node));
799
+ });
800
+ return graph;
801
+ };
802
+
803
+ /**
804
+ * Add nodes to the graph.
805
+ */
806
+ export function addNodes<T extends WritableGraph>(graph: T, nodes: Node.NodeArg<any, Record<string, any>>[]): T;
807
+ export function addNodes(nodes: Node.NodeArg<any, Record<string, any>>[]): <T extends WritableGraph>(graph: T) => T;
808
+ export function addNodes<T extends WritableGraph>(
809
+ graphOrNodes: T | Node.NodeArg<any, Record<string, any>>[],
810
+ nodes?: Node.NodeArg<any, Record<string, any>>[],
811
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
812
+ if (nodes === undefined) {
813
+ // Curried: addNodes(nodes)
814
+ const nodes = graphOrNodes as Node.NodeArg<any, Record<string, any>>[];
815
+ return <T extends WritableGraph>(graph: T) => addNodesImpl(graph, nodes);
816
+ } else {
817
+ // Direct: addNodes(graph, nodes)
818
+ const graph = graphOrNodes as T;
819
+ return addNodesImpl(graph, nodes);
497
820
  }
821
+ }
498
822
 
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),
823
+ /**
824
+ * Implementation helper for addNode.
825
+ */
826
+ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<any, Record<string, any>>): T => {
827
+ const internal = getInternal(graph);
828
+ // Extract known NodeArg fields, preserve any extra fields (like _actionContext) in rest.
829
+ const {
830
+ nodes,
831
+ edges,
832
+ id,
833
+ type,
834
+ data = null,
835
+ properties = {},
836
+ ...rest
837
+ } = nodeArg as Node.NodeArg<any> & {
838
+ _actionContext?: Node.ActionContext;
839
+ };
840
+ const nodeAtom = internal._node(id);
841
+ const existingNode = internal._registry.get(nodeAtom);
842
+ Option.match(existingNode, {
843
+ onSome: (existing) => {
844
+ const typeChanged = existing.type !== type;
845
+ const dataChanged = existing.data !== data;
846
+ const propertiesChanged = Object.keys(properties).some((key) => existing.properties[key] !== properties[key]);
847
+ log('existing node', {
848
+ id,
849
+ typeChanged,
850
+ dataChanged,
851
+ propertiesChanged,
506
852
  });
507
- }
853
+ if (typeChanged || dataChanged || propertiesChanged) {
854
+ log('updating node', { id, type, data, properties });
855
+ const newNode = Option.some({
856
+ ...existing,
857
+ ...rest,
858
+ type,
859
+ data,
860
+ properties: { ...existing.properties, ...properties },
861
+ });
862
+ internal._registry.set(nodeAtom, newNode);
863
+ graph.onNodeChanged.emit({ id, node: newNode });
864
+ }
865
+ },
866
+ onNone: () => {
867
+ log('new node', { id, type, data, properties });
868
+ const newNode = internal._constructNode({ id, type, data, properties, ...rest });
869
+ internal._registry.set(nodeAtom, newNode);
870
+ graph.onNodeChanged.emit({ id, node: newNode });
871
+ },
872
+ });
508
873
 
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
- }
874
+ if (nodes) {
875
+ addNodesImpl(graph, nodes);
876
+ const _edges = nodes.map((node) => ({ source: id, target: node.id }));
877
+ addEdgesImpl(graph, _edges);
878
+ }
517
879
 
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
- }
880
+ if (edges) {
881
+ todo();
882
+ }
883
+ return graph;
884
+ };
885
+
886
+ /**
887
+ * Add a node to the graph.
888
+ */
889
+ export function addNode<T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<any, Record<string, any>>): T;
890
+ export function addNode(nodeArg: Node.NodeArg<any, Record<string, any>>): <T extends WritableGraph>(graph: T) => T;
891
+ export function addNode<T extends WritableGraph>(
892
+ graphOrNodeArg: T | Node.NodeArg<any, Record<string, any>>,
893
+ nodeArg?: Node.NodeArg<any, Record<string, any>>,
894
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
895
+ if (nodeArg === undefined) {
896
+ // Curried: addNode(nodeArg)
897
+ const nodeArg = graphOrNodeArg as Node.NodeArg<any, Record<string, any>>;
898
+ return <T extends WritableGraph>(graph: T) => addNodeImpl(graph, nodeArg);
899
+ } else {
900
+ // Direct: addNode(graph, nodeArg)
901
+ const graph = graphOrNodeArg as T;
902
+ return addNodeImpl(graph, nodeArg);
528
903
  }
904
+ }
529
905
 
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);
906
+ /**
907
+ * Implementation helper for removeNodes.
908
+ */
909
+ const removeNodesImpl = <T extends WritableGraph>(graph: T, ids: string[], edges = false): T => {
910
+ Atom.batch(() => {
911
+ ids.map((id) => removeNodeImpl(graph, id, edges));
912
+ });
913
+ return graph;
914
+ };
915
+
916
+ /**
917
+ * Remove nodes from the graph.
918
+ */
919
+ export function removeNodes<T extends WritableGraph>(graph: T, ids: string[], edges?: boolean): T;
920
+ export function removeNodes(ids: string[], edges?: boolean): <T extends WritableGraph>(graph: T) => T;
921
+ export function removeNodes<T extends WritableGraph>(
922
+ graphOrIds: T | string[],
923
+ idsOrEdges?: string[] | boolean,
924
+ edges?: boolean,
925
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
926
+ if (Array.isArray(graphOrIds)) {
927
+ // Curried: removeNodes(ids, edges?)
928
+ const ids = graphOrIds;
929
+ const edgesArg = typeof idsOrEdges === 'boolean' ? idsOrEdges : false;
930
+ return <T extends WritableGraph>(graph: T) => removeNodesImpl(graph, ids, edgesArg);
931
+ } else {
932
+ // Direct: removeNodes(graph, ids, edges?)
933
+ const graph = graphOrIds;
934
+ const ids = idsOrEdges as string[];
935
+ const edgesArg = edges ?? false;
936
+ return removeNodesImpl(graph, ids, edgesArg);
537
937
  }
938
+ }
538
939
 
539
- traverse({ visitor, source = ROOT_ID, relation = 'outbound' }: GraphTraversalOptions, path: string[] = []): void {
540
- // Break cycles.
541
- if (path.includes(source)) {
542
- return;
543
- }
940
+ /**
941
+ * Implementation helper for removeNode.
942
+ */
943
+ const removeNodeImpl = <T extends WritableGraph>(graph: T, id: string, edges = false): T => {
944
+ const internal = getInternal(graph);
945
+ const nodeAtom = internal._node(id);
946
+ // TODO(wittjosiah): Is there a way to mark these atom values for garbage collection?
947
+ internal._registry.set(nodeAtom, Option.none());
948
+ graph.onNodeChanged.emit({ id, node: Option.none() });
949
+ // TODO(wittjosiah): Reset expanded and initialized flags?
950
+
951
+ if (edges) {
952
+ const { inbound, outbound } = internal._registry.get(internal._edges(id));
953
+ const edgesToRemove = [
954
+ ...inbound.map((source) => ({ source, target: id })),
955
+ ...outbound.map((target) => ({ source: id, target })),
956
+ ];
957
+ removeEdgesImpl(graph, edgesToRemove);
958
+ }
544
959
 
545
- const node = this.getNodeOrThrow(source);
546
- const shouldContinue = visitor(node, [...path, source]);
547
- if (shouldContinue === false) {
548
- return;
549
- }
960
+ internal._onRemoveNode?.(id);
961
+ return graph;
962
+ };
550
963
 
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
- });
964
+ /**
965
+ * Remove a node from the graph.
966
+ */
967
+ export function removeNode<T extends WritableGraph>(graph: T, id: string, edges?: boolean): T;
968
+ export function removeNode(id: string, edges?: boolean): <T extends WritableGraph>(graph: T) => T;
969
+ export function removeNode<T extends WritableGraph>(
970
+ graphOrId: T | string,
971
+ idOrEdges?: string | boolean,
972
+ edges?: boolean,
973
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
974
+ if (typeof graphOrId === 'string') {
975
+ // Curried: removeNode(id, edges?)
976
+ const id = graphOrId;
977
+ const edgesArg = typeof idOrEdges === 'boolean' ? idOrEdges : false;
978
+ return <T extends WritableGraph>(graph: T) => removeNodeImpl(graph, id, edgesArg);
979
+ } else {
980
+ // Direct: removeNode(graph, id, edges?)
981
+ const graph = graphOrId;
982
+ const id = idOrEdges as string;
983
+ const edgesArg = edges ?? false;
984
+ return removeNodeImpl(graph, id, edgesArg);
985
+ }
986
+ }
573
987
 
574
- return found;
575
- }),
576
- );
988
+ /**
989
+ * Implementation helper for addEdges.
990
+ */
991
+ const addEdgesImpl = <T extends WritableGraph>(graph: T, edges: Edge[]): T => {
992
+ Atom.batch(() => {
993
+ edges.map((edge) => addEdgeImpl(graph, edge));
994
+ });
995
+ return graph;
996
+ };
997
+
998
+ /**
999
+ * Add edges to the graph.
1000
+ */
1001
+ export function addEdges<T extends WritableGraph>(graph: T, edges: Edge[]): T;
1002
+ export function addEdges(edges: Edge[]): <T extends WritableGraph>(graph: T) => T;
1003
+ export function addEdges<T extends WritableGraph>(
1004
+ graphOrEdges: T | Edge[],
1005
+ edges?: Edge[],
1006
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
1007
+ if (edges === undefined) {
1008
+ // Curried: addEdges(edges)
1009
+ const edges = graphOrEdges as Edge[];
1010
+ return <T extends WritableGraph>(graph: T) => addEdgesImpl(graph, edges);
1011
+ } else {
1012
+ // Direct: addEdges(graph, edges)
1013
+ const graph = graphOrEdges as T;
1014
+ return addEdgesImpl(graph, edges);
577
1015
  }
1016
+ }
578
1017
 
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
- }
1018
+ /**
1019
+ * Implementation helper for addEdge.
1020
+ */
1021
+ const addEdgeImpl = <T extends WritableGraph>(graph: T, edgeArg: Edge): T => {
1022
+ const internal = getInternal(graph);
1023
+ const sourceAtom = internal._edges(edgeArg.source);
1024
+ const source = internal._registry.get(sourceAtom);
1025
+ if (!source.outbound.includes(edgeArg.target)) {
1026
+ log('add outbound edge', {
1027
+ source: edgeArg.source,
1028
+ target: edgeArg.target,
1029
+ });
1030
+ internal._registry.set(sourceAtom, {
1031
+ inbound: source.inbound,
1032
+ outbound: [...source.outbound, edgeArg.target],
1033
+ });
1034
+ }
587
1035
 
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);
1036
+ const targetAtom = internal._edges(edgeArg.target);
1037
+ const target = internal._registry.get(targetAtom);
1038
+ if (!target.inbound.includes(edgeArg.source)) {
1039
+ log('add inbound edge', {
1040
+ source: edgeArg.source,
1041
+ target: edgeArg.target,
1042
+ });
1043
+ internal._registry.set(targetAtom, {
1044
+ inbound: [...target.inbound, edgeArg.source],
1045
+ outbound: target.outbound,
1046
+ });
1047
+ }
1048
+ return graph;
1049
+ };
595
1050
 
596
- return trigger.wait({ timeout }).finally(() => clearInterval(i));
1051
+ /**
1052
+ * Add an edge to the graph.
1053
+ */
1054
+ export function addEdge<T extends WritableGraph>(graph: T, edgeArg: Edge): T;
1055
+ export function addEdge(edgeArg: Edge): <T extends WritableGraph>(graph: T) => T;
1056
+ export function addEdge<T extends WritableGraph>(
1057
+ graphOrEdgeArg: T | Edge,
1058
+ edgeArg?: Edge,
1059
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
1060
+ if (edgeArg === undefined) {
1061
+ // Curried: addEdge(edgeArg)
1062
+ const edgeArg = graphOrEdgeArg as Edge;
1063
+ return <T extends WritableGraph>(graph: T) => addEdgeImpl(graph, edgeArg);
1064
+ } else {
1065
+ // Direct: addEdge(graph, edgeArg)
1066
+ const graph = graphOrEdgeArg as T;
1067
+ return addEdgeImpl(graph, edgeArg);
597
1068
  }
1069
+ }
598
1070
 
599
- /** @internal */
600
- _constructNode(node: NodeArg<any>): Option.Option<Node> {
601
- return Option.some({ [graphSymbol]: this, data: null, properties: {}, ...node });
1071
+ /**
1072
+ * Implementation helper for removeEdges.
1073
+ */
1074
+ const removeEdgesImpl = <T extends WritableGraph>(graph: T, edges: Edge[], removeOrphans = false): T => {
1075
+ Atom.batch(() => {
1076
+ edges.map((edge) => removeEdgeImpl(graph, edge, removeOrphans));
1077
+ });
1078
+ return graph;
1079
+ };
1080
+
1081
+ /**
1082
+ * Remove edges from the graph.
1083
+ */
1084
+ export function removeEdges<T extends WritableGraph>(graph: T, edges: Edge[], removeOrphans?: boolean): T;
1085
+ export function removeEdges(edges: Edge[], removeOrphans?: boolean): <T extends WritableGraph>(graph: T) => T;
1086
+ export function removeEdges<T extends WritableGraph>(
1087
+ graphOrEdges: T | Edge[],
1088
+ edgesOrRemoveOrphans?: Edge[] | boolean,
1089
+ removeOrphans?: boolean,
1090
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
1091
+ if (Array.isArray(graphOrEdges)) {
1092
+ // Curried: removeEdges(edges, removeOrphans?)
1093
+ const edges = graphOrEdges;
1094
+ const removeOrphansArg = typeof edgesOrRemoveOrphans === 'boolean' ? edgesOrRemoveOrphans : false;
1095
+ return <T extends WritableGraph>(graph: T) => removeEdgesImpl(graph, edges, removeOrphansArg);
1096
+ } else {
1097
+ // Direct: removeEdges(graph, edges, removeOrphans?)
1098
+ const graph = graphOrEdges;
1099
+ const edges = edgesOrRemoveOrphans as Edge[];
1100
+ const removeOrphansArg = removeOrphans ?? false;
1101
+ return removeEdgesImpl(graph, edges, removeOrphansArg);
602
1102
  }
603
1103
  }
1104
+
1105
+ /**
1106
+ * Implementation helper for removeEdge.
1107
+ */
1108
+ const removeEdgeImpl = <T extends WritableGraph>(graph: T, edgeArg: Edge, removeOrphans = false): T => {
1109
+ const internal = getInternal(graph);
1110
+ const sourceAtom = internal._edges(edgeArg.source);
1111
+ const source = internal._registry.get(sourceAtom);
1112
+ if (source.outbound.includes(edgeArg.target)) {
1113
+ internal._registry.set(sourceAtom, {
1114
+ inbound: source.inbound,
1115
+ outbound: source.outbound.filter((id) => id !== edgeArg.target),
1116
+ });
1117
+ }
1118
+
1119
+ const targetAtom = internal._edges(edgeArg.target);
1120
+ const target = internal._registry.get(targetAtom);
1121
+ if (target.inbound.includes(edgeArg.source)) {
1122
+ internal._registry.set(targetAtom, {
1123
+ inbound: target.inbound.filter((id) => id !== edgeArg.source),
1124
+ outbound: target.outbound,
1125
+ });
1126
+ }
1127
+
1128
+ if (removeOrphans) {
1129
+ const source = internal._registry.get(sourceAtom);
1130
+ const target = internal._registry.get(targetAtom);
1131
+ if (source.outbound.length === 0 && source.inbound.length === 0 && edgeArg.source !== Node.RootId) {
1132
+ removeNodesImpl(graph, [edgeArg.source]);
1133
+ }
1134
+ if (target.outbound.length === 0 && target.inbound.length === 0 && edgeArg.target !== Node.RootId) {
1135
+ removeNodesImpl(graph, [edgeArg.target]);
1136
+ }
1137
+ }
1138
+ return graph;
1139
+ };
1140
+
1141
+ /**
1142
+ * Remove an edge from the graph.
1143
+ */
1144
+ export function removeEdge<T extends WritableGraph>(graph: T, edgeArg: Edge, removeOrphans?: boolean): T;
1145
+ export function removeEdge(edgeArg: Edge, removeOrphans?: boolean): <T extends WritableGraph>(graph: T) => T;
1146
+ export function removeEdge<T extends WritableGraph>(
1147
+ graphOrEdgeArg: T | Edge,
1148
+ edgeArgOrRemoveOrphans?: Edge | boolean,
1149
+ removeOrphans?: boolean,
1150
+ ): T | (<T extends WritableGraph>(graph: T) => T) {
1151
+ if (
1152
+ edgeArgOrRemoveOrphans === undefined ||
1153
+ typeof edgeArgOrRemoveOrphans === 'boolean' ||
1154
+ 'source' in graphOrEdgeArg
1155
+ ) {
1156
+ // Curried: removeEdge(edgeArg, removeOrphans?)
1157
+ const edgeArg = graphOrEdgeArg as Edge;
1158
+ const removeOrphansArg = typeof edgeArgOrRemoveOrphans === 'boolean' ? edgeArgOrRemoveOrphans : false;
1159
+ return <T extends WritableGraph>(graph: T) => removeEdgeImpl(graph, edgeArg, removeOrphansArg);
1160
+ } else {
1161
+ // Direct: removeEdge(graph, edgeArg, removeOrphans?)
1162
+ const graph = graphOrEdgeArg as T;
1163
+ const edgeArg = edgeArgOrRemoveOrphans as Edge;
1164
+ const removeOrphansArg = removeOrphans ?? false;
1165
+ return removeEdgeImpl(graph, edgeArg, removeOrphansArg);
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Creates a new Graph instance.
1171
+ */
1172
+ export const make = (params?: GraphProps): Graph => {
1173
+ return new GraphImpl(params);
1174
+ };