@dxos/app-graph 0.8.4-main.bc674ce → 0.8.4-main.bcb3aa67d6

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 (45) hide show
  1. package/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
  2. package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +17 -1276
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +39 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs +1604 -0
  9. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +17 -1276
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +40 -0
  14. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  15. package/dist/types/src/graph-builder.d.ts +11 -7
  16. package/dist/types/src/graph-builder.d.ts.map +1 -1
  17. package/dist/types/src/graph.d.ts +13 -17
  18. package/dist/types/src/graph.d.ts.map +1 -1
  19. package/dist/types/src/index.d.ts +1 -0
  20. package/dist/types/src/index.d.ts.map +1 -1
  21. package/dist/types/src/node-matcher.d.ts +43 -17
  22. package/dist/types/src/node-matcher.d.ts.map +1 -1
  23. package/dist/types/src/node.d.ts +21 -5
  24. package/dist/types/src/node.d.ts.map +1 -1
  25. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  26. package/dist/types/src/testing/index.d.ts +2 -0
  27. package/dist/types/src/testing/index.d.ts.map +1 -0
  28. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  29. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  30. package/dist/types/src/util.d.ts +39 -0
  31. package/dist/types/src/util.d.ts.map +1 -0
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +36 -26
  34. package/src/graph-builder.test.ts +569 -102
  35. package/src/graph-builder.ts +202 -74
  36. package/src/graph.test.ts +187 -52
  37. package/src/graph.ts +174 -98
  38. package/src/index.ts +1 -0
  39. package/src/node-matcher.ts +58 -28
  40. package/src/node.ts +46 -5
  41. package/src/stories/EchoGraph.stories.tsx +90 -61
  42. package/src/stories/Tree.tsx +1 -1
  43. package/src/testing/index.ts +5 -0
  44. package/src/testing/setup-graph-builder.ts +41 -0
  45. package/src/util.ts +95 -0
package/src/graph.ts CHANGED
@@ -15,6 +15,7 @@ import { log } from '@dxos/log';
15
15
  import { type MakeOptional, isNonNullable } from '@dxos/util';
16
16
 
17
17
  import * as Node from './node';
18
+ import { normalizeRelation, primaryKey, primaryParts, secondaryKey, secondaryParts, shallowEqual } from './util';
18
19
 
19
20
  const graphSymbol = Symbol('graph');
20
21
 
@@ -48,12 +49,8 @@ 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?: Node.Relation;
52
+ /** The relation(s) to traverse graph edges. */
53
+ relation: Node.RelationInput | Node.RelationInput[];
57
54
  };
58
55
 
59
56
  export type GraphProps = {
@@ -65,8 +62,8 @@ export type GraphProps = {
65
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[] };
65
+ export type Edge = { source: string; target: string; relation: Node.RelationInput };
66
+ export type Edges = Record<string, string[]>;
70
67
 
71
68
  /**
72
69
  * Identifier denoting a Graph.
@@ -104,7 +101,7 @@ export interface BaseGraph extends Pipeable.Pipeable {
104
101
  /**
105
102
  * Get the atom key for the connections of the node with the given id.
106
103
  */
107
- connections(id: string, relation?: Node.Relation): Atom.Atom<Node.Node[]>;
104
+ connections(id: string, relation: Node.RelationInput): Atom.Atom<Node.Node[]>;
108
105
  /**
109
106
  * Get the atom key for the actions of the node with the given id.
110
107
  */
@@ -148,6 +145,7 @@ class GraphImpl implements WritableGraph {
148
145
 
149
146
  readonly _registry: Registry.Registry;
150
147
  readonly _expanded = Record.empty<string, boolean>();
148
+ readonly _pendingExpands = new Set<string>();
151
149
  readonly _initialized = Record.empty<string, boolean>();
152
150
  readonly _initialEdges = Record.empty<string, Edges>();
153
151
  readonly _initialNodes = Record.fromEntries([
@@ -177,7 +175,7 @@ class GraphImpl implements WritableGraph {
177
175
  });
178
176
 
179
177
  readonly _edges = Atom.family<string, Atom.Writable<Edges>>((id) => {
180
- const initial = Record.get(this._initialEdges, id).pipe(Option.getOrElse(() => ({ inbound: [], outbound: [] })));
178
+ const initial = Record.get(this._initialEdges, id).pipe(Option.getOrElse(() => ({}) as Edges));
181
179
  return Atom.make<Edges>(initial).pipe(Atom.keepAlive, Atom.withLabel(`graph:edges:${id}`));
182
180
  });
183
181
 
@@ -185,9 +183,12 @@ class GraphImpl implements WritableGraph {
185
183
  // TODO(wittjosiah): Atom feature request, support for something akin to `ComplexMap` to allow for complex arguments.
186
184
  readonly _connections = Atom.family<string, Atom.Atom<Node.Node[]>>((key) => {
187
185
  return Atom.make((get) => {
188
- const [id, relation] = key.split('$');
186
+ if (!key || primaryParts(key).length < 2) {
187
+ return [];
188
+ }
189
+ const { id, relation } = relationFromConnectionKey(key);
189
190
  const edges = get(this._edges(id));
190
- return edges[relation as Node.Relation]
191
+ return (edges[relationKey(relation)] ?? [])
191
192
  .map((id) => get(this._node(id)))
192
193
  .filter(Option.isSome)
193
194
  .map((o) => o.value);
@@ -196,16 +197,17 @@ class GraphImpl implements WritableGraph {
196
197
 
197
198
  readonly _actions = Atom.family<string, Atom.Atom<(Node.Action | Node.ActionGroup)[]>>((id) => {
198
199
  return Atom.make((get) => {
199
- return get(this._connections(`${id}$outbound`)).filter(
200
- (node) => node.type === Node.ActionType || node.type === Node.ActionGroupType,
201
- );
200
+ if (!id) {
201
+ return [];
202
+ }
203
+ return get(this._connections(connectionKey(id, Node.actionRelation()))) as (Node.Action | Node.ActionGroup)[];
202
204
  }).pipe(Atom.withLabel(`graph:actions:${id}`));
203
205
  });
204
206
 
205
207
  readonly _json = Atom.family<string, Atom.Atom<any>>((id) => {
206
208
  return Atom.make((get) => {
207
209
  const toJSON = (node: Node.Node, seen: string[] = []): any => {
208
- const nodes = get(this._connections(`${node.id}$outbound`));
210
+ const nodes = get(this._connections(connectionKey(node.id, 'child')));
209
211
  const obj: Record<string, any> = {
210
212
  id: node.id,
211
213
  type: node.type,
@@ -261,7 +263,7 @@ class GraphImpl implements WritableGraph {
261
263
  return nodeOrThrowImpl(this, id);
262
264
  }
263
265
 
264
- connections(id: string, relation: Node.Relation = 'outbound'): Atom.Atom<Node.Node[]> {
266
+ connections(id: string, relation: Node.RelationInput): Atom.Atom<Node.Node[]> {
265
267
  return connectionsImpl(this, id, relation);
266
268
  }
267
269
 
@@ -327,13 +329,9 @@ const nodeOrThrowImpl = (graph: BaseGraph, id: string): Atom.Atom<Node.Node> =>
327
329
  /**
328
330
  * Implementation helper for connections.
329
331
  */
330
- const connectionsImpl = (
331
- graph: BaseGraph,
332
- id: string,
333
- relation: Node.Relation = 'outbound',
334
- ): Atom.Atom<Node.Node[]> => {
332
+ const connectionsImpl = (graph: BaseGraph, id: string, relation: Node.RelationInput): Atom.Atom<Node.Node[]> => {
335
333
  const internal = getInternal(graph);
336
- return internal._connections(`${id}$${relation}`);
334
+ return internal._connections(connectionKey(id, relation));
337
335
  };
338
336
 
339
337
  /**
@@ -421,7 +419,7 @@ export function getRoot(graph: BaseGraph): Node.Node {
421
419
  /**
422
420
  * Implementation helper for getConnections.
423
421
  */
424
- const getConnectionsImpl = (graph: BaseGraph, id: string, relation: Node.Relation = 'outbound'): Node.Node[] => {
422
+ const getConnectionsImpl = (graph: BaseGraph, id: string, relation: Node.RelationInput): Node.Node[] => {
425
423
  const internal = getInternal(graph);
426
424
  return internal._registry.get(connectionsImpl(graph, id, relation));
427
425
  };
@@ -429,23 +427,24 @@ const getConnectionsImpl = (graph: BaseGraph, id: string, relation: Node.Relatio
429
427
  /**
430
428
  * Get all nodes connected to the node with the given id by the given relation from the graph's registry.
431
429
  */
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[];
430
+ export function getConnections(graph: BaseGraph, id: string, relation: Node.RelationInput): Node.Node[];
431
+ export function getConnections(id: string, relation: Node.RelationInput): (graph: BaseGraph) => Node.Node[];
434
432
  export function getConnections(
435
433
  graphOrId: BaseGraph | string,
436
- idOrRelation?: string | Node.Relation,
437
- relation?: Node.Relation,
434
+ idOrRelation: string | Node.RelationInput,
435
+ relation?: Node.RelationInput,
438
436
  ): Node.Node[] | ((graph: BaseGraph) => Node.Node[]) {
439
437
  if (typeof graphOrId === 'string') {
440
- // Curried: getConnections(id, relation?)
438
+ // Curried: getConnections(id, relation)
441
439
  const id = graphOrId;
442
- const rel = (typeof idOrRelation === 'string' ? 'outbound' : idOrRelation) ?? 'outbound';
440
+ const rel = idOrRelation as Node.RelationInput;
443
441
  return (graph: BaseGraph) => getConnectionsImpl(graph, id, rel);
444
442
  } else {
445
- // Direct: getConnections(graph, id, relation?)
443
+ // Direct: getConnections(graph, id, relation)
446
444
  const graph = graphOrId;
447
445
  const id = idOrRelation as string;
448
- const rel = relation ?? 'outbound';
446
+ invariant(relation !== undefined, 'Relation is required.');
447
+ const rel = relation;
449
448
  return getConnectionsImpl(graph, id, rel);
450
449
  }
451
450
  }
@@ -510,7 +509,7 @@ export function getEdges(graphOrId: BaseGraph | string, id?: string): Edges | ((
510
509
  * Implementation helper for traverse.
511
510
  */
512
511
  const traverseImpl = (graph: BaseGraph, options: GraphTraversalOptions, path: string[] = []): void => {
513
- const { visitor, source = Node.RootId, relation = 'outbound' } = options;
512
+ const { visitor, source = Node.RootId, relation } = options;
514
513
  // Break cycles.
515
514
  if (path.includes(source)) {
516
515
  return;
@@ -522,9 +521,16 @@ const traverseImpl = (graph: BaseGraph, options: GraphTraversalOptions, path: st
522
521
  return;
523
522
  }
524
523
 
525
- Object.values(getConnections(graph, source, relation)).forEach((child) =>
526
- traverseImpl(graph, { source: child.id, relation, visitor }, [...path, source]),
527
- );
524
+ const relations = Array.isArray(relation) ? relation : [relation];
525
+ const seen = new Set<string>();
526
+ for (const rel of relations) {
527
+ for (const connected of getConnections(graph, source, rel)) {
528
+ if (!seen.has(connected.id)) {
529
+ seen.add(connected.id);
530
+ traverseImpl(graph, { source: connected.id, relation, visitor }, [...path, source]);
531
+ }
532
+ }
533
+ }
528
534
  };
529
535
 
530
536
  /**
@@ -561,6 +567,7 @@ const getPathImpl = (graph: BaseGraph, params: { source?: string; target: string
561
567
  let found: Option.Option<string[]> = Option.none();
562
568
  traverseImpl(graph, {
563
569
  source: node.id,
570
+ relation: 'child',
564
571
  visitor: (node, path) => {
565
572
  if (Option.isSome(found)) {
566
573
  return false;
@@ -660,8 +667,8 @@ const initializeImpl = async <T extends ExpandableGraph | WritableGraph>(graph:
660
667
  const initialized = Record.get(internal._initialized, id).pipe(Option.getOrElse(() => false));
661
668
  log('initialize', { id, initialized });
662
669
  if (!initialized) {
663
- await internal._onInitialize?.(id);
664
670
  Record.set(internal._initialized, id, true);
671
+ await internal._onInitialize?.(id);
665
672
  }
666
673
  return graph;
667
674
  };
@@ -690,19 +697,29 @@ export function initialize<T extends ExpandableGraph | WritableGraph>(
690
697
 
691
698
  /**
692
699
  * Implementation helper for expand.
700
+ * If the node does not exist yet, the expand is recorded as pending and applied when the node is added.
693
701
  */
694
702
  const expandImpl = <T extends ExpandableGraph | WritableGraph>(
695
703
  graph: T,
696
704
  id: string,
697
- relation: Node.Relation = 'outbound',
705
+ relation: Node.RelationInput,
698
706
  ): T => {
699
707
  const internal = getInternal(graph);
700
- const key = `${id}$${relation}`;
708
+ const normalizedRelation = normalizeRelation(relation);
709
+ const key = primaryKey(id, relationKey(normalizedRelation));
710
+ const nodeOpt = internal._registry.get(internal._node(id));
711
+ if (Option.isNone(nodeOpt)) {
712
+ // Node not yet in graph: record expand to run when the node is added.
713
+ internal._pendingExpands.add(key);
714
+ log('expand', { key, deferred: true });
715
+ return graph;
716
+ }
717
+
701
718
  const expanded = Record.get(internal._expanded, key).pipe(Option.getOrElse(() => false));
702
719
  log('expand', { key, expanded });
703
720
  if (!expanded) {
704
- internal._onExpand?.(id, relation);
705
721
  Record.set(internal._expanded, key, true);
722
+ internal._onExpand?.(id, normalizedRelation);
706
723
  }
707
724
  return graph;
708
725
  };
@@ -712,26 +729,31 @@ const expandImpl = <T extends ExpandableGraph | WritableGraph>(
712
729
  *
713
730
  * Fires the `onExpand` callback to add connections to the node.
714
731
  */
715
- export function expand<T extends ExpandableGraph | WritableGraph>(graph: T, id: string, relation?: Node.Relation): T;
732
+ export function expand<T extends ExpandableGraph | WritableGraph>(
733
+ graph: T,
734
+ id: string,
735
+ relation: Node.RelationInput,
736
+ ): T;
716
737
  export function expand(
717
738
  id: string,
718
- relation?: Node.Relation,
739
+ relation: Node.RelationInput,
719
740
  ): <T extends ExpandableGraph | WritableGraph>(graph: T) => T;
720
741
  export function expand<T extends ExpandableGraph | WritableGraph>(
721
742
  graphOrId: T | string,
722
- idOrRelation?: string | Node.Relation,
723
- relation?: Node.Relation,
743
+ idOrRelation: string | Node.RelationInput,
744
+ relation?: Node.RelationInput,
724
745
  ): T | (<T extends ExpandableGraph | WritableGraph>(graph: T) => T) {
725
746
  if (typeof graphOrId === 'string') {
726
- // Curried: expand(id, relation?)
747
+ // Curried: expand(id, relation)
727
748
  const id = graphOrId;
728
- const rel = (typeof idOrRelation === 'string' ? 'outbound' : idOrRelation) ?? 'outbound';
749
+ const rel = idOrRelation as Node.RelationInput;
729
750
  return <T extends ExpandableGraph | WritableGraph>(graph: T) => expandImpl(graph, id, rel);
730
751
  } else {
731
- // Direct: expand(graph, id, relation?)
752
+ // Direct: expand(graph, id, relation)
732
753
  const graph = graphOrId;
733
754
  const id = idOrRelation as string;
734
- const rel = relation ?? 'outbound';
755
+ invariant(relation !== undefined, 'Relation is required.');
756
+ const rel = relation;
735
757
  return expandImpl(graph, id, rel);
736
758
  }
737
759
  }
@@ -742,16 +764,24 @@ export function expand<T extends ExpandableGraph | WritableGraph>(
742
764
  const sortEdgesImpl = <T extends ExpandableGraph | WritableGraph>(
743
765
  graph: T,
744
766
  id: string,
745
- relation: Node.Relation,
767
+ relation: Node.RelationInput,
746
768
  order: string[],
747
769
  ): T => {
748
770
  const internal = getInternal(graph);
749
771
  const edgesAtom = internal._edges(id);
750
772
  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);
773
+ const relationId = relationKey(relation);
774
+ const current = edges[relationId] ?? [];
775
+ const unsorted = current.filter((id) => !order.includes(id));
776
+ const sorted = order.filter((id) => current.includes(id));
777
+ const newOrder = [...sorted, ...unsorted];
778
+ if (newOrder.length === current.length && newOrder.every((id, i) => id === current[i])) {
779
+ return graph;
780
+ }
781
+ internal._registry.set(edgesAtom, {
782
+ ...edges,
783
+ [relationId]: newOrder,
784
+ });
755
785
  return graph;
756
786
  };
757
787
 
@@ -761,31 +791,31 @@ const sortEdgesImpl = <T extends ExpandableGraph | WritableGraph>(
761
791
  export function sortEdges<T extends ExpandableGraph | WritableGraph>(
762
792
  graph: T,
763
793
  id: string,
764
- relation: Node.Relation,
794
+ relation: Node.RelationInput,
765
795
  order: string[],
766
796
  ): T;
767
797
  export function sortEdges(
768
798
  id: string,
769
- relation: Node.Relation,
799
+ relation: Node.RelationInput,
770
800
  order: string[],
771
801
  ): <T extends ExpandableGraph | WritableGraph>(graph: T) => T;
772
802
  export function sortEdges<T extends ExpandableGraph | WritableGraph>(
773
803
  graphOrId: T | string,
774
- idOrRelation?: string | Node.Relation,
775
- relationOrOrder?: Node.Relation | string[],
804
+ idOrRelation?: string | Node.RelationInput,
805
+ relationOrOrder?: Node.RelationInput | string[],
776
806
  order?: string[],
777
807
  ): T | (<T extends ExpandableGraph | WritableGraph>(graph: T) => T) {
778
808
  if (typeof graphOrId === 'string') {
779
809
  // Curried: sortEdges(id, relation, order)
780
810
  const id = graphOrId;
781
- const relation = idOrRelation as Node.Relation;
811
+ const relation = idOrRelation as Node.RelationInput;
782
812
  const order = relationOrOrder as string[];
783
813
  return <T extends ExpandableGraph | WritableGraph>(graph: T) => sortEdgesImpl(graph, id, relation, order);
784
814
  } else {
785
815
  // Direct: sortEdges(graph, id, relation, order)
786
816
  const graph = graphOrId;
787
817
  const id = idOrRelation as string;
788
- const relation = relationOrOrder as Node.Relation;
818
+ const relation = relationOrOrder as Node.RelationInput;
789
819
  return sortEdgesImpl(graph, id, relation, order!);
790
820
  }
791
821
  }
@@ -842,7 +872,7 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
842
872
  Option.match(existingNode, {
843
873
  onSome: (existing) => {
844
874
  const typeChanged = existing.type !== type;
845
- const dataChanged = existing.data !== data;
875
+ const dataChanged = !shallowEqual(existing.data, data);
846
876
  const propertiesChanged = Object.keys(properties).some((key) => existing.properties[key] !== properties[key]);
847
877
  log('existing node', {
848
878
  id,
@@ -868,12 +898,21 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
868
898
  const newNode = internal._constructNode({ id, type, data, properties, ...rest });
869
899
  internal._registry.set(nodeAtom, newNode);
870
900
  graph.onNodeChanged.emit({ id, node: newNode });
901
+
902
+ // Apply any expands that were deferred because this node did not exist yet.
903
+ const toApply = [...internal._pendingExpands].filter((k) => primaryParts(k)[0] === id);
904
+ for (const pendingKey of toApply) {
905
+ internal._pendingExpands.delete(pendingKey);
906
+ const relation = relationFromKey(primaryParts(pendingKey)[1]);
907
+ Record.set(internal._expanded, pendingKey, true);
908
+ internal._onExpand?.(id, relation);
909
+ }
871
910
  },
872
911
  });
873
912
 
874
913
  if (nodes) {
875
914
  addNodesImpl(graph, nodes);
876
- const _edges = nodes.map((node) => ({ source: id, target: node.id }));
915
+ const _edges = nodes.map((node) => ({ source: id, target: node.id, relation: 'child' as const }));
877
916
  addEdgesImpl(graph, _edges);
878
917
  }
879
918
 
@@ -949,11 +988,20 @@ const removeNodeImpl = <T extends WritableGraph>(graph: T, id: string, edges = f
949
988
  // TODO(wittjosiah): Reset expanded and initialized flags?
950
989
 
951
990
  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
- ];
991
+ const nodeEdges = internal._registry.get(internal._edges(id));
992
+ const edgesToRemove: Edge[] = [];
993
+ for (const [relationKeyValue, relatedIds] of Object.entries(nodeEdges)) {
994
+ const relation = relationFromKey(relationKeyValue);
995
+ const isInboundRelation = relation.direction === 'inbound';
996
+ for (const relatedId of relatedIds) {
997
+ if (isInboundRelation) {
998
+ // Inbound edge lists store source node IDs; reconstruct the canonical outbound edge.
999
+ edgesToRemove.push({ source: relatedId, target: id, relation: inverseRelation(relation) });
1000
+ } else {
1001
+ edgesToRemove.push({ source: id, target: relatedId, relation });
1002
+ }
1003
+ }
1004
+ }
957
1005
  removeEdgesImpl(graph, edgesToRemove);
958
1006
  }
959
1007
 
@@ -1019,32 +1067,28 @@ export function addEdges<T extends WritableGraph>(
1019
1067
  * Implementation helper for addEdge.
1020
1068
  */
1021
1069
  const addEdgeImpl = <T extends WritableGraph>(graph: T, edgeArg: Edge): T => {
1070
+ const relation = normalizeRelation(edgeArg.relation);
1071
+ const relationId = relationKey(relation);
1072
+ const inverse = inverseRelation(relation);
1073
+ const inverseId = relationKey(inverse);
1022
1074
  const internal = getInternal(graph);
1075
+
1023
1076
  const sourceAtom = internal._edges(edgeArg.source);
1024
1077
  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
- });
1078
+ const sourceList = source[relationId] ?? [];
1079
+ if (!sourceList.includes(edgeArg.target)) {
1080
+ log('add edge', { source: edgeArg.source, target: edgeArg.target, relation: relationId });
1081
+ internal._registry.set(sourceAtom, { ...source, [relationId]: [...sourceList, edgeArg.target] });
1034
1082
  }
1035
1083
 
1036
1084
  const targetAtom = internal._edges(edgeArg.target);
1037
1085
  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
- });
1086
+ const targetList = target[inverseId] ?? [];
1087
+ if (!targetList.includes(edgeArg.source)) {
1088
+ log('add inverse edge', { source: edgeArg.source, target: edgeArg.target, relation: inverseId });
1089
+ internal._registry.set(targetAtom, { ...target, [inverseId]: [...targetList, edgeArg.source] });
1047
1090
  }
1091
+
1048
1092
  return graph;
1049
1093
  };
1050
1094
 
@@ -1106,32 +1150,34 @@ export function removeEdges<T extends WritableGraph>(
1106
1150
  * Implementation helper for removeEdge.
1107
1151
  */
1108
1152
  const removeEdgeImpl = <T extends WritableGraph>(graph: T, edgeArg: Edge, removeOrphans = false): T => {
1153
+ const relation = normalizeRelation(edgeArg.relation);
1154
+ const relationId = relationKey(relation);
1155
+ const inverse = inverseRelation(relation);
1156
+ const inverseId = relationKey(inverse);
1109
1157
  const internal = getInternal(graph);
1158
+
1110
1159
  const sourceAtom = internal._edges(edgeArg.source);
1111
1160
  const source = internal._registry.get(sourceAtom);
1112
- if (source.outbound.includes(edgeArg.target)) {
1113
- internal._registry.set(sourceAtom, {
1114
- inbound: source.inbound,
1115
- outbound: source.outbound.filter((id) => id !== edgeArg.target),
1116
- });
1161
+ const sourceList = source[relationId] ?? [];
1162
+ if (sourceList.includes(edgeArg.target)) {
1163
+ internal._registry.set(sourceAtom, { ...source, [relationId]: sourceList.filter((id) => id !== edgeArg.target) });
1117
1164
  }
1118
1165
 
1119
1166
  const targetAtom = internal._edges(edgeArg.target);
1120
1167
  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
- });
1168
+ const targetList = target[inverseId] ?? [];
1169
+ if (targetList.includes(edgeArg.source)) {
1170
+ internal._registry.set(targetAtom, { ...target, [inverseId]: targetList.filter((id) => id !== edgeArg.source) });
1126
1171
  }
1127
1172
 
1128
1173
  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) {
1174
+ const sourceAfter = internal._registry.get(sourceAtom);
1175
+ const targetAfter = internal._registry.get(targetAtom);
1176
+ const isEmpty = (edges: Edges) => Object.values(edges).every((ids) => ids.length === 0);
1177
+ if (isEmpty(sourceAfter) && edgeArg.source !== Node.RootId) {
1132
1178
  removeNodesImpl(graph, [edgeArg.source]);
1133
1179
  }
1134
- if (target.outbound.length === 0 && target.inbound.length === 0 && edgeArg.target !== Node.RootId) {
1180
+ if (isEmpty(targetAfter) && edgeArg.target !== Node.RootId) {
1135
1181
  removeNodesImpl(graph, [edgeArg.target]);
1136
1182
  }
1137
1183
  }
@@ -1172,3 +1218,33 @@ export function removeEdge<T extends WritableGraph>(
1172
1218
  export const make = (params?: GraphProps): Graph => {
1173
1219
  return new GraphImpl(params);
1174
1220
  };
1221
+
1222
+ //
1223
+ // Utilities
1224
+ //
1225
+
1226
+ export const relationKey = (relation: Node.RelationInput): string => {
1227
+ const normalized = normalizeRelation(relation);
1228
+ return secondaryKey(normalized.kind, normalized.direction);
1229
+ };
1230
+
1231
+ export const relationFromKey = (encoded: string): Node.Relation => {
1232
+ const parts = secondaryParts(encoded);
1233
+ invariant(parts.length === 2 && parts[0].length > 0 && parts[1].length > 0, `Invalid relation key: ${encoded}`);
1234
+ const [kind, directionRaw] = parts;
1235
+ invariant(directionRaw === 'outbound' || directionRaw === 'inbound', `Invalid relation direction: ${directionRaw}`);
1236
+ return Node.relation(kind, directionRaw);
1237
+ };
1238
+
1239
+ const connectionKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, relationKey(relation));
1240
+
1241
+ const relationFromConnectionKey = (key: string): { id: string; relation: Node.Relation } => {
1242
+ const [id, encodedRelation] = primaryParts(key);
1243
+ invariant(id && encodedRelation, `Invalid connection key: ${key}`);
1244
+ return { id, relation: relationFromKey(encodedRelation) };
1245
+ };
1246
+
1247
+ const inverseRelation = (relation: Node.RelationInput): Node.Relation => {
1248
+ const normalized = normalizeRelation(relation);
1249
+ return Node.relation(normalized.kind, normalized.direction === 'outbound' ? 'inbound' : 'outbound');
1250
+ };
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * as Graph from './graph';
8
8
  export * as GraphBuilder from './graph-builder';
9
9
  export * as Node from './node';
10
10
  export * as NodeMatcher from './node-matcher';
11
+ export { qualifyId, getParentId, getSegmentId } from './util';
11
12
 
12
13
  // TODO(wittjosiah): Direct re-export needed for portable type references.
13
14
  export type { BuilderExtensions } from './graph-builder';