@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/dist/lib/browser/index.mjs +480 -198
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +480 -198
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/graph-builder.d.ts +11 -7
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +13 -17
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +4 -4
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +15 -5
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +34 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -25
- package/src/graph-builder.test.ts +444 -102
- package/src/graph-builder.ts +191 -72
- package/src/graph.test.ts +187 -52
- package/src/graph.ts +179 -98
- package/src/node-matcher.ts +8 -10
- package/src/node.ts +20 -5
- package/src/stories/EchoGraph.stories.tsx +86 -56
- package/src/stories/Tree.tsx +1 -1
- package/src/util.ts +71 -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 { 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
|
-
|
|
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 =
|
|
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
|
|
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(() => ({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
|
433
|
-
export function getConnections(id: string, relation
|
|
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
|
|
437
|
-
relation?: Node.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
|
|
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.
|
|
705
|
+
relation: Node.RelationInput,
|
|
698
706
|
): T => {
|
|
699
707
|
const internal = getInternal(graph);
|
|
700
|
-
const
|
|
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>(
|
|
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
|
|
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
|
|
723
|
-
relation?: Node.
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
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.
|
|
794
|
+
relation: Node.RelationInput,
|
|
765
795
|
order: string[],
|
|
766
796
|
): T;
|
|
767
797
|
export function sortEdges(
|
|
768
798
|
id: string,
|
|
769
|
-
relation: Node.
|
|
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.
|
|
775
|
-
relationOrOrder?: Node.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
953
|
-
const edgesToRemove = [
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
|
1130
|
-
const
|
|
1131
|
-
|
|
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 (
|
|
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
|
+
};
|
package/src/node-matcher.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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}
|
|
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
|
|
166
|
-
|
|
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
|
|
194
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|