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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
  2. package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +17 -1276
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +39 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs +1604 -0
  9. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +17 -1276
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +40 -0
  14. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  15. package/dist/types/src/graph-builder.d.ts +11 -7
  16. package/dist/types/src/graph-builder.d.ts.map +1 -1
  17. package/dist/types/src/graph.d.ts +13 -17
  18. package/dist/types/src/graph.d.ts.map +1 -1
  19. package/dist/types/src/index.d.ts +1 -0
  20. package/dist/types/src/index.d.ts.map +1 -1
  21. package/dist/types/src/node-matcher.d.ts +43 -17
  22. package/dist/types/src/node-matcher.d.ts.map +1 -1
  23. package/dist/types/src/node.d.ts +21 -5
  24. package/dist/types/src/node.d.ts.map +1 -1
  25. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  26. package/dist/types/src/testing/index.d.ts +2 -0
  27. package/dist/types/src/testing/index.d.ts.map +1 -0
  28. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  29. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  30. package/dist/types/src/util.d.ts +39 -0
  31. package/dist/types/src/util.d.ts.map +1 -0
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +36 -26
  34. package/src/graph-builder.test.ts +569 -102
  35. package/src/graph-builder.ts +202 -74
  36. package/src/graph.test.ts +187 -52
  37. package/src/graph.ts +174 -98
  38. package/src/index.ts +1 -0
  39. package/src/node-matcher.ts +58 -28
  40. package/src/node.ts +46 -5
  41. package/src/stories/EchoGraph.stories.tsx +90 -61
  42. package/src/stories/Tree.tsx +1 -1
  43. package/src/testing/index.ts +5 -0
  44. package/src/testing/setup-graph-builder.ts +41 -0
  45. package/src/util.ts +95 -0
@@ -9,7 +9,7 @@ import * as Function from 'effect/Function';
9
9
  import * as Option from 'effect/Option';
10
10
  import { describe, expect, onTestFinished, test } from 'vitest';
11
11
 
12
- import { Trigger, sleep } from '@dxos/async';
12
+ import { Trigger } from '@dxos/async';
13
13
  import { Obj } from '@dxos/echo';
14
14
  import { TestSchema } from '@dxos/echo/testing';
15
15
 
@@ -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', () => {
@@ -39,10 +40,7 @@ describe('GraphBuilder', () => {
39
40
  builder,
40
41
  GraphBuilder.createExtensionRaw({
41
42
  id: 'resolver',
42
- resolver: () => {
43
- console.log('resolver');
44
- return Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
45
- },
43
+ resolver: () => Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }),
46
44
  }),
47
45
  );
48
46
  await Graph.initialize(graph, EXAMPLE_ID);
@@ -81,10 +79,191 @@ describe('GraphBuilder', () => {
81
79
  expect(node?.data).to.equal('updated');
82
80
  }
83
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
+ });
84
263
  });
85
264
 
86
265
  describe('connector', () => {
87
- test('works', () => {
266
+ test('works', async () => {
88
267
  const registry = Registry.make();
89
268
  const builder = GraphBuilder.make({ registry });
90
269
  GraphBuilder.addExtension(
@@ -98,27 +277,28 @@ describe('GraphBuilder', () => {
98
277
  builder,
99
278
  GraphBuilder.createExtensionRaw({
100
279
  id: 'inbound-connector',
101
- relation: 'inbound',
280
+ relation: Node.childRelation('inbound'),
102
281
  connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
103
282
  }),
104
283
  );
105
284
 
106
285
  const graph = builder.graph;
107
- Graph.expand(graph, Node.RootId);
108
- Graph.expand(graph, Node.RootId, 'inbound');
286
+ Graph.expand(graph, Node.RootId, 'child');
287
+ Graph.expand(graph, Node.RootId, Node.childRelation('inbound'));
288
+ await GraphBuilder.flush(builder);
109
289
 
110
- const outbound = registry.get(graph.connections(Node.RootId));
111
- const inbound = registry.get(graph.connections(Node.RootId, 'inbound'));
290
+ const outbound = registry.get(graph.connections(Node.RootId, 'child'));
291
+ const inbound = registry.get(graph.connections(Node.RootId, Node.childRelation('inbound')));
112
292
 
113
293
  expect(outbound).has.length(1);
114
- expect(outbound[0].id).to.equal('child');
294
+ expect(outbound[0].id).to.equal('root/child');
115
295
  expect(outbound[0].data).to.equal(2);
116
296
  expect(inbound).has.length(1);
117
- expect(inbound[0].id).to.equal('parent');
297
+ expect(inbound[0].id).to.equal('root/parent');
118
298
  expect(inbound[0].data).to.equal(0);
119
299
  });
120
300
 
121
- test('updates', () => {
301
+ test('updates', async () => {
122
302
  const registry = Registry.make();
123
303
  const builder = GraphBuilder.make({ registry });
124
304
  const state = Atom.make(0);
@@ -130,21 +310,23 @@ describe('GraphBuilder', () => {
130
310
  }),
131
311
  );
132
312
  const graph = builder.graph;
133
- Graph.expand(graph, Node.RootId);
313
+ Graph.expand(graph, Node.RootId, 'child');
314
+ await GraphBuilder.flush(builder);
134
315
 
135
316
  {
136
- const [node] = registry.get(graph.connections(Node.RootId));
317
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
137
318
  expect(node.data).to.equal(0);
138
319
  }
139
320
 
140
321
  {
141
322
  registry.set(state, 1);
142
- const [node] = registry.get(graph.connections(Node.RootId));
323
+ await GraphBuilder.flush(builder);
324
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
143
325
  expect(node.data).to.equal(1);
144
326
  }
145
327
  });
146
328
 
147
- test('subscribes to updates', () => {
329
+ test('subscribes to updates', async () => {
148
330
  const registry = Registry.make();
149
331
  const builder = GraphBuilder.make({ registry });
150
332
  const state = Atom.make(0);
@@ -158,22 +340,25 @@ describe('GraphBuilder', () => {
158
340
  const graph = builder.graph;
159
341
 
160
342
  let count = 0;
161
- const cancel = registry.subscribe(graph.connections(Node.RootId), (_) => {
343
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_) => {
162
344
  count++;
163
345
  });
164
346
  onTestFinished(() => cancel());
165
347
 
166
348
  expect(count).to.equal(0);
167
- expect(registry.get(graph.connections(Node.RootId))).to.have.length(0);
349
+ expect(registry.get(graph.connections(Node.RootId, 'child'))).to.have.length(0);
168
350
  expect(count).to.equal(1);
169
351
 
170
- Graph.expand(graph, Node.RootId);
352
+ Graph.expand(graph, Node.RootId, 'child');
353
+ await GraphBuilder.flush(builder);
171
354
  expect(count).to.equal(2);
355
+
172
356
  registry.set(state, 1);
357
+ await GraphBuilder.flush(builder);
173
358
  expect(count).to.equal(3);
174
359
  });
175
360
 
176
- test('updates with new extensions', () => {
361
+ test('updates with new extensions', async () => {
177
362
  const registry = Registry.make();
178
363
  const builder = GraphBuilder.make({ registry });
179
364
  GraphBuilder.addExtension(
@@ -184,11 +369,12 @@ describe('GraphBuilder', () => {
184
369
  }),
185
370
  );
186
371
  const graph = builder.graph;
187
- Graph.expand(graph, Node.RootId);
372
+ Graph.expand(graph, Node.RootId, 'child');
373
+ await GraphBuilder.flush(builder);
188
374
 
189
375
  let nodes: Node.Node[] = [];
190
376
  let count = 0;
191
- const cancel = registry.subscribe(graph.connections(Node.RootId), (_nodes) => {
377
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_nodes) => {
192
378
  count++;
193
379
  nodes = _nodes;
194
380
  });
@@ -196,7 +382,7 @@ describe('GraphBuilder', () => {
196
382
 
197
383
  expect(nodes).has.length(0);
198
384
  expect(count).to.equal(0);
199
- registry.get(graph.connections(Node.RootId));
385
+ registry.get(graph.connections(Node.RootId, 'child'));
200
386
  expect(nodes).has.length(1);
201
387
  expect(count).to.equal(1);
202
388
 
@@ -207,11 +393,12 @@ describe('GraphBuilder', () => {
207
393
  connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
208
394
  }),
209
395
  );
396
+ await GraphBuilder.flush(builder);
210
397
  expect(nodes).has.length(2);
211
398
  expect(count).to.equal(2);
212
399
  });
213
400
 
214
- test('removes', () => {
401
+ test('removes', async () => {
215
402
  const registry = Registry.make();
216
403
  const builder = GraphBuilder.make({ registry });
217
404
  const nodes = Atom.make([
@@ -226,25 +413,27 @@ describe('GraphBuilder', () => {
226
413
  }),
227
414
  );
228
415
  const graph = builder.graph;
229
- Graph.expand(graph, Node.RootId);
416
+ Graph.expand(graph, Node.RootId, 'child');
417
+ await GraphBuilder.flush(builder);
230
418
 
231
419
  {
232
- const nodes = registry.get(graph.connections(Node.RootId));
420
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
233
421
  expect(nodes).has.length(2);
234
- expect(nodes[0].id).to.equal(exampleId(1));
235
- 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)));
236
424
  }
237
425
 
238
426
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
427
+ await GraphBuilder.flush(builder);
239
428
 
240
429
  {
241
- const nodes = registry.get(graph.connections(Node.RootId));
430
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
242
431
  expect(nodes).has.length(1);
243
- expect(nodes[0].id).to.equal(exampleId(3));
432
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
244
433
  }
245
434
  });
246
435
 
247
- test('nodes are updated when removed', () => {
436
+ test('nodes are updated when removed', async () => {
248
437
  const registry = Registry.make();
249
438
  const builder = GraphBuilder.make({ registry });
250
439
  const name = Atom.make('removed');
@@ -269,25 +458,29 @@ describe('GraphBuilder', () => {
269
458
 
270
459
  let count = 0;
271
460
  let exists = false;
272
- const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
461
+ const cancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (node) => {
273
462
  count++;
274
463
  exists = Option.isSome(node);
275
464
  });
276
465
  onTestFinished(() => cancel());
277
466
 
278
- Graph.expand(graph, Node.RootId);
467
+ Graph.expand(graph, Node.RootId, 'child');
468
+ await GraphBuilder.flush(builder);
279
469
  expect(count).to.equal(0);
280
470
  expect(exists).to.be.false;
281
471
 
282
472
  registry.set(name, 'default');
473
+ await GraphBuilder.flush(builder);
283
474
  expect(count).to.equal(1);
284
475
  expect(exists).to.be.true;
285
476
 
286
477
  registry.set(name, 'removed');
478
+ await GraphBuilder.flush(builder);
287
479
  expect(count).to.equal(2);
288
480
  expect(exists).to.be.false;
289
481
 
290
482
  registry.set(name, 'added');
483
+ await GraphBuilder.flush(builder);
291
484
  expect(count).to.equal(3);
292
485
  expect(exists).to.be.true;
293
486
  });
@@ -308,14 +501,15 @@ describe('GraphBuilder', () => {
308
501
  }),
309
502
  );
310
503
  const graph = builder.graph;
311
- Graph.expand(graph, Node.RootId);
504
+ Graph.expand(graph, Node.RootId, 'child');
505
+ await GraphBuilder.flush(builder);
312
506
 
313
507
  {
314
- const nodes = registry.get(graph.connections(Node.RootId));
508
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
315
509
  expect(nodes).has.length(3);
316
- expect(nodes[0].id).to.equal(exampleId(1));
317
- expect(nodes[1].id).to.equal(exampleId(2));
318
- expect(nodes[2].id).to.equal(exampleId(3));
510
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
511
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
512
+ expect(nodes[2].id).to.equal(qualifyId('root', exampleId(3)));
319
513
  }
320
514
 
321
515
  registry.set(nodes, [
@@ -323,20 +517,18 @@ describe('GraphBuilder', () => {
323
517
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
324
518
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
325
519
  ]);
326
-
327
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
328
- await sleep(0);
520
+ await GraphBuilder.flush(builder);
329
521
 
330
522
  {
331
- const nodes = registry.get(graph.connections(Node.RootId));
523
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
332
524
  expect(nodes).has.length(3);
333
- expect(nodes[0].id).to.equal(exampleId(3));
334
- expect(nodes[1].id).to.equal(exampleId(1));
335
- expect(nodes[2].id).to.equal(exampleId(2));
525
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
526
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(1)));
527
+ expect(nodes[2].id).to.equal(qualifyId('root', exampleId(2)));
336
528
  }
337
529
  });
338
530
 
339
- test('updates are constrained', () => {
531
+ test('updates are constrained', async () => {
340
532
  const registry = Registry.make();
341
533
  const builder = GraphBuilder.make({ registry });
342
534
  const name = Atom.make('default');
@@ -362,7 +554,9 @@ describe('GraphBuilder', () => {
362
554
  Atom.make((get) =>
363
555
  Function.pipe(
364
556
  get(node),
365
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
557
+ Option.flatMap((node) =>
558
+ node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(get(sub)) : Option.none(),
559
+ ),
366
560
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
367
561
  Option.getOrElse(() => []),
368
562
  ),
@@ -374,7 +568,9 @@ describe('GraphBuilder', () => {
374
568
  Atom.make((get) =>
375
569
  Function.pipe(
376
570
  get(node),
377
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
571
+ Option.flatMap((node) =>
572
+ node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(node.data) : Option.none(),
573
+ ),
378
574
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
379
575
  Option.getOrElse(() => []),
380
576
  ),
@@ -385,43 +581,47 @@ describe('GraphBuilder', () => {
385
581
  const graph = builder.graph;
386
582
 
387
583
  let parentCount = 0;
388
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
584
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
389
585
  parentCount++;
390
586
  });
391
587
  onTestFinished(() => parentCancel());
392
588
 
393
589
  let independentCount = 0;
394
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
590
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
395
591
  independentCount++;
396
592
  });
397
593
  onTestFinished(() => independentCancel());
398
594
 
399
595
  let dependentCount = 0;
400
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
596
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
401
597
  dependentCount++;
402
598
  });
403
599
  onTestFinished(() => dependentCancel());
404
600
 
405
601
  // Counts should not increment until the node is expanded.
406
- Graph.expand(graph, Node.RootId);
602
+ Graph.expand(graph, Node.RootId, 'child');
603
+ await GraphBuilder.flush(builder);
407
604
  expect(parentCount).to.equal(1);
408
605
  expect(independentCount).to.equal(0);
409
606
  expect(dependentCount).to.equal(0);
410
607
 
411
608
  // Counts should increment when the node is expanded.
412
- Graph.expand(graph, EXAMPLE_ID);
609
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
610
+ await GraphBuilder.flush(builder);
413
611
  expect(parentCount).to.equal(1);
414
612
  expect(independentCount).to.equal(1);
415
613
  expect(dependentCount).to.equal(1);
416
614
 
417
615
  // Only dependent count should increment when the parent changes.
418
616
  registry.set(name, 'updated');
617
+ await GraphBuilder.flush(builder);
419
618
  expect(parentCount).to.equal(2);
420
619
  expect(independentCount).to.equal(1);
421
620
  expect(dependentCount).to.equal(2);
422
621
 
423
622
  // Only independent count should increment when its state changes.
424
623
  registry.set(sub, 'updated');
624
+ await GraphBuilder.flush(builder);
425
625
  expect(parentCount).to.equal(2);
426
626
  expect(independentCount).to.equal(2);
427
627
  expect(dependentCount).to.equal(2);
@@ -431,18 +631,21 @@ describe('GraphBuilder', () => {
431
631
  registry.set(name, 'removed');
432
632
  registry.set(sub, 'batch');
433
633
  });
634
+ await GraphBuilder.flush(builder);
434
635
  expect(parentCount).to.equal(2);
435
636
  expect(independentCount).to.equal(3);
436
637
  expect(dependentCount).to.equal(2);
437
638
 
438
639
  // Dependent count should increment when the node is added back.
439
640
  registry.set(name, 'added');
641
+ await GraphBuilder.flush(builder);
440
642
  expect(parentCount).to.equal(3);
441
643
  expect(independentCount).to.equal(3);
442
644
  expect(dependentCount).to.equal(3);
443
645
 
444
646
  // Counts should not increment when the node is expanded again.
445
- Graph.expand(graph, EXAMPLE_ID);
647
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
648
+ await GraphBuilder.flush(builder);
446
649
  expect(parentCount).to.equal(3);
447
650
  expect(independentCount).to.equal(3);
448
651
  expect(dependentCount).to.equal(3);
@@ -472,14 +675,14 @@ describe('GraphBuilder', () => {
472
675
  let count = 0;
473
676
  const trigger = new Trigger();
474
677
  builder.graph.onNodeChanged.on(({ id }) => {
475
- Graph.expand(builder.graph, id);
678
+ Graph.expand(builder.graph, id, 'child');
476
679
  count++;
477
680
  if (count === 5) {
478
681
  trigger.wake();
479
682
  }
480
683
  });
481
684
 
482
- Graph.expand(builder.graph, Node.RootId);
685
+ Graph.expand(builder.graph, Node.RootId, 'child');
483
686
  await trigger.wait();
484
687
  expect(count).to.equal(5);
485
688
  });
@@ -507,6 +710,7 @@ describe('GraphBuilder', () => {
507
710
 
508
711
  let count = 0;
509
712
  await GraphBuilder.explore(builder, {
713
+ relation: 'child',
510
714
  visitor: () => {
511
715
  count++;
512
716
  },
@@ -518,7 +722,7 @@ describe('GraphBuilder', () => {
518
722
 
519
723
  describe('helpers', () => {
520
724
  describe('createConnector', () => {
521
- test('creates connector with type inference', () => {
725
+ test('creates connector with type inference', async () => {
522
726
  const registry = Registry.make();
523
727
  const builder = GraphBuilder.make({ registry });
524
728
  const graph = builder.graph;
@@ -536,16 +740,17 @@ describe('GraphBuilder', () => {
536
740
  }),
537
741
  );
538
742
 
539
- Graph.expand(graph, Node.RootId);
743
+ Graph.expand(graph, Node.RootId, 'child');
744
+ await GraphBuilder.flush(builder);
540
745
 
541
- const connections = registry.get(graph.connections(Node.RootId));
746
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
542
747
  expect(connections).has.length(1);
543
- expect(connections[0].id).to.equal('child');
748
+ expect(connections[0].id).to.equal('root/child');
544
749
  });
545
750
  });
546
751
 
547
752
  describe('createExtension', () => {
548
- test('works with Effect connector', () => {
753
+ test('works with Effect connector', async () => {
549
754
  const registry = Registry.make();
550
755
  const builder = GraphBuilder.make({ registry });
551
756
  const graph = builder.graph;
@@ -562,15 +767,16 @@ describe('GraphBuilder', () => {
562
767
 
563
768
  const writableGraph = graph as Graph.WritableGraph;
564
769
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
565
- Graph.expand(graph, 'parent');
770
+ Graph.expand(graph, 'parent', 'child');
771
+ await GraphBuilder.flush(builder);
566
772
 
567
- const connections = registry.get(graph.connections('parent'));
773
+ const connections = registry.get(graph.connections('parent', 'child'));
568
774
  expect(connections).has.length(1);
569
- expect(connections[0].id).to.equal('child');
775
+ expect(connections[0].id).to.equal('parent/child');
570
776
  expect(connections[0].data).to.equal('test');
571
777
  });
572
778
 
573
- test('works with Effect actions', () => {
779
+ test('works with Effect actions', async () => {
574
780
  const registry = Registry.make();
575
781
  const builder = GraphBuilder.make({ registry });
576
782
  const graph = builder.graph;
@@ -583,10 +789,7 @@ describe('GraphBuilder', () => {
583
789
  Effect.succeed([
584
790
  {
585
791
  id: 'test-action',
586
- data: () =>
587
- Effect.sync(() => {
588
- console.log('TestAction');
589
- }),
792
+ data: () => Effect.void,
590
793
  properties: { label: 'Test' },
591
794
  },
592
795
  ]),
@@ -597,14 +800,83 @@ describe('GraphBuilder', () => {
597
800
 
598
801
  const writableGraph = graph as Graph.WritableGraph;
599
802
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
600
- Graph.expand(graph, 'parent');
803
+ Graph.expand(graph, 'parent', 'child');
804
+ await GraphBuilder.flush(builder);
601
805
 
806
+ const edges = registry.get(graph.edges('parent'));
807
+ expect(edges[Graph.relationKey('action')] ?? []).to.have.length(1);
808
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/test-action');
809
+ expect(edges[Graph.relationKey('child')] ?? []).to.have.length(0);
602
810
  const actions = registry.get(graph.actions('parent'));
603
811
  expect(actions).has.length(1);
604
- expect(actions[0].id).to.equal('test-action');
812
+ expect(actions[0].id).to.equal('parent/test-action');
605
813
  });
606
814
 
607
- test('_actionContext captures and provides services to action execution', () => {
815
+ test('actions expand automatically with child relation', async ({ expect }) => {
816
+ const registry = Registry.make();
817
+ const builder = GraphBuilder.make({ registry });
818
+ const graph = builder.graph;
819
+
820
+ const extensions = Effect.runSync(
821
+ GraphBuilder.createExtension({
822
+ id: 'test-extension',
823
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
824
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: 'c' }]),
825
+ actions: (node, get) =>
826
+ Effect.succeed([{ id: 'act1', data: () => Effect.void, properties: { label: 'A' } }]),
827
+ }),
828
+ );
829
+
830
+ GraphBuilder.addExtension(builder, extensions);
831
+
832
+ const writableGraph = graph as Graph.WritableGraph;
833
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
834
+ Graph.expand(graph, 'parent', 'child');
835
+ await GraphBuilder.flush(builder);
836
+
837
+ const edges = registry.get(graph.edges('parent'));
838
+ expect(edges[Graph.relationKey('child')] ?? []).to.include('parent/child');
839
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/act1');
840
+ const actions = registry.get(graph.actions('parent'));
841
+ expect(actions).has.length(1);
842
+ expect(actions[0].id).to.equal('parent/act1');
843
+ const connections = registry.get(graph.connections('parent', 'child'));
844
+ expect(connections).has.length(1);
845
+ expect(connections[0].id).to.equal('parent/child');
846
+ });
847
+
848
+ test('actions appear when extension registered after expand', async ({ expect }) => {
849
+ const registry = Registry.make();
850
+ const builder = GraphBuilder.make({ registry });
851
+ const graph = builder.graph;
852
+ const writableGraph = graph as Graph.WritableGraph;
853
+
854
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
855
+ Graph.expand(graph, 'parent', 'child');
856
+ await GraphBuilder.flush(builder);
857
+
858
+ expect(registry.get(graph.actions('parent'))).to.have.length(0);
859
+
860
+ const extensions = Effect.runSync(
861
+ GraphBuilder.createExtension({
862
+ id: 'late-extension',
863
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
864
+ actions: (node, get) =>
865
+ Effect.succeed([{ id: 'late-act', data: () => Effect.void, properties: { label: 'Late' } }]),
866
+ }),
867
+ );
868
+
869
+ GraphBuilder.addExtension(builder, extensions);
870
+ await GraphBuilder.flush(builder);
871
+
872
+ const edges = registry.get(graph.edges('parent'));
873
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
874
+ const actions = registry.get(graph.actions('parent'));
875
+ expect(actions).has.length(1);
876
+ expect(actions[0].id).to.equal('parent/late-act');
877
+ });
878
+
879
+ test('_actionContext captures and provides services to action execution', async () => {
608
880
  const registry = Registry.make();
609
881
  const builder = GraphBuilder.make({ registry });
610
882
  const graph = builder.graph;
@@ -648,7 +920,8 @@ describe('GraphBuilder', () => {
648
920
 
649
921
  const writableGraph = graph as Graph.WritableGraph;
650
922
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
651
- Graph.expand(graph, 'parent');
923
+ Graph.expand(graph, 'parent', 'child');
924
+ await GraphBuilder.flush(builder);
652
925
 
653
926
  const actions = registry.get(graph.actions('parent'));
654
927
  expect(actions).has.length(1);
@@ -691,7 +964,7 @@ describe('GraphBuilder', () => {
691
964
  expect(node?.data).to.equal('resolved');
692
965
  });
693
966
 
694
- test('works with connector and actions together', () => {
967
+ test('works with connector and actions together', async () => {
695
968
  const registry = Registry.make();
696
969
  const builder = GraphBuilder.make({ registry });
697
970
  const graph = builder.graph;
@@ -705,10 +978,7 @@ describe('GraphBuilder', () => {
705
978
  Effect.succeed([
706
979
  {
707
980
  id: 'test-action',
708
- data: () =>
709
- Effect.sync(() => {
710
- console.log('TestAction');
711
- }),
981
+ data: () => Effect.void,
712
982
  properties: { label: 'Test' },
713
983
  },
714
984
  ]),
@@ -719,21 +989,22 @@ describe('GraphBuilder', () => {
719
989
 
720
990
  const writableGraph = graph as Graph.WritableGraph;
721
991
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
722
- Graph.expand(graph, 'parent');
992
+ Graph.expand(graph, 'parent', 'child');
993
+ await GraphBuilder.flush(builder);
723
994
 
724
- const connections = registry.get(graph.connections('parent'));
995
+ const connections = registry.get(graph.connections('parent', 'child'));
725
996
  // Should have both the child node and the action node.
726
997
  expect(connections.length).to.be.greaterThanOrEqual(1);
727
- const childNode = connections.find((n) => n.id === 'child');
998
+ const childNode = connections.find((n) => n.id === 'parent/child');
728
999
  expect(childNode).to.not.be.undefined;
729
1000
  expect(childNode?.data).to.equal('test');
730
1001
 
731
1002
  const actions = registry.get(graph.actions('parent'));
732
1003
  expect(actions).has.length(1);
733
- expect(actions[0].id).to.equal('test-action');
1004
+ expect(actions[0].id).to.equal('parent/test-action');
734
1005
  });
735
1006
 
736
- test('works with reactive connector using get context', () => {
1007
+ test('works with reactive connector using get context', async () => {
737
1008
  const registry = Registry.make();
738
1009
  const builder = GraphBuilder.make({ registry });
739
1010
  const graph = builder.graph;
@@ -752,18 +1023,20 @@ describe('GraphBuilder', () => {
752
1023
 
753
1024
  const writableGraph = graph as Graph.WritableGraph;
754
1025
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
755
- Graph.expand(graph, 'parent');
1026
+ Graph.expand(graph, 'parent', 'child');
1027
+ await GraphBuilder.flush(builder);
756
1028
 
757
1029
  {
758
- const connections = registry.get(graph.connections('parent'));
1030
+ const connections = registry.get(graph.connections('parent', 'child'));
759
1031
  expect(connections).has.length(1);
760
1032
  expect(connections[0].data).to.equal('initial');
761
1033
  }
762
1034
 
763
1035
  registry.set(state, 'updated');
1036
+ await GraphBuilder.flush(builder);
764
1037
 
765
1038
  {
766
- const connections = registry.get(graph.connections('parent'));
1039
+ const connections = registry.get(graph.connections('parent', 'child'));
767
1040
  expect(connections).has.length(1);
768
1041
  expect(connections[0].data).to.equal('updated');
769
1042
  }
@@ -771,7 +1044,7 @@ describe('GraphBuilder', () => {
771
1044
  });
772
1045
 
773
1046
  describe('extension error handling', () => {
774
- test('connector failure is caught and logged, returns empty array', () => {
1047
+ test('connector failure is caught and logged, returns empty array', async () => {
775
1048
  const registry = Registry.make();
776
1049
  const builder = GraphBuilder.make({ registry });
777
1050
  const graph = builder.graph;
@@ -790,14 +1063,15 @@ describe('GraphBuilder', () => {
790
1063
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
791
1064
 
792
1065
  // Should not throw, error is caught internally.
793
- Graph.expand(graph, 'parent');
1066
+ Graph.expand(graph, 'parent', 'child');
1067
+ await GraphBuilder.flush(builder);
794
1068
 
795
1069
  // Should return empty connections since the connector failed.
796
- const connections = registry.get(graph.connections('parent'));
1070
+ const connections = registry.get(graph.connections('parent', 'child'));
797
1071
  expect(connections).has.length(0);
798
1072
  });
799
1073
 
800
- test('actions failure is caught and logged, returns empty array', () => {
1074
+ test('actions failure is caught and logged, returns empty array', async () => {
801
1075
  const registry = Registry.make();
802
1076
  const builder = GraphBuilder.make({ registry });
803
1077
  const graph = builder.graph;
@@ -816,7 +1090,8 @@ describe('GraphBuilder', () => {
816
1090
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
817
1091
 
818
1092
  // Should not throw, error is caught internally.
819
- Graph.expand(graph, 'parent');
1093
+ Graph.expand(graph, 'parent', 'child');
1094
+ await GraphBuilder.flush(builder);
820
1095
 
821
1096
  // Should return empty actions since the actions callback failed.
822
1097
  const actions = registry.get(graph.actions('parent'));
@@ -846,7 +1121,7 @@ describe('GraphBuilder', () => {
846
1121
  expect(node).to.be.null;
847
1122
  });
848
1123
 
849
- test('failing extension does not affect other extensions', () => {
1124
+ test('failing extension does not affect other extensions', async () => {
850
1125
  const registry = Registry.make();
851
1126
  const builder = GraphBuilder.make({ registry });
852
1127
  const graph = builder.graph;
@@ -875,18 +1150,19 @@ describe('GraphBuilder', () => {
875
1150
 
876
1151
  const writableGraph = graph as Graph.WritableGraph;
877
1152
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
878
- Graph.expand(graph, 'parent');
1153
+ Graph.expand(graph, 'parent', 'child');
1154
+ await GraphBuilder.flush(builder);
879
1155
 
880
1156
  // The working extension should still produce its node.
881
- const connections = registry.get(graph.connections('parent'));
1157
+ const connections = registry.get(graph.connections('parent', 'child'));
882
1158
  expect(connections).has.length(1);
883
- expect(connections[0].id).to.equal('child-from-working');
1159
+ expect(connections[0].id).to.equal('parent/child-from-working');
884
1160
  expect(connections[0].data).to.equal('success');
885
1161
  });
886
1162
  });
887
1163
 
888
1164
  describe('createTypeExtension', () => {
889
- test('creates extension matching by schema type with inferred object type', () => {
1165
+ test('creates extension matching by schema type with inferred object type', async () => {
890
1166
  const registry = Registry.make();
891
1167
  const builder = GraphBuilder.make({ registry });
892
1168
  const graph = builder.graph;
@@ -904,13 +1180,204 @@ describe('GraphBuilder', () => {
904
1180
  const writableGraph = graph as Graph.WritableGraph;
905
1181
  const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
906
1182
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
907
- Graph.expand(graph, 'parent');
1183
+ Graph.expand(graph, 'parent', 'child');
1184
+ await GraphBuilder.flush(builder);
908
1185
 
909
- const connections = registry.get(graph.connections('parent'));
1186
+ const connections = registry.get(graph.connections('parent', 'child'));
910
1187
  expect(connections).has.length(1);
911
- expect(connections[0].id).to.equal('child');
1188
+ expect(connections[0].id).to.equal('parent/child');
912
1189
  expect(connections[0].data).to.equal(testObject);
913
1190
  });
914
1191
  });
915
1192
  });
1193
+ describe('path-based ID qualification', () => {
1194
+ test('rejects segment IDs containing slash', async () => {
1195
+ const registry = Registry.make();
1196
+ const builder = GraphBuilder.make({ registry });
1197
+ GraphBuilder.addExtension(
1198
+ builder,
1199
+ GraphBuilder.createExtensionRaw({
1200
+ id: 'bad-connector',
1201
+ connector: () => Atom.make([{ id: 'foo/bar', type: EXAMPLE_TYPE, data: null }]),
1202
+ }),
1203
+ );
1204
+
1205
+ expect(() => Graph.expand(builder.graph, Node.RootId, 'child')).toThrow(/must not contain/);
1206
+ });
1207
+
1208
+ test('multi-level path qualification', async () => {
1209
+ const registry = Registry.make();
1210
+ const builder = GraphBuilder.make({ registry });
1211
+ GraphBuilder.addExtension(builder, [
1212
+ GraphBuilder.createExtensionRaw({
1213
+ id: 'level1',
1214
+ connector: (node) =>
1215
+ Atom.make((get) =>
1216
+ Function.pipe(
1217
+ get(node),
1218
+ Option.filter((n) => n.id === 'root'),
1219
+ Option.map(() => [{ id: 'A', type: EXAMPLE_TYPE, data: 'a' }]),
1220
+ Option.getOrElse(() => []),
1221
+ ),
1222
+ ),
1223
+ }),
1224
+ GraphBuilder.createExtensionRaw({
1225
+ id: 'level2',
1226
+ connector: (node) =>
1227
+ Atom.make((get) =>
1228
+ Function.pipe(
1229
+ get(node),
1230
+ Option.filter((n) => n.id === 'root/A'),
1231
+ Option.map(() => [{ id: 'B', type: EXAMPLE_TYPE, data: 'b' }]),
1232
+ Option.getOrElse(() => []),
1233
+ ),
1234
+ ),
1235
+ }),
1236
+ ]);
1237
+
1238
+ const graph = builder.graph;
1239
+
1240
+ Graph.expand(graph, Node.RootId, 'child');
1241
+ await GraphBuilder.flush(builder);
1242
+
1243
+ const level1 = registry.get(graph.connections(Node.RootId, 'child'));
1244
+ expect(level1).has.length(1);
1245
+ expect(level1[0].id).to.equal('root/A');
1246
+
1247
+ Graph.expand(graph, 'root/A', 'child');
1248
+ await GraphBuilder.flush(builder);
1249
+
1250
+ const level2 = registry.get(graph.connections('root/A', 'child'));
1251
+ expect(level2).has.length(1);
1252
+ expect(level2[0].id).to.equal('root/A/B');
1253
+ });
1254
+
1255
+ test('inline nodes are recursively qualified', async () => {
1256
+ const registry = Registry.make();
1257
+ const builder = GraphBuilder.make({ registry });
1258
+ GraphBuilder.addExtension(
1259
+ builder,
1260
+ GraphBuilder.createExtensionRaw({
1261
+ id: 'inline-connector',
1262
+ connector: () =>
1263
+ Atom.make([
1264
+ {
1265
+ id: 'parent-node',
1266
+ type: EXAMPLE_TYPE,
1267
+ data: null,
1268
+ nodes: [
1269
+ {
1270
+ id: 'inline-child',
1271
+ type: EXAMPLE_TYPE,
1272
+ data: null,
1273
+ nodes: [{ id: 'deep-child', type: EXAMPLE_TYPE, data: null }],
1274
+ },
1275
+ ],
1276
+ },
1277
+ ]),
1278
+ }),
1279
+ );
1280
+
1281
+ const graph = builder.graph;
1282
+ Graph.expand(graph, Node.RootId, 'child');
1283
+ await GraphBuilder.flush(builder);
1284
+
1285
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
1286
+ expect(connections).has.length(1);
1287
+ expect(connections[0].id).to.equal('root/parent-node');
1288
+
1289
+ const inlineNode = Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
1290
+ expect(inlineNode).to.not.be.null;
1291
+ expect(inlineNode?.id).to.equal('root/parent-node/inline-child');
1292
+
1293
+ const deepNode = Graph.getNode(graph, 'root/parent-node/inline-child/deep-child').pipe(Option.getOrNull);
1294
+ expect(deepNode).to.not.be.null;
1295
+ expect(deepNode?.id).to.equal('root/parent-node/inline-child/deep-child');
1296
+ });
1297
+
1298
+ test('constant connector produces distinct nodes under different parents', async () => {
1299
+ const registry = Registry.make();
1300
+ const builder = GraphBuilder.make({ registry });
1301
+
1302
+ GraphBuilder.addExtension(builder, [
1303
+ GraphBuilder.createExtensionRaw({
1304
+ id: 'parents',
1305
+ connector: (node) =>
1306
+ Atom.make((get) =>
1307
+ Function.pipe(
1308
+ get(node),
1309
+ Option.filter((n) => n.id === 'root'),
1310
+ Option.map(() => [
1311
+ { id: 'A', type: EXAMPLE_TYPE, data: 'a' },
1312
+ { id: 'B', type: EXAMPLE_TYPE, data: 'b' },
1313
+ ]),
1314
+ Option.getOrElse(() => []),
1315
+ ),
1316
+ ),
1317
+ }),
1318
+ GraphBuilder.createExtensionRaw({
1319
+ id: 'constant-child',
1320
+ connector: () => Atom.make([{ id: 'shared', type: EXAMPLE_TYPE, data: 'constant' }]),
1321
+ }),
1322
+ ]);
1323
+
1324
+ const graph = builder.graph;
1325
+
1326
+ Graph.expand(graph, Node.RootId, 'child');
1327
+ await GraphBuilder.flush(builder);
1328
+
1329
+ Graph.expand(graph, 'root/A', 'child');
1330
+ Graph.expand(graph, 'root/B', 'child');
1331
+ await GraphBuilder.flush(builder);
1332
+
1333
+ const childrenOfA = registry.get(graph.connections('root/A', 'child'));
1334
+ const childrenOfB = registry.get(graph.connections('root/B', 'child'));
1335
+
1336
+ expect(childrenOfA).has.length(1);
1337
+ expect(childrenOfB).has.length(1);
1338
+ expect(childrenOfA[0].id).to.equal('root/A/shared');
1339
+ expect(childrenOfB[0].id).to.equal('root/B/shared');
1340
+
1341
+ const nodeA = Graph.getNode(graph, 'root/A/shared').pipe(Option.getOrNull);
1342
+ const nodeB = Graph.getNode(graph, 'root/B/shared').pipe(Option.getOrNull);
1343
+ expect(nodeA).to.not.be.null;
1344
+ expect(nodeB).to.not.be.null;
1345
+ expect(nodeA?.id).to.not.equal(nodeB?.id);
1346
+ });
1347
+
1348
+ test('explore qualifies node IDs', async () => {
1349
+ const builder = GraphBuilder.make();
1350
+ GraphBuilder.addExtension(
1351
+ builder,
1352
+ GraphBuilder.createExtensionRaw({
1353
+ id: 'connector',
1354
+ connector: (node) =>
1355
+ Atom.make((get) =>
1356
+ Function.pipe(
1357
+ get(node),
1358
+ Option.filter((n) => n.id === 'root'),
1359
+ Option.map(() => [
1360
+ { id: 'first', type: EXAMPLE_TYPE, data: 1 },
1361
+ { id: 'second', type: EXAMPLE_TYPE, data: 2 },
1362
+ ]),
1363
+ Option.getOrElse(() => []),
1364
+ ),
1365
+ ),
1366
+ }),
1367
+ );
1368
+
1369
+ const visited: Array<{ id: string; path: string[] }> = [];
1370
+ await GraphBuilder.explore(builder, {
1371
+ relation: 'child',
1372
+ visitor: (node, path) => {
1373
+ visited.push({ id: node.id, path });
1374
+ },
1375
+ });
1376
+
1377
+ expect(visited).has.length(3);
1378
+ expect(visited[0].id).to.equal('root');
1379
+ expect(visited[1].id).to.equal('root/first');
1380
+ expect(visited[2].id).to.equal('root/second');
1381
+ });
1382
+ });
916
1383
  });