@dxos/app-graph 0.8.4-main.ef1bc66f44 → 0.8.4-main.f466a3d56e

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 (56) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +564 -384
  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 +13 -9
  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 +46 -20
  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/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 +40 -0
  35. package/dist/types/src/util.d.ts.map +1 -0
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +38 -27
  38. package/src/graph-builder.test.ts +695 -102
  39. package/src/graph-builder.ts +223 -76
  40. package/src/graph.test.ts +187 -52
  41. package/src/graph.ts +177 -98
  42. package/src/index.ts +1 -0
  43. package/src/node-matcher.ts +61 -31
  44. package/src/node.ts +46 -5
  45. package/src/scheduler.browser.ts +5 -0
  46. package/src/scheduler.ts +17 -0
  47. package/src/stories/EchoGraph.stories.tsx +94 -66
  48. package/src/stories/Tree.tsx +1 -1
  49. package/src/testing/index.ts +5 -0
  50. package/src/testing/setup-graph-builder.ts +41 -0
  51. package/src/util.ts +101 -0
  52. package/dist/lib/browser/index.mjs.map +0 -7
  53. package/dist/lib/browser/meta.json +0 -1
  54. package/dist/lib/node-esm/index.mjs +0 -1298
  55. package/dist/lib/node-esm/index.mjs.map +0 -7
  56. package/dist/lib/node-esm/meta.json +0 -1
@@ -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,29 +458,159 @@ 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
  });
294
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
+
295
614
  test('sort edges', async () => {
296
615
  const registry = Registry.make();
297
616
  const builder = GraphBuilder.make({ registry });
@@ -308,14 +627,15 @@ describe('GraphBuilder', () => {
308
627
  }),
309
628
  );
310
629
  const graph = builder.graph;
311
- Graph.expand(graph, Node.RootId);
630
+ Graph.expand(graph, Node.RootId, 'child');
631
+ await GraphBuilder.flush(builder);
312
632
 
313
633
  {
314
- const nodes = registry.get(graph.connections(Node.RootId));
634
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
315
635
  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));
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)));
319
639
  }
320
640
 
321
641
  registry.set(nodes, [
@@ -323,20 +643,18 @@ describe('GraphBuilder', () => {
323
643
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
324
644
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
325
645
  ]);
326
-
327
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
328
- await sleep(0);
646
+ await GraphBuilder.flush(builder);
329
647
 
330
648
  {
331
- const nodes = registry.get(graph.connections(Node.RootId));
649
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
332
650
  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));
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)));
336
654
  }
337
655
  });
338
656
 
339
- test('updates are constrained', () => {
657
+ test('updates are constrained', async () => {
340
658
  const registry = Registry.make();
341
659
  const builder = GraphBuilder.make({ registry });
342
660
  const name = Atom.make('default');
@@ -362,7 +680,9 @@ describe('GraphBuilder', () => {
362
680
  Atom.make((get) =>
363
681
  Function.pipe(
364
682
  get(node),
365
- 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
+ ),
366
686
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
367
687
  Option.getOrElse(() => []),
368
688
  ),
@@ -374,7 +694,9 @@ describe('GraphBuilder', () => {
374
694
  Atom.make((get) =>
375
695
  Function.pipe(
376
696
  get(node),
377
- 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
+ ),
378
700
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
379
701
  Option.getOrElse(() => []),
380
702
  ),
@@ -385,43 +707,47 @@ describe('GraphBuilder', () => {
385
707
  const graph = builder.graph;
386
708
 
387
709
  let parentCount = 0;
388
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
710
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
389
711
  parentCount++;
390
712
  });
391
713
  onTestFinished(() => parentCancel());
392
714
 
393
715
  let independentCount = 0;
394
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
716
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
395
717
  independentCount++;
396
718
  });
397
719
  onTestFinished(() => independentCancel());
398
720
 
399
721
  let dependentCount = 0;
400
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
722
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
401
723
  dependentCount++;
402
724
  });
403
725
  onTestFinished(() => dependentCancel());
404
726
 
405
727
  // Counts should not increment until the node is expanded.
406
- Graph.expand(graph, Node.RootId);
728
+ Graph.expand(graph, Node.RootId, 'child');
729
+ await GraphBuilder.flush(builder);
407
730
  expect(parentCount).to.equal(1);
408
731
  expect(independentCount).to.equal(0);
409
732
  expect(dependentCount).to.equal(0);
410
733
 
411
734
  // Counts should increment when the node is expanded.
412
- Graph.expand(graph, EXAMPLE_ID);
735
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
736
+ await GraphBuilder.flush(builder);
413
737
  expect(parentCount).to.equal(1);
414
738
  expect(independentCount).to.equal(1);
415
739
  expect(dependentCount).to.equal(1);
416
740
 
417
741
  // Only dependent count should increment when the parent changes.
418
742
  registry.set(name, 'updated');
743
+ await GraphBuilder.flush(builder);
419
744
  expect(parentCount).to.equal(2);
420
745
  expect(independentCount).to.equal(1);
421
746
  expect(dependentCount).to.equal(2);
422
747
 
423
748
  // Only independent count should increment when its state changes.
424
749
  registry.set(sub, 'updated');
750
+ await GraphBuilder.flush(builder);
425
751
  expect(parentCount).to.equal(2);
426
752
  expect(independentCount).to.equal(2);
427
753
  expect(dependentCount).to.equal(2);
@@ -431,18 +757,21 @@ describe('GraphBuilder', () => {
431
757
  registry.set(name, 'removed');
432
758
  registry.set(sub, 'batch');
433
759
  });
760
+ await GraphBuilder.flush(builder);
434
761
  expect(parentCount).to.equal(2);
435
762
  expect(independentCount).to.equal(3);
436
763
  expect(dependentCount).to.equal(2);
437
764
 
438
765
  // Dependent count should increment when the node is added back.
439
766
  registry.set(name, 'added');
767
+ await GraphBuilder.flush(builder);
440
768
  expect(parentCount).to.equal(3);
441
769
  expect(independentCount).to.equal(3);
442
770
  expect(dependentCount).to.equal(3);
443
771
 
444
772
  // Counts should not increment when the node is expanded again.
445
- Graph.expand(graph, EXAMPLE_ID);
773
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
774
+ await GraphBuilder.flush(builder);
446
775
  expect(parentCount).to.equal(3);
447
776
  expect(independentCount).to.equal(3);
448
777
  expect(dependentCount).to.equal(3);
@@ -472,14 +801,14 @@ describe('GraphBuilder', () => {
472
801
  let count = 0;
473
802
  const trigger = new Trigger();
474
803
  builder.graph.onNodeChanged.on(({ id }) => {
475
- Graph.expand(builder.graph, id);
804
+ Graph.expand(builder.graph, id, 'child');
476
805
  count++;
477
806
  if (count === 5) {
478
807
  trigger.wake();
479
808
  }
480
809
  });
481
810
 
482
- Graph.expand(builder.graph, Node.RootId);
811
+ Graph.expand(builder.graph, Node.RootId, 'child');
483
812
  await trigger.wait();
484
813
  expect(count).to.equal(5);
485
814
  });
@@ -507,6 +836,7 @@ describe('GraphBuilder', () => {
507
836
 
508
837
  let count = 0;
509
838
  await GraphBuilder.explore(builder, {
839
+ relation: 'child',
510
840
  visitor: () => {
511
841
  count++;
512
842
  },
@@ -518,7 +848,7 @@ describe('GraphBuilder', () => {
518
848
 
519
849
  describe('helpers', () => {
520
850
  describe('createConnector', () => {
521
- test('creates connector with type inference', () => {
851
+ test('creates connector with type inference', async () => {
522
852
  const registry = Registry.make();
523
853
  const builder = GraphBuilder.make({ registry });
524
854
  const graph = builder.graph;
@@ -536,16 +866,17 @@ describe('GraphBuilder', () => {
536
866
  }),
537
867
  );
538
868
 
539
- Graph.expand(graph, Node.RootId);
869
+ Graph.expand(graph, Node.RootId, 'child');
870
+ await GraphBuilder.flush(builder);
540
871
 
541
- const connections = registry.get(graph.connections(Node.RootId));
872
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
542
873
  expect(connections).has.length(1);
543
- expect(connections[0].id).to.equal('child');
874
+ expect(connections[0].id).to.equal('root/child');
544
875
  });
545
876
  });
546
877
 
547
878
  describe('createExtension', () => {
548
- test('works with Effect connector', () => {
879
+ test('works with Effect connector', async () => {
549
880
  const registry = Registry.make();
550
881
  const builder = GraphBuilder.make({ registry });
551
882
  const graph = builder.graph;
@@ -562,15 +893,16 @@ describe('GraphBuilder', () => {
562
893
 
563
894
  const writableGraph = graph as Graph.WritableGraph;
564
895
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
565
- Graph.expand(graph, 'parent');
896
+ Graph.expand(graph, 'parent', 'child');
897
+ await GraphBuilder.flush(builder);
566
898
 
567
- const connections = registry.get(graph.connections('parent'));
899
+ const connections = registry.get(graph.connections('parent', 'child'));
568
900
  expect(connections).has.length(1);
569
- expect(connections[0].id).to.equal('child');
901
+ expect(connections[0].id).to.equal('parent/child');
570
902
  expect(connections[0].data).to.equal('test');
571
903
  });
572
904
 
573
- test('works with Effect actions', () => {
905
+ test('works with Effect actions', async () => {
574
906
  const registry = Registry.make();
575
907
  const builder = GraphBuilder.make({ registry });
576
908
  const graph = builder.graph;
@@ -583,10 +915,7 @@ describe('GraphBuilder', () => {
583
915
  Effect.succeed([
584
916
  {
585
917
  id: 'test-action',
586
- data: () =>
587
- Effect.sync(() => {
588
- console.log('TestAction');
589
- }),
918
+ data: () => Effect.void,
590
919
  properties: { label: 'Test' },
591
920
  },
592
921
  ]),
@@ -597,14 +926,83 @@ describe('GraphBuilder', () => {
597
926
 
598
927
  const writableGraph = graph as Graph.WritableGraph;
599
928
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
600
- Graph.expand(graph, 'parent');
929
+ Graph.expand(graph, 'parent', 'child');
930
+ await GraphBuilder.flush(builder);
931
+
932
+ const edges = registry.get(graph.edges('parent'));
933
+ expect(edges[Graph.relationKey('action')] ?? []).to.have.length(1);
934
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/test-action');
935
+ expect(edges[Graph.relationKey('child')] ?? []).to.have.length(0);
936
+ const actions = registry.get(graph.actions('parent'));
937
+ expect(actions).has.length(1);
938
+ expect(actions[0].id).to.equal('parent/test-action');
939
+ });
940
+
941
+ test('actions expand automatically with child relation', async ({ expect }) => {
942
+ const registry = Registry.make();
943
+ const builder = GraphBuilder.make({ registry });
944
+ const graph = builder.graph;
945
+
946
+ const extensions = Effect.runSync(
947
+ GraphBuilder.createExtension({
948
+ id: 'test-extension',
949
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
950
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: 'c' }]),
951
+ actions: (node, get) =>
952
+ Effect.succeed([{ id: 'act1', data: () => Effect.void, properties: { label: 'A' } }]),
953
+ }),
954
+ );
955
+
956
+ GraphBuilder.addExtension(builder, extensions);
957
+
958
+ const writableGraph = graph as Graph.WritableGraph;
959
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
960
+ Graph.expand(graph, 'parent', 'child');
961
+ await GraphBuilder.flush(builder);
962
+
963
+ const edges = registry.get(graph.edges('parent'));
964
+ expect(edges[Graph.relationKey('child')] ?? []).to.include('parent/child');
965
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/act1');
966
+ const actions = registry.get(graph.actions('parent'));
967
+ expect(actions).has.length(1);
968
+ expect(actions[0].id).to.equal('parent/act1');
969
+ const connections = registry.get(graph.connections('parent', 'child'));
970
+ expect(connections).has.length(1);
971
+ expect(connections[0].id).to.equal('parent/child');
972
+ });
973
+
974
+ test('actions appear when extension registered after expand', async ({ expect }) => {
975
+ const registry = Registry.make();
976
+ const builder = GraphBuilder.make({ registry });
977
+ const graph = builder.graph;
978
+ const writableGraph = graph as Graph.WritableGraph;
979
+
980
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
981
+ Graph.expand(graph, 'parent', 'child');
982
+ await GraphBuilder.flush(builder);
983
+
984
+ expect(registry.get(graph.actions('parent'))).to.have.length(0);
985
+
986
+ const extensions = Effect.runSync(
987
+ GraphBuilder.createExtension({
988
+ id: 'late-extension',
989
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
990
+ actions: (node, get) =>
991
+ Effect.succeed([{ id: 'late-act', data: () => Effect.void, properties: { label: 'Late' } }]),
992
+ }),
993
+ );
994
+
995
+ GraphBuilder.addExtension(builder, extensions);
996
+ await GraphBuilder.flush(builder);
601
997
 
998
+ const edges = registry.get(graph.edges('parent'));
999
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
602
1000
  const actions = registry.get(graph.actions('parent'));
603
1001
  expect(actions).has.length(1);
604
- expect(actions[0].id).to.equal('test-action');
1002
+ expect(actions[0].id).to.equal('parent/late-act');
605
1003
  });
606
1004
 
607
- test('_actionContext captures and provides services to action execution', () => {
1005
+ test('_actionContext captures and provides services to action execution', async () => {
608
1006
  const registry = Registry.make();
609
1007
  const builder = GraphBuilder.make({ registry });
610
1008
  const graph = builder.graph;
@@ -648,7 +1046,8 @@ describe('GraphBuilder', () => {
648
1046
 
649
1047
  const writableGraph = graph as Graph.WritableGraph;
650
1048
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
651
- Graph.expand(graph, 'parent');
1049
+ Graph.expand(graph, 'parent', 'child');
1050
+ await GraphBuilder.flush(builder);
652
1051
 
653
1052
  const actions = registry.get(graph.actions('parent'));
654
1053
  expect(actions).has.length(1);
@@ -691,7 +1090,7 @@ describe('GraphBuilder', () => {
691
1090
  expect(node?.data).to.equal('resolved');
692
1091
  });
693
1092
 
694
- test('works with connector and actions together', () => {
1093
+ test('works with connector and actions together', async () => {
695
1094
  const registry = Registry.make();
696
1095
  const builder = GraphBuilder.make({ registry });
697
1096
  const graph = builder.graph;
@@ -705,10 +1104,7 @@ describe('GraphBuilder', () => {
705
1104
  Effect.succeed([
706
1105
  {
707
1106
  id: 'test-action',
708
- data: () =>
709
- Effect.sync(() => {
710
- console.log('TestAction');
711
- }),
1107
+ data: () => Effect.void,
712
1108
  properties: { label: 'Test' },
713
1109
  },
714
1110
  ]),
@@ -719,21 +1115,22 @@ describe('GraphBuilder', () => {
719
1115
 
720
1116
  const writableGraph = graph as Graph.WritableGraph;
721
1117
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
722
- Graph.expand(graph, 'parent');
1118
+ Graph.expand(graph, 'parent', 'child');
1119
+ await GraphBuilder.flush(builder);
723
1120
 
724
- const connections = registry.get(graph.connections('parent'));
1121
+ const connections = registry.get(graph.connections('parent', 'child'));
725
1122
  // Should have both the child node and the action node.
726
1123
  expect(connections.length).to.be.greaterThanOrEqual(1);
727
- const childNode = connections.find((n) => n.id === 'child');
1124
+ const childNode = connections.find((n) => n.id === 'parent/child');
728
1125
  expect(childNode).to.not.be.undefined;
729
1126
  expect(childNode?.data).to.equal('test');
730
1127
 
731
1128
  const actions = registry.get(graph.actions('parent'));
732
1129
  expect(actions).has.length(1);
733
- expect(actions[0].id).to.equal('test-action');
1130
+ expect(actions[0].id).to.equal('parent/test-action');
734
1131
  });
735
1132
 
736
- test('works with reactive connector using get context', () => {
1133
+ test('works with reactive connector using get context', async () => {
737
1134
  const registry = Registry.make();
738
1135
  const builder = GraphBuilder.make({ registry });
739
1136
  const graph = builder.graph;
@@ -752,18 +1149,20 @@ describe('GraphBuilder', () => {
752
1149
 
753
1150
  const writableGraph = graph as Graph.WritableGraph;
754
1151
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
755
- Graph.expand(graph, 'parent');
1152
+ Graph.expand(graph, 'parent', 'child');
1153
+ await GraphBuilder.flush(builder);
756
1154
 
757
1155
  {
758
- const connections = registry.get(graph.connections('parent'));
1156
+ const connections = registry.get(graph.connections('parent', 'child'));
759
1157
  expect(connections).has.length(1);
760
1158
  expect(connections[0].data).to.equal('initial');
761
1159
  }
762
1160
 
763
1161
  registry.set(state, 'updated');
1162
+ await GraphBuilder.flush(builder);
764
1163
 
765
1164
  {
766
- const connections = registry.get(graph.connections('parent'));
1165
+ const connections = registry.get(graph.connections('parent', 'child'));
767
1166
  expect(connections).has.length(1);
768
1167
  expect(connections[0].data).to.equal('updated');
769
1168
  }
@@ -771,7 +1170,7 @@ describe('GraphBuilder', () => {
771
1170
  });
772
1171
 
773
1172
  describe('extension error handling', () => {
774
- test('connector failure is caught and logged, returns empty array', () => {
1173
+ test('connector failure is caught and logged, returns empty array', async () => {
775
1174
  const registry = Registry.make();
776
1175
  const builder = GraphBuilder.make({ registry });
777
1176
  const graph = builder.graph;
@@ -790,14 +1189,15 @@ describe('GraphBuilder', () => {
790
1189
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
791
1190
 
792
1191
  // Should not throw, error is caught internally.
793
- Graph.expand(graph, 'parent');
1192
+ Graph.expand(graph, 'parent', 'child');
1193
+ await GraphBuilder.flush(builder);
794
1194
 
795
1195
  // Should return empty connections since the connector failed.
796
- const connections = registry.get(graph.connections('parent'));
1196
+ const connections = registry.get(graph.connections('parent', 'child'));
797
1197
  expect(connections).has.length(0);
798
1198
  });
799
1199
 
800
- test('actions failure is caught and logged, returns empty array', () => {
1200
+ test('actions failure is caught and logged, returns empty array', async () => {
801
1201
  const registry = Registry.make();
802
1202
  const builder = GraphBuilder.make({ registry });
803
1203
  const graph = builder.graph;
@@ -816,7 +1216,8 @@ describe('GraphBuilder', () => {
816
1216
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
817
1217
 
818
1218
  // Should not throw, error is caught internally.
819
- Graph.expand(graph, 'parent');
1219
+ Graph.expand(graph, 'parent', 'child');
1220
+ await GraphBuilder.flush(builder);
820
1221
 
821
1222
  // Should return empty actions since the actions callback failed.
822
1223
  const actions = registry.get(graph.actions('parent'));
@@ -846,7 +1247,7 @@ describe('GraphBuilder', () => {
846
1247
  expect(node).to.be.null;
847
1248
  });
848
1249
 
849
- test('failing extension does not affect other extensions', () => {
1250
+ test('failing extension does not affect other extensions', async () => {
850
1251
  const registry = Registry.make();
851
1252
  const builder = GraphBuilder.make({ registry });
852
1253
  const graph = builder.graph;
@@ -875,18 +1276,19 @@ describe('GraphBuilder', () => {
875
1276
 
876
1277
  const writableGraph = graph as Graph.WritableGraph;
877
1278
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
878
- Graph.expand(graph, 'parent');
1279
+ Graph.expand(graph, 'parent', 'child');
1280
+ await GraphBuilder.flush(builder);
879
1281
 
880
1282
  // The working extension should still produce its node.
881
- const connections = registry.get(graph.connections('parent'));
1283
+ const connections = registry.get(graph.connections('parent', 'child'));
882
1284
  expect(connections).has.length(1);
883
- expect(connections[0].id).to.equal('child-from-working');
1285
+ expect(connections[0].id).to.equal('parent/child-from-working');
884
1286
  expect(connections[0].data).to.equal('success');
885
1287
  });
886
1288
  });
887
1289
 
888
1290
  describe('createTypeExtension', () => {
889
- test('creates extension matching by schema type with inferred object type', () => {
1291
+ test('creates extension matching by schema type with inferred object type', async () => {
890
1292
  const registry = Registry.make();
891
1293
  const builder = GraphBuilder.make({ registry });
892
1294
  const graph = builder.graph;
@@ -904,13 +1306,204 @@ describe('GraphBuilder', () => {
904
1306
  const writableGraph = graph as Graph.WritableGraph;
905
1307
  const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
906
1308
  Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
907
- Graph.expand(graph, 'parent');
1309
+ Graph.expand(graph, 'parent', 'child');
1310
+ await GraphBuilder.flush(builder);
908
1311
 
909
- const connections = registry.get(graph.connections('parent'));
1312
+ const connections = registry.get(graph.connections('parent', 'child'));
910
1313
  expect(connections).has.length(1);
911
- expect(connections[0].id).to.equal('child');
1314
+ expect(connections[0].id).to.equal('parent/child');
912
1315
  expect(connections[0].data).to.equal(testObject);
913
1316
  });
914
1317
  });
915
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
+ });
916
1509
  });