@dxos/app-graph 0.8.4-main.e8ec1fe → 0.8.4-main.ef1bc66f44

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 (43) hide show
  1. package/dist/lib/browser/index.mjs +1012 -551
  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 +1011 -551
  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 +107 -65
  10. package/dist/types/src/graph-builder.d.ts.map +1 -1
  11. package/dist/types/src/graph.d.ts +174 -204
  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.map +1 -1
  22. package/dist/types/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +35 -33
  24. package/src/atoms.ts +25 -0
  25. package/src/graph-builder.test.ts +494 -78
  26. package/src/graph-builder.ts +543 -250
  27. package/src/graph.test.ts +297 -104
  28. package/src/graph.ts +934 -404
  29. package/src/index.ts +9 -3
  30. package/src/node-matcher.test.ts +301 -0
  31. package/src/node-matcher.ts +284 -0
  32. package/src/node.ts +39 -6
  33. package/src/stories/EchoGraph.stories.tsx +94 -92
  34. package/src/stories/Tree.tsx +1 -1
  35. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  36. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  37. package/dist/types/src/signals-integration.test.d.ts +0 -2
  38. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  39. package/dist/types/src/testing.d.ts +0 -5
  40. package/dist/types/src/testing.d.ts.map +0 -1
  41. package/src/experimental/graph-projections.test.ts +0 -56
  42. package/src/signals-integration.test.ts +0 -219
  43. package/src/testing.ts +0 -20
package/src/graph.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { Atom, Registry } from '@effect-atom/atom-react';
6
6
  import * as Function from 'effect/Function';
7
7
  import * as Option from 'effect/Option';
8
+ import * as Pipeable from 'effect/Pipeable';
8
9
  import * as Record from 'effect/Record';
9
10
 
10
11
  import { Event, Trigger } from '@dxos/async';
@@ -13,35 +14,32 @@ import { invariant } from '@dxos/invariant';
13
14
  import { log } from '@dxos/log';
14
15
  import { type MakeOptional, isNonNullable } from '@dxos/util';
15
16
 
16
- import { type Action, type ActionGroup, type Node, type NodeArg, type Relation } from './node';
17
+ import * as Node from './node';
17
18
 
18
19
  const graphSymbol = Symbol('graph');
20
+
19
21
  type DeepWriteable<T> = {
20
22
  -readonly [K in keyof T]: T[K] extends object ? DeepWriteable<T[K]> : T[K];
21
23
  };
22
- type NodeInternal = DeepWriteable<Node> & { [graphSymbol]: Graph };
24
+
25
+ type NodeInternal = DeepWriteable<Node.Node> & { [graphSymbol]: GraphImpl };
23
26
 
24
27
  /**
25
28
  * Get the Graph a Node is currently associated with.
26
29
  */
27
- export const getGraph = (node: Node): Graph => {
30
+ export const getGraph = (node: Node.Node): Graph => {
28
31
  const graph = (node as NodeInternal)[graphSymbol];
29
32
  invariant(graph, 'Node is not associated with a graph.');
30
- return graph;
33
+ return graph as Graph;
31
34
  };
32
35
 
33
- export const ROOT_ID = 'root';
34
- export const ROOT_TYPE = 'dxos.org/type/GraphRoot';
35
- export const ACTION_TYPE = 'dxos.org/type/GraphAction';
36
- export const ACTION_GROUP_TYPE = 'dxos.org/type/GraphActionGroup';
37
-
38
36
  export type GraphTraversalOptions = {
39
37
  /**
40
38
  * A callback which is called for each node visited during traversal.
41
39
  *
42
40
  * If the callback returns `false`, traversal is stops recursing.
43
41
  */
44
- visitor: (node: Node, path: string[]) => boolean | void;
42
+ visitor: (node: Node.Node, path: string[]) => boolean | void;
45
43
 
46
44
  /**
47
45
  * The node to start traversing from.
@@ -55,200 +53,109 @@ export type GraphTraversalOptions = {
55
53
  *
56
54
  * @default 'outbound'
57
55
  */
58
- relation?: Relation;
56
+ relation?: Node.Relation;
59
57
  };
60
58
 
61
- export type GraphParams = {
59
+ export type GraphProps = {
62
60
  registry?: Registry.Registry;
63
- nodes?: MakeOptional<Node, 'data' | 'cacheable'>[];
61
+ nodes?: MakeOptional<Node.Node, 'data' | 'cacheable'>[];
64
62
  edges?: Record<string, Edges>;
65
- onExpand?: Graph['_onExpand'];
66
- onInitialize?: Graph['_onInitialize'];
67
- onRemoveNode?: Graph['_onRemoveNode'];
63
+ onExpand?: (id: string, relation: Node.Relation) => void;
64
+ onInitialize?: (id: string) => Promise<void>;
65
+ onRemoveNode?: (id: string) => void;
68
66
  };
69
67
 
70
68
  export type Edge = { source: string; target: string };
71
69
  export type Edges = { inbound: string[]; outbound: string[] };
72
70
 
73
- export interface ReadableGraph {
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;
76
+
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;
82
+
83
+ export type GraphKindType = 'readable' | 'expandable' | 'writable';
84
+
85
+ export interface BaseGraph extends Pipeable.Pipeable {
86
+ readonly [GraphTypeId]: GraphTypeId;
87
+ readonly [GraphKind]: GraphKindType;
74
88
  /**
75
89
  * Event emitted when a node is changed.
76
90
  */
77
- onNodeChanged: Event<{ id: string; node: Option.Option<Node> }>;
78
-
91
+ readonly onNodeChanged: Event<{ id: string; node: Option.Option<Node.Node> }>;
79
92
  /**
80
- * Convert the graph to a JSON object.
93
+ * Get the atom key for the JSON representation of the graph.
81
94
  */
82
- toJSON(id?: string): object;
83
-
84
95
  json(id?: string): Atom.Atom<any>;
85
-
86
96
  /**
87
97
  * Get the atom key for the node with the given id.
88
98
  */
89
- node(id: string): Atom.Atom<Option.Option<Node>>;
90
-
99
+ node(id: string): Atom.Atom<Option.Option<Node.Node>>;
91
100
  /**
92
101
  * Get the atom key for the node with the given id.
93
102
  */
94
- nodeOrThrow(id: string): Atom.Atom<Node>;
95
-
103
+ nodeOrThrow(id: string): Atom.Atom<Node.Node>;
96
104
  /**
97
105
  * Get the atom key for the connections of the node with the given id.
98
106
  */
99
- connections(id: string, relation?: Relation): Atom.Atom<Node[]>;
100
-
107
+ connections(id: string, relation?: Node.Relation): Atom.Atom<Node.Node[]>;
101
108
  /**
102
109
  * Get the atom key for the actions of the node with the given id.
103
110
  */
104
- actions(id: string): Atom.Atom<(Action | ActionGroup)[]>;
105
-
111
+ actions(id: string): Atom.Atom<(Node.Action | Node.ActionGroup)[]>;
106
112
  /**
107
113
  * Get the atom key for the edges of the node with the given id.
108
114
  */
109
115
  edges(id: string): Atom.Atom<Edges>;
110
-
111
- /**
112
- * Alias for `getNodeOrThrow(ROOT_ID)`.
113
- */
114
- get root(): Node;
115
-
116
- /**
117
- * Get the node with the given id from the graph's registry.
118
- */
119
- getNode(id: string): Option.Option<Node>;
120
-
121
- /**
122
- * Get the node with the given id from the graph's registry.
123
- *
124
- * @throws If the node is Option.none().
125
- */
126
- getNodeOrThrow(id: string): Node;
127
-
128
- /**
129
- * Get all nodes connected to the node with the given id by the given relation from the graph's registry.
130
- */
131
- getConnections(id: string, relation?: Relation): Node[];
132
-
133
- /**
134
- * Get all actions connected to the node with the given id from the graph's registry.
135
- */
136
- getActions(id: string): Node[];
137
-
138
- /**
139
- * Get the edges from the node with the given id from the graph's registry.
140
- */
141
- getEdges(id: string): Edges;
142
-
143
- /**
144
- * Recursive depth-first traversal of the graph.
145
- *
146
- * @param options.node The node to start traversing from.
147
- * @param options.relation The relation to traverse graph edges.
148
- * @param options.visitor A callback which is called for each node visited during traversal.
149
- */
150
- traverse(options: GraphTraversalOptions, path?: string[]): void;
151
-
152
- /**
153
- * Get the path between two nodes in the graph.
154
- */
155
- getPath(params: { source?: string; target: string }): Option.Option<string[]>;
156
-
157
- /**
158
- * Wait for the path between two nodes in the graph to be established.
159
- */
160
- waitForPath(
161
- params: { source?: string; target: string },
162
- options?: { timeout?: number; interval?: number },
163
- ): Promise<string[]>;
164
- }
165
-
166
- export interface ExpandableGraph extends ReadableGraph {
167
- /**
168
- * Initialize a node in the graph.
169
- *
170
- * Fires the `onInitialize` callback to provide initial data for a node.
171
- */
172
- initialize(id: string): Promise<void>;
173
-
174
- /**
175
- * Expand a node in the graph.
176
- *
177
- * Fires the `onExpand` callback to add connections to the node.
178
- */
179
- expand(id: string, relation?: Relation): void;
180
-
181
- /**
182
- * Sort the edges of the node with the given id.
183
- */
184
- sortEdges(id: string, relation: Relation, order: string[]): void;
185
116
  }
186
117
 
187
- export interface WritableGraph extends ExpandableGraph {
188
- /**
189
- * Add nodes to the graph.
190
- */
191
- addNodes(nodes: 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' };
192
121
 
193
- /**
194
- * Add a node to the graph.
195
- */
196
- addNode(node: NodeArg<any, Record<string, any>>): void;
197
-
198
- /**
199
- * Remove nodes from the graph.
200
- */
201
- removeNodes(ids: string[], edges?: boolean): void;
202
-
203
- /**
204
- * Remove a node from the graph.
205
- */
206
- removeNode(id: string, edges?: boolean): void;
207
-
208
- /**
209
- * Add edges to the graph.
210
- */
211
- addEdges(edges: Edge[]): void;
212
-
213
- /**
214
- * Add an edge to the graph.
215
- */
216
- addEdge(edge: Edge): void;
217
-
218
- /**
219
- * Remove edges from the graph.
220
- */
221
- removeEdges(edges: Edge[], removeOrphans?: boolean): void;
222
-
223
- /**
224
- * Remove an edge from the graph.
225
- */
226
- removeEdge(edge: Edge, removeOrphans?: boolean): void;
227
- }
122
+ /**
123
+ * Graph interface.
124
+ */
125
+ export type Graph = WritableGraph;
228
126
 
229
127
  /**
230
128
  * The Graph represents the user interface information architecture of the application constructed via plugins.
129
+ * @internal
231
130
  */
232
- export class Graph implements WritableGraph {
131
+ class GraphImpl implements WritableGraph {
132
+ readonly [GraphTypeId]: GraphTypeId = GraphTypeId;
133
+ readonly [GraphKind] = 'writable' as const;
134
+
135
+ pipe() {
136
+ // eslint-disable-next-line prefer-rest-params
137
+ return Pipeable.pipeArguments(this, arguments);
138
+ }
139
+
233
140
  readonly onNodeChanged = new Event<{
234
141
  id: string;
235
- node: Option.Option<Node>;
142
+ node: Option.Option<Node.Node>;
236
143
  }>();
237
144
 
238
- private readonly _onExpand?: (id: string, relation: Relation) => void;
239
- private readonly _onInitialize?: (id: string) => Promise<void>;
240
- private readonly _onRemoveNode?: (id: string) => void;
145
+ readonly _onExpand?: GraphProps['onExpand'];
146
+ readonly _onInitialize?: GraphProps['onInitialize'];
147
+ readonly _onRemoveNode?: GraphProps['onRemoveNode'];
241
148
 
242
- private readonly _registry: Registry.Registry;
243
- private readonly _expanded = Record.empty<string, boolean>();
244
- private readonly _initialized = Record.empty<string, boolean>();
245
- private readonly _initialEdges = Record.empty<string, Edges>();
246
- private readonly _initialNodes = Record.fromEntries([
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([
247
154
  [
248
- ROOT_ID,
155
+ Node.RootId,
249
156
  this._constructNode({
250
- id: ROOT_ID,
251
- type: ROOT_TYPE,
157
+ id: Node.RootId,
158
+ type: Node.RootType,
252
159
  data: null,
253
160
  properties: {},
254
161
  }),
@@ -256,12 +163,12 @@ export class Graph implements WritableGraph {
256
163
  ]);
257
164
 
258
165
  /** @internal */
259
- readonly _node = Atom.family<string, Atom.Writable<Option.Option<Node>>>((id) => {
166
+ readonly _node = Atom.family<string, Atom.Writable<Option.Option<Node.Node>>>((id) => {
260
167
  const initial = Option.flatten(Record.get(this._initialNodes, id));
261
- return Atom.make<Option.Option<Node>>(initial).pipe(Atom.keepAlive, Atom.withLabel(`graph:node:${id}`));
168
+ return Atom.make<Option.Option<Node.Node>>(initial).pipe(Atom.keepAlive, Atom.withLabel(`graph:node:${id}`));
262
169
  });
263
170
 
264
- private readonly _nodeOrThrow = Atom.family<string, Atom.Atom<Node>>((id) => {
171
+ readonly _nodeOrThrow = Atom.family<string, Atom.Atom<Node.Node>>((id) => {
265
172
  return Atom.make((get) => {
266
173
  const node = get(this._node(id));
267
174
  invariant(Option.isSome(node), `Node not available: ${id}`);
@@ -269,36 +176,36 @@ export class Graph implements WritableGraph {
269
176
  });
270
177
  });
271
178
 
272
- private readonly _edges = Atom.family<string, Atom.Writable<Edges>>((id) => {
179
+ readonly _edges = Atom.family<string, Atom.Writable<Edges>>((id) => {
273
180
  const initial = Record.get(this._initialEdges, id).pipe(Option.getOrElse(() => ({ inbound: [], outbound: [] })));
274
181
  return Atom.make<Edges>(initial).pipe(Atom.keepAlive, Atom.withLabel(`graph:edges:${id}`));
275
182
  });
276
183
 
277
184
  // NOTE: Currently the argument to the family needs to be referentially stable for the atom to be referentially stable.
278
185
  // TODO(wittjosiah): Atom feature request, support for something akin to `ComplexMap` to allow for complex arguments.
279
- private readonly _connections = Atom.family<string, Atom.Atom<Node[]>>((key) => {
186
+ readonly _connections = Atom.family<string, Atom.Atom<Node.Node[]>>((key) => {
280
187
  return Atom.make((get) => {
281
188
  const [id, relation] = key.split('$');
282
189
  const edges = get(this._edges(id));
283
- return edges[relation as Relation]
190
+ return edges[relation as Node.Relation]
284
191
  .map((id) => get(this._node(id)))
285
192
  .filter(Option.isSome)
286
193
  .map((o) => o.value);
287
194
  }).pipe(Atom.withLabel(`graph:connections:${key}`));
288
195
  });
289
196
 
290
- private readonly _actions = Atom.family<string, Atom.Atom<(Action | ActionGroup)[]>>((id) => {
197
+ readonly _actions = Atom.family<string, Atom.Atom<(Node.Action | Node.ActionGroup)[]>>((id) => {
291
198
  return Atom.make((get) => {
292
199
  return get(this._connections(`${id}$outbound`)).filter(
293
- (node) => node.type === ACTION_TYPE || node.type === ACTION_GROUP_TYPE,
200
+ (node) => node.type === Node.ActionType || node.type === Node.ActionGroupType,
294
201
  );
295
202
  }).pipe(Atom.withLabel(`graph:actions:${id}`));
296
203
  });
297
204
 
298
- private readonly _json = Atom.family<string, Atom.Atom<any>>((id) => {
205
+ readonly _json = Atom.family<string, Atom.Atom<any>>((id) => {
299
206
  return Atom.make((get) => {
300
- const toJSON = (node: Node, seen: string[] = []): any => {
301
- const nodes = get(this.connections(node.id));
207
+ const toJSON = (node: Node.Node, seen: string[] = []): any => {
208
+ const nodes = get(this._connections(`${node.id}$outbound`));
302
209
  const obj: Record<string, any> = {
303
210
  id: node.id,
304
211
  type: node.type,
@@ -308,7 +215,7 @@ export class Graph implements WritableGraph {
308
215
  }
309
216
  if (nodes.length) {
310
217
  obj.nodes = nodes
311
- .map((n) => {
218
+ .map((n: Node.Node) => {
312
219
  // Break cycles.
313
220
  const nextSeen = [...seen, node.id];
314
221
  return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
@@ -318,12 +225,12 @@ export class Graph implements WritableGraph {
318
225
  return obj;
319
226
  };
320
227
 
321
- const root = get(this.nodeOrThrow(id));
228
+ const root = get(this._nodeOrThrow(id));
322
229
  return toJSON(root);
323
230
  }).pipe(Atom.withLabel(`graph:json:${id}`));
324
231
  });
325
232
 
326
- constructor({ registry, nodes, edges, onInitialize, onExpand, onRemoveNode }: GraphParams = {}) {
233
+ constructor({ registry, nodes, edges, onInitialize, onExpand, onRemoveNode }: GraphProps = {}) {
327
234
  this._registry = registry ?? Registry.make();
328
235
  this._onInitialize = onInitialize;
329
236
  this._onExpand = onExpand;
@@ -342,303 +249,926 @@ export class Graph implements WritableGraph {
342
249
  }
343
250
  }
344
251
 
345
- toJSON(id = ROOT_ID) {
346
- return this._registry.get(this._json(id));
252
+ json(id = Node.RootId): Atom.Atom<any> {
253
+ return jsonImpl(this, id);
347
254
  }
348
255
 
349
- json(id = ROOT_ID) {
350
- return this._json(id);
256
+ node(id: string): Atom.Atom<Option.Option<Node.Node>> {
257
+ return nodeImpl(this, id);
351
258
  }
352
259
 
353
- node(id: string): Atom.Atom<Option.Option<Node>> {
354
- return this._node(id);
260
+ nodeOrThrow(id: string): Atom.Atom<Node.Node> {
261
+ return nodeOrThrowImpl(this, id);
355
262
  }
356
263
 
357
- nodeOrThrow(id: string): Atom.Atom<Node> {
358
- return this._nodeOrThrow(id);
264
+ connections(id: string, relation: Node.Relation = 'outbound'): Atom.Atom<Node.Node[]> {
265
+ return connectionsImpl(this, id, relation);
359
266
  }
360
267
 
361
- connections(id: string, relation: Relation = 'outbound'): Atom.Atom<Node[]> {
362
- return this._connections(`${id}$${relation}`);
268
+ actions(id: string): Atom.Atom<(Node.Action | Node.ActionGroup)[]> {
269
+ return actionsImpl(this, id);
363
270
  }
364
271
 
365
- actions(id: string) {
366
- return this._actions(id);
272
+ edges(id: string): Atom.Atom<Edges> {
273
+ return edgesImpl(this, id);
367
274
  }
368
275
 
369
- edges(id: string): Atom.Atom<Edges> {
370
- 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
+ });
371
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
+ };
372
310
 
373
- get root() {
374
- return this.getNodeOrThrow(ROOT_ID);
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!);
375
380
  }
381
+ }
376
382
 
377
- getNode(id: string): Option.Option<Node> {
378
- return this._registry.get(this.node(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!);
379
410
  }
411
+ }
412
+
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
+ }
380
420
 
381
- getNodeOrThrow(id: string): Node {
382
- return this._registry.get(this.nodeOrThrow(id));
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);
383
450
  }
451
+ }
452
+
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
+ };
384
460
 
385
- getConnections(id: string, relation: Relation = 'outbound'): Node[] {
386
- return this._registry.get(this.connections(id, relation));
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!);
387
478
  }
479
+ }
388
480
 
389
- getActions(id: string): Node[] {
390
- return this._registry.get(this.actions(id));
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
+ };
488
+
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!);
391
503
  }
504
+ }
392
505
 
393
- getEdges(id: string): Edges {
394
- return this._registry.get(this.edges(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;
395
517
  }
396
518
 
397
- async initialize(id: string) {
398
- const initialized = Record.get(this._initialized, id).pipe(Option.getOrElse(() => false));
399
- log('initialize', { id, initialized });
400
- if (!initialized) {
401
- await this._onInitialize?.(id);
402
- Record.set(this._initialized, id, true);
403
- }
519
+ const node = getNodeOrThrow(graph, source);
520
+ const shouldContinue = visitor(node, [...path, source]);
521
+ if (shouldContinue === false) {
522
+ return;
404
523
  }
405
524
 
406
- expand(id: string, relation: Relation = 'outbound'): void {
407
- const key = `${id}$${relation}`;
408
- const expanded = Record.get(this._expanded, key).pipe(Option.getOrElse(() => false));
409
- log('expand', { key, expanded });
410
- if (!expanded) {
411
- this._onExpand?.(id, relation);
412
- Record.set(this._expanded, key, true);
413
- }
525
+ Object.values(getConnections(graph, source, relation)).forEach((child) =>
526
+ traverseImpl(graph, { source: child.id, relation, visitor }, [...path, source]),
527
+ );
528
+ };
529
+
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);
414
551
  }
552
+ }
415
553
 
416
- addNodes(nodes: NodeArg<any, Record<string, any>>[]): void {
417
- Atom.batch(() => {
418
- nodes.map((node) => this.addNode(node));
419
- });
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!);
420
597
  }
598
+ }
421
599
 
422
- addNode({ nodes, edges, ...nodeArg }: NodeArg<any, Record<string, any>>): void {
423
- const { id, type, data = null, properties = {} } = nodeArg;
424
- const nodeAtom = this._node(id);
425
- const node = this._registry.get(nodeAtom);
426
- Option.match(node, {
427
- onSome: (node) => {
428
- const typeChanged = node.type !== type;
429
- const dataChanged = node.data !== data;
430
- const propertiesChanged = Object.keys(properties).some((key) => node.properties[key] !== properties[key]);
431
- log('existing node', {
432
- id,
433
- typeChanged,
434
- dataChanged,
435
- propertiesChanged,
436
- });
437
- if (typeChanged || dataChanged || propertiesChanged) {
438
- log('updating node', { id, type, data, properties });
439
- const newNode = Option.some({
440
- ...node,
441
- type,
442
- data,
443
- properties: { ...node.properties, ...properties },
444
- });
445
- this._registry.set(nodeAtom, newNode);
446
- this.onNodeChanged.emit({ id, node: newNode });
447
- }
448
- },
449
- onNone: () => {
450
- log('new node', { id, type, data, properties });
451
- const newNode = this._constructNode({ id, type, data, properties });
452
- this._registry.set(nodeAtom, newNode);
453
- this.onNodeChanged.emit({ id, node: newNode });
454
- },
455
- });
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
+ }
456
613
 
457
- if (nodes) {
458
- // Atom.batch(() => {
459
- this.addNodes(nodes);
460
- const _edges = nodes.map((node) => ({ source: id, target: node.id }));
461
- this.addEdges(_edges);
462
- // });
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);
463
619
  }
620
+ }, interval);
464
621
 
465
- if (edges) {
466
- todo();
467
- }
622
+ return trigger.wait({ timeout }).finally(() => clearInterval(i));
623
+ };
624
+
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);
468
652
  }
653
+ }
469
654
 
470
- removeNodes(ids: string[], edges = false): void {
471
- Atom.batch(() => {
472
- ids.map((id) => this.removeNode(id, edges));
473
- });
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);
474
665
  }
666
+ return graph;
667
+ };
475
668
 
476
- removeNode(id: string, edges = false): void {
477
- const nodeAtom = this._node(id);
478
- // TODO(wittjosiah): Is there a way to mark these atom values for garbage collection?
479
- this._registry.set(nodeAtom, Option.none());
480
- this.onNodeChanged.emit({ id, node: Option.none() });
481
- // TODO(wittjosiah): Reset expanded and initialized flags?
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
+ }
482
690
 
483
- if (edges) {
484
- const { inbound, outbound } = this._registry.get(this._edges(id));
485
- const edges = [
486
- ...inbound.map((source) => ({ source, target: id })),
487
- ...outbound.map((target) => ({ source: id, target })),
488
- ];
489
- this.removeEdges(edges);
490
- }
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);
706
+ }
707
+ return graph;
708
+ };
491
709
 
492
- this._onRemoveNode?.(id);
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);
493
736
  }
737
+ }
494
738
 
495
- addEdges(edges: Edge[]): void {
496
- Atom.batch(() => {
497
- edges.map((edge) => this.addEdge(edge));
498
- });
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
+ };
757
+
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!);
499
790
  }
791
+ }
500
792
 
501
- addEdge(edgeArg: Edge): void {
502
- const sourceAtom = this._edges(edgeArg.source);
503
- const source = this._registry.get(sourceAtom);
504
- if (!source.outbound.includes(edgeArg.target)) {
505
- log('add outbound edge', {
506
- source: edgeArg.source,
507
- target: edgeArg.target,
508
- });
509
- this._registry.set(sourceAtom, {
510
- inbound: source.inbound,
511
- outbound: [...source.outbound, edgeArg.target],
512
- });
513
- }
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
+ };
514
802
 
515
- const targetAtom = this._edges(edgeArg.target);
516
- const target = this._registry.get(targetAtom);
517
- if (!target.inbound.includes(edgeArg.source)) {
518
- log('add inbound edge', {
519
- source: edgeArg.source,
520
- target: edgeArg.target,
521
- });
522
- this._registry.set(targetAtom, {
523
- inbound: [...target.inbound, edgeArg.source],
524
- outbound: target.outbound,
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);
820
+ }
821
+ }
822
+
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,
525
852
  });
526
- }
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
+ });
873
+
874
+ if (nodes) {
875
+ addNodesImpl(graph, nodes);
876
+ const _edges = nodes.map((node) => ({ source: id, target: node.id }));
877
+ addEdgesImpl(graph, _edges);
527
878
  }
528
879
 
529
- removeEdges(edges: Edge[], removeOrphans = false): void {
530
- Atom.batch(() => {
531
- edges.map((edge) => this.removeEdge(edge, removeOrphans));
532
- });
880
+ if (edges) {
881
+ todo();
533
882
  }
883
+ return graph;
884
+ };
534
885
 
535
- removeEdge(edgeArg: Edge, removeOrphans = false): void {
536
- const sourceAtom = this._edges(edgeArg.source);
537
- const source = this._registry.get(sourceAtom);
538
- if (source.outbound.includes(edgeArg.target)) {
539
- this._registry.set(sourceAtom, {
540
- inbound: source.inbound,
541
- outbound: source.outbound.filter((id) => id !== edgeArg.target),
542
- });
543
- }
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);
903
+ }
904
+ }
544
905
 
545
- const targetAtom = this._edges(edgeArg.target);
546
- const target = this._registry.get(targetAtom);
547
- if (target.inbound.includes(edgeArg.source)) {
548
- this._registry.set(targetAtom, {
549
- inbound: target.inbound.filter((id) => id !== edgeArg.source),
550
- outbound: target.outbound,
551
- });
552
- }
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
+ };
553
915
 
554
- if (removeOrphans) {
555
- const source = this._registry.get(sourceAtom);
556
- const target = this._registry.get(targetAtom);
557
- if (source.outbound.length === 0 && source.inbound.length === 0 && edgeArg.source !== ROOT_ID) {
558
- this.removeNodes([edgeArg.source]);
559
- }
560
- if (target.outbound.length === 0 && target.inbound.length === 0 && edgeArg.target !== ROOT_ID) {
561
- this.removeNodes([edgeArg.target]);
562
- }
563
- }
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);
564
937
  }
938
+ }
565
939
 
566
- sortEdges(id: string, relation: Relation, order: string[]): void {
567
- const edgesAtom = this._edges(id);
568
- const edges = this._registry.get(edgesAtom);
569
- const unsorted = edges[relation].filter((id) => !order.includes(id)) ?? [];
570
- const sorted = order.filter((id) => edges[relation].includes(id)) ?? [];
571
- edges[relation].splice(0, edges[relation].length, ...[...sorted, ...unsorted]);
572
- this._registry.set(edgesAtom, edges);
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);
573
958
  }
574
959
 
575
- traverse({ visitor, source = ROOT_ID, relation = 'outbound' }: GraphTraversalOptions, path: string[] = []): void {
576
- // Break cycles.
577
- if (path.includes(source)) {
578
- return;
579
- }
960
+ internal._onRemoveNode?.(id);
961
+ return graph;
962
+ };
580
963
 
581
- const node = this.getNodeOrThrow(source);
582
- const shouldContinue = visitor(node, [...path, source]);
583
- if (shouldContinue === false) {
584
- return;
585
- }
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
+ }
586
987
 
587
- Object.values(this.getConnections(source, relation)).forEach((child) =>
588
- this.traverse({ source: child.id, relation, visitor }, [...path, source]),
589
- );
590
- }
591
-
592
- getPath({ source = 'root', target }: { source?: string; target: string }): Option.Option<string[]> {
593
- return Function.pipe(
594
- this.getNode(source),
595
- Option.flatMap((node) => {
596
- let found: Option.Option<string[]> = Option.none();
597
- this.traverse({
598
- source: node.id,
599
- visitor: (node, path) => {
600
- if (Option.isSome(found)) {
601
- return false;
602
- }
603
-
604
- if (node.id === target) {
605
- found = Option.some(path);
606
- }
607
- },
608
- });
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
+ };
609
997
 
610
- return found;
611
- }),
612
- );
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);
613
1015
  }
1016
+ }
614
1017
 
615
- async waitForPath(
616
- params: { source?: string; target: string },
617
- { timeout = 5_000, interval = 500 }: { timeout?: number; interval?: number } = {},
618
- ): Promise<string[]> {
619
- const path = this.getPath(params);
620
- if (Option.isSome(path)) {
621
- return path.value;
622
- }
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
+ }
623
1035
 
624
- const trigger = new Trigger<string[]>();
625
- const i = setInterval(() => {
626
- const path = this.getPath(params);
627
- if (Option.isSome(path)) {
628
- trigger.wake(path.value);
629
- }
630
- }, 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
+ };
631
1050
 
632
- 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);
633
1068
  }
1069
+ }
634
1070
 
635
- /** @internal */
636
- _constructNode(node: NodeArg<any>): Option.Option<Node> {
637
- return Option.some({
638
- [graphSymbol]: this,
639
- data: null,
640
- properties: {},
641
- ...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);
1102
+ }
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),
642
1116
  });
643
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
+ }
644
1167
  }
1168
+
1169
+ /**
1170
+ * Creates a new Graph instance.
1171
+ */
1172
+ export const make = (params?: GraphProps): Graph => {
1173
+ return new GraphImpl(params);
1174
+ };