@dxos/app-graph 0.8.4-main.ae835ea → 0.8.4-main.bc2380dfbc

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 (71) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  4. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
  6. package/dist/lib/neutral/chunk-WJJ5KEOH.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 +8 -0
  15. package/dist/types/src/atoms.d.ts.map +1 -0
  16. package/dist/types/src/graph-builder.d.ts +112 -66
  17. package/dist/types/src/graph-builder.d.ts.map +1 -1
  18. package/dist/types/src/graph.d.ts +187 -221
  19. package/dist/types/src/graph.d.ts.map +1 -1
  20. package/dist/types/src/index.d.ts +7 -3
  21. package/dist/types/src/index.d.ts.map +1 -1
  22. package/dist/types/src/node-matcher.d.ts +244 -0
  23. package/dist/types/src/node-matcher.d.ts.map +1 -0
  24. package/dist/types/src/node-matcher.test.d.ts +2 -0
  25. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  26. package/dist/types/src/node.d.ts +50 -5
  27. package/dist/types/src/node.d.ts.map +1 -1
  28. package/dist/types/src/scheduler.browser.d.ts +2 -0
  29. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  30. package/dist/types/src/scheduler.d.ts +8 -0
  31. package/dist/types/src/scheduler.d.ts.map +1 -0
  32. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  33. package/dist/types/src/testing/index.d.ts +2 -0
  34. package/dist/types/src/testing/index.d.ts.map +1 -0
  35. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  36. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  37. package/dist/types/src/util.d.ts +40 -0
  38. package/dist/types/src/util.d.ts.map +1 -0
  39. package/dist/types/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +53 -40
  41. package/src/atoms.ts +25 -0
  42. package/src/graph-builder.test.ts +1146 -137
  43. package/src/graph-builder.ts +732 -290
  44. package/src/graph.test.ts +450 -122
  45. package/src/graph.ts +1050 -401
  46. package/src/index.ts +10 -3
  47. package/src/node-matcher.test.ts +301 -0
  48. package/src/node-matcher.ts +314 -0
  49. package/src/node.ts +82 -8
  50. package/src/scheduler.browser.ts +5 -0
  51. package/src/scheduler.ts +17 -0
  52. package/src/stories/EchoGraph.stories.tsx +160 -123
  53. package/src/stories/Tree.tsx +1 -1
  54. package/src/testing/index.ts +5 -0
  55. package/src/testing/setup-graph-builder.ts +41 -0
  56. package/src/util.ts +101 -0
  57. package/dist/lib/browser/index.mjs +0 -836
  58. package/dist/lib/browser/index.mjs.map +0 -7
  59. package/dist/lib/browser/meta.json +0 -1
  60. package/dist/lib/node-esm/index.mjs +0 -838
  61. package/dist/lib/node-esm/index.mjs.map +0 -7
  62. package/dist/lib/node-esm/meta.json +0 -1
  63. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  64. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  65. package/dist/types/src/signals-integration.test.d.ts +0 -2
  66. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  67. package/dist/types/src/testing.d.ts +0 -5
  68. package/dist/types/src/testing.d.ts.map +0 -1
  69. package/src/experimental/graph-projections.test.ts +0 -56
  70. package/src/signals-integration.test.ts +0 -218
  71. package/src/testing.ts +0 -20
@@ -2,46 +2,51 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
6
8
  import * as Function from 'effect/Function';
7
9
  import * as Option from 'effect/Option';
8
10
  import { describe, expect, onTestFinished, test } from 'vitest';
9
11
 
10
- import { Trigger, sleep } from '@dxos/async';
12
+ import { Trigger } from '@dxos/async';
13
+ import { Obj } from '@dxos/echo';
14
+ import { TestSchema } from '@dxos/echo/testing';
11
15
 
12
- import { ROOT_ID } from './graph';
13
- import { GraphBuilder, createExtension } from './graph-builder';
14
- import { type Node } from './node';
16
+ import * as Graph from './graph';
17
+ import * as GraphBuilder from './graph-builder';
18
+ import * as Node from './node';
19
+ import * as NodeMatcher from './node-matcher';
20
+ import { qualifyId } from './util';
15
21
 
16
22
  const exampleId = (id: number) => `dx:test:${id}`;
17
23
  const EXAMPLE_ID = exampleId(1);
18
- const EXAMPLE_TYPE = 'dxos.org/type/example';
24
+ const EXAMPLE_TYPE = 'org.dxos.type.example';
19
25
 
20
26
  describe('GraphBuilder', () => {
21
27
  describe('resolver', () => {
22
28
  test('works', async () => {
23
29
  const registry = Registry.make();
24
- const builder = new GraphBuilder({ registry });
30
+ const builder = GraphBuilder.make({ registry });
25
31
  const graph = builder.graph;
26
32
 
27
33
  {
28
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
34
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
29
35
  expect(node).to.be.null;
30
36
  }
31
37
 
32
- builder.addExtension(
33
- createExtension({
38
+ // Test direct API
39
+ GraphBuilder.addExtension(
40
+ builder,
41
+ GraphBuilder.createExtensionRaw({
34
42
  id: 'resolver',
35
- resolver: () => {
36
- console.log('resolver');
37
- return Rx.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
38
- },
43
+ resolver: () => Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }),
39
44
  }),
40
45
  );
41
- await graph.initialize(EXAMPLE_ID);
46
+ await Graph.initialize(graph, EXAMPLE_ID);
42
47
 
43
48
  {
44
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
49
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
45
50
  expect(node?.id).to.equal(EXAMPLE_ID);
46
51
  expect(node?.type).to.equal(EXAMPLE_TYPE);
47
52
  expect(node?.data).to.equal(1);
@@ -50,132 +55,326 @@ describe('GraphBuilder', () => {
50
55
 
51
56
  test('updates', async () => {
52
57
  const registry = Registry.make();
53
- const builder = new GraphBuilder({ registry });
54
- const name = Rx.make('default');
55
- builder.addExtension(
56
- createExtension({
58
+ const builder = GraphBuilder.make({ registry });
59
+ const name = Atom.make('default');
60
+ GraphBuilder.addExtension(
61
+ builder,
62
+ GraphBuilder.createExtensionRaw({
57
63
  id: 'resolver',
58
- resolver: () => Rx.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
64
+ resolver: () => Atom.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
59
65
  }),
60
66
  );
61
67
  const graph = builder.graph;
62
- await graph.initialize(EXAMPLE_ID);
68
+ await Graph.initialize(graph, EXAMPLE_ID);
63
69
 
64
70
  {
65
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
71
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
66
72
  expect(node?.data).to.equal('default');
67
73
  }
68
74
 
69
75
  registry.set(name, 'updated');
70
76
 
71
77
  {
72
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
78
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
73
79
  expect(node?.data).to.equal('updated');
74
80
  }
75
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
+ });
76
263
  });
77
264
 
78
265
  describe('connector', () => {
79
- test('works', () => {
266
+ test('works', async () => {
80
267
  const registry = Registry.make();
81
- const builder = new GraphBuilder({ registry });
82
- builder.addExtension(
83
- createExtension({
268
+ const builder = GraphBuilder.make({ registry });
269
+ GraphBuilder.addExtension(
270
+ builder,
271
+ GraphBuilder.createExtensionRaw({
84
272
  id: 'outbound-connector',
85
- connector: () => Rx.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
273
+ connector: () => Atom.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
86
274
  }),
87
275
  );
88
- builder.addExtension(
89
- createExtension({
276
+ GraphBuilder.addExtension(
277
+ builder,
278
+ GraphBuilder.createExtensionRaw({
90
279
  id: 'inbound-connector',
91
- relation: 'inbound',
92
- connector: () => Rx.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
280
+ relation: Node.childRelation('inbound'),
281
+ connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
93
282
  }),
94
283
  );
95
284
 
96
285
  const graph = builder.graph;
97
- graph.expand(ROOT_ID);
98
- graph.expand(ROOT_ID, 'inbound');
286
+ Graph.expand(graph, Node.RootId, 'child');
287
+ Graph.expand(graph, Node.RootId, Node.childRelation('inbound'));
288
+ await GraphBuilder.flush(builder);
99
289
 
100
- const outbound = registry.get(graph.connections(ROOT_ID));
101
- const inbound = registry.get(graph.connections(ROOT_ID, 'inbound'));
290
+ const outbound = registry.get(graph.connections(Node.RootId, 'child'));
291
+ const inbound = registry.get(graph.connections(Node.RootId, Node.childRelation('inbound')));
102
292
 
103
293
  expect(outbound).has.length(1);
104
- expect(outbound[0].id).to.equal('child');
294
+ expect(outbound[0].id).to.equal('root/child');
105
295
  expect(outbound[0].data).to.equal(2);
106
296
  expect(inbound).has.length(1);
107
- expect(inbound[0].id).to.equal('parent');
297
+ expect(inbound[0].id).to.equal('root/parent');
108
298
  expect(inbound[0].data).to.equal(0);
109
299
  });
110
300
 
111
- test('updates', () => {
301
+ test('updates', async () => {
112
302
  const registry = Registry.make();
113
- const builder = new GraphBuilder({ registry });
114
- const state = Rx.make(0);
115
- builder.addExtension(
116
- createExtension({
303
+ const builder = GraphBuilder.make({ registry });
304
+ const state = Atom.make(0);
305
+ GraphBuilder.addExtension(
306
+ builder,
307
+ GraphBuilder.createExtensionRaw({
117
308
  id: 'connector',
118
- connector: () => Rx.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
309
+ connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
119
310
  }),
120
311
  );
121
312
  const graph = builder.graph;
122
- graph.expand(ROOT_ID);
313
+ Graph.expand(graph, Node.RootId, 'child');
314
+ await GraphBuilder.flush(builder);
123
315
 
124
316
  {
125
- const [node] = registry.get(graph.connections(ROOT_ID));
317
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
126
318
  expect(node.data).to.equal(0);
127
319
  }
128
320
 
129
321
  {
130
322
  registry.set(state, 1);
131
- const [node] = registry.get(graph.connections(ROOT_ID));
323
+ await GraphBuilder.flush(builder);
324
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
132
325
  expect(node.data).to.equal(1);
133
326
  }
134
327
  });
135
328
 
136
- test('subscribes to updates', () => {
329
+ test('subscribes to updates', async () => {
137
330
  const registry = Registry.make();
138
- const builder = new GraphBuilder({ registry });
139
- const state = Rx.make(0);
140
- builder.addExtension(
141
- createExtension({
331
+ const builder = GraphBuilder.make({ registry });
332
+ const state = Atom.make(0);
333
+ GraphBuilder.addExtension(
334
+ builder,
335
+ GraphBuilder.createExtensionRaw({
142
336
  id: 'connector',
143
- connector: () => Rx.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
337
+ connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
144
338
  }),
145
339
  );
146
340
  const graph = builder.graph;
147
341
 
148
342
  let count = 0;
149
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_) => {
343
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_) => {
150
344
  count++;
151
345
  });
152
346
  onTestFinished(() => cancel());
153
347
 
154
348
  expect(count).to.equal(0);
155
- expect(registry.get(graph.connections(ROOT_ID))).to.have.length(0);
349
+ expect(registry.get(graph.connections(Node.RootId, 'child'))).to.have.length(0);
156
350
  expect(count).to.equal(1);
157
351
 
158
- graph.expand(ROOT_ID);
352
+ Graph.expand(graph, Node.RootId, 'child');
353
+ await GraphBuilder.flush(builder);
159
354
  expect(count).to.equal(2);
355
+
160
356
  registry.set(state, 1);
357
+ await GraphBuilder.flush(builder);
161
358
  expect(count).to.equal(3);
162
359
  });
163
360
 
164
- test('updates with new extensions', () => {
361
+ test('updates with new extensions', async () => {
165
362
  const registry = Registry.make();
166
- const builder = new GraphBuilder({ registry });
167
- builder.addExtension(
168
- createExtension({
363
+ const builder = GraphBuilder.make({ registry });
364
+ GraphBuilder.addExtension(
365
+ builder,
366
+ GraphBuilder.createExtensionRaw({
169
367
  id: 'connector',
170
- connector: () => Rx.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
368
+ connector: () => Atom.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
171
369
  }),
172
370
  );
173
371
  const graph = builder.graph;
174
- graph.expand(ROOT_ID);
372
+ Graph.expand(graph, Node.RootId, 'child');
373
+ await GraphBuilder.flush(builder);
175
374
 
176
- let nodes: Node[] = [];
375
+ let nodes: Node.Node[] = [];
177
376
  let count = 0;
178
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_nodes) => {
377
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_nodes) => {
179
378
  count++;
180
379
  nodes = _nodes;
181
380
  });
@@ -183,62 +382,67 @@ describe('GraphBuilder', () => {
183
382
 
184
383
  expect(nodes).has.length(0);
185
384
  expect(count).to.equal(0);
186
- registry.get(graph.connections(ROOT_ID));
385
+ registry.get(graph.connections(Node.RootId, 'child'));
187
386
  expect(nodes).has.length(1);
188
387
  expect(count).to.equal(1);
189
388
 
190
- builder.addExtension(
191
- createExtension({
389
+ GraphBuilder.addExtension(
390
+ builder,
391
+ GraphBuilder.createExtensionRaw({
192
392
  id: 'connector-2',
193
- connector: () => Rx.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
393
+ connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
194
394
  }),
195
395
  );
396
+ await GraphBuilder.flush(builder);
196
397
  expect(nodes).has.length(2);
197
398
  expect(count).to.equal(2);
198
399
  });
199
400
 
200
- test('removes', () => {
401
+ test('removes', async () => {
201
402
  const registry = Registry.make();
202
- const builder = new GraphBuilder({ registry });
203
- const nodes = Rx.make([
403
+ const builder = GraphBuilder.make({ registry });
404
+ const nodes = Atom.make([
204
405
  { id: exampleId(1), type: EXAMPLE_TYPE },
205
406
  { id: exampleId(2), type: EXAMPLE_TYPE },
206
407
  ]);
207
- builder.addExtension(
208
- createExtension({
408
+ GraphBuilder.addExtension(
409
+ builder,
410
+ GraphBuilder.createExtensionRaw({
209
411
  id: 'connector',
210
- connector: () => Rx.make((get) => get(nodes)),
412
+ connector: () => Atom.make((get) => get(nodes)),
211
413
  }),
212
414
  );
213
415
  const graph = builder.graph;
214
- graph.expand(ROOT_ID);
416
+ Graph.expand(graph, Node.RootId, 'child');
417
+ await GraphBuilder.flush(builder);
215
418
 
216
419
  {
217
- const nodes = registry.get(graph.connections(ROOT_ID));
420
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
218
421
  expect(nodes).has.length(2);
219
- expect(nodes[0].id).to.equal(exampleId(1));
220
- 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)));
221
424
  }
222
425
 
223
426
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
427
+ await GraphBuilder.flush(builder);
224
428
 
225
429
  {
226
- const nodes = registry.get(graph.connections(ROOT_ID));
430
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
227
431
  expect(nodes).has.length(1);
228
- expect(nodes[0].id).to.equal(exampleId(3));
432
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
229
433
  }
230
434
  });
231
435
 
232
- test('nodes are updated when removed', () => {
436
+ test('nodes are updated when removed', async () => {
233
437
  const registry = Registry.make();
234
- const builder = new GraphBuilder({ registry });
235
- const name = Rx.make('removed');
438
+ const builder = GraphBuilder.make({ registry });
439
+ const name = Atom.make('removed');
236
440
 
237
- builder.addExtension([
238
- createExtension({
441
+ GraphBuilder.addExtension(builder, [
442
+ GraphBuilder.createExtensionRaw({
239
443
  id: 'root',
240
444
  connector: (node) =>
241
- Rx.make((get) =>
445
+ Atom.make((get) =>
242
446
  Function.pipe(
243
447
  get(node),
244
448
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
@@ -254,52 +458,184 @@ describe('GraphBuilder', () => {
254
458
 
255
459
  let count = 0;
256
460
  let exists = false;
257
- const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
461
+ const cancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (node) => {
258
462
  count++;
259
463
  exists = Option.isSome(node);
260
464
  });
261
465
  onTestFinished(() => cancel());
262
466
 
263
- graph.expand(ROOT_ID);
467
+ Graph.expand(graph, Node.RootId, 'child');
468
+ await GraphBuilder.flush(builder);
264
469
  expect(count).to.equal(0);
265
470
  expect(exists).to.be.false;
266
471
 
267
472
  registry.set(name, 'default');
473
+ await GraphBuilder.flush(builder);
268
474
  expect(count).to.equal(1);
269
475
  expect(exists).to.be.true;
270
476
 
271
477
  registry.set(name, 'removed');
478
+ await GraphBuilder.flush(builder);
272
479
  expect(count).to.equal(2);
273
480
  expect(exists).to.be.false;
274
481
 
275
482
  registry.set(name, 'added');
483
+ await GraphBuilder.flush(builder);
276
484
  expect(count).to.equal(3);
277
485
  expect(exists).to.be.true;
278
486
  });
279
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
+
280
614
  test('sort edges', async () => {
281
615
  const registry = Registry.make();
282
- const builder = new GraphBuilder({ registry });
283
- const nodes = Rx.make([
616
+ const builder = GraphBuilder.make({ registry });
617
+ const nodes = Atom.make([
284
618
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
285
619
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
286
620
  { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
287
621
  ]);
288
- builder.addExtension(
289
- createExtension({
622
+ GraphBuilder.addExtension(
623
+ builder,
624
+ GraphBuilder.createExtensionRaw({
290
625
  id: 'connector',
291
- connector: () => Rx.make((get) => get(nodes)),
626
+ connector: () => Atom.make((get) => get(nodes)),
292
627
  }),
293
628
  );
294
629
  const graph = builder.graph;
295
- graph.expand(ROOT_ID);
630
+ Graph.expand(graph, Node.RootId, 'child');
631
+ await GraphBuilder.flush(builder);
296
632
 
297
633
  {
298
- const nodes = registry.get(graph.connections(ROOT_ID));
634
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
299
635
  expect(nodes).has.length(3);
300
- expect(nodes[0].id).to.equal(exampleId(1));
301
- expect(nodes[1].id).to.equal(exampleId(2));
302
- 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)));
303
639
  }
304
640
 
305
641
  registry.set(nodes, [
@@ -307,30 +643,28 @@ describe('GraphBuilder', () => {
307
643
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
308
644
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
309
645
  ]);
310
-
311
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
312
- await sleep(0);
646
+ await GraphBuilder.flush(builder);
313
647
 
314
648
  {
315
- const nodes = registry.get(graph.connections(ROOT_ID));
649
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
316
650
  expect(nodes).has.length(3);
317
- expect(nodes[0].id).to.equal(exampleId(3));
318
- expect(nodes[1].id).to.equal(exampleId(1));
319
- 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)));
320
654
  }
321
655
  });
322
656
 
323
- test('updates are constrained', () => {
657
+ test('updates are constrained', async () => {
324
658
  const registry = Registry.make();
325
- const builder = new GraphBuilder({ registry });
326
- const name = Rx.make('default');
327
- const sub = Rx.make('default');
659
+ const builder = GraphBuilder.make({ registry });
660
+ const name = Atom.make('default');
661
+ const sub = Atom.make('default');
328
662
 
329
- builder.addExtension([
330
- createExtension({
663
+ GraphBuilder.addExtension(builder, [
664
+ GraphBuilder.createExtensionRaw({
331
665
  id: 'root',
332
666
  connector: (node) =>
333
- Rx.make((get) =>
667
+ Atom.make((get) =>
334
668
  Function.pipe(
335
669
  get(node),
336
670
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
@@ -340,25 +674,29 @@ describe('GraphBuilder', () => {
340
674
  ),
341
675
  ),
342
676
  }),
343
- createExtension({
677
+ GraphBuilder.createExtensionRaw({
344
678
  id: 'connector1',
345
679
  connector: (node) =>
346
- Rx.make((get) =>
680
+ Atom.make((get) =>
347
681
  Function.pipe(
348
682
  get(node),
349
- 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
+ ),
350
686
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
351
687
  Option.getOrElse(() => []),
352
688
  ),
353
689
  ),
354
690
  }),
355
- createExtension({
691
+ GraphBuilder.createExtensionRaw({
356
692
  id: 'connector2',
357
693
  connector: (node) =>
358
- Rx.make((get) =>
694
+ Atom.make((get) =>
359
695
  Function.pipe(
360
696
  get(node),
361
- 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
+ ),
362
700
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
363
701
  Option.getOrElse(() => []),
364
702
  ),
@@ -369,64 +707,71 @@ describe('GraphBuilder', () => {
369
707
  const graph = builder.graph;
370
708
 
371
709
  let parentCount = 0;
372
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
710
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
373
711
  parentCount++;
374
712
  });
375
713
  onTestFinished(() => parentCancel());
376
714
 
377
715
  let independentCount = 0;
378
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
716
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
379
717
  independentCount++;
380
718
  });
381
719
  onTestFinished(() => independentCancel());
382
720
 
383
721
  let dependentCount = 0;
384
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
722
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
385
723
  dependentCount++;
386
724
  });
387
725
  onTestFinished(() => dependentCancel());
388
726
 
389
727
  // Counts should not increment until the node is expanded.
390
- graph.expand(ROOT_ID);
728
+ Graph.expand(graph, Node.RootId, 'child');
729
+ await GraphBuilder.flush(builder);
391
730
  expect(parentCount).to.equal(1);
392
731
  expect(independentCount).to.equal(0);
393
732
  expect(dependentCount).to.equal(0);
394
733
 
395
734
  // Counts should increment when the node is expanded.
396
- graph.expand(EXAMPLE_ID);
735
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
736
+ await GraphBuilder.flush(builder);
397
737
  expect(parentCount).to.equal(1);
398
738
  expect(independentCount).to.equal(1);
399
739
  expect(dependentCount).to.equal(1);
400
740
 
401
741
  // Only dependent count should increment when the parent changes.
402
742
  registry.set(name, 'updated');
743
+ await GraphBuilder.flush(builder);
403
744
  expect(parentCount).to.equal(2);
404
745
  expect(independentCount).to.equal(1);
405
746
  expect(dependentCount).to.equal(2);
406
747
 
407
748
  // Only independent count should increment when its state changes.
408
749
  registry.set(sub, 'updated');
750
+ await GraphBuilder.flush(builder);
409
751
  expect(parentCount).to.equal(2);
410
752
  expect(independentCount).to.equal(2);
411
753
  expect(dependentCount).to.equal(2);
412
754
 
413
755
  // Independent count should update if its state changes even if the parent is removed.
414
- Rx.batch(() => {
756
+ Atom.batch(() => {
415
757
  registry.set(name, 'removed');
416
758
  registry.set(sub, 'batch');
417
759
  });
760
+ await GraphBuilder.flush(builder);
418
761
  expect(parentCount).to.equal(2);
419
762
  expect(independentCount).to.equal(3);
420
763
  expect(dependentCount).to.equal(2);
421
764
 
422
765
  // Dependent count should increment when the node is added back.
423
766
  registry.set(name, 'added');
767
+ await GraphBuilder.flush(builder);
424
768
  expect(parentCount).to.equal(3);
425
769
  expect(independentCount).to.equal(3);
426
770
  expect(dependentCount).to.equal(3);
427
771
 
428
772
  // Counts should not increment when the node is expanded again.
429
- graph.expand(EXAMPLE_ID);
773
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
774
+ await GraphBuilder.flush(builder);
430
775
  expect(parentCount).to.equal(3);
431
776
  expect(independentCount).to.equal(3);
432
777
  expect(dependentCount).to.equal(3);
@@ -434,12 +779,13 @@ describe('GraphBuilder', () => {
434
779
 
435
780
  test('eager graph expansion', async () => {
436
781
  const registry = Registry.make();
437
- const builder = new GraphBuilder({ registry });
438
- builder.addExtension(
439
- createExtension({
782
+ const builder = GraphBuilder.make({ registry });
783
+ GraphBuilder.addExtension(
784
+ builder,
785
+ GraphBuilder.createExtensionRaw({
440
786
  id: 'connector',
441
787
  connector: (node) => {
442
- return Rx.make((get) =>
788
+ return Atom.make((get) =>
443
789
  Function.pipe(
444
790
  get(node),
445
791
  Option.map((node) => (node.data ? node.data + 1 : 1)),
@@ -455,14 +801,14 @@ describe('GraphBuilder', () => {
455
801
  let count = 0;
456
802
  const trigger = new Trigger();
457
803
  builder.graph.onNodeChanged.on(({ id }) => {
458
- builder.graph.expand(id);
804
+ Graph.expand(builder.graph, id, 'child');
459
805
  count++;
460
806
  if (count === 5) {
461
807
  trigger.wake();
462
808
  }
463
809
  });
464
810
 
465
- builder.graph.expand(ROOT_ID);
811
+ Graph.expand(builder.graph, Node.RootId, 'child');
466
812
  await trigger.wait();
467
813
  expect(count).to.equal(5);
468
814
  });
@@ -470,12 +816,13 @@ describe('GraphBuilder', () => {
470
816
 
471
817
  describe('explore', () => {
472
818
  test('works', async () => {
473
- const builder = new GraphBuilder();
474
- builder.addExtension(
475
- createExtension({
819
+ const builder = GraphBuilder.make();
820
+ GraphBuilder.addExtension(
821
+ builder,
822
+ GraphBuilder.createExtensionRaw({
476
823
  id: 'connector',
477
824
  connector: (node) =>
478
- Rx.make((get) =>
825
+ Atom.make((get) =>
479
826
  Function.pipe(
480
827
  get(node),
481
828
  Option.map((node) => (node.data ? node.data + 1 : 1)),
@@ -488,7 +835,8 @@ describe('GraphBuilder', () => {
488
835
  );
489
836
 
490
837
  let count = 0;
491
- await builder.explore({
838
+ await GraphBuilder.explore(builder, {
839
+ relation: 'child',
492
840
  visitor: () => {
493
841
  count++;
494
842
  },
@@ -497,4 +845,665 @@ describe('GraphBuilder', () => {
497
845
  expect(count).to.equal(6);
498
846
  });
499
847
  });
848
+
849
+ describe('helpers', () => {
850
+ describe('createConnector', () => {
851
+ test('creates connector with type inference', async () => {
852
+ const registry = Registry.make();
853
+ const builder = GraphBuilder.make({ registry });
854
+ const graph = builder.graph;
855
+
856
+ const matcher = (node: Node.Node) => NodeMatcher.whenId('root')(node);
857
+ const factory = (node: Node.Node) => [{ id: 'child', type: EXAMPLE_TYPE, data: node.id }];
858
+
859
+ const connector = GraphBuilder.createConnector(matcher, factory);
860
+
861
+ GraphBuilder.addExtension(
862
+ builder,
863
+ GraphBuilder.createExtensionRaw({
864
+ id: 'test-connector',
865
+ connector,
866
+ }),
867
+ );
868
+
869
+ Graph.expand(graph, Node.RootId, 'child');
870
+ await GraphBuilder.flush(builder);
871
+
872
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
873
+ expect(connections).has.length(1);
874
+ expect(connections[0].id).to.equal('root/child');
875
+ });
876
+ });
877
+
878
+ describe('createExtension', () => {
879
+ test('works with Effect connector', async () => {
880
+ const registry = Registry.make();
881
+ const builder = GraphBuilder.make({ registry });
882
+ const graph = builder.graph;
883
+
884
+ const extensions = Effect.runSync(
885
+ GraphBuilder.createExtension({
886
+ id: 'test-extension',
887
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
888
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
889
+ }),
890
+ );
891
+
892
+ GraphBuilder.addExtension(builder, extensions);
893
+
894
+ const writableGraph = graph as Graph.WritableGraph;
895
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
896
+ Graph.expand(graph, 'parent', 'child');
897
+ await GraphBuilder.flush(builder);
898
+
899
+ const connections = registry.get(graph.connections('parent', 'child'));
900
+ expect(connections).has.length(1);
901
+ expect(connections[0].id).to.equal('parent/child');
902
+ expect(connections[0].data).to.equal('test');
903
+ });
904
+
905
+ test('works with Effect actions', async () => {
906
+ const registry = Registry.make();
907
+ const builder = GraphBuilder.make({ registry });
908
+ const graph = builder.graph;
909
+
910
+ const extensions = Effect.runSync(
911
+ GraphBuilder.createExtension({
912
+ id: 'test-extension',
913
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
914
+ actions: (node, get) =>
915
+ Effect.succeed([
916
+ {
917
+ id: 'test-action',
918
+ data: () => Effect.void,
919
+ properties: { label: 'Test' },
920
+ },
921
+ ]),
922
+ }),
923
+ );
924
+
925
+ GraphBuilder.addExtension(builder, extensions);
926
+
927
+ const writableGraph = graph as Graph.WritableGraph;
928
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
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);
997
+
998
+ const edges = registry.get(graph.edges('parent'));
999
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
1000
+ const actions = registry.get(graph.actions('parent'));
1001
+ expect(actions).has.length(1);
1002
+ expect(actions[0].id).to.equal('parent/late-act');
1003
+ });
1004
+
1005
+ test('_actionContext captures and provides services to action execution', async () => {
1006
+ const registry = Registry.make();
1007
+ const builder = GraphBuilder.make({ registry });
1008
+ const graph = builder.graph;
1009
+
1010
+ // Define a test service using Context.GenericTag pattern.
1011
+ interface TestServiceInterface {
1012
+ getValue(): number;
1013
+ }
1014
+ const TestService = Context.GenericTag<TestServiceInterface>('TestService');
1015
+
1016
+ // Track whether the action was executed with the correct context.
1017
+ let executionResult: number | null = null;
1018
+
1019
+ // Create extension with service requirement.
1020
+ // Note: The actions callback must USE the service for R to be inferred correctly.
1021
+ const extensions = Effect.runSync(
1022
+ GraphBuilder.createExtension({
1023
+ id: 'test-extension',
1024
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1025
+ actions: (node, get) =>
1026
+ // Use TestService in the callback to include it in R.
1027
+ Effect.gen(function* () {
1028
+ const service = yield* TestService;
1029
+ return [
1030
+ {
1031
+ id: 'test-action',
1032
+ data: () =>
1033
+ Effect.gen(function* () {
1034
+ // Action can use the same service from captured context.
1035
+ const svc = yield* TestService;
1036
+ executionResult = svc.getValue();
1037
+ }).pipe(Effect.asVoid),
1038
+ properties: { label: `Test ${service.getValue()}` },
1039
+ },
1040
+ ];
1041
+ }),
1042
+ }).pipe(Effect.provideService(TestService, { getValue: () => 42 })),
1043
+ );
1044
+
1045
+ GraphBuilder.addExtension(builder, extensions);
1046
+
1047
+ const writableGraph = graph as Graph.WritableGraph;
1048
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1049
+ Graph.expand(graph, 'parent', 'child');
1050
+ await GraphBuilder.flush(builder);
1051
+
1052
+ const actions = registry.get(graph.actions('parent'));
1053
+ expect(actions).has.length(1);
1054
+
1055
+ // Verify _actionContext is captured.
1056
+ const action = actions[0] as Node.Action;
1057
+ expect(action._actionContext).to.not.be.undefined;
1058
+
1059
+ // Execute the action with the captured context.
1060
+ const actionEffect = action.data();
1061
+ const effectWithContext = action._actionContext
1062
+ ? actionEffect.pipe(Effect.provide(action._actionContext))
1063
+ : actionEffect;
1064
+
1065
+ Effect.runSync(effectWithContext);
1066
+
1067
+ // Verify the service was accessible during execution.
1068
+ expect(executionResult).to.equal(42);
1069
+ });
1070
+
1071
+ test('works with resolver', async () => {
1072
+ const registry = Registry.make();
1073
+ const builder = GraphBuilder.make({ registry });
1074
+ const graph = builder.graph;
1075
+
1076
+ const extensions = Effect.runSync(
1077
+ GraphBuilder.createExtension({
1078
+ id: 'test-extension',
1079
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1080
+ resolver: (id, get) => Effect.succeed({ id, type: EXAMPLE_TYPE, properties: {}, data: 'resolved' }),
1081
+ }),
1082
+ );
1083
+
1084
+ GraphBuilder.addExtension(builder, extensions);
1085
+ await Graph.initialize(graph, EXAMPLE_ID);
1086
+
1087
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
1088
+ expect(node).to.not.be.null;
1089
+ expect(node?.id).to.equal(EXAMPLE_ID);
1090
+ expect(node?.data).to.equal('resolved');
1091
+ });
1092
+
1093
+ test('works with connector and actions together', async () => {
1094
+ const registry = Registry.make();
1095
+ const builder = GraphBuilder.make({ registry });
1096
+ const graph = builder.graph;
1097
+
1098
+ const extensions = Effect.runSync(
1099
+ GraphBuilder.createExtension({
1100
+ id: 'test-extension',
1101
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1102
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
1103
+ actions: (node, get) =>
1104
+ Effect.succeed([
1105
+ {
1106
+ id: 'test-action',
1107
+ data: () => Effect.void,
1108
+ properties: { label: 'Test' },
1109
+ },
1110
+ ]),
1111
+ }),
1112
+ );
1113
+
1114
+ GraphBuilder.addExtension(builder, extensions);
1115
+
1116
+ const writableGraph = graph as Graph.WritableGraph;
1117
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1118
+ Graph.expand(graph, 'parent', 'child');
1119
+ await GraphBuilder.flush(builder);
1120
+
1121
+ const connections = registry.get(graph.connections('parent', 'child'));
1122
+ // Should have both the child node and the action node.
1123
+ expect(connections.length).to.be.greaterThanOrEqual(1);
1124
+ const childNode = connections.find((n) => n.id === 'parent/child');
1125
+ expect(childNode).to.not.be.undefined;
1126
+ expect(childNode?.data).to.equal('test');
1127
+
1128
+ const actions = registry.get(graph.actions('parent'));
1129
+ expect(actions).has.length(1);
1130
+ expect(actions[0].id).to.equal('parent/test-action');
1131
+ });
1132
+
1133
+ test('works with reactive connector using get context', async () => {
1134
+ const registry = Registry.make();
1135
+ const builder = GraphBuilder.make({ registry });
1136
+ const graph = builder.graph;
1137
+
1138
+ const state = Atom.make('initial');
1139
+
1140
+ const extensions = Effect.runSync(
1141
+ GraphBuilder.createExtension({
1142
+ id: 'test-extension',
1143
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1144
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: get(state) }]),
1145
+ }),
1146
+ );
1147
+
1148
+ GraphBuilder.addExtension(builder, extensions);
1149
+
1150
+ const writableGraph = graph as Graph.WritableGraph;
1151
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1152
+ Graph.expand(graph, 'parent', 'child');
1153
+ await GraphBuilder.flush(builder);
1154
+
1155
+ {
1156
+ const connections = registry.get(graph.connections('parent', 'child'));
1157
+ expect(connections).has.length(1);
1158
+ expect(connections[0].data).to.equal('initial');
1159
+ }
1160
+
1161
+ registry.set(state, 'updated');
1162
+ await GraphBuilder.flush(builder);
1163
+
1164
+ {
1165
+ const connections = registry.get(graph.connections('parent', 'child'));
1166
+ expect(connections).has.length(1);
1167
+ expect(connections[0].data).to.equal('updated');
1168
+ }
1169
+ });
1170
+ });
1171
+
1172
+ describe('extension error handling', () => {
1173
+ test('connector failure is caught and logged, returns empty array', async () => {
1174
+ const registry = Registry.make();
1175
+ const builder = GraphBuilder.make({ registry });
1176
+ const graph = builder.graph;
1177
+
1178
+ const extensions = Effect.runSync(
1179
+ GraphBuilder.createExtension({
1180
+ id: 'failing-extension',
1181
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1182
+ connector: (node, get) => Effect.fail(new Error('Connector failed intentionally')),
1183
+ }),
1184
+ );
1185
+
1186
+ GraphBuilder.addExtension(builder, extensions);
1187
+
1188
+ const writableGraph = graph as Graph.WritableGraph;
1189
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1190
+
1191
+ // Should not throw, error is caught internally.
1192
+ Graph.expand(graph, 'parent', 'child');
1193
+ await GraphBuilder.flush(builder);
1194
+
1195
+ // Should return empty connections since the connector failed.
1196
+ const connections = registry.get(graph.connections('parent', 'child'));
1197
+ expect(connections).has.length(0);
1198
+ });
1199
+
1200
+ test('actions failure is caught and logged, returns empty array', async () => {
1201
+ const registry = Registry.make();
1202
+ const builder = GraphBuilder.make({ registry });
1203
+ const graph = builder.graph;
1204
+
1205
+ const extensions = Effect.runSync(
1206
+ GraphBuilder.createExtension({
1207
+ id: 'failing-actions-extension',
1208
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1209
+ actions: (node, get) => Effect.fail(new Error('Actions failed intentionally')),
1210
+ }),
1211
+ );
1212
+
1213
+ GraphBuilder.addExtension(builder, extensions);
1214
+
1215
+ const writableGraph = graph as Graph.WritableGraph;
1216
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1217
+
1218
+ // Should not throw, error is caught internally.
1219
+ Graph.expand(graph, 'parent', 'child');
1220
+ await GraphBuilder.flush(builder);
1221
+
1222
+ // Should return empty actions since the actions callback failed.
1223
+ const actions = registry.get(graph.actions('parent'));
1224
+ expect(actions).has.length(0);
1225
+ });
1226
+
1227
+ test('resolver failure is caught and logged, returns null', async () => {
1228
+ const registry = Registry.make();
1229
+ const builder = GraphBuilder.make({ registry });
1230
+ const graph = builder.graph;
1231
+
1232
+ const extensions = Effect.runSync(
1233
+ GraphBuilder.createExtension({
1234
+ id: 'failing-resolver-extension',
1235
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1236
+ resolver: (id, get) => Effect.fail(new Error('Resolver failed intentionally')),
1237
+ }),
1238
+ );
1239
+
1240
+ GraphBuilder.addExtension(builder, extensions);
1241
+
1242
+ // Should not throw, error is caught internally.
1243
+ await Graph.initialize(graph, EXAMPLE_ID);
1244
+
1245
+ // Should return null/none since the resolver failed.
1246
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
1247
+ expect(node).to.be.null;
1248
+ });
1249
+
1250
+ test('failing extension does not affect other extensions', async () => {
1251
+ const registry = Registry.make();
1252
+ const builder = GraphBuilder.make({ registry });
1253
+ const graph = builder.graph;
1254
+
1255
+ // Add a failing extension.
1256
+ const failingExtensions = Effect.runSync(
1257
+ GraphBuilder.createExtension({
1258
+ id: 'failing-extension',
1259
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1260
+ connector: (node, get) => Effect.fail(new Error('This one fails')),
1261
+ }),
1262
+ );
1263
+
1264
+ // Add a working extension.
1265
+ const workingExtensions = Effect.runSync(
1266
+ GraphBuilder.createExtension({
1267
+ id: 'working-extension',
1268
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1269
+ connector: (node, get) =>
1270
+ Effect.succeed([{ id: 'child-from-working', type: EXAMPLE_TYPE, data: 'success' }]),
1271
+ }),
1272
+ );
1273
+
1274
+ GraphBuilder.addExtension(builder, failingExtensions);
1275
+ GraphBuilder.addExtension(builder, workingExtensions);
1276
+
1277
+ const writableGraph = graph as Graph.WritableGraph;
1278
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1279
+ Graph.expand(graph, 'parent', 'child');
1280
+ await GraphBuilder.flush(builder);
1281
+
1282
+ // The working extension should still produce its node.
1283
+ const connections = registry.get(graph.connections('parent', 'child'));
1284
+ expect(connections).has.length(1);
1285
+ expect(connections[0].id).to.equal('parent/child-from-working');
1286
+ expect(connections[0].data).to.equal('success');
1287
+ });
1288
+ });
1289
+
1290
+ describe('createTypeExtension', () => {
1291
+ test('creates extension matching by schema type with inferred object type', async () => {
1292
+ const registry = Registry.make();
1293
+ const builder = GraphBuilder.make({ registry });
1294
+ const graph = builder.graph;
1295
+
1296
+ const extensions = Effect.runSync(
1297
+ GraphBuilder.createTypeExtension({
1298
+ id: 'type-extension',
1299
+ type: TestSchema.Person,
1300
+ connector: (object) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: object }]),
1301
+ }),
1302
+ );
1303
+
1304
+ GraphBuilder.addExtension(builder, extensions);
1305
+
1306
+ const writableGraph = graph as Graph.WritableGraph;
1307
+ const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
1308
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
1309
+ Graph.expand(graph, 'parent', 'child');
1310
+ await GraphBuilder.flush(builder);
1311
+
1312
+ const connections = registry.get(graph.connections('parent', 'child'));
1313
+ expect(connections).has.length(1);
1314
+ expect(connections[0].id).to.equal('parent/child');
1315
+ expect(connections[0].data).to.equal(testObject);
1316
+ });
1317
+ });
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
+ });
500
1509
  });