@dxos/app-graph 0.8.4-main.69d29f4 → 0.8.4-main.6fa680abb7

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.
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 { Separators, normalizeRelation, 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 || key.indexOf(Separators.primary) <= 0) {
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 = `${id}${Separators.primary}${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,22 @@ 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 prefix = `${id}${Separators.primary}`;
904
+ const toApply = [...internal._pendingExpands].filter((k) => k.startsWith(prefix));
905
+ for (const pendingKey of toApply) {
906
+ internal._pendingExpands.delete(pendingKey);
907
+ const relation = relationFromKey(pendingKey.slice(prefix.length));
908
+ Record.set(internal._expanded, pendingKey, true);
909
+ internal._onExpand?.(id, relation);
910
+ }
871
911
  },
872
912
  });
873
913
 
874
914
  if (nodes) {
875
915
  addNodesImpl(graph, nodes);
876
- const _edges = nodes.map((node) => ({ source: id, target: node.id }));
916
+ const _edges = nodes.map((node) => ({ source: id, target: node.id, relation: 'child' as const }));
877
917
  addEdgesImpl(graph, _edges);
878
918
  }
879
919
 
@@ -949,11 +989,20 @@ const removeNodeImpl = <T extends WritableGraph>(graph: T, id: string, edges = f
949
989
  // TODO(wittjosiah): Reset expanded and initialized flags?
950
990
 
951
991
  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
- ];
992
+ const nodeEdges = internal._registry.get(internal._edges(id));
993
+ const edgesToRemove: Edge[] = [];
994
+ for (const [relationKeyValue, relatedIds] of Object.entries(nodeEdges)) {
995
+ const relation = relationFromKey(relationKeyValue);
996
+ const isInboundRelation = relation.direction === 'inbound';
997
+ for (const relatedId of relatedIds) {
998
+ if (isInboundRelation) {
999
+ // Inbound edge lists store source node IDs; reconstruct the canonical outbound edge.
1000
+ edgesToRemove.push({ source: relatedId, target: id, relation: inverseRelation(relation) });
1001
+ } else {
1002
+ edgesToRemove.push({ source: id, target: relatedId, relation });
1003
+ }
1004
+ }
1005
+ }
957
1006
  removeEdgesImpl(graph, edgesToRemove);
958
1007
  }
959
1008
 
@@ -1019,32 +1068,28 @@ export function addEdges<T extends WritableGraph>(
1019
1068
  * Implementation helper for addEdge.
1020
1069
  */
1021
1070
  const addEdgeImpl = <T extends WritableGraph>(graph: T, edgeArg: Edge): T => {
1071
+ const relation = normalizeRelation(edgeArg.relation);
1072
+ const relationId = relationKey(relation);
1073
+ const inverse = inverseRelation(relation);
1074
+ const inverseId = relationKey(inverse);
1022
1075
  const internal = getInternal(graph);
1076
+
1023
1077
  const sourceAtom = internal._edges(edgeArg.source);
1024
1078
  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
- });
1079
+ const sourceList = source[relationId] ?? [];
1080
+ if (!sourceList.includes(edgeArg.target)) {
1081
+ log('add edge', { source: edgeArg.source, target: edgeArg.target, relation: relationId });
1082
+ internal._registry.set(sourceAtom, { ...source, [relationId]: [...sourceList, edgeArg.target] });
1034
1083
  }
1035
1084
 
1036
1085
  const targetAtom = internal._edges(edgeArg.target);
1037
1086
  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
- });
1087
+ const targetList = target[inverseId] ?? [];
1088
+ if (!targetList.includes(edgeArg.source)) {
1089
+ log('add inverse edge', { source: edgeArg.source, target: edgeArg.target, relation: inverseId });
1090
+ internal._registry.set(targetAtom, { ...target, [inverseId]: [...targetList, edgeArg.source] });
1047
1091
  }
1092
+
1048
1093
  return graph;
1049
1094
  };
1050
1095
 
@@ -1106,32 +1151,34 @@ export function removeEdges<T extends WritableGraph>(
1106
1151
  * Implementation helper for removeEdge.
1107
1152
  */
1108
1153
  const removeEdgeImpl = <T extends WritableGraph>(graph: T, edgeArg: Edge, removeOrphans = false): T => {
1154
+ const relation = normalizeRelation(edgeArg.relation);
1155
+ const relationId = relationKey(relation);
1156
+ const inverse = inverseRelation(relation);
1157
+ const inverseId = relationKey(inverse);
1109
1158
  const internal = getInternal(graph);
1159
+
1110
1160
  const sourceAtom = internal._edges(edgeArg.source);
1111
1161
  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
- });
1162
+ const sourceList = source[relationId] ?? [];
1163
+ if (sourceList.includes(edgeArg.target)) {
1164
+ internal._registry.set(sourceAtom, { ...source, [relationId]: sourceList.filter((id) => id !== edgeArg.target) });
1117
1165
  }
1118
1166
 
1119
1167
  const targetAtom = internal._edges(edgeArg.target);
1120
1168
  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
- });
1169
+ const targetList = target[inverseId] ?? [];
1170
+ if (targetList.includes(edgeArg.source)) {
1171
+ internal._registry.set(targetAtom, { ...target, [inverseId]: targetList.filter((id) => id !== edgeArg.source) });
1126
1172
  }
1127
1173
 
1128
1174
  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) {
1175
+ const sourceAfter = internal._registry.get(sourceAtom);
1176
+ const targetAfter = internal._registry.get(targetAtom);
1177
+ const isEmpty = (edges: Edges) => Object.values(edges).every((ids) => ids.length === 0);
1178
+ if (isEmpty(sourceAfter) && edgeArg.source !== Node.RootId) {
1132
1179
  removeNodesImpl(graph, [edgeArg.source]);
1133
1180
  }
1134
- if (target.outbound.length === 0 && target.inbound.length === 0 && edgeArg.target !== Node.RootId) {
1181
+ if (isEmpty(targetAfter) && edgeArg.target !== Node.RootId) {
1135
1182
  removeNodesImpl(graph, [edgeArg.target]);
1136
1183
  }
1137
1184
  }
@@ -1172,3 +1219,37 @@ export function removeEdge<T extends WritableGraph>(
1172
1219
  export const make = (params?: GraphProps): Graph => {
1173
1220
  return new GraphImpl(params);
1174
1221
  };
1222
+
1223
+ //
1224
+ // Utilities
1225
+ //
1226
+
1227
+ export const relationKey = (relation: Node.RelationInput): string => {
1228
+ const normalized = normalizeRelation(relation);
1229
+ return `${normalized.kind}${Separators.secondary}${normalized.direction}`;
1230
+ };
1231
+
1232
+ export const relationFromKey = (encoded: string): Node.Relation => {
1233
+ const separatorIndex = encoded.lastIndexOf(Separators.secondary);
1234
+ invariant(separatorIndex > 0 && separatorIndex < encoded.length - 1, `Invalid relation key: ${encoded}`);
1235
+ const kind = encoded.slice(0, separatorIndex);
1236
+ const directionRaw = encoded.slice(separatorIndex + 1);
1237
+ invariant(directionRaw === 'outbound' || directionRaw === 'inbound', `Invalid relation direction: ${directionRaw}`);
1238
+ return Node.relation(kind, directionRaw);
1239
+ };
1240
+
1241
+ const connectionKey = (id: string, relation: Node.RelationInput): string =>
1242
+ `${id}${Separators.primary}${relationKey(relation)}`;
1243
+
1244
+ const relationFromConnectionKey = (key: string): { id: string; relation: Node.Relation } => {
1245
+ const separatorIndex = key.indexOf(Separators.primary);
1246
+ invariant(separatorIndex > 0 && separatorIndex < key.length - 1, `Invalid connection key: ${key}`);
1247
+ const id = key.slice(0, separatorIndex);
1248
+ const encodedRelation = key.slice(separatorIndex + 1);
1249
+ return { id, relation: relationFromKey(encodedRelation) };
1250
+ };
1251
+
1252
+ const inverseRelation = (relation: Node.RelationInput): Node.Relation => {
1253
+ const normalized = normalizeRelation(relation);
1254
+ return Node.relation(normalized.kind, normalized.direction === 'outbound' ? 'inbound' : 'outbound');
1255
+ };
@@ -69,7 +69,7 @@ export const whenId =
69
69
  * ```ts
70
70
  * GraphBuilder.createExtension({
71
71
  * id: 'space-settings-extension',
72
- * match: NodeMatcher.whenNodeType('dxos.org/plugin/space/settings'),
72
+ * match: NodeMatcher.whenNodeType('org.dxos.plugin.space.settings'),
73
73
  * connector: (node) => Effect.succeed([...]),
74
74
  * });
75
75
  * ```
@@ -109,7 +109,7 @@ export const whenNodeType =
109
109
  * @see {@link whenEchoTypeMatches} - Use instead when composing with whenAll/whenAny.
110
110
  */
111
111
  export const whenEchoType =
112
- <T extends Type.Entity.Any>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
112
+ <T extends Type.AnyEntity>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
113
113
  (node: Node.Node): Option.Option<Entity.Entity<Schema.Schema.Type<T>>> =>
114
114
  Obj.instanceOf(type, node.data) ? Option.some(node.data) : Option.none();
115
115
 
@@ -129,7 +129,7 @@ export const whenEchoType =
129
129
  * connector: (object) => {
130
130
  * // `object` is typed as Obj.Unknown
131
131
  * const id = Obj.getDXN(object).toString();
132
- * return Effect.succeed([{ id: `${id}/settings`, ... }]);
132
+ * return Effect.succeed([{ id: `${id}.settings`, ... }]);
133
133
  * },
134
134
  * });
135
135
  * ```
@@ -162,9 +162,8 @@ export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
162
162
  export const whenAll =
163
163
  (...matchers: NodeMatcher[]): NodeMatcher =>
164
164
  (node: Node.Node): Option.Option<Node.Node> => {
165
- for (const matcher of matchers) {
166
- const result = matcher(node);
167
- if (Option.isNone(result)) {
165
+ for (const candidate of matchers) {
166
+ if (Option.isNone(candidate(node))) {
168
167
  return Option.none();
169
168
  }
170
169
  }
@@ -190,9 +189,8 @@ export const whenAll =
190
189
  export const whenAny =
191
190
  (...matchers: NodeMatcher[]): NodeMatcher =>
192
191
  (node: Node.Node): Option.Option<Node.Node> => {
193
- for (const matcher of matchers) {
194
- const result = matcher(node);
195
- if (Option.isSome(result)) {
192
+ for (const candidate of matchers) {
193
+ if (Option.isSome(candidate(node))) {
196
194
  return Option.some(node);
197
195
  }
198
196
  }
@@ -229,7 +227,7 @@ export const whenAny =
229
227
  * @see {@link whenEchoType} - Use instead when you need the typed entity directly.
230
228
  */
231
229
  export const whenEchoTypeMatches =
232
- <T extends Type.Entity.Any>(type: T): NodeMatcher =>
230
+ <T extends Type.AnyEntity>(type: T): NodeMatcher =>
233
231
  (node: Node.Node): Option.Option<Node.Node> =>
234
232
  Obj.instanceOf(type, node.data) ? Option.some(node) : Option.none();
235
233
 
package/src/node.ts CHANGED
@@ -15,17 +15,17 @@ export const RootId = 'root';
15
15
  /**
16
16
  * Root node type.
17
17
  */
18
- export const RootType = 'dxos.org/type/GraphRoot';
18
+ export const RootType = 'org.dxos.type.graph-root';
19
19
 
20
20
  /**
21
21
  * Action node type.
22
22
  */
23
- export const ActionType = 'dxos.org/type/GraphAction';
23
+ export const ActionType = 'org.dxos.type.graph-action';
24
24
 
25
25
  /**
26
26
  * Action group node type.
27
27
  */
28
- export const ActionGroupType = 'dxos.org/type/GraphActionGroup';
28
+ export const ActionGroupType = 'org.dxos.type.graph-action-group';
29
29
 
30
30
  /**
31
31
  * Represents a node in the graph.
@@ -69,7 +69,19 @@ export type NodeFilter<TData = any, TProperties extends Record<string, any> = Re
69
69
  connectedNode: Node,
70
70
  ) => node is Node<TData, TProperties>;
71
71
 
72
- export type Relation = 'outbound' | 'inbound';
72
+ export type RelationDirection = 'outbound' | 'inbound';
73
+
74
+ export type Relation = Readonly<{
75
+ kind: string;
76
+ direction: RelationDirection;
77
+ }>;
78
+
79
+ export type RelationInput = Relation | string;
80
+
81
+ export const relation = (kind: string, direction: RelationDirection = 'outbound'): Relation => ({ kind, direction });
82
+ // TODO(wittjosiah): Consider moving these helpers out of the core API.
83
+ export const childRelation = (direction: RelationDirection = 'outbound'): Relation => relation('child', direction);
84
+ export const actionRelation = (direction: RelationDirection = 'outbound'): Relation => relation('action', direction);
73
85
 
74
86
  export const isGraphNode = (data: unknown): data is Node =>
75
87
  data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties
@@ -84,7 +96,7 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
84
96
  nodes?: NodeArg<unknown>[];
85
97
 
86
98
  /** Will automatically add specified edges. */
87
- edges?: [string, Relation][];
99
+ edges?: [string, RelationInput][];
88
100
  };
89
101
 
90
102
  //
@@ -95,6 +107,9 @@ export type InvokeProps = {
95
107
  /** Node the invoked action is connected to. */
96
108
  parent?: Node;
97
109
 
110
+ /** Path from root to the node in the current tree context. */
111
+ path?: string[];
112
+
98
113
  caller?: string;
99
114
  };
100
115