@dxos/app-graph 0.8.3 → 0.8.4-main.03d5cd7b56

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 (73) hide show
  1. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  2. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  3. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
  4. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs.map +7 -0
  5. package/dist/lib/neutral/index.mjs +40 -0
  6. package/dist/lib/neutral/index.mjs.map +7 -0
  7. package/dist/lib/neutral/meta.json +1 -0
  8. package/dist/lib/neutral/scheduler.mjs +15 -0
  9. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  10. package/dist/lib/neutral/testing/index.mjs +40 -0
  11. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  12. package/dist/types/src/atoms.d.ts +8 -0
  13. package/dist/types/src/atoms.d.ts.map +1 -0
  14. package/dist/types/src/graph-builder.d.ts +117 -60
  15. package/dist/types/src/graph-builder.d.ts.map +1 -1
  16. package/dist/types/src/graph.d.ts +188 -218
  17. package/dist/types/src/graph.d.ts.map +1 -1
  18. package/dist/types/src/index.d.ts +7 -3
  19. package/dist/types/src/index.d.ts.map +1 -1
  20. package/dist/types/src/node-matcher.d.ts +244 -0
  21. package/dist/types/src/node-matcher.d.ts.map +1 -0
  22. package/dist/types/src/node-matcher.test.d.ts +2 -0
  23. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  24. package/dist/types/src/node.d.ts +50 -5
  25. package/dist/types/src/node.d.ts.map +1 -1
  26. package/dist/types/src/scheduler.browser.d.ts +2 -0
  27. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  28. package/dist/types/src/scheduler.d.ts +8 -0
  29. package/dist/types/src/scheduler.d.ts.map +1 -0
  30. package/dist/types/src/stories/EchoGraph.stories.d.ts +6 -13
  31. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  32. package/dist/types/src/testing/index.d.ts +2 -0
  33. package/dist/types/src/testing/index.d.ts.map +1 -0
  34. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  35. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  36. package/dist/types/src/util.d.ts +40 -0
  37. package/dist/types/src/util.d.ts.map +1 -0
  38. package/dist/types/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +53 -42
  40. package/src/atoms.ts +25 -0
  41. package/src/graph-builder.test.ts +1193 -126
  42. package/src/graph-builder.ts +753 -264
  43. package/src/graph.test.ts +451 -123
  44. package/src/graph.ts +1057 -407
  45. package/src/index.ts +10 -3
  46. package/src/node-matcher.test.ts +301 -0
  47. package/src/node-matcher.ts +314 -0
  48. package/src/node.ts +83 -7
  49. package/src/scheduler.browser.ts +5 -0
  50. package/src/scheduler.ts +17 -0
  51. package/src/stories/EchoGraph.stories.tsx +178 -255
  52. package/src/stories/Tree.tsx +1 -1
  53. package/src/testing/index.ts +5 -0
  54. package/src/testing/setup-graph-builder.ts +41 -0
  55. package/src/util.ts +101 -0
  56. package/dist/lib/browser/index.mjs +0 -778
  57. package/dist/lib/browser/index.mjs.map +0 -7
  58. package/dist/lib/browser/meta.json +0 -1
  59. package/dist/lib/node/index.cjs +0 -816
  60. package/dist/lib/node/index.cjs.map +0 -7
  61. package/dist/lib/node/meta.json +0 -1
  62. package/dist/lib/node-esm/index.mjs +0 -780
  63. package/dist/lib/node-esm/index.mjs.map +0 -7
  64. package/dist/lib/node-esm/meta.json +0 -1
  65. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  66. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  67. package/dist/types/src/signals-integration.test.d.ts +0 -2
  68. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  69. package/dist/types/src/testing.d.ts +0 -5
  70. package/dist/types/src/testing.d.ts.map +0 -1
  71. package/src/experimental/graph-projections.test.ts +0 -56
  72. package/src/signals-integration.test.ts +0 -218
  73. package/src/testing.ts +0 -20
@@ -2,122 +2,379 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
6
- import { Option, pipe } from 'effect';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Function from 'effect/Function';
9
+ import * as Option from 'effect/Option';
7
10
  import { describe, expect, onTestFinished, test } from 'vitest';
8
11
 
9
- import { sleep, Trigger } from '@dxos/async';
12
+ import { Trigger } from '@dxos/async';
13
+ import { Obj } from '@dxos/echo';
14
+ import { TestSchema } from '@dxos/echo/testing';
10
15
 
11
- import { ROOT_ID } from './graph';
12
- import { createExtension, GraphBuilder } from './graph-builder';
13
- 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';
14
21
 
15
22
  const exampleId = (id: number) => `dx:test:${id}`;
16
23
  const EXAMPLE_ID = exampleId(1);
17
- const EXAMPLE_TYPE = 'dxos.org/type/example';
24
+ const EXAMPLE_TYPE = 'org.dxos.type.example';
18
25
 
19
26
  describe('GraphBuilder', () => {
27
+ describe('resolver', () => {
28
+ test('works', async () => {
29
+ const registry = Registry.make();
30
+ const builder = GraphBuilder.make({ registry });
31
+ const graph = builder.graph;
32
+
33
+ {
34
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
35
+ expect(node).to.be.null;
36
+ }
37
+
38
+ // Test direct API
39
+ GraphBuilder.addExtension(
40
+ builder,
41
+ GraphBuilder.createExtensionRaw({
42
+ id: 'resolver',
43
+ resolver: () => Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }),
44
+ }),
45
+ );
46
+ await Graph.initialize(graph, EXAMPLE_ID);
47
+
48
+ {
49
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
50
+ expect(node?.id).to.equal(EXAMPLE_ID);
51
+ expect(node?.type).to.equal(EXAMPLE_TYPE);
52
+ expect(node?.data).to.equal(1);
53
+ }
54
+ });
55
+
56
+ test('updates', async () => {
57
+ const registry = Registry.make();
58
+ const builder = GraphBuilder.make({ registry });
59
+ const name = Atom.make('default');
60
+ GraphBuilder.addExtension(
61
+ builder,
62
+ GraphBuilder.createExtensionRaw({
63
+ id: 'resolver',
64
+ resolver: () => Atom.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
65
+ }),
66
+ );
67
+ const graph = builder.graph;
68
+ await Graph.initialize(graph, EXAMPLE_ID);
69
+
70
+ {
71
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
72
+ expect(node?.data).to.equal('default');
73
+ }
74
+
75
+ registry.set(name, 'updated');
76
+
77
+ {
78
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
79
+ expect(node?.data).to.equal('updated');
80
+ }
81
+ });
82
+
83
+ test('connects resolved node to parent via child edge', async ({ expect }) => {
84
+ const registry = Registry.make();
85
+ const builder = GraphBuilder.make({ registry });
86
+ const childId = qualifyId('root', '~child');
87
+
88
+ GraphBuilder.addExtension(
89
+ builder,
90
+ GraphBuilder.createExtensionRaw({
91
+ id: 'resolver',
92
+ resolver: (id) =>
93
+ id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved' }) : Atom.make(null),
94
+ }),
95
+ );
96
+
97
+ const graph = builder.graph;
98
+ await Graph.initialize(graph, childId);
99
+
100
+ {
101
+ const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
102
+ expect(node?.id).to.equal(childId);
103
+ expect(node?.data).to.equal('resolved');
104
+ }
105
+
106
+ // Verify the resolved node is a child of root.
107
+ {
108
+ const children = registry.get(graph.connections('root', 'child'));
109
+ expect(children.some((n) => n.id === childId)).to.be.true;
110
+ }
111
+ });
112
+
113
+ test('out-of-order: resolver fires before parent exists', async ({ expect }) => {
114
+ const registry = Registry.make();
115
+ const builder = GraphBuilder.make({ registry });
116
+ const parentId = qualifyId('root', 'parent');
117
+ const childId = qualifyId('root', 'parent', '~child');
118
+
119
+ GraphBuilder.addExtension(builder, [
120
+ GraphBuilder.createExtensionRaw({
121
+ id: 'resolver',
122
+ resolver: (id) =>
123
+ id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved-child' }) : Atom.make(null),
124
+ }),
125
+ GraphBuilder.createExtensionRaw({
126
+ id: 'connector',
127
+ connector: (node) =>
128
+ Atom.make((get) =>
129
+ Function.pipe(
130
+ get(node),
131
+ Option.filter((n) => n.id === 'root'),
132
+ Option.map(() => [{ id: 'parent', type: EXAMPLE_TYPE, data: 'parent-data' }]),
133
+ Option.getOrElse(() => []),
134
+ ),
135
+ ),
136
+ }),
137
+ ]);
138
+
139
+ const graph = builder.graph;
140
+
141
+ // Resolve child BEFORE parent exists in the graph.
142
+ await Graph.initialize(graph, childId);
143
+
144
+ {
145
+ const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
146
+ expect(node?.id).to.equal(childId);
147
+ expect(node?.data).to.equal('resolved-child');
148
+ }
149
+
150
+ // Now expand root to create parent via connector.
151
+ Graph.expand(graph, Node.RootId, 'child');
152
+ await GraphBuilder.flush(builder);
153
+
154
+ {
155
+ const parent = Graph.getNode(graph, parentId).pipe(Option.getOrNull);
156
+ expect(parent?.data).to.equal('parent-data');
157
+ }
158
+
159
+ // The resolved child should be connected to the parent.
160
+ {
161
+ const children = registry.get(graph.connections(parentId, 'child'));
162
+ expect(children.some((n) => n.id === childId)).to.be.true;
163
+ }
164
+ });
165
+
166
+ test('onNone does not remove connector-owned node', async ({ expect }) => {
167
+ const registry = Registry.make();
168
+ const builder = GraphBuilder.make({ registry });
169
+ const nodeId = qualifyId('root', 'shared');
170
+
171
+ // Connector that produces root/shared. No resolver matches root/shared.
172
+ GraphBuilder.addExtension(
173
+ builder,
174
+ GraphBuilder.createExtensionRaw({
175
+ id: 'connector',
176
+ connector: (node) =>
177
+ Atom.make((get) =>
178
+ Function.pipe(
179
+ get(node),
180
+ Option.filter((n) => n.id === 'root'),
181
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
182
+ Option.getOrElse(() => []),
183
+ ),
184
+ ),
185
+ }),
186
+ );
187
+
188
+ const graph = builder.graph;
189
+
190
+ // Connector produces root/shared.
191
+ Graph.expand(graph, Node.RootId, 'child');
192
+ await GraphBuilder.flush(builder);
193
+
194
+ {
195
+ const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
196
+ expect(node?.data).to.equal('from-connector');
197
+ }
198
+
199
+ // Initialize fires for the same ID. No resolver matches, so onNone fires.
200
+ // The connector-owned node should NOT be removed.
201
+ await Graph.initialize(graph, nodeId);
202
+
203
+ {
204
+ const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
205
+ expect(node?.data).to.equal('from-connector');
206
+ }
207
+ });
208
+
209
+ test('does not overwrite connector-produced node', async () => {
210
+ const registry = Registry.make();
211
+ const builder = GraphBuilder.make({ registry });
212
+ const resolverData = Atom.make('from-resolver');
213
+
214
+ GraphBuilder.addExtension(builder, [
215
+ GraphBuilder.createExtensionRaw({
216
+ id: 'resolver',
217
+ resolver: (id) =>
218
+ id === qualifyId('root', 'shared')
219
+ ? Atom.make((get) => ({ id: qualifyId('root', 'shared'), type: EXAMPLE_TYPE, data: get(resolverData) }))
220
+ : Atom.make(null),
221
+ }),
222
+ GraphBuilder.createExtensionRaw({
223
+ id: 'connector',
224
+ connector: (node) =>
225
+ Atom.make((get) =>
226
+ Function.pipe(
227
+ get(node),
228
+ Option.filter((n) => n.id === 'root'),
229
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
230
+ Option.getOrElse(() => []),
231
+ ),
232
+ ),
233
+ }),
234
+ ]);
235
+
236
+ const graph = builder.graph;
237
+
238
+ // Connector produces root/shared.
239
+ Graph.expand(graph, Node.RootId, 'child');
240
+ await GraphBuilder.flush(builder);
241
+
242
+ {
243
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
244
+ expect(node?.data).to.equal('from-connector');
245
+ }
246
+
247
+ // Resolver fires for the same ID but should not overwrite.
248
+ await Graph.initialize(graph, qualifyId('root', 'shared'));
249
+
250
+ {
251
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
252
+ expect(node?.data).to.equal('from-connector');
253
+ }
254
+
255
+ // Updating the resolver's atom should still not overwrite.
256
+ registry.set(resolverData, 'updated-resolver');
257
+
258
+ {
259
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
260
+ expect(node?.data).to.equal('from-connector');
261
+ }
262
+ });
263
+ });
264
+
20
265
  describe('connector', () => {
21
- test('works', () => {
266
+ test('works', async () => {
22
267
  const registry = Registry.make();
23
- const builder = new GraphBuilder({ registry });
24
- builder.addExtension(
25
- createExtension({
268
+ const builder = GraphBuilder.make({ registry });
269
+ GraphBuilder.addExtension(
270
+ builder,
271
+ GraphBuilder.createExtensionRaw({
26
272
  id: 'outbound-connector',
27
- connector: () => Rx.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
273
+ connector: () => Atom.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
28
274
  }),
29
275
  );
30
- builder.addExtension(
31
- createExtension({
276
+ GraphBuilder.addExtension(
277
+ builder,
278
+ GraphBuilder.createExtensionRaw({
32
279
  id: 'inbound-connector',
33
- relation: 'inbound',
34
- 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 }]),
35
282
  }),
36
283
  );
37
284
 
38
285
  const graph = builder.graph;
39
- graph.expand(ROOT_ID);
40
- 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);
41
289
 
42
- const outbound = registry.get(graph.connections(ROOT_ID));
43
- 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')));
44
292
 
45
293
  expect(outbound).has.length(1);
46
- expect(outbound[0].id).to.equal('child');
294
+ expect(outbound[0].id).to.equal('root/child');
47
295
  expect(outbound[0].data).to.equal(2);
48
296
  expect(inbound).has.length(1);
49
- expect(inbound[0].id).to.equal('parent');
297
+ expect(inbound[0].id).to.equal('root/parent');
50
298
  expect(inbound[0].data).to.equal(0);
51
299
  });
52
300
 
53
- test('updates', () => {
301
+ test('updates', async () => {
54
302
  const registry = Registry.make();
55
- const builder = new GraphBuilder({ registry });
56
- const state = Rx.make(0);
57
- builder.addExtension(
58
- createExtension({
303
+ const builder = GraphBuilder.make({ registry });
304
+ const state = Atom.make(0);
305
+ GraphBuilder.addExtension(
306
+ builder,
307
+ GraphBuilder.createExtensionRaw({
59
308
  id: 'connector',
60
- 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) }]),
61
310
  }),
62
311
  );
63
312
  const graph = builder.graph;
64
- graph.expand(ROOT_ID);
313
+ Graph.expand(graph, Node.RootId, 'child');
314
+ await GraphBuilder.flush(builder);
65
315
 
66
316
  {
67
- const [node] = registry.get(graph.connections(ROOT_ID));
317
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
68
318
  expect(node.data).to.equal(0);
69
319
  }
70
320
 
71
321
  {
72
322
  registry.set(state, 1);
73
- const [node] = registry.get(graph.connections(ROOT_ID));
323
+ await GraphBuilder.flush(builder);
324
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
74
325
  expect(node.data).to.equal(1);
75
326
  }
76
327
  });
77
328
 
78
- test('subscribes to updates', () => {
329
+ test('subscribes to updates', async () => {
79
330
  const registry = Registry.make();
80
- const builder = new GraphBuilder({ registry });
81
- const state = Rx.make(0);
82
- builder.addExtension(
83
- createExtension({
331
+ const builder = GraphBuilder.make({ registry });
332
+ const state = Atom.make(0);
333
+ GraphBuilder.addExtension(
334
+ builder,
335
+ GraphBuilder.createExtensionRaw({
84
336
  id: 'connector',
85
- 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) }]),
86
338
  }),
87
339
  );
88
340
  const graph = builder.graph;
89
341
 
90
342
  let count = 0;
91
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_) => {
343
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_) => {
92
344
  count++;
93
345
  });
94
346
  onTestFinished(() => cancel());
95
347
 
96
348
  expect(count).to.equal(0);
97
- expect(registry.get(graph.connections(ROOT_ID))).to.have.length(0);
349
+ expect(registry.get(graph.connections(Node.RootId, 'child'))).to.have.length(0);
98
350
  expect(count).to.equal(1);
99
351
 
100
- graph.expand(ROOT_ID);
352
+ Graph.expand(graph, Node.RootId, 'child');
353
+ await GraphBuilder.flush(builder);
101
354
  expect(count).to.equal(2);
355
+
102
356
  registry.set(state, 1);
357
+ await GraphBuilder.flush(builder);
103
358
  expect(count).to.equal(3);
104
359
  });
105
360
 
106
- test('updates with new extensions', () => {
361
+ test('updates with new extensions', async () => {
107
362
  const registry = Registry.make();
108
- const builder = new GraphBuilder({ registry });
109
- builder.addExtension(
110
- createExtension({
363
+ const builder = GraphBuilder.make({ registry });
364
+ GraphBuilder.addExtension(
365
+ builder,
366
+ GraphBuilder.createExtensionRaw({
111
367
  id: 'connector',
112
- connector: () => Rx.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
368
+ connector: () => Atom.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
113
369
  }),
114
370
  );
115
371
  const graph = builder.graph;
116
- graph.expand(ROOT_ID);
372
+ Graph.expand(graph, Node.RootId, 'child');
373
+ await GraphBuilder.flush(builder);
117
374
 
118
- let nodes: Node[] = [];
375
+ let nodes: Node.Node[] = [];
119
376
  let count = 0;
120
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_nodes) => {
377
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_nodes) => {
121
378
  count++;
122
379
  nodes = _nodes;
123
380
  });
@@ -125,63 +382,68 @@ describe('GraphBuilder', () => {
125
382
 
126
383
  expect(nodes).has.length(0);
127
384
  expect(count).to.equal(0);
128
- registry.get(graph.connections(ROOT_ID));
385
+ registry.get(graph.connections(Node.RootId, 'child'));
129
386
  expect(nodes).has.length(1);
130
387
  expect(count).to.equal(1);
131
388
 
132
- builder.addExtension(
133
- createExtension({
389
+ GraphBuilder.addExtension(
390
+ builder,
391
+ GraphBuilder.createExtensionRaw({
134
392
  id: 'connector-2',
135
- connector: () => Rx.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
393
+ connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
136
394
  }),
137
395
  );
396
+ await GraphBuilder.flush(builder);
138
397
  expect(nodes).has.length(2);
139
398
  expect(count).to.equal(2);
140
399
  });
141
400
 
142
- test('removes', () => {
401
+ test('removes', async () => {
143
402
  const registry = Registry.make();
144
- const builder = new GraphBuilder({ registry });
145
- const nodes = Rx.make([
403
+ const builder = GraphBuilder.make({ registry });
404
+ const nodes = Atom.make([
146
405
  { id: exampleId(1), type: EXAMPLE_TYPE },
147
406
  { id: exampleId(2), type: EXAMPLE_TYPE },
148
407
  ]);
149
- builder.addExtension(
150
- createExtension({
408
+ GraphBuilder.addExtension(
409
+ builder,
410
+ GraphBuilder.createExtensionRaw({
151
411
  id: 'connector',
152
- connector: () => Rx.make((get) => get(nodes)),
412
+ connector: () => Atom.make((get) => get(nodes)),
153
413
  }),
154
414
  );
155
415
  const graph = builder.graph;
156
- graph.expand(ROOT_ID);
416
+ Graph.expand(graph, Node.RootId, 'child');
417
+ await GraphBuilder.flush(builder);
157
418
 
158
419
  {
159
- const nodes = registry.get(graph.connections(ROOT_ID));
420
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
160
421
  expect(nodes).has.length(2);
161
- expect(nodes[0].id).to.equal(exampleId(1));
162
- 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)));
163
424
  }
164
425
 
165
426
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
427
+ await GraphBuilder.flush(builder);
166
428
 
167
429
  {
168
- const nodes = registry.get(graph.connections(ROOT_ID));
430
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
169
431
  expect(nodes).has.length(1);
170
- expect(nodes[0].id).to.equal(exampleId(3));
432
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
171
433
  }
172
434
  });
173
435
 
174
- test('nodes are updated when removed', () => {
436
+ test('nodes are updated when removed', async () => {
175
437
  const registry = Registry.make();
176
- const builder = new GraphBuilder({ registry });
177
- const name = Rx.make('removed');
438
+ const builder = GraphBuilder.make({ registry });
439
+ const name = Atom.make('removed');
178
440
 
179
- builder.addExtension([
180
- createExtension({
441
+ GraphBuilder.addExtension(builder, [
442
+ GraphBuilder.createExtensionRaw({
181
443
  id: 'root',
182
444
  connector: (node) =>
183
- Rx.make((get) =>
184
- pipe(
445
+ Atom.make((get) =>
446
+ Function.pipe(
185
447
  get(node),
186
448
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
187
449
  Option.filter((name) => name !== 'removed'),
@@ -196,52 +458,184 @@ describe('GraphBuilder', () => {
196
458
 
197
459
  let count = 0;
198
460
  let exists = false;
199
- const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
461
+ const cancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (node) => {
200
462
  count++;
201
463
  exists = Option.isSome(node);
202
464
  });
203
465
  onTestFinished(() => cancel());
204
466
 
205
- graph.expand(ROOT_ID);
467
+ Graph.expand(graph, Node.RootId, 'child');
468
+ await GraphBuilder.flush(builder);
206
469
  expect(count).to.equal(0);
207
470
  expect(exists).to.be.false;
208
471
 
209
472
  registry.set(name, 'default');
473
+ await GraphBuilder.flush(builder);
210
474
  expect(count).to.equal(1);
211
475
  expect(exists).to.be.true;
212
476
 
213
477
  registry.set(name, 'removed');
478
+ await GraphBuilder.flush(builder);
214
479
  expect(count).to.equal(2);
215
480
  expect(exists).to.be.false;
216
481
 
217
482
  registry.set(name, 'added');
483
+ await GraphBuilder.flush(builder);
218
484
  expect(count).to.equal(3);
219
485
  expect(exists).to.be.true;
220
486
  });
221
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
+
222
614
  test('sort edges', async () => {
223
615
  const registry = Registry.make();
224
- const builder = new GraphBuilder({ registry });
225
- const nodes = Rx.make([
616
+ const builder = GraphBuilder.make({ registry });
617
+ const nodes = Atom.make([
226
618
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
227
619
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
228
620
  { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
229
621
  ]);
230
- builder.addExtension(
231
- createExtension({
622
+ GraphBuilder.addExtension(
623
+ builder,
624
+ GraphBuilder.createExtensionRaw({
232
625
  id: 'connector',
233
- connector: () => Rx.make((get) => get(nodes)),
626
+ connector: () => Atom.make((get) => get(nodes)),
234
627
  }),
235
628
  );
236
629
  const graph = builder.graph;
237
- graph.expand(ROOT_ID);
630
+ Graph.expand(graph, Node.RootId, 'child');
631
+ await GraphBuilder.flush(builder);
238
632
 
239
633
  {
240
- const nodes = registry.get(graph.connections(ROOT_ID));
634
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
241
635
  expect(nodes).has.length(3);
242
- expect(nodes[0].id).to.equal(exampleId(1));
243
- expect(nodes[1].id).to.equal(exampleId(2));
244
- 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)));
245
639
  }
246
640
 
247
641
  registry.set(nodes, [
@@ -249,31 +643,29 @@ describe('GraphBuilder', () => {
249
643
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
250
644
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
251
645
  ]);
252
-
253
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
254
- await sleep(0);
646
+ await GraphBuilder.flush(builder);
255
647
 
256
648
  {
257
- const nodes = registry.get(graph.connections(ROOT_ID));
649
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
258
650
  expect(nodes).has.length(3);
259
- expect(nodes[0].id).to.equal(exampleId(3));
260
- expect(nodes[1].id).to.equal(exampleId(1));
261
- 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)));
262
654
  }
263
655
  });
264
656
 
265
- test('updates are constrained', () => {
657
+ test('updates are constrained', async () => {
266
658
  const registry = Registry.make();
267
- const builder = new GraphBuilder({ registry });
268
- const name = Rx.make('default');
269
- const sub = Rx.make('default');
659
+ const builder = GraphBuilder.make({ registry });
660
+ const name = Atom.make('default');
661
+ const sub = Atom.make('default');
270
662
 
271
- builder.addExtension([
272
- createExtension({
663
+ GraphBuilder.addExtension(builder, [
664
+ GraphBuilder.createExtensionRaw({
273
665
  id: 'root',
274
666
  connector: (node) =>
275
- Rx.make((get) =>
276
- pipe(
667
+ Atom.make((get) =>
668
+ Function.pipe(
277
669
  get(node),
278
670
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
279
671
  Option.filter((name) => name !== 'removed'),
@@ -282,25 +674,29 @@ describe('GraphBuilder', () => {
282
674
  ),
283
675
  ),
284
676
  }),
285
- createExtension({
677
+ GraphBuilder.createExtensionRaw({
286
678
  id: 'connector1',
287
679
  connector: (node) =>
288
- Rx.make((get) =>
289
- pipe(
680
+ Atom.make((get) =>
681
+ Function.pipe(
290
682
  get(node),
291
- 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
+ ),
292
686
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
293
687
  Option.getOrElse(() => []),
294
688
  ),
295
689
  ),
296
690
  }),
297
- createExtension({
691
+ GraphBuilder.createExtensionRaw({
298
692
  id: 'connector2',
299
693
  connector: (node) =>
300
- Rx.make((get) =>
301
- pipe(
694
+ Atom.make((get) =>
695
+ Function.pipe(
302
696
  get(node),
303
- 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
+ ),
304
700
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
305
701
  Option.getOrElse(() => []),
306
702
  ),
@@ -311,64 +707,71 @@ describe('GraphBuilder', () => {
311
707
  const graph = builder.graph;
312
708
 
313
709
  let parentCount = 0;
314
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
710
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
315
711
  parentCount++;
316
712
  });
317
713
  onTestFinished(() => parentCancel());
318
714
 
319
715
  let independentCount = 0;
320
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
716
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
321
717
  independentCount++;
322
718
  });
323
719
  onTestFinished(() => independentCancel());
324
720
 
325
721
  let dependentCount = 0;
326
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
722
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
327
723
  dependentCount++;
328
724
  });
329
725
  onTestFinished(() => dependentCancel());
330
726
 
331
727
  // Counts should not increment until the node is expanded.
332
- graph.expand(ROOT_ID);
728
+ Graph.expand(graph, Node.RootId, 'child');
729
+ await GraphBuilder.flush(builder);
333
730
  expect(parentCount).to.equal(1);
334
731
  expect(independentCount).to.equal(0);
335
732
  expect(dependentCount).to.equal(0);
336
733
 
337
734
  // Counts should increment when the node is expanded.
338
- graph.expand(EXAMPLE_ID);
735
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
736
+ await GraphBuilder.flush(builder);
339
737
  expect(parentCount).to.equal(1);
340
738
  expect(independentCount).to.equal(1);
341
739
  expect(dependentCount).to.equal(1);
342
740
 
343
741
  // Only dependent count should increment when the parent changes.
344
742
  registry.set(name, 'updated');
743
+ await GraphBuilder.flush(builder);
345
744
  expect(parentCount).to.equal(2);
346
745
  expect(independentCount).to.equal(1);
347
746
  expect(dependentCount).to.equal(2);
348
747
 
349
748
  // Only independent count should increment when its state changes.
350
749
  registry.set(sub, 'updated');
750
+ await GraphBuilder.flush(builder);
351
751
  expect(parentCount).to.equal(2);
352
752
  expect(independentCount).to.equal(2);
353
753
  expect(dependentCount).to.equal(2);
354
754
 
355
755
  // Independent count should update if its state changes even if the parent is removed.
356
- Rx.batch(() => {
756
+ Atom.batch(() => {
357
757
  registry.set(name, 'removed');
358
758
  registry.set(sub, 'batch');
359
759
  });
760
+ await GraphBuilder.flush(builder);
360
761
  expect(parentCount).to.equal(2);
361
762
  expect(independentCount).to.equal(3);
362
763
  expect(dependentCount).to.equal(2);
363
764
 
364
765
  // Dependent count should increment when the node is added back.
365
766
  registry.set(name, 'added');
767
+ await GraphBuilder.flush(builder);
366
768
  expect(parentCount).to.equal(3);
367
769
  expect(independentCount).to.equal(3);
368
770
  expect(dependentCount).to.equal(3);
369
771
 
370
772
  // Counts should not increment when the node is expanded again.
371
- graph.expand(EXAMPLE_ID);
773
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
774
+ await GraphBuilder.flush(builder);
372
775
  expect(parentCount).to.equal(3);
373
776
  expect(independentCount).to.equal(3);
374
777
  expect(dependentCount).to.equal(3);
@@ -376,13 +779,14 @@ describe('GraphBuilder', () => {
376
779
 
377
780
  test('eager graph expansion', async () => {
378
781
  const registry = Registry.make();
379
- const builder = new GraphBuilder({ registry });
380
- builder.addExtension(
381
- createExtension({
782
+ const builder = GraphBuilder.make({ registry });
783
+ GraphBuilder.addExtension(
784
+ builder,
785
+ GraphBuilder.createExtensionRaw({
382
786
  id: 'connector',
383
787
  connector: (node) => {
384
- return Rx.make((get) =>
385
- pipe(
788
+ return Atom.make((get) =>
789
+ Function.pipe(
386
790
  get(node),
387
791
  Option.map((node) => (node.data ? node.data + 1 : 1)),
388
792
  Option.filter((data) => data <= 5),
@@ -397,14 +801,14 @@ describe('GraphBuilder', () => {
397
801
  let count = 0;
398
802
  const trigger = new Trigger();
399
803
  builder.graph.onNodeChanged.on(({ id }) => {
400
- builder.graph.expand(id);
804
+ Graph.expand(builder.graph, id, 'child');
401
805
  count++;
402
806
  if (count === 5) {
403
807
  trigger.wake();
404
808
  }
405
809
  });
406
810
 
407
- builder.graph.expand(ROOT_ID);
811
+ Graph.expand(builder.graph, Node.RootId, 'child');
408
812
  await trigger.wait();
409
813
  expect(count).to.equal(5);
410
814
  });
@@ -412,13 +816,14 @@ describe('GraphBuilder', () => {
412
816
 
413
817
  describe('explore', () => {
414
818
  test('works', async () => {
415
- const builder = new GraphBuilder();
416
- builder.addExtension(
417
- createExtension({
819
+ const builder = GraphBuilder.make();
820
+ GraphBuilder.addExtension(
821
+ builder,
822
+ GraphBuilder.createExtensionRaw({
418
823
  id: 'connector',
419
824
  connector: (node) =>
420
- Rx.make((get) =>
421
- pipe(
825
+ Atom.make((get) =>
826
+ Function.pipe(
422
827
  get(node),
423
828
  Option.map((node) => (node.data ? node.data + 1 : 1)),
424
829
  Option.filter((data) => data <= 5),
@@ -430,7 +835,8 @@ describe('GraphBuilder', () => {
430
835
  );
431
836
 
432
837
  let count = 0;
433
- await builder.explore({
838
+ await GraphBuilder.explore(builder, {
839
+ relation: 'child',
434
840
  visitor: () => {
435
841
  count++;
436
842
  },
@@ -439,4 +845,665 @@ describe('GraphBuilder', () => {
439
845
  expect(count).to.equal(6);
440
846
  });
441
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
+ });
442
1509
  });