@dxos/app-graph 0.8.4-main.c4373fc → 0.8.4-main.c85a9c8dae

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