@dxos/app-graph 0.8.4-main.dedc0f3 → 0.8.4-main.dfabb4ec29

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