@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.
@@ -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
 
@@ -20,7 +20,9 @@ import * as NodeMatcher from './node-matcher';
20
20
 
21
21
  const exampleId = (id: number) => `dx:test:${id}`;
22
22
  const EXAMPLE_ID = exampleId(1);
23
- const EXAMPLE_TYPE = 'dxos.org/type/example';
23
+ const EXAMPLE_TYPE = 'org.dxos.type.example';
24
+
25
+ const qualifiedId = (...segments: string[]) => segments.join('/');
24
26
 
25
27
  describe('GraphBuilder', () => {
26
28
  describe('resolver', () => {
@@ -39,10 +41,7 @@ describe('GraphBuilder', () => {
39
41
  builder,
40
42
  GraphBuilder.createExtensionRaw({
41
43
  id: 'resolver',
42
- resolver: () => {
43
- console.log('resolver');
44
- return Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
45
- },
44
+ resolver: () => Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }),
46
45
  }),
47
46
  );
48
47
  await Graph.initialize(graph, EXAMPLE_ID);
@@ -81,10 +80,65 @@ describe('GraphBuilder', () => {
81
80
  expect(node?.data).to.equal('updated');
82
81
  }
83
82
  });
83
+
84
+ test('does not overwrite connector-produced node', async () => {
85
+ const registry = Registry.make();
86
+ const builder = GraphBuilder.make({ registry });
87
+ const resolverData = Atom.make('from-resolver');
88
+
89
+ GraphBuilder.addExtension(builder, [
90
+ GraphBuilder.createExtensionRaw({
91
+ id: 'resolver',
92
+ resolver: (id) =>
93
+ id === qualifiedId('root', 'shared')
94
+ ? Atom.make((get) => ({ id: qualifiedId('root', 'shared'), type: EXAMPLE_TYPE, data: get(resolverData) }))
95
+ : Atom.make(null),
96
+ }),
97
+ GraphBuilder.createExtensionRaw({
98
+ id: 'connector',
99
+ connector: (node) =>
100
+ Atom.make((get) =>
101
+ Function.pipe(
102
+ get(node),
103
+ Option.filter((n) => n.id === 'root'),
104
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
105
+ Option.getOrElse(() => []),
106
+ ),
107
+ ),
108
+ }),
109
+ ]);
110
+
111
+ const graph = builder.graph;
112
+
113
+ // Connector produces root/shared.
114
+ Graph.expand(graph, Node.RootId, 'child');
115
+ await GraphBuilder.flush(builder);
116
+
117
+ {
118
+ const node = Graph.getNode(graph, qualifiedId('root', 'shared')).pipe(Option.getOrNull);
119
+ expect(node?.data).to.equal('from-connector');
120
+ }
121
+
122
+ // Resolver fires for the same ID but should not overwrite.
123
+ await Graph.initialize(graph, qualifiedId('root', 'shared'));
124
+
125
+ {
126
+ const node = Graph.getNode(graph, qualifiedId('root', 'shared')).pipe(Option.getOrNull);
127
+ expect(node?.data).to.equal('from-connector');
128
+ }
129
+
130
+ // Updating the resolver's atom should still not overwrite.
131
+ registry.set(resolverData, 'updated-resolver');
132
+
133
+ {
134
+ const node = Graph.getNode(graph, qualifiedId('root', 'shared')).pipe(Option.getOrNull);
135
+ expect(node?.data).to.equal('from-connector');
136
+ }
137
+ });
84
138
  });
85
139
 
86
140
  describe('connector', () => {
87
- test('works', () => {
141
+ test('works', async () => {
88
142
  const registry = Registry.make();
89
143
  const builder = GraphBuilder.make({ registry });
90
144
  GraphBuilder.addExtension(
@@ -98,27 +152,28 @@ describe('GraphBuilder', () => {
98
152
  builder,
99
153
  GraphBuilder.createExtensionRaw({
100
154
  id: 'inbound-connector',
101
- relation: 'inbound',
155
+ relation: Node.childRelation('inbound'),
102
156
  connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
103
157
  }),
104
158
  );
105
159
 
106
160
  const graph = builder.graph;
107
- Graph.expand(graph, Node.RootId);
108
- Graph.expand(graph, Node.RootId, 'inbound');
161
+ Graph.expand(graph, Node.RootId, 'child');
162
+ Graph.expand(graph, Node.RootId, Node.childRelation('inbound'));
163
+ await GraphBuilder.flush(builder);
109
164
 
110
- const outbound = registry.get(graph.connections(Node.RootId));
111
- const inbound = registry.get(graph.connections(Node.RootId, 'inbound'));
165
+ const outbound = registry.get(graph.connections(Node.RootId, 'child'));
166
+ const inbound = registry.get(graph.connections(Node.RootId, Node.childRelation('inbound')));
112
167
 
113
168
  expect(outbound).has.length(1);
114
- expect(outbound[0].id).to.equal('child');
169
+ expect(outbound[0].id).to.equal('root/child');
115
170
  expect(outbound[0].data).to.equal(2);
116
171
  expect(inbound).has.length(1);
117
- expect(inbound[0].id).to.equal('parent');
172
+ expect(inbound[0].id).to.equal('root/parent');
118
173
  expect(inbound[0].data).to.equal(0);
119
174
  });
120
175
 
121
- test('updates', () => {
176
+ test('updates', async () => {
122
177
  const registry = Registry.make();
123
178
  const builder = GraphBuilder.make({ registry });
124
179
  const state = Atom.make(0);
@@ -130,21 +185,23 @@ describe('GraphBuilder', () => {
130
185
  }),
131
186
  );
132
187
  const graph = builder.graph;
133
- Graph.expand(graph, Node.RootId);
188
+ Graph.expand(graph, Node.RootId, 'child');
189
+ await GraphBuilder.flush(builder);
134
190
 
135
191
  {
136
- const [node] = registry.get(graph.connections(Node.RootId));
192
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
137
193
  expect(node.data).to.equal(0);
138
194
  }
139
195
 
140
196
  {
141
197
  registry.set(state, 1);
142
- const [node] = registry.get(graph.connections(Node.RootId));
198
+ await GraphBuilder.flush(builder);
199
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
143
200
  expect(node.data).to.equal(1);
144
201
  }
145
202
  });
146
203
 
147
- test('subscribes to updates', () => {
204
+ test('subscribes to updates', async () => {
148
205
  const registry = Registry.make();
149
206
  const builder = GraphBuilder.make({ registry });
150
207
  const state = Atom.make(0);
@@ -158,22 +215,25 @@ describe('GraphBuilder', () => {
158
215
  const graph = builder.graph;
159
216
 
160
217
  let count = 0;
161
- const cancel = registry.subscribe(graph.connections(Node.RootId), (_) => {
218
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_) => {
162
219
  count++;
163
220
  });
164
221
  onTestFinished(() => cancel());
165
222
 
166
223
  expect(count).to.equal(0);
167
- expect(registry.get(graph.connections(Node.RootId))).to.have.length(0);
224
+ expect(registry.get(graph.connections(Node.RootId, 'child'))).to.have.length(0);
168
225
  expect(count).to.equal(1);
169
226
 
170
- Graph.expand(graph, Node.RootId);
227
+ Graph.expand(graph, Node.RootId, 'child');
228
+ await GraphBuilder.flush(builder);
171
229
  expect(count).to.equal(2);
230
+
172
231
  registry.set(state, 1);
232
+ await GraphBuilder.flush(builder);
173
233
  expect(count).to.equal(3);
174
234
  });
175
235
 
176
- test('updates with new extensions', () => {
236
+ test('updates with new extensions', async () => {
177
237
  const registry = Registry.make();
178
238
  const builder = GraphBuilder.make({ registry });
179
239
  GraphBuilder.addExtension(
@@ -184,11 +244,12 @@ describe('GraphBuilder', () => {
184
244
  }),
185
245
  );
186
246
  const graph = builder.graph;
187
- Graph.expand(graph, Node.RootId);
247
+ Graph.expand(graph, Node.RootId, 'child');
248
+ await GraphBuilder.flush(builder);
188
249
 
189
250
  let nodes: Node.Node[] = [];
190
251
  let count = 0;
191
- const cancel = registry.subscribe(graph.connections(Node.RootId), (_nodes) => {
252
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_nodes) => {
192
253
  count++;
193
254
  nodes = _nodes;
194
255
  });
@@ -196,7 +257,7 @@ describe('GraphBuilder', () => {
196
257
 
197
258
  expect(nodes).has.length(0);
198
259
  expect(count).to.equal(0);
199
- registry.get(graph.connections(Node.RootId));
260
+ registry.get(graph.connections(Node.RootId, 'child'));
200
261
  expect(nodes).has.length(1);
201
262
  expect(count).to.equal(1);
202
263
 
@@ -207,11 +268,12 @@ describe('GraphBuilder', () => {
207
268
  connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
208
269
  }),
209
270
  );
271
+ await GraphBuilder.flush(builder);
210
272
  expect(nodes).has.length(2);
211
273
  expect(count).to.equal(2);
212
274
  });
213
275
 
214
- test('removes', () => {
276
+ test('removes', async () => {
215
277
  const registry = Registry.make();
216
278
  const builder = GraphBuilder.make({ registry });
217
279
  const nodes = Atom.make([
@@ -226,25 +288,27 @@ describe('GraphBuilder', () => {
226
288
  }),
227
289
  );
228
290
  const graph = builder.graph;
229
- Graph.expand(graph, Node.RootId);
291
+ Graph.expand(graph, Node.RootId, 'child');
292
+ await GraphBuilder.flush(builder);
230
293
 
231
294
  {
232
- const nodes = registry.get(graph.connections(Node.RootId));
295
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
233
296
  expect(nodes).has.length(2);
234
- expect(nodes[0].id).to.equal(exampleId(1));
235
- expect(nodes[1].id).to.equal(exampleId(2));
297
+ expect(nodes[0].id).to.equal(qualifiedId('root', exampleId(1)));
298
+ expect(nodes[1].id).to.equal(qualifiedId('root', exampleId(2)));
236
299
  }
237
300
 
238
301
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
302
+ await GraphBuilder.flush(builder);
239
303
 
240
304
  {
241
- const nodes = registry.get(graph.connections(Node.RootId));
305
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
242
306
  expect(nodes).has.length(1);
243
- expect(nodes[0].id).to.equal(exampleId(3));
307
+ expect(nodes[0].id).to.equal(qualifiedId('root', exampleId(3)));
244
308
  }
245
309
  });
246
310
 
247
- test('nodes are updated when removed', () => {
311
+ test('nodes are updated when removed', async () => {
248
312
  const registry = Registry.make();
249
313
  const builder = GraphBuilder.make({ registry });
250
314
  const name = Atom.make('removed');
@@ -269,25 +333,29 @@ describe('GraphBuilder', () => {
269
333
 
270
334
  let count = 0;
271
335
  let exists = false;
272
- const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
336
+ const cancel = registry.subscribe(graph.node(qualifiedId('root', EXAMPLE_ID)), (node) => {
273
337
  count++;
274
338
  exists = Option.isSome(node);
275
339
  });
276
340
  onTestFinished(() => cancel());
277
341
 
278
- Graph.expand(graph, Node.RootId);
342
+ Graph.expand(graph, Node.RootId, 'child');
343
+ await GraphBuilder.flush(builder);
279
344
  expect(count).to.equal(0);
280
345
  expect(exists).to.be.false;
281
346
 
282
347
  registry.set(name, 'default');
348
+ await GraphBuilder.flush(builder);
283
349
  expect(count).to.equal(1);
284
350
  expect(exists).to.be.true;
285
351
 
286
352
  registry.set(name, 'removed');
353
+ await GraphBuilder.flush(builder);
287
354
  expect(count).to.equal(2);
288
355
  expect(exists).to.be.false;
289
356
 
290
357
  registry.set(name, 'added');
358
+ await GraphBuilder.flush(builder);
291
359
  expect(count).to.equal(3);
292
360
  expect(exists).to.be.true;
293
361
  });
@@ -308,14 +376,15 @@ describe('GraphBuilder', () => {
308
376
  }),
309
377
  );
310
378
  const graph = builder.graph;
311
- Graph.expand(graph, Node.RootId);
379
+ Graph.expand(graph, Node.RootId, 'child');
380
+ await GraphBuilder.flush(builder);
312
381
 
313
382
  {
314
- const nodes = registry.get(graph.connections(Node.RootId));
383
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
315
384
  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));
385
+ expect(nodes[0].id).to.equal(qualifiedId('root', exampleId(1)));
386
+ expect(nodes[1].id).to.equal(qualifiedId('root', exampleId(2)));
387
+ expect(nodes[2].id).to.equal(qualifiedId('root', exampleId(3)));
319
388
  }
320
389
 
321
390
  registry.set(nodes, [
@@ -323,20 +392,18 @@ describe('GraphBuilder', () => {
323
392
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
324
393
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
325
394
  ]);
326
-
327
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
328
- await sleep(0);
395
+ await GraphBuilder.flush(builder);
329
396
 
330
397
  {
331
- const nodes = registry.get(graph.connections(Node.RootId));
398
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
332
399
  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));
400
+ expect(nodes[0].id).to.equal(qualifiedId('root', exampleId(3)));
401
+ expect(nodes[1].id).to.equal(qualifiedId('root', exampleId(1)));
402
+ expect(nodes[2].id).to.equal(qualifiedId('root', exampleId(2)));
336
403
  }
337
404
  });
338
405
 
339
- test('updates are constrained', () => {
406
+ test('updates are constrained', async () => {
340
407
  const registry = Registry.make();
341
408
  const builder = GraphBuilder.make({ registry });
342
409
  const name = Atom.make('default');
@@ -362,7 +429,9 @@ describe('GraphBuilder', () => {
362
429
  Atom.make((get) =>
363
430
  Function.pipe(
364
431
  get(node),
365
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
432
+ Option.flatMap((node) =>
433
+ node.id === qualifiedId('root', EXAMPLE_ID) ? Option.some(get(sub)) : Option.none(),
434
+ ),
366
435
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
367
436
  Option.getOrElse(() => []),
368
437
  ),
@@ -374,7 +443,9 @@ describe('GraphBuilder', () => {
374
443
  Atom.make((get) =>
375
444
  Function.pipe(
376
445
  get(node),
377
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
446
+ Option.flatMap((node) =>
447
+ node.id === qualifiedId('root', EXAMPLE_ID) ? Option.some(node.data) : Option.none(),
448
+ ),
378
449
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
379
450
  Option.getOrElse(() => []),
380
451
  ),
@@ -385,43 +456,47 @@ describe('GraphBuilder', () => {
385
456
  const graph = builder.graph;
386
457
 
387
458
  let parentCount = 0;
388
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
459
+ const parentCancel = registry.subscribe(graph.node(qualifiedId('root', EXAMPLE_ID)), (_) => {
389
460
  parentCount++;
390
461
  });
391
462
  onTestFinished(() => parentCancel());
392
463
 
393
464
  let independentCount = 0;
394
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
465
+ const independentCancel = registry.subscribe(graph.node(qualifiedId('root', EXAMPLE_ID, exampleId(2))), (_) => {
395
466
  independentCount++;
396
467
  });
397
468
  onTestFinished(() => independentCancel());
398
469
 
399
470
  let dependentCount = 0;
400
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
471
+ const dependentCancel = registry.subscribe(graph.node(qualifiedId('root', EXAMPLE_ID, exampleId(3))), (_) => {
401
472
  dependentCount++;
402
473
  });
403
474
  onTestFinished(() => dependentCancel());
404
475
 
405
476
  // Counts should not increment until the node is expanded.
406
- Graph.expand(graph, Node.RootId);
477
+ Graph.expand(graph, Node.RootId, 'child');
478
+ await GraphBuilder.flush(builder);
407
479
  expect(parentCount).to.equal(1);
408
480
  expect(independentCount).to.equal(0);
409
481
  expect(dependentCount).to.equal(0);
410
482
 
411
483
  // Counts should increment when the node is expanded.
412
- Graph.expand(graph, EXAMPLE_ID);
484
+ Graph.expand(graph, qualifiedId('root', EXAMPLE_ID), 'child');
485
+ await GraphBuilder.flush(builder);
413
486
  expect(parentCount).to.equal(1);
414
487
  expect(independentCount).to.equal(1);
415
488
  expect(dependentCount).to.equal(1);
416
489
 
417
490
  // Only dependent count should increment when the parent changes.
418
491
  registry.set(name, 'updated');
492
+ await GraphBuilder.flush(builder);
419
493
  expect(parentCount).to.equal(2);
420
494
  expect(independentCount).to.equal(1);
421
495
  expect(dependentCount).to.equal(2);
422
496
 
423
497
  // Only independent count should increment when its state changes.
424
498
  registry.set(sub, 'updated');
499
+ await GraphBuilder.flush(builder);
425
500
  expect(parentCount).to.equal(2);
426
501
  expect(independentCount).to.equal(2);
427
502
  expect(dependentCount).to.equal(2);
@@ -431,18 +506,21 @@ describe('GraphBuilder', () => {
431
506
  registry.set(name, 'removed');
432
507
  registry.set(sub, 'batch');
433
508
  });
509
+ await GraphBuilder.flush(builder);
434
510
  expect(parentCount).to.equal(2);
435
511
  expect(independentCount).to.equal(3);
436
512
  expect(dependentCount).to.equal(2);
437
513
 
438
514
  // Dependent count should increment when the node is added back.
439
515
  registry.set(name, 'added');
516
+ await GraphBuilder.flush(builder);
440
517
  expect(parentCount).to.equal(3);
441
518
  expect(independentCount).to.equal(3);
442
519
  expect(dependentCount).to.equal(3);
443
520
 
444
521
  // Counts should not increment when the node is expanded again.
445
- Graph.expand(graph, EXAMPLE_ID);
522
+ Graph.expand(graph, qualifiedId('root', EXAMPLE_ID), 'child');
523
+ await GraphBuilder.flush(builder);
446
524
  expect(parentCount).to.equal(3);
447
525
  expect(independentCount).to.equal(3);
448
526
  expect(dependentCount).to.equal(3);
@@ -472,14 +550,14 @@ describe('GraphBuilder', () => {
472
550
  let count = 0;
473
551
  const trigger = new Trigger();
474
552
  builder.graph.onNodeChanged.on(({ id }) => {
475
- Graph.expand(builder.graph, id);
553
+ Graph.expand(builder.graph, id, 'child');
476
554
  count++;
477
555
  if (count === 5) {
478
556
  trigger.wake();
479
557
  }
480
558
  });
481
559
 
482
- Graph.expand(builder.graph, Node.RootId);
560
+ Graph.expand(builder.graph, Node.RootId, 'child');
483
561
  await trigger.wait();
484
562
  expect(count).to.equal(5);
485
563
  });
@@ -507,6 +585,7 @@ describe('GraphBuilder', () => {
507
585
 
508
586
  let count = 0;
509
587
  await GraphBuilder.explore(builder, {
588
+ relation: 'child',
510
589
  visitor: () => {
511
590
  count++;
512
591
  },
@@ -518,7 +597,7 @@ describe('GraphBuilder', () => {
518
597
 
519
598
  describe('helpers', () => {
520
599
  describe('createConnector', () => {
521
- test('creates connector with type inference', () => {
600
+ test('creates connector with type inference', async () => {
522
601
  const registry = Registry.make();
523
602
  const builder = GraphBuilder.make({ registry });
524
603
  const graph = builder.graph;
@@ -536,16 +615,17 @@ describe('GraphBuilder', () => {
536
615
  }),
537
616
  );
538
617
 
539
- Graph.expand(graph, Node.RootId);
618
+ Graph.expand(graph, Node.RootId, 'child');
619
+ await GraphBuilder.flush(builder);
540
620
 
541
- const connections = registry.get(graph.connections(Node.RootId));
621
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
542
622
  expect(connections).has.length(1);
543
- expect(connections[0].id).to.equal('child');
623
+ expect(connections[0].id).to.equal('root/child');
544
624
  });
545
625
  });
546
626
 
547
627
  describe('createExtension', () => {
548
- test('works with Effect connector', () => {
628
+ test('works with Effect connector', async () => {
549
629
  const registry = Registry.make();
550
630
  const builder = GraphBuilder.make({ registry });
551
631
  const graph = builder.graph;
@@ -562,15 +642,16 @@ describe('GraphBuilder', () => {
562
642
 
563
643
  const writableGraph = graph as Graph.WritableGraph;
564
644
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
565
- Graph.expand(graph, 'parent');
645
+ Graph.expand(graph, 'parent', 'child');
646
+ await GraphBuilder.flush(builder);
566
647
 
567
- const connections = registry.get(graph.connections('parent'));
648
+ const connections = registry.get(graph.connections('parent', 'child'));
568
649
  expect(connections).has.length(1);
569
- expect(connections[0].id).to.equal('child');
650
+ expect(connections[0].id).to.equal('parent/child');
570
651
  expect(connections[0].data).to.equal('test');
571
652
  });
572
653
 
573
- test('works with Effect actions', () => {
654
+ test('works with Effect actions', async () => {
574
655
  const registry = Registry.make();
575
656
  const builder = GraphBuilder.make({ registry });
576
657
  const graph = builder.graph;
@@ -583,10 +664,7 @@ describe('GraphBuilder', () => {
583
664
  Effect.succeed([
584
665
  {
585
666
  id: 'test-action',
586
- data: () =>
587
- Effect.sync(() => {
588
- console.log('TestAction');
589
- }),
667
+ data: () => Effect.void,
590
668
  properties: { label: 'Test' },
591
669
  },
592
670
  ]),
@@ -597,14 +675,83 @@ describe('GraphBuilder', () => {
597
675
 
598
676
  const writableGraph = graph as Graph.WritableGraph;
599
677
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
600
- Graph.expand(graph, 'parent');
678
+ Graph.expand(graph, 'parent', 'child');
679
+ await GraphBuilder.flush(builder);
601
680
 
681
+ const edges = registry.get(graph.edges('parent'));
682
+ expect(edges[Graph.relationKey('action')] ?? []).to.have.length(1);
683
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/test-action');
684
+ expect(edges[Graph.relationKey('child')] ?? []).to.have.length(0);
602
685
  const actions = registry.get(graph.actions('parent'));
603
686
  expect(actions).has.length(1);
604
- expect(actions[0].id).to.equal('test-action');
687
+ expect(actions[0].id).to.equal('parent/test-action');
605
688
  });
606
689
 
607
- test('_actionContext captures and provides services to action execution', () => {
690
+ test('actions expand automatically with child relation', async ({ expect }) => {
691
+ const registry = Registry.make();
692
+ const builder = GraphBuilder.make({ registry });
693
+ const graph = builder.graph;
694
+
695
+ const extensions = Effect.runSync(
696
+ GraphBuilder.createExtension({
697
+ id: 'test-extension',
698
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
699
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: 'c' }]),
700
+ actions: (node, get) =>
701
+ Effect.succeed([{ id: 'act1', data: () => Effect.void, properties: { label: 'A' } }]),
702
+ }),
703
+ );
704
+
705
+ GraphBuilder.addExtension(builder, extensions);
706
+
707
+ const writableGraph = graph as Graph.WritableGraph;
708
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
709
+ Graph.expand(graph, 'parent', 'child');
710
+ await GraphBuilder.flush(builder);
711
+
712
+ const edges = registry.get(graph.edges('parent'));
713
+ expect(edges[Graph.relationKey('child')] ?? []).to.include('parent/child');
714
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/act1');
715
+ const actions = registry.get(graph.actions('parent'));
716
+ expect(actions).has.length(1);
717
+ expect(actions[0].id).to.equal('parent/act1');
718
+ const connections = registry.get(graph.connections('parent', 'child'));
719
+ expect(connections).has.length(1);
720
+ expect(connections[0].id).to.equal('parent/child');
721
+ });
722
+
723
+ test('actions appear when extension registered after expand', async ({ expect }) => {
724
+ const registry = Registry.make();
725
+ const builder = GraphBuilder.make({ registry });
726
+ const graph = builder.graph;
727
+ const writableGraph = graph as Graph.WritableGraph;
728
+
729
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
730
+ Graph.expand(graph, 'parent', 'child');
731
+ await GraphBuilder.flush(builder);
732
+
733
+ expect(registry.get(graph.actions('parent'))).to.have.length(0);
734
+
735
+ const extensions = Effect.runSync(
736
+ GraphBuilder.createExtension({
737
+ id: 'late-extension',
738
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
739
+ actions: (node, get) =>
740
+ Effect.succeed([{ id: 'late-act', data: () => Effect.void, properties: { label: 'Late' } }]),
741
+ }),
742
+ );
743
+
744
+ GraphBuilder.addExtension(builder, extensions);
745
+ await GraphBuilder.flush(builder);
746
+
747
+ const edges = registry.get(graph.edges('parent'));
748
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
749
+ const actions = registry.get(graph.actions('parent'));
750
+ expect(actions).has.length(1);
751
+ expect(actions[0].id).to.equal('parent/late-act');
752
+ });
753
+
754
+ test('_actionContext captures and provides services to action execution', async () => {
608
755
  const registry = Registry.make();
609
756
  const builder = GraphBuilder.make({ registry });
610
757
  const graph = builder.graph;
@@ -648,7 +795,8 @@ describe('GraphBuilder', () => {
648
795
 
649
796
  const writableGraph = graph as Graph.WritableGraph;
650
797
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
651
- Graph.expand(graph, 'parent');
798
+ Graph.expand(graph, 'parent', 'child');
799
+ await GraphBuilder.flush(builder);
652
800
 
653
801
  const actions = registry.get(graph.actions('parent'));
654
802
  expect(actions).has.length(1);
@@ -691,7 +839,7 @@ describe('GraphBuilder', () => {
691
839
  expect(node?.data).to.equal('resolved');
692
840
  });
693
841
 
694
- test('works with connector and actions together', () => {
842
+ test('works with connector and actions together', async () => {
695
843
  const registry = Registry.make();
696
844
  const builder = GraphBuilder.make({ registry });
697
845
  const graph = builder.graph;
@@ -705,10 +853,7 @@ describe('GraphBuilder', () => {
705
853
  Effect.succeed([
706
854
  {
707
855
  id: 'test-action',
708
- data: () =>
709
- Effect.sync(() => {
710
- console.log('TestAction');
711
- }),
856
+ data: () => Effect.void,
712
857
  properties: { label: 'Test' },
713
858
  },
714
859
  ]),
@@ -719,21 +864,22 @@ describe('GraphBuilder', () => {
719
864
 
720
865
  const writableGraph = graph as Graph.WritableGraph;
721
866
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
722
- Graph.expand(graph, 'parent');
867
+ Graph.expand(graph, 'parent', 'child');
868
+ await GraphBuilder.flush(builder);
723
869
 
724
- const connections = registry.get(graph.connections('parent'));
870
+ const connections = registry.get(graph.connections('parent', 'child'));
725
871
  // Should have both the child node and the action node.
726
872
  expect(connections.length).to.be.greaterThanOrEqual(1);
727
- const childNode = connections.find((n) => n.id === 'child');
873
+ const childNode = connections.find((n) => n.id === 'parent/child');
728
874
  expect(childNode).to.not.be.undefined;
729
875
  expect(childNode?.data).to.equal('test');
730
876
 
731
877
  const actions = registry.get(graph.actions('parent'));
732
878
  expect(actions).has.length(1);
733
- expect(actions[0].id).to.equal('test-action');
879
+ expect(actions[0].id).to.equal('parent/test-action');
734
880
  });
735
881
 
736
- test('works with reactive connector using get context', () => {
882
+ test('works with reactive connector using get context', async () => {
737
883
  const registry = Registry.make();
738
884
  const builder = GraphBuilder.make({ registry });
739
885
  const graph = builder.graph;
@@ -752,18 +898,20 @@ describe('GraphBuilder', () => {
752
898
 
753
899
  const writableGraph = graph as Graph.WritableGraph;
754
900
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
755
- Graph.expand(graph, 'parent');
901
+ Graph.expand(graph, 'parent', 'child');
902
+ await GraphBuilder.flush(builder);
756
903
 
757
904
  {
758
- const connections = registry.get(graph.connections('parent'));
905
+ const connections = registry.get(graph.connections('parent', 'child'));
759
906
  expect(connections).has.length(1);
760
907
  expect(connections[0].data).to.equal('initial');
761
908
  }
762
909
 
763
910
  registry.set(state, 'updated');
911
+ await GraphBuilder.flush(builder);
764
912
 
765
913
  {
766
- const connections = registry.get(graph.connections('parent'));
914
+ const connections = registry.get(graph.connections('parent', 'child'));
767
915
  expect(connections).has.length(1);
768
916
  expect(connections[0].data).to.equal('updated');
769
917
  }
@@ -771,7 +919,7 @@ describe('GraphBuilder', () => {
771
919
  });
772
920
 
773
921
  describe('extension error handling', () => {
774
- test('connector failure is caught and logged, returns empty array', () => {
922
+ test('connector failure is caught and logged, returns empty array', async () => {
775
923
  const registry = Registry.make();
776
924
  const builder = GraphBuilder.make({ registry });
777
925
  const graph = builder.graph;
@@ -790,14 +938,15 @@ describe('GraphBuilder', () => {
790
938
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
791
939
 
792
940
  // Should not throw, error is caught internally.
793
- Graph.expand(graph, 'parent');
941
+ Graph.expand(graph, 'parent', 'child');
942
+ await GraphBuilder.flush(builder);
794
943
 
795
944
  // Should return empty connections since the connector failed.
796
- const connections = registry.get(graph.connections('parent'));
945
+ const connections = registry.get(graph.connections('parent', 'child'));
797
946
  expect(connections).has.length(0);
798
947
  });
799
948
 
800
- test('actions failure is caught and logged, returns empty array', () => {
949
+ test('actions failure is caught and logged, returns empty array', async () => {
801
950
  const registry = Registry.make();
802
951
  const builder = GraphBuilder.make({ registry });
803
952
  const graph = builder.graph;
@@ -816,7 +965,8 @@ describe('GraphBuilder', () => {
816
965
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
817
966
 
818
967
  // Should not throw, error is caught internally.
819
- Graph.expand(graph, 'parent');
968
+ Graph.expand(graph, 'parent', 'child');
969
+ await GraphBuilder.flush(builder);
820
970
 
821
971
  // Should return empty actions since the actions callback failed.
822
972
  const actions = registry.get(graph.actions('parent'));
@@ -846,7 +996,7 @@ describe('GraphBuilder', () => {
846
996
  expect(node).to.be.null;
847
997
  });
848
998
 
849
- test('failing extension does not affect other extensions', () => {
999
+ test('failing extension does not affect other extensions', async () => {
850
1000
  const registry = Registry.make();
851
1001
  const builder = GraphBuilder.make({ registry });
852
1002
  const graph = builder.graph;
@@ -875,18 +1025,19 @@ describe('GraphBuilder', () => {
875
1025
 
876
1026
  const writableGraph = graph as Graph.WritableGraph;
877
1027
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
878
- Graph.expand(graph, 'parent');
1028
+ Graph.expand(graph, 'parent', 'child');
1029
+ await GraphBuilder.flush(builder);
879
1030
 
880
1031
  // The working extension should still produce its node.
881
- const connections = registry.get(graph.connections('parent'));
1032
+ const connections = registry.get(graph.connections('parent', 'child'));
882
1033
  expect(connections).has.length(1);
883
- expect(connections[0].id).to.equal('child-from-working');
1034
+ expect(connections[0].id).to.equal('parent/child-from-working');
884
1035
  expect(connections[0].data).to.equal('success');
885
1036
  });
886
1037
  });
887
1038
 
888
1039
  describe('createTypeExtension', () => {
889
- test('creates extension matching by schema type with inferred object type', () => {
1040
+ test('creates extension matching by schema type with inferred object type', async () => {
890
1041
  const registry = Registry.make();
891
1042
  const builder = GraphBuilder.make({ registry });
892
1043
  const graph = builder.graph;
@@ -904,13 +1055,204 @@ describe('GraphBuilder', () => {
904
1055
  const writableGraph = graph as Graph.WritableGraph;
905
1056
  const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
906
1057
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
907
- Graph.expand(graph, 'parent');
1058
+ Graph.expand(graph, 'parent', 'child');
1059
+ await GraphBuilder.flush(builder);
908
1060
 
909
- const connections = registry.get(graph.connections('parent'));
1061
+ const connections = registry.get(graph.connections('parent', 'child'));
910
1062
  expect(connections).has.length(1);
911
- expect(connections[0].id).to.equal('child');
1063
+ expect(connections[0].id).to.equal('parent/child');
912
1064
  expect(connections[0].data).to.equal(testObject);
913
1065
  });
914
1066
  });
915
1067
  });
1068
+ describe('path-based ID qualification', () => {
1069
+ test('rejects segment IDs containing slash', async () => {
1070
+ const registry = Registry.make();
1071
+ const builder = GraphBuilder.make({ registry });
1072
+ GraphBuilder.addExtension(
1073
+ builder,
1074
+ GraphBuilder.createExtensionRaw({
1075
+ id: 'bad-connector',
1076
+ connector: () => Atom.make([{ id: 'foo/bar', type: EXAMPLE_TYPE, data: null }]),
1077
+ }),
1078
+ );
1079
+
1080
+ expect(() => Graph.expand(builder.graph, Node.RootId, 'child')).toThrow(/must not contain/);
1081
+ });
1082
+
1083
+ test('multi-level path qualification', async () => {
1084
+ const registry = Registry.make();
1085
+ const builder = GraphBuilder.make({ registry });
1086
+ GraphBuilder.addExtension(builder, [
1087
+ GraphBuilder.createExtensionRaw({
1088
+ id: 'level1',
1089
+ connector: (node) =>
1090
+ Atom.make((get) =>
1091
+ Function.pipe(
1092
+ get(node),
1093
+ Option.filter((n) => n.id === 'root'),
1094
+ Option.map(() => [{ id: 'A', type: EXAMPLE_TYPE, data: 'a' }]),
1095
+ Option.getOrElse(() => []),
1096
+ ),
1097
+ ),
1098
+ }),
1099
+ GraphBuilder.createExtensionRaw({
1100
+ id: 'level2',
1101
+ connector: (node) =>
1102
+ Atom.make((get) =>
1103
+ Function.pipe(
1104
+ get(node),
1105
+ Option.filter((n) => n.id === 'root/A'),
1106
+ Option.map(() => [{ id: 'B', type: EXAMPLE_TYPE, data: 'b' }]),
1107
+ Option.getOrElse(() => []),
1108
+ ),
1109
+ ),
1110
+ }),
1111
+ ]);
1112
+
1113
+ const graph = builder.graph;
1114
+
1115
+ Graph.expand(graph, Node.RootId, 'child');
1116
+ await GraphBuilder.flush(builder);
1117
+
1118
+ const level1 = registry.get(graph.connections(Node.RootId, 'child'));
1119
+ expect(level1).has.length(1);
1120
+ expect(level1[0].id).to.equal('root/A');
1121
+
1122
+ Graph.expand(graph, 'root/A', 'child');
1123
+ await GraphBuilder.flush(builder);
1124
+
1125
+ const level2 = registry.get(graph.connections('root/A', 'child'));
1126
+ expect(level2).has.length(1);
1127
+ expect(level2[0].id).to.equal('root/A/B');
1128
+ });
1129
+
1130
+ test('inline nodes are recursively qualified', async () => {
1131
+ const registry = Registry.make();
1132
+ const builder = GraphBuilder.make({ registry });
1133
+ GraphBuilder.addExtension(
1134
+ builder,
1135
+ GraphBuilder.createExtensionRaw({
1136
+ id: 'inline-connector',
1137
+ connector: () =>
1138
+ Atom.make([
1139
+ {
1140
+ id: 'parent-node',
1141
+ type: EXAMPLE_TYPE,
1142
+ data: null,
1143
+ nodes: [
1144
+ {
1145
+ id: 'inline-child',
1146
+ type: EXAMPLE_TYPE,
1147
+ data: null,
1148
+ nodes: [{ id: 'deep-child', type: EXAMPLE_TYPE, data: null }],
1149
+ },
1150
+ ],
1151
+ },
1152
+ ]),
1153
+ }),
1154
+ );
1155
+
1156
+ const graph = builder.graph;
1157
+ Graph.expand(graph, Node.RootId, 'child');
1158
+ await GraphBuilder.flush(builder);
1159
+
1160
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
1161
+ expect(connections).has.length(1);
1162
+ expect(connections[0].id).to.equal('root/parent-node');
1163
+
1164
+ const inlineNode = Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
1165
+ expect(inlineNode).to.not.be.null;
1166
+ expect(inlineNode?.id).to.equal('root/parent-node/inline-child');
1167
+
1168
+ const deepNode = Graph.getNode(graph, 'root/parent-node/inline-child/deep-child').pipe(Option.getOrNull);
1169
+ expect(deepNode).to.not.be.null;
1170
+ expect(deepNode?.id).to.equal('root/parent-node/inline-child/deep-child');
1171
+ });
1172
+
1173
+ test('constant connector produces distinct nodes under different parents', async () => {
1174
+ const registry = Registry.make();
1175
+ const builder = GraphBuilder.make({ registry });
1176
+
1177
+ GraphBuilder.addExtension(builder, [
1178
+ GraphBuilder.createExtensionRaw({
1179
+ id: 'parents',
1180
+ connector: (node) =>
1181
+ Atom.make((get) =>
1182
+ Function.pipe(
1183
+ get(node),
1184
+ Option.filter((n) => n.id === 'root'),
1185
+ Option.map(() => [
1186
+ { id: 'A', type: EXAMPLE_TYPE, data: 'a' },
1187
+ { id: 'B', type: EXAMPLE_TYPE, data: 'b' },
1188
+ ]),
1189
+ Option.getOrElse(() => []),
1190
+ ),
1191
+ ),
1192
+ }),
1193
+ GraphBuilder.createExtensionRaw({
1194
+ id: 'constant-child',
1195
+ connector: () => Atom.make([{ id: 'shared', type: EXAMPLE_TYPE, data: 'constant' }]),
1196
+ }),
1197
+ ]);
1198
+
1199
+ const graph = builder.graph;
1200
+
1201
+ Graph.expand(graph, Node.RootId, 'child');
1202
+ await GraphBuilder.flush(builder);
1203
+
1204
+ Graph.expand(graph, 'root/A', 'child');
1205
+ Graph.expand(graph, 'root/B', 'child');
1206
+ await GraphBuilder.flush(builder);
1207
+
1208
+ const childrenOfA = registry.get(graph.connections('root/A', 'child'));
1209
+ const childrenOfB = registry.get(graph.connections('root/B', 'child'));
1210
+
1211
+ expect(childrenOfA).has.length(1);
1212
+ expect(childrenOfB).has.length(1);
1213
+ expect(childrenOfA[0].id).to.equal('root/A/shared');
1214
+ expect(childrenOfB[0].id).to.equal('root/B/shared');
1215
+
1216
+ const nodeA = Graph.getNode(graph, 'root/A/shared').pipe(Option.getOrNull);
1217
+ const nodeB = Graph.getNode(graph, 'root/B/shared').pipe(Option.getOrNull);
1218
+ expect(nodeA).to.not.be.null;
1219
+ expect(nodeB).to.not.be.null;
1220
+ expect(nodeA?.id).to.not.equal(nodeB?.id);
1221
+ });
1222
+
1223
+ test('explore qualifies node IDs', async () => {
1224
+ const builder = GraphBuilder.make();
1225
+ GraphBuilder.addExtension(
1226
+ builder,
1227
+ GraphBuilder.createExtensionRaw({
1228
+ id: 'connector',
1229
+ connector: (node) =>
1230
+ Atom.make((get) =>
1231
+ Function.pipe(
1232
+ get(node),
1233
+ Option.filter((n) => n.id === 'root'),
1234
+ Option.map(() => [
1235
+ { id: 'first', type: EXAMPLE_TYPE, data: 1 },
1236
+ { id: 'second', type: EXAMPLE_TYPE, data: 2 },
1237
+ ]),
1238
+ Option.getOrElse(() => []),
1239
+ ),
1240
+ ),
1241
+ }),
1242
+ );
1243
+
1244
+ const visited: Array<{ id: string; path: string[] }> = [];
1245
+ await GraphBuilder.explore(builder, {
1246
+ relation: 'child',
1247
+ visitor: (node, path) => {
1248
+ visited.push({ id: node.id, path });
1249
+ },
1250
+ });
1251
+
1252
+ expect(visited).has.length(3);
1253
+ expect(visited[0].id).to.equal('root');
1254
+ expect(visited[1].id).to.equal('root/first');
1255
+ expect(visited[2].id).to.equal('root/second');
1256
+ });
1257
+ });
916
1258
  });