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

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 (55) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +304 -349
  4. package/dist/lib/neutral/chunk-32XXJE6M.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  6. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  7. package/dist/lib/neutral/index.mjs +40 -0
  8. package/dist/lib/neutral/index.mjs.map +7 -0
  9. package/dist/lib/neutral/meta.json +1 -0
  10. package/dist/lib/neutral/scheduler.mjs +15 -0
  11. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  12. package/dist/lib/neutral/testing/index.mjs +40 -0
  13. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  14. package/dist/types/src/atoms.d.ts.map +1 -1
  15. package/dist/types/src/graph-builder.d.ts +3 -3
  16. package/dist/types/src/graph-builder.d.ts.map +1 -1
  17. package/dist/types/src/graph.d.ts +2 -2
  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 +44 -18
  22. package/dist/types/src/node-matcher.d.ts.map +1 -1
  23. package/dist/types/src/node.d.ts +11 -3
  24. package/dist/types/src/node.d.ts.map +1 -1
  25. package/dist/types/src/scheduler.browser.d.ts +2 -0
  26. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  27. package/dist/types/src/scheduler.d.ts +8 -0
  28. package/dist/types/src/scheduler.d.ts.map +1 -0
  29. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  30. package/dist/types/src/testing/index.d.ts +2 -0
  31. package/dist/types/src/testing/index.d.ts.map +1 -0
  32. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  33. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  34. package/dist/types/src/util.d.ts +26 -10
  35. package/dist/types/src/util.d.ts.map +1 -1
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +38 -27
  38. package/src/graph-builder.test.ts +536 -34
  39. package/src/graph-builder.ts +87 -24
  40. package/src/graph.test.ts +21 -1
  41. package/src/graph.ts +32 -21
  42. package/src/index.ts +1 -0
  43. package/src/node-matcher.ts +59 -27
  44. package/src/node.ts +32 -3
  45. package/src/scheduler.browser.ts +5 -0
  46. package/src/scheduler.ts +17 -0
  47. package/src/stories/EchoGraph.stories.tsx +13 -15
  48. package/src/testing/index.ts +5 -0
  49. package/src/testing/setup-graph-builder.ts +41 -0
  50. package/src/util.ts +59 -13
  51. package/dist/lib/browser/index.mjs.map +0 -7
  52. package/dist/lib/browser/meta.json +0 -1
  53. package/dist/lib/node-esm/index.mjs +0 -1523
  54. package/dist/lib/node-esm/index.mjs.map +0 -7
  55. package/dist/lib/node-esm/meta.json +0 -1
@@ -17,10 +17,11 @@ import * as Graph from './graph';
17
17
  import * as GraphBuilder from './graph-builder';
18
18
  import * as Node from './node';
19
19
  import * as NodeMatcher from './node-matcher';
20
+ import { qualifyId } from './util';
20
21
 
21
22
  const exampleId = (id: number) => `dx:test:${id}`;
22
23
  const EXAMPLE_ID = exampleId(1);
23
- const EXAMPLE_TYPE = 'dxos.org/type/example';
24
+ const EXAMPLE_TYPE = 'org.dxos.type.example';
24
25
 
25
26
  describe('GraphBuilder', () => {
26
27
  describe('resolver', () => {
@@ -78,6 +79,187 @@ describe('GraphBuilder', () => {
78
79
  expect(node?.data).to.equal('updated');
79
80
  }
80
81
  });
82
+
83
+ test('connects resolved node to parent via child edge', async ({ expect }) => {
84
+ const registry = Registry.make();
85
+ const builder = GraphBuilder.make({ registry });
86
+ const childId = qualifyId('root', '~child');
87
+
88
+ GraphBuilder.addExtension(
89
+ builder,
90
+ GraphBuilder.createExtensionRaw({
91
+ id: 'resolver',
92
+ resolver: (id) =>
93
+ id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved' }) : Atom.make(null),
94
+ }),
95
+ );
96
+
97
+ const graph = builder.graph;
98
+ await Graph.initialize(graph, childId);
99
+
100
+ {
101
+ const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
102
+ expect(node?.id).to.equal(childId);
103
+ expect(node?.data).to.equal('resolved');
104
+ }
105
+
106
+ // Verify the resolved node is a child of root.
107
+ {
108
+ const children = registry.get(graph.connections('root', 'child'));
109
+ expect(children.some((n) => n.id === childId)).to.be.true;
110
+ }
111
+ });
112
+
113
+ test('out-of-order: resolver fires before parent exists', async ({ expect }) => {
114
+ const registry = Registry.make();
115
+ const builder = GraphBuilder.make({ registry });
116
+ const parentId = qualifyId('root', 'parent');
117
+ const childId = qualifyId('root', 'parent', '~child');
118
+
119
+ GraphBuilder.addExtension(builder, [
120
+ GraphBuilder.createExtensionRaw({
121
+ id: 'resolver',
122
+ resolver: (id) =>
123
+ id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved-child' }) : Atom.make(null),
124
+ }),
125
+ GraphBuilder.createExtensionRaw({
126
+ id: 'connector',
127
+ connector: (node) =>
128
+ Atom.make((get) =>
129
+ Function.pipe(
130
+ get(node),
131
+ Option.filter((n) => n.id === 'root'),
132
+ Option.map(() => [{ id: 'parent', type: EXAMPLE_TYPE, data: 'parent-data' }]),
133
+ Option.getOrElse(() => []),
134
+ ),
135
+ ),
136
+ }),
137
+ ]);
138
+
139
+ const graph = builder.graph;
140
+
141
+ // Resolve child BEFORE parent exists in the graph.
142
+ await Graph.initialize(graph, childId);
143
+
144
+ {
145
+ const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
146
+ expect(node?.id).to.equal(childId);
147
+ expect(node?.data).to.equal('resolved-child');
148
+ }
149
+
150
+ // Now expand root to create parent via connector.
151
+ Graph.expand(graph, Node.RootId, 'child');
152
+ await GraphBuilder.flush(builder);
153
+
154
+ {
155
+ const parent = Graph.getNode(graph, parentId).pipe(Option.getOrNull);
156
+ expect(parent?.data).to.equal('parent-data');
157
+ }
158
+
159
+ // The resolved child should be connected to the parent.
160
+ {
161
+ const children = registry.get(graph.connections(parentId, 'child'));
162
+ expect(children.some((n) => n.id === childId)).to.be.true;
163
+ }
164
+ });
165
+
166
+ test('onNone does not remove connector-owned node', async ({ expect }) => {
167
+ const registry = Registry.make();
168
+ const builder = GraphBuilder.make({ registry });
169
+ const nodeId = qualifyId('root', 'shared');
170
+
171
+ // Connector that produces root/shared. No resolver matches root/shared.
172
+ GraphBuilder.addExtension(
173
+ builder,
174
+ GraphBuilder.createExtensionRaw({
175
+ id: 'connector',
176
+ connector: (node) =>
177
+ Atom.make((get) =>
178
+ Function.pipe(
179
+ get(node),
180
+ Option.filter((n) => n.id === 'root'),
181
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
182
+ Option.getOrElse(() => []),
183
+ ),
184
+ ),
185
+ }),
186
+ );
187
+
188
+ const graph = builder.graph;
189
+
190
+ // Connector produces root/shared.
191
+ Graph.expand(graph, Node.RootId, 'child');
192
+ await GraphBuilder.flush(builder);
193
+
194
+ {
195
+ const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
196
+ expect(node?.data).to.equal('from-connector');
197
+ }
198
+
199
+ // Initialize fires for the same ID. No resolver matches, so onNone fires.
200
+ // The connector-owned node should NOT be removed.
201
+ await Graph.initialize(graph, nodeId);
202
+
203
+ {
204
+ const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
205
+ expect(node?.data).to.equal('from-connector');
206
+ }
207
+ });
208
+
209
+ test('does not overwrite connector-produced node', async () => {
210
+ const registry = Registry.make();
211
+ const builder = GraphBuilder.make({ registry });
212
+ const resolverData = Atom.make('from-resolver');
213
+
214
+ GraphBuilder.addExtension(builder, [
215
+ GraphBuilder.createExtensionRaw({
216
+ id: 'resolver',
217
+ resolver: (id) =>
218
+ id === qualifyId('root', 'shared')
219
+ ? Atom.make((get) => ({ id: qualifyId('root', 'shared'), type: EXAMPLE_TYPE, data: get(resolverData) }))
220
+ : Atom.make(null),
221
+ }),
222
+ GraphBuilder.createExtensionRaw({
223
+ id: 'connector',
224
+ connector: (node) =>
225
+ Atom.make((get) =>
226
+ Function.pipe(
227
+ get(node),
228
+ Option.filter((n) => n.id === 'root'),
229
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
230
+ Option.getOrElse(() => []),
231
+ ),
232
+ ),
233
+ }),
234
+ ]);
235
+
236
+ const graph = builder.graph;
237
+
238
+ // Connector produces root/shared.
239
+ Graph.expand(graph, Node.RootId, 'child');
240
+ await GraphBuilder.flush(builder);
241
+
242
+ {
243
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
244
+ expect(node?.data).to.equal('from-connector');
245
+ }
246
+
247
+ // Resolver fires for the same ID but should not overwrite.
248
+ await Graph.initialize(graph, qualifyId('root', 'shared'));
249
+
250
+ {
251
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
252
+ expect(node?.data).to.equal('from-connector');
253
+ }
254
+
255
+ // Updating the resolver's atom should still not overwrite.
256
+ registry.set(resolverData, 'updated-resolver');
257
+
258
+ {
259
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
260
+ expect(node?.data).to.equal('from-connector');
261
+ }
262
+ });
81
263
  });
82
264
 
83
265
  describe('connector', () => {
@@ -109,10 +291,10 @@ describe('GraphBuilder', () => {
109
291
  const inbound = registry.get(graph.connections(Node.RootId, Node.childRelation('inbound')));
110
292
 
111
293
  expect(outbound).has.length(1);
112
- expect(outbound[0].id).to.equal('child');
294
+ expect(outbound[0].id).to.equal('root/child');
113
295
  expect(outbound[0].data).to.equal(2);
114
296
  expect(inbound).has.length(1);
115
- expect(inbound[0].id).to.equal('parent');
297
+ expect(inbound[0].id).to.equal('root/parent');
116
298
  expect(inbound[0].data).to.equal(0);
117
299
  });
118
300
 
@@ -237,8 +419,8 @@ describe('GraphBuilder', () => {
237
419
  {
238
420
  const nodes = registry.get(graph.connections(Node.RootId, 'child'));
239
421
  expect(nodes).has.length(2);
240
- expect(nodes[0].id).to.equal(exampleId(1));
241
- expect(nodes[1].id).to.equal(exampleId(2));
422
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
423
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
242
424
  }
243
425
 
244
426
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
@@ -247,7 +429,7 @@ describe('GraphBuilder', () => {
247
429
  {
248
430
  const nodes = registry.get(graph.connections(Node.RootId, 'child'));
249
431
  expect(nodes).has.length(1);
250
- expect(nodes[0].id).to.equal(exampleId(3));
432
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
251
433
  }
252
434
  });
253
435
 
@@ -276,7 +458,7 @@ describe('GraphBuilder', () => {
276
458
 
277
459
  let count = 0;
278
460
  let exists = false;
279
- const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
461
+ const cancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (node) => {
280
462
  count++;
281
463
  exists = Option.isSome(node);
282
464
  });
@@ -303,6 +485,132 @@ describe('GraphBuilder', () => {
303
485
  expect(exists).to.be.true;
304
486
  });
305
487
 
488
+ describe('inline nodes', () => {
489
+ const parent = (child?: Node.NodeArg<any>): Node.NodeArg<any> => ({
490
+ id: 'parent-node',
491
+ type: EXAMPLE_TYPE,
492
+ data: null,
493
+ nodes: child ? [child] : [],
494
+ });
495
+
496
+ const inlineChild = (overrides?: Partial<Node.NodeArg<any>>): Node.NodeArg<any> => ({
497
+ id: 'inline-child',
498
+ type: EXAMPLE_TYPE,
499
+ data: null,
500
+ ...overrides,
501
+ });
502
+
503
+ const makeGraph = () => {
504
+ const registry = Registry.make();
505
+ const builder = GraphBuilder.make({ registry });
506
+ const nodesAtom = Atom.make<Node.NodeArg<any>[]>([]);
507
+ GraphBuilder.addExtension(
508
+ builder,
509
+ GraphBuilder.createExtensionRaw({
510
+ id: 'inline-connector',
511
+ connector: () => Atom.make((get) => get(nodesAtom)),
512
+ }),
513
+ );
514
+ const graph = builder.graph;
515
+ Graph.expand(graph, Node.RootId, 'child');
516
+ return { registry, builder, graph, nodesAtom };
517
+ };
518
+
519
+ const getInlineChild = (graph: Graph.ExpandableGraph) =>
520
+ Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
521
+
522
+ test('are removed when connector re-runs with data change', async () => {
523
+ const { registry, builder, graph, nodesAtom } = makeGraph();
524
+ registry.set(nodesAtom, [parent(inlineChild())]);
525
+ await GraphBuilder.flush(builder);
526
+
527
+ expect(getInlineChild(graph)).to.not.be.null;
528
+
529
+ // Remove inline child while also changing parent data to trigger the update.
530
+ registry.set(nodesAtom, [{ ...parent(), data: 'v2' }]);
531
+ await GraphBuilder.flush(builder);
532
+
533
+ expect(getInlineChild(graph)).to.be.null;
534
+ });
535
+
536
+ test('are removed when only inline children change', async () => {
537
+ const { registry, builder, graph, nodesAtom } = makeGraph();
538
+ registry.set(nodesAtom, [parent(inlineChild())]);
539
+ await GraphBuilder.flush(builder);
540
+
541
+ expect(getInlineChild(graph)).to.not.be.null;
542
+
543
+ // Remove inline child without touching parent — tests change detection covers inline children.
544
+ registry.set(nodesAtom, [parent()]);
545
+ await GraphBuilder.flush(builder);
546
+
547
+ expect(getInlineChild(graph)).to.be.null;
548
+ });
549
+
550
+ test('are added when connector re-runs', async () => {
551
+ const { registry, builder, graph, nodesAtom } = makeGraph();
552
+ registry.set(nodesAtom, [parent()]);
553
+ await GraphBuilder.flush(builder);
554
+
555
+ expect(getInlineChild(graph)).to.be.null;
556
+
557
+ registry.set(nodesAtom, [parent(inlineChild())]);
558
+ await GraphBuilder.flush(builder);
559
+
560
+ expect(getInlineChild(graph)).to.not.be.null;
561
+ });
562
+
563
+ test('reactively update data', async ({ expect }) => {
564
+ const { registry, builder, graph, nodesAtom } = makeGraph();
565
+ registry.set(nodesAtom, [parent(inlineChild({ data: 'v1' }))]);
566
+ await GraphBuilder.flush(builder);
567
+
568
+ expect(getInlineChild(graph)?.data).to.equal('v1');
569
+
570
+ // Change only the inline child's data — parent is unchanged.
571
+ registry.set(nodesAtom, [parent(inlineChild({ data: 'v2' }))]);
572
+ await GraphBuilder.flush(builder);
573
+
574
+ expect(getInlineChild(graph)?.data).to.equal('v2');
575
+ });
576
+
577
+ test('reactively update properties', async ({ expect }) => {
578
+ const { registry, builder, graph, nodesAtom } = makeGraph();
579
+ registry.set(nodesAtom, [parent(inlineChild({ properties: { label: 'before' } }))]);
580
+ await GraphBuilder.flush(builder);
581
+
582
+ expect(getInlineChild(graph)?.properties.label).to.equal('before');
583
+
584
+ registry.set(nodesAtom, [parent(inlineChild({ properties: { label: 'after' } }))]);
585
+ await GraphBuilder.flush(builder);
586
+
587
+ expect(getInlineChild(graph)?.properties.label).to.equal('after');
588
+ });
589
+
590
+ test('deeply nested inline nodes reactively update', async ({ expect }) => {
591
+ const { registry, builder, graph, nodesAtom } = makeGraph();
592
+
593
+ const withGrandchild = (data: string) =>
594
+ parent({
595
+ id: 'child',
596
+ type: EXAMPLE_TYPE,
597
+ data: null,
598
+ nodes: [{ id: 'grandchild', type: EXAMPLE_TYPE, data }],
599
+ });
600
+
601
+ registry.set(nodesAtom, [withGrandchild('v1')]);
602
+ await GraphBuilder.flush(builder);
603
+
604
+ expect(Graph.getNode(graph, 'root/parent-node/child/grandchild').pipe(Option.getOrNull)?.data).to.equal('v1');
605
+
606
+ // Change only the grandchild's data — all ancestors unchanged.
607
+ registry.set(nodesAtom, [withGrandchild('v2')]);
608
+ await GraphBuilder.flush(builder);
609
+
610
+ expect(Graph.getNode(graph, 'root/parent-node/child/grandchild').pipe(Option.getOrNull)?.data).to.equal('v2');
611
+ });
612
+ });
613
+
306
614
  test('sort edges', async () => {
307
615
  const registry = Registry.make();
308
616
  const builder = GraphBuilder.make({ registry });
@@ -325,9 +633,9 @@ describe('GraphBuilder', () => {
325
633
  {
326
634
  const nodes = registry.get(graph.connections(Node.RootId, 'child'));
327
635
  expect(nodes).has.length(3);
328
- expect(nodes[0].id).to.equal(exampleId(1));
329
- expect(nodes[1].id).to.equal(exampleId(2));
330
- expect(nodes[2].id).to.equal(exampleId(3));
636
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
637
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
638
+ expect(nodes[2].id).to.equal(qualifyId('root', exampleId(3)));
331
639
  }
332
640
 
333
641
  registry.set(nodes, [
@@ -340,9 +648,9 @@ describe('GraphBuilder', () => {
340
648
  {
341
649
  const nodes = registry.get(graph.connections(Node.RootId, 'child'));
342
650
  expect(nodes).has.length(3);
343
- expect(nodes[0].id).to.equal(exampleId(3));
344
- expect(nodes[1].id).to.equal(exampleId(1));
345
- expect(nodes[2].id).to.equal(exampleId(2));
651
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
652
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(1)));
653
+ expect(nodes[2].id).to.equal(qualifyId('root', exampleId(2)));
346
654
  }
347
655
  });
348
656
 
@@ -372,7 +680,9 @@ describe('GraphBuilder', () => {
372
680
  Atom.make((get) =>
373
681
  Function.pipe(
374
682
  get(node),
375
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
683
+ Option.flatMap((node) =>
684
+ node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(get(sub)) : Option.none(),
685
+ ),
376
686
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
377
687
  Option.getOrElse(() => []),
378
688
  ),
@@ -384,7 +694,9 @@ describe('GraphBuilder', () => {
384
694
  Atom.make((get) =>
385
695
  Function.pipe(
386
696
  get(node),
387
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
697
+ Option.flatMap((node) =>
698
+ node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(node.data) : Option.none(),
699
+ ),
388
700
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
389
701
  Option.getOrElse(() => []),
390
702
  ),
@@ -395,19 +707,19 @@ describe('GraphBuilder', () => {
395
707
  const graph = builder.graph;
396
708
 
397
709
  let parentCount = 0;
398
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
710
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
399
711
  parentCount++;
400
712
  });
401
713
  onTestFinished(() => parentCancel());
402
714
 
403
715
  let independentCount = 0;
404
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
716
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
405
717
  independentCount++;
406
718
  });
407
719
  onTestFinished(() => independentCancel());
408
720
 
409
721
  let dependentCount = 0;
410
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
722
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
411
723
  dependentCount++;
412
724
  });
413
725
  onTestFinished(() => dependentCancel());
@@ -420,7 +732,7 @@ describe('GraphBuilder', () => {
420
732
  expect(dependentCount).to.equal(0);
421
733
 
422
734
  // Counts should increment when the node is expanded.
423
- Graph.expand(graph, EXAMPLE_ID, 'child');
735
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
424
736
  await GraphBuilder.flush(builder);
425
737
  expect(parentCount).to.equal(1);
426
738
  expect(independentCount).to.equal(1);
@@ -458,7 +770,7 @@ describe('GraphBuilder', () => {
458
770
  expect(dependentCount).to.equal(3);
459
771
 
460
772
  // Counts should not increment when the node is expanded again.
461
- Graph.expand(graph, EXAMPLE_ID, 'child');
773
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
462
774
  await GraphBuilder.flush(builder);
463
775
  expect(parentCount).to.equal(3);
464
776
  expect(independentCount).to.equal(3);
@@ -559,7 +871,7 @@ describe('GraphBuilder', () => {
559
871
 
560
872
  const connections = registry.get(graph.connections(Node.RootId, 'child'));
561
873
  expect(connections).has.length(1);
562
- expect(connections[0].id).to.equal('child');
874
+ expect(connections[0].id).to.equal('root/child');
563
875
  });
564
876
  });
565
877
 
@@ -586,7 +898,7 @@ describe('GraphBuilder', () => {
586
898
 
587
899
  const connections = registry.get(graph.connections('parent', 'child'));
588
900
  expect(connections).has.length(1);
589
- expect(connections[0].id).to.equal('child');
901
+ expect(connections[0].id).to.equal('parent/child');
590
902
  expect(connections[0].data).to.equal('test');
591
903
  });
592
904
 
@@ -619,11 +931,11 @@ describe('GraphBuilder', () => {
619
931
 
620
932
  const edges = registry.get(graph.edges('parent'));
621
933
  expect(edges[Graph.relationKey('action')] ?? []).to.have.length(1);
622
- expect(edges[Graph.relationKey('action')] ?? []).to.include('test-action');
934
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/test-action');
623
935
  expect(edges[Graph.relationKey('child')] ?? []).to.have.length(0);
624
936
  const actions = registry.get(graph.actions('parent'));
625
937
  expect(actions).has.length(1);
626
- expect(actions[0].id).to.equal('test-action');
938
+ expect(actions[0].id).to.equal('parent/test-action');
627
939
  });
628
940
 
629
941
  test('actions expand automatically with child relation', async ({ expect }) => {
@@ -649,14 +961,14 @@ describe('GraphBuilder', () => {
649
961
  await GraphBuilder.flush(builder);
650
962
 
651
963
  const edges = registry.get(graph.edges('parent'));
652
- expect(edges[Graph.relationKey('child')] ?? []).to.include('child');
653
- expect(edges[Graph.relationKey('action')] ?? []).to.include('act1');
964
+ expect(edges[Graph.relationKey('child')] ?? []).to.include('parent/child');
965
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/act1');
654
966
  const actions = registry.get(graph.actions('parent'));
655
967
  expect(actions).has.length(1);
656
- expect(actions[0].id).to.equal('act1');
968
+ expect(actions[0].id).to.equal('parent/act1');
657
969
  const connections = registry.get(graph.connections('parent', 'child'));
658
970
  expect(connections).has.length(1);
659
- expect(connections[0].id).to.equal('child');
971
+ expect(connections[0].id).to.equal('parent/child');
660
972
  });
661
973
 
662
974
  test('actions appear when extension registered after expand', async ({ expect }) => {
@@ -684,10 +996,10 @@ describe('GraphBuilder', () => {
684
996
  await GraphBuilder.flush(builder);
685
997
 
686
998
  const edges = registry.get(graph.edges('parent'));
687
- expect(edges[Graph.relationKey('action')] ?? []).to.include('late-act');
999
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
688
1000
  const actions = registry.get(graph.actions('parent'));
689
1001
  expect(actions).has.length(1);
690
- expect(actions[0].id).to.equal('late-act');
1002
+ expect(actions[0].id).to.equal('parent/late-act');
691
1003
  });
692
1004
 
693
1005
  test('_actionContext captures and provides services to action execution', async () => {
@@ -809,13 +1121,13 @@ describe('GraphBuilder', () => {
809
1121
  const connections = registry.get(graph.connections('parent', 'child'));
810
1122
  // Should have both the child node and the action node.
811
1123
  expect(connections.length).to.be.greaterThanOrEqual(1);
812
- const childNode = connections.find((n) => n.id === 'child');
1124
+ const childNode = connections.find((n) => n.id === 'parent/child');
813
1125
  expect(childNode).to.not.be.undefined;
814
1126
  expect(childNode?.data).to.equal('test');
815
1127
 
816
1128
  const actions = registry.get(graph.actions('parent'));
817
1129
  expect(actions).has.length(1);
818
- expect(actions[0].id).to.equal('test-action');
1130
+ expect(actions[0].id).to.equal('parent/test-action');
819
1131
  });
820
1132
 
821
1133
  test('works with reactive connector using get context', async () => {
@@ -970,7 +1282,7 @@ describe('GraphBuilder', () => {
970
1282
  // The working extension should still produce its node.
971
1283
  const connections = registry.get(graph.connections('parent', 'child'));
972
1284
  expect(connections).has.length(1);
973
- expect(connections[0].id).to.equal('child-from-working');
1285
+ expect(connections[0].id).to.equal('parent/child-from-working');
974
1286
  expect(connections[0].data).to.equal('success');
975
1287
  });
976
1288
  });
@@ -999,9 +1311,199 @@ describe('GraphBuilder', () => {
999
1311
 
1000
1312
  const connections = registry.get(graph.connections('parent', 'child'));
1001
1313
  expect(connections).has.length(1);
1002
- expect(connections[0].id).to.equal('child');
1314
+ expect(connections[0].id).to.equal('parent/child');
1003
1315
  expect(connections[0].data).to.equal(testObject);
1004
1316
  });
1005
1317
  });
1006
1318
  });
1319
+ describe('path-based ID qualification', () => {
1320
+ test('rejects segment IDs containing slash', async () => {
1321
+ const registry = Registry.make();
1322
+ const builder = GraphBuilder.make({ registry });
1323
+ GraphBuilder.addExtension(
1324
+ builder,
1325
+ GraphBuilder.createExtensionRaw({
1326
+ id: 'bad-connector',
1327
+ connector: () => Atom.make([{ id: 'foo/bar', type: EXAMPLE_TYPE, data: null }]),
1328
+ }),
1329
+ );
1330
+
1331
+ expect(() => Graph.expand(builder.graph, Node.RootId, 'child')).toThrow(/must not contain/);
1332
+ });
1333
+
1334
+ test('multi-level path qualification', async () => {
1335
+ const registry = Registry.make();
1336
+ const builder = GraphBuilder.make({ registry });
1337
+ GraphBuilder.addExtension(builder, [
1338
+ GraphBuilder.createExtensionRaw({
1339
+ id: 'level1',
1340
+ connector: (node) =>
1341
+ Atom.make((get) =>
1342
+ Function.pipe(
1343
+ get(node),
1344
+ Option.filter((n) => n.id === 'root'),
1345
+ Option.map(() => [{ id: 'A', type: EXAMPLE_TYPE, data: 'a' }]),
1346
+ Option.getOrElse(() => []),
1347
+ ),
1348
+ ),
1349
+ }),
1350
+ GraphBuilder.createExtensionRaw({
1351
+ id: 'level2',
1352
+ connector: (node) =>
1353
+ Atom.make((get) =>
1354
+ Function.pipe(
1355
+ get(node),
1356
+ Option.filter((n) => n.id === 'root/A'),
1357
+ Option.map(() => [{ id: 'B', type: EXAMPLE_TYPE, data: 'b' }]),
1358
+ Option.getOrElse(() => []),
1359
+ ),
1360
+ ),
1361
+ }),
1362
+ ]);
1363
+
1364
+ const graph = builder.graph;
1365
+
1366
+ Graph.expand(graph, Node.RootId, 'child');
1367
+ await GraphBuilder.flush(builder);
1368
+
1369
+ const level1 = registry.get(graph.connections(Node.RootId, 'child'));
1370
+ expect(level1).has.length(1);
1371
+ expect(level1[0].id).to.equal('root/A');
1372
+
1373
+ Graph.expand(graph, 'root/A', 'child');
1374
+ await GraphBuilder.flush(builder);
1375
+
1376
+ const level2 = registry.get(graph.connections('root/A', 'child'));
1377
+ expect(level2).has.length(1);
1378
+ expect(level2[0].id).to.equal('root/A/B');
1379
+ });
1380
+
1381
+ test('inline nodes are recursively qualified', async () => {
1382
+ const registry = Registry.make();
1383
+ const builder = GraphBuilder.make({ registry });
1384
+ GraphBuilder.addExtension(
1385
+ builder,
1386
+ GraphBuilder.createExtensionRaw({
1387
+ id: 'inline-connector',
1388
+ connector: () =>
1389
+ Atom.make([
1390
+ {
1391
+ id: 'parent-node',
1392
+ type: EXAMPLE_TYPE,
1393
+ data: null,
1394
+ nodes: [
1395
+ {
1396
+ id: 'inline-child',
1397
+ type: EXAMPLE_TYPE,
1398
+ data: null,
1399
+ nodes: [{ id: 'deep-child', type: EXAMPLE_TYPE, data: null }],
1400
+ },
1401
+ ],
1402
+ },
1403
+ ]),
1404
+ }),
1405
+ );
1406
+
1407
+ const graph = builder.graph;
1408
+ Graph.expand(graph, Node.RootId, 'child');
1409
+ await GraphBuilder.flush(builder);
1410
+
1411
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
1412
+ expect(connections).has.length(1);
1413
+ expect(connections[0].id).to.equal('root/parent-node');
1414
+
1415
+ const inlineNode = Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
1416
+ expect(inlineNode).to.not.be.null;
1417
+ expect(inlineNode?.id).to.equal('root/parent-node/inline-child');
1418
+
1419
+ const deepNode = Graph.getNode(graph, 'root/parent-node/inline-child/deep-child').pipe(Option.getOrNull);
1420
+ expect(deepNode).to.not.be.null;
1421
+ expect(deepNode?.id).to.equal('root/parent-node/inline-child/deep-child');
1422
+ });
1423
+
1424
+ test('constant connector produces distinct nodes under different parents', async () => {
1425
+ const registry = Registry.make();
1426
+ const builder = GraphBuilder.make({ registry });
1427
+
1428
+ GraphBuilder.addExtension(builder, [
1429
+ GraphBuilder.createExtensionRaw({
1430
+ id: 'parents',
1431
+ connector: (node) =>
1432
+ Atom.make((get) =>
1433
+ Function.pipe(
1434
+ get(node),
1435
+ Option.filter((n) => n.id === 'root'),
1436
+ Option.map(() => [
1437
+ { id: 'A', type: EXAMPLE_TYPE, data: 'a' },
1438
+ { id: 'B', type: EXAMPLE_TYPE, data: 'b' },
1439
+ ]),
1440
+ Option.getOrElse(() => []),
1441
+ ),
1442
+ ),
1443
+ }),
1444
+ GraphBuilder.createExtensionRaw({
1445
+ id: 'constant-child',
1446
+ connector: () => Atom.make([{ id: 'shared', type: EXAMPLE_TYPE, data: 'constant' }]),
1447
+ }),
1448
+ ]);
1449
+
1450
+ const graph = builder.graph;
1451
+
1452
+ Graph.expand(graph, Node.RootId, 'child');
1453
+ await GraphBuilder.flush(builder);
1454
+
1455
+ Graph.expand(graph, 'root/A', 'child');
1456
+ Graph.expand(graph, 'root/B', 'child');
1457
+ await GraphBuilder.flush(builder);
1458
+
1459
+ const childrenOfA = registry.get(graph.connections('root/A', 'child'));
1460
+ const childrenOfB = registry.get(graph.connections('root/B', 'child'));
1461
+
1462
+ expect(childrenOfA).has.length(1);
1463
+ expect(childrenOfB).has.length(1);
1464
+ expect(childrenOfA[0].id).to.equal('root/A/shared');
1465
+ expect(childrenOfB[0].id).to.equal('root/B/shared');
1466
+
1467
+ const nodeA = Graph.getNode(graph, 'root/A/shared').pipe(Option.getOrNull);
1468
+ const nodeB = Graph.getNode(graph, 'root/B/shared').pipe(Option.getOrNull);
1469
+ expect(nodeA).to.not.be.null;
1470
+ expect(nodeB).to.not.be.null;
1471
+ expect(nodeA?.id).to.not.equal(nodeB?.id);
1472
+ });
1473
+
1474
+ test('explore qualifies node IDs', async () => {
1475
+ const builder = GraphBuilder.make();
1476
+ GraphBuilder.addExtension(
1477
+ builder,
1478
+ GraphBuilder.createExtensionRaw({
1479
+ id: 'connector',
1480
+ connector: (node) =>
1481
+ Atom.make((get) =>
1482
+ Function.pipe(
1483
+ get(node),
1484
+ Option.filter((n) => n.id === 'root'),
1485
+ Option.map(() => [
1486
+ { id: 'first', type: EXAMPLE_TYPE, data: 1 },
1487
+ { id: 'second', type: EXAMPLE_TYPE, data: 2 },
1488
+ ]),
1489
+ Option.getOrElse(() => []),
1490
+ ),
1491
+ ),
1492
+ }),
1493
+ );
1494
+
1495
+ const visited: Array<{ id: string; path: string[] }> = [];
1496
+ await GraphBuilder.explore(builder, {
1497
+ relation: 'child',
1498
+ visitor: (node, path) => {
1499
+ visited.push({ id: node.id, path });
1500
+ },
1501
+ });
1502
+
1503
+ expect(visited).has.length(3);
1504
+ expect(visited[0].id).to.equal('root');
1505
+ expect(visited[1].id).to.equal('root/first');
1506
+ expect(visited[2].id).to.equal('root/second');
1507
+ });
1508
+ });
1007
1509
  });