@dxos/app-graph 0.8.4-main.f9ba587 → 0.8.4-main.fcfe5033a5

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 (61) hide show
  1. package/dist/lib/browser/chunk-W47H2NND.mjs +1604 -0
  2. package/dist/lib/browser/chunk-W47H2NND.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +27 -767
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +39 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/chunk-LYZWNJ7J.mjs +1605 -0
  9. package/dist/lib/node-esm/chunk-LYZWNJ7J.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +27 -768
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +40 -0
  14. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  15. package/dist/types/src/atoms.d.ts +8 -0
  16. package/dist/types/src/atoms.d.ts.map +1 -0
  17. package/dist/types/src/graph-builder.d.ts +117 -60
  18. package/dist/types/src/graph-builder.d.ts.map +1 -1
  19. package/dist/types/src/graph.d.ts +188 -218
  20. package/dist/types/src/graph.d.ts.map +1 -1
  21. package/dist/types/src/index.d.ts +7 -3
  22. package/dist/types/src/index.d.ts.map +1 -1
  23. package/dist/types/src/node-matcher.d.ts +244 -0
  24. package/dist/types/src/node-matcher.d.ts.map +1 -0
  25. package/dist/types/src/node-matcher.test.d.ts +2 -0
  26. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  27. package/dist/types/src/node.d.ts +50 -5
  28. package/dist/types/src/node.d.ts.map +1 -1
  29. package/dist/types/src/stories/EchoGraph.stories.d.ts +8 -10
  30. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  31. package/dist/types/src/testing/index.d.ts +2 -0
  32. package/dist/types/src/testing/index.d.ts.map +1 -0
  33. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  34. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  35. package/dist/types/src/util.d.ts +39 -0
  36. package/dist/types/src/util.d.ts.map +1 -0
  37. package/dist/types/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +48 -38
  39. package/src/atoms.ts +25 -0
  40. package/src/graph-builder.test.ts +1067 -126
  41. package/src/graph-builder.ts +734 -264
  42. package/src/graph.test.ts +451 -123
  43. package/src/graph.ts +1057 -407
  44. package/src/index.ts +10 -3
  45. package/src/node-matcher.test.ts +301 -0
  46. package/src/node-matcher.ts +314 -0
  47. package/src/node.ts +83 -7
  48. package/src/stories/EchoGraph.stories.tsx +180 -140
  49. package/src/stories/Tree.tsx +1 -1
  50. package/src/testing/index.ts +5 -0
  51. package/src/testing/setup-graph-builder.ts +41 -0
  52. package/src/util.ts +95 -0
  53. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  54. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  55. package/dist/types/src/signals-integration.test.d.ts +0 -2
  56. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  57. package/dist/types/src/testing.d.ts +0 -5
  58. package/dist/types/src/testing.d.ts.map +0 -1
  59. package/src/experimental/graph-projections.test.ts +0 -56
  60. package/src/signals-integration.test.ts +0 -218
  61. 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,58 @@ 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
 
222
488
  test('sort edges', async () => {
223
489
  const registry = Registry.make();
224
- const builder = new GraphBuilder({ registry });
225
- const nodes = Rx.make([
490
+ const builder = GraphBuilder.make({ registry });
491
+ const nodes = Atom.make([
226
492
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
227
493
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
228
494
  { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
229
495
  ]);
230
- builder.addExtension(
231
- createExtension({
496
+ GraphBuilder.addExtension(
497
+ builder,
498
+ GraphBuilder.createExtensionRaw({
232
499
  id: 'connector',
233
- connector: () => Rx.make((get) => get(nodes)),
500
+ connector: () => Atom.make((get) => get(nodes)),
234
501
  }),
235
502
  );
236
503
  const graph = builder.graph;
237
- graph.expand(ROOT_ID);
504
+ Graph.expand(graph, Node.RootId, 'child');
505
+ await GraphBuilder.flush(builder);
238
506
 
239
507
  {
240
- const nodes = registry.get(graph.connections(ROOT_ID));
508
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
241
509
  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));
510
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
511
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
512
+ expect(nodes[2].id).to.equal(qualifyId('root', exampleId(3)));
245
513
  }
246
514
 
247
515
  registry.set(nodes, [
@@ -249,31 +517,29 @@ describe('GraphBuilder', () => {
249
517
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
250
518
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
251
519
  ]);
252
-
253
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
254
- await sleep(0);
520
+ await GraphBuilder.flush(builder);
255
521
 
256
522
  {
257
- const nodes = registry.get(graph.connections(ROOT_ID));
523
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
258
524
  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));
525
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
526
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(1)));
527
+ expect(nodes[2].id).to.equal(qualifyId('root', exampleId(2)));
262
528
  }
263
529
  });
264
530
 
265
- test('updates are constrained', () => {
531
+ test('updates are constrained', async () => {
266
532
  const registry = Registry.make();
267
- const builder = new GraphBuilder({ registry });
268
- const name = Rx.make('default');
269
- const sub = Rx.make('default');
533
+ const builder = GraphBuilder.make({ registry });
534
+ const name = Atom.make('default');
535
+ const sub = Atom.make('default');
270
536
 
271
- builder.addExtension([
272
- createExtension({
537
+ GraphBuilder.addExtension(builder, [
538
+ GraphBuilder.createExtensionRaw({
273
539
  id: 'root',
274
540
  connector: (node) =>
275
- Rx.make((get) =>
276
- pipe(
541
+ Atom.make((get) =>
542
+ Function.pipe(
277
543
  get(node),
278
544
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
279
545
  Option.filter((name) => name !== 'removed'),
@@ -282,25 +548,29 @@ describe('GraphBuilder', () => {
282
548
  ),
283
549
  ),
284
550
  }),
285
- createExtension({
551
+ GraphBuilder.createExtensionRaw({
286
552
  id: 'connector1',
287
553
  connector: (node) =>
288
- Rx.make((get) =>
289
- pipe(
554
+ Atom.make((get) =>
555
+ Function.pipe(
290
556
  get(node),
291
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
557
+ Option.flatMap((node) =>
558
+ node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(get(sub)) : Option.none(),
559
+ ),
292
560
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
293
561
  Option.getOrElse(() => []),
294
562
  ),
295
563
  ),
296
564
  }),
297
- createExtension({
565
+ GraphBuilder.createExtensionRaw({
298
566
  id: 'connector2',
299
567
  connector: (node) =>
300
- Rx.make((get) =>
301
- pipe(
568
+ Atom.make((get) =>
569
+ Function.pipe(
302
570
  get(node),
303
- Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
571
+ Option.flatMap((node) =>
572
+ node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(node.data) : Option.none(),
573
+ ),
304
574
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
305
575
  Option.getOrElse(() => []),
306
576
  ),
@@ -311,64 +581,71 @@ describe('GraphBuilder', () => {
311
581
  const graph = builder.graph;
312
582
 
313
583
  let parentCount = 0;
314
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
584
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
315
585
  parentCount++;
316
586
  });
317
587
  onTestFinished(() => parentCancel());
318
588
 
319
589
  let independentCount = 0;
320
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
590
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
321
591
  independentCount++;
322
592
  });
323
593
  onTestFinished(() => independentCancel());
324
594
 
325
595
  let dependentCount = 0;
326
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
596
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
327
597
  dependentCount++;
328
598
  });
329
599
  onTestFinished(() => dependentCancel());
330
600
 
331
601
  // Counts should not increment until the node is expanded.
332
- graph.expand(ROOT_ID);
602
+ Graph.expand(graph, Node.RootId, 'child');
603
+ await GraphBuilder.flush(builder);
333
604
  expect(parentCount).to.equal(1);
334
605
  expect(independentCount).to.equal(0);
335
606
  expect(dependentCount).to.equal(0);
336
607
 
337
608
  // Counts should increment when the node is expanded.
338
- graph.expand(EXAMPLE_ID);
609
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
610
+ await GraphBuilder.flush(builder);
339
611
  expect(parentCount).to.equal(1);
340
612
  expect(independentCount).to.equal(1);
341
613
  expect(dependentCount).to.equal(1);
342
614
 
343
615
  // Only dependent count should increment when the parent changes.
344
616
  registry.set(name, 'updated');
617
+ await GraphBuilder.flush(builder);
345
618
  expect(parentCount).to.equal(2);
346
619
  expect(independentCount).to.equal(1);
347
620
  expect(dependentCount).to.equal(2);
348
621
 
349
622
  // Only independent count should increment when its state changes.
350
623
  registry.set(sub, 'updated');
624
+ await GraphBuilder.flush(builder);
351
625
  expect(parentCount).to.equal(2);
352
626
  expect(independentCount).to.equal(2);
353
627
  expect(dependentCount).to.equal(2);
354
628
 
355
629
  // Independent count should update if its state changes even if the parent is removed.
356
- Rx.batch(() => {
630
+ Atom.batch(() => {
357
631
  registry.set(name, 'removed');
358
632
  registry.set(sub, 'batch');
359
633
  });
634
+ await GraphBuilder.flush(builder);
360
635
  expect(parentCount).to.equal(2);
361
636
  expect(independentCount).to.equal(3);
362
637
  expect(dependentCount).to.equal(2);
363
638
 
364
639
  // Dependent count should increment when the node is added back.
365
640
  registry.set(name, 'added');
641
+ await GraphBuilder.flush(builder);
366
642
  expect(parentCount).to.equal(3);
367
643
  expect(independentCount).to.equal(3);
368
644
  expect(dependentCount).to.equal(3);
369
645
 
370
646
  // Counts should not increment when the node is expanded again.
371
- graph.expand(EXAMPLE_ID);
647
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
648
+ await GraphBuilder.flush(builder);
372
649
  expect(parentCount).to.equal(3);
373
650
  expect(independentCount).to.equal(3);
374
651
  expect(dependentCount).to.equal(3);
@@ -376,13 +653,14 @@ describe('GraphBuilder', () => {
376
653
 
377
654
  test('eager graph expansion', async () => {
378
655
  const registry = Registry.make();
379
- const builder = new GraphBuilder({ registry });
380
- builder.addExtension(
381
- createExtension({
656
+ const builder = GraphBuilder.make({ registry });
657
+ GraphBuilder.addExtension(
658
+ builder,
659
+ GraphBuilder.createExtensionRaw({
382
660
  id: 'connector',
383
661
  connector: (node) => {
384
- return Rx.make((get) =>
385
- pipe(
662
+ return Atom.make((get) =>
663
+ Function.pipe(
386
664
  get(node),
387
665
  Option.map((node) => (node.data ? node.data + 1 : 1)),
388
666
  Option.filter((data) => data <= 5),
@@ -397,14 +675,14 @@ describe('GraphBuilder', () => {
397
675
  let count = 0;
398
676
  const trigger = new Trigger();
399
677
  builder.graph.onNodeChanged.on(({ id }) => {
400
- builder.graph.expand(id);
678
+ Graph.expand(builder.graph, id, 'child');
401
679
  count++;
402
680
  if (count === 5) {
403
681
  trigger.wake();
404
682
  }
405
683
  });
406
684
 
407
- builder.graph.expand(ROOT_ID);
685
+ Graph.expand(builder.graph, Node.RootId, 'child');
408
686
  await trigger.wait();
409
687
  expect(count).to.equal(5);
410
688
  });
@@ -412,13 +690,14 @@ describe('GraphBuilder', () => {
412
690
 
413
691
  describe('explore', () => {
414
692
  test('works', async () => {
415
- const builder = new GraphBuilder();
416
- builder.addExtension(
417
- createExtension({
693
+ const builder = GraphBuilder.make();
694
+ GraphBuilder.addExtension(
695
+ builder,
696
+ GraphBuilder.createExtensionRaw({
418
697
  id: 'connector',
419
698
  connector: (node) =>
420
- Rx.make((get) =>
421
- pipe(
699
+ Atom.make((get) =>
700
+ Function.pipe(
422
701
  get(node),
423
702
  Option.map((node) => (node.data ? node.data + 1 : 1)),
424
703
  Option.filter((data) => data <= 5),
@@ -430,7 +709,8 @@ describe('GraphBuilder', () => {
430
709
  );
431
710
 
432
711
  let count = 0;
433
- await builder.explore({
712
+ await GraphBuilder.explore(builder, {
713
+ relation: 'child',
434
714
  visitor: () => {
435
715
  count++;
436
716
  },
@@ -439,4 +719,665 @@ describe('GraphBuilder', () => {
439
719
  expect(count).to.equal(6);
440
720
  });
441
721
  });
722
+
723
+ describe('helpers', () => {
724
+ describe('createConnector', () => {
725
+ test('creates connector with type inference', async () => {
726
+ const registry = Registry.make();
727
+ const builder = GraphBuilder.make({ registry });
728
+ const graph = builder.graph;
729
+
730
+ const matcher = (node: Node.Node) => NodeMatcher.whenId('root')(node);
731
+ const factory = (node: Node.Node) => [{ id: 'child', type: EXAMPLE_TYPE, data: node.id }];
732
+
733
+ const connector = GraphBuilder.createConnector(matcher, factory);
734
+
735
+ GraphBuilder.addExtension(
736
+ builder,
737
+ GraphBuilder.createExtensionRaw({
738
+ id: 'test-connector',
739
+ connector,
740
+ }),
741
+ );
742
+
743
+ Graph.expand(graph, Node.RootId, 'child');
744
+ await GraphBuilder.flush(builder);
745
+
746
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
747
+ expect(connections).has.length(1);
748
+ expect(connections[0].id).to.equal('root/child');
749
+ });
750
+ });
751
+
752
+ describe('createExtension', () => {
753
+ test('works with Effect connector', async () => {
754
+ const registry = Registry.make();
755
+ const builder = GraphBuilder.make({ registry });
756
+ const graph = builder.graph;
757
+
758
+ const extensions = Effect.runSync(
759
+ GraphBuilder.createExtension({
760
+ id: 'test-extension',
761
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
762
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
763
+ }),
764
+ );
765
+
766
+ GraphBuilder.addExtension(builder, extensions);
767
+
768
+ const writableGraph = graph as Graph.WritableGraph;
769
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
770
+ Graph.expand(graph, 'parent', 'child');
771
+ await GraphBuilder.flush(builder);
772
+
773
+ const connections = registry.get(graph.connections('parent', 'child'));
774
+ expect(connections).has.length(1);
775
+ expect(connections[0].id).to.equal('parent/child');
776
+ expect(connections[0].data).to.equal('test');
777
+ });
778
+
779
+ test('works with Effect actions', async () => {
780
+ const registry = Registry.make();
781
+ const builder = GraphBuilder.make({ registry });
782
+ const graph = builder.graph;
783
+
784
+ const extensions = Effect.runSync(
785
+ GraphBuilder.createExtension({
786
+ id: 'test-extension',
787
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
788
+ actions: (node, get) =>
789
+ Effect.succeed([
790
+ {
791
+ id: 'test-action',
792
+ data: () => Effect.void,
793
+ properties: { label: 'Test' },
794
+ },
795
+ ]),
796
+ }),
797
+ );
798
+
799
+ GraphBuilder.addExtension(builder, extensions);
800
+
801
+ const writableGraph = graph as Graph.WritableGraph;
802
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
803
+ Graph.expand(graph, 'parent', 'child');
804
+ await GraphBuilder.flush(builder);
805
+
806
+ const edges = registry.get(graph.edges('parent'));
807
+ expect(edges[Graph.relationKey('action')] ?? []).to.have.length(1);
808
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/test-action');
809
+ expect(edges[Graph.relationKey('child')] ?? []).to.have.length(0);
810
+ const actions = registry.get(graph.actions('parent'));
811
+ expect(actions).has.length(1);
812
+ expect(actions[0].id).to.equal('parent/test-action');
813
+ });
814
+
815
+ test('actions expand automatically with child relation', async ({ expect }) => {
816
+ const registry = Registry.make();
817
+ const builder = GraphBuilder.make({ registry });
818
+ const graph = builder.graph;
819
+
820
+ const extensions = Effect.runSync(
821
+ GraphBuilder.createExtension({
822
+ id: 'test-extension',
823
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
824
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: 'c' }]),
825
+ actions: (node, get) =>
826
+ Effect.succeed([{ id: 'act1', data: () => Effect.void, properties: { label: 'A' } }]),
827
+ }),
828
+ );
829
+
830
+ GraphBuilder.addExtension(builder, extensions);
831
+
832
+ const writableGraph = graph as Graph.WritableGraph;
833
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
834
+ Graph.expand(graph, 'parent', 'child');
835
+ await GraphBuilder.flush(builder);
836
+
837
+ const edges = registry.get(graph.edges('parent'));
838
+ expect(edges[Graph.relationKey('child')] ?? []).to.include('parent/child');
839
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/act1');
840
+ const actions = registry.get(graph.actions('parent'));
841
+ expect(actions).has.length(1);
842
+ expect(actions[0].id).to.equal('parent/act1');
843
+ const connections = registry.get(graph.connections('parent', 'child'));
844
+ expect(connections).has.length(1);
845
+ expect(connections[0].id).to.equal('parent/child');
846
+ });
847
+
848
+ test('actions appear when extension registered after expand', async ({ expect }) => {
849
+ const registry = Registry.make();
850
+ const builder = GraphBuilder.make({ registry });
851
+ const graph = builder.graph;
852
+ const writableGraph = graph as Graph.WritableGraph;
853
+
854
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
855
+ Graph.expand(graph, 'parent', 'child');
856
+ await GraphBuilder.flush(builder);
857
+
858
+ expect(registry.get(graph.actions('parent'))).to.have.length(0);
859
+
860
+ const extensions = Effect.runSync(
861
+ GraphBuilder.createExtension({
862
+ id: 'late-extension',
863
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
864
+ actions: (node, get) =>
865
+ Effect.succeed([{ id: 'late-act', data: () => Effect.void, properties: { label: 'Late' } }]),
866
+ }),
867
+ );
868
+
869
+ GraphBuilder.addExtension(builder, extensions);
870
+ await GraphBuilder.flush(builder);
871
+
872
+ const edges = registry.get(graph.edges('parent'));
873
+ expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
874
+ const actions = registry.get(graph.actions('parent'));
875
+ expect(actions).has.length(1);
876
+ expect(actions[0].id).to.equal('parent/late-act');
877
+ });
878
+
879
+ test('_actionContext captures and provides services to action execution', async () => {
880
+ const registry = Registry.make();
881
+ const builder = GraphBuilder.make({ registry });
882
+ const graph = builder.graph;
883
+
884
+ // Define a test service using Context.GenericTag pattern.
885
+ interface TestServiceInterface {
886
+ getValue(): number;
887
+ }
888
+ const TestService = Context.GenericTag<TestServiceInterface>('TestService');
889
+
890
+ // Track whether the action was executed with the correct context.
891
+ let executionResult: number | null = null;
892
+
893
+ // Create extension with service requirement.
894
+ // Note: The actions callback must USE the service for R to be inferred correctly.
895
+ const extensions = Effect.runSync(
896
+ GraphBuilder.createExtension({
897
+ id: 'test-extension',
898
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
899
+ actions: (node, get) =>
900
+ // Use TestService in the callback to include it in R.
901
+ Effect.gen(function* () {
902
+ const service = yield* TestService;
903
+ return [
904
+ {
905
+ id: 'test-action',
906
+ data: () =>
907
+ Effect.gen(function* () {
908
+ // Action can use the same service from captured context.
909
+ const svc = yield* TestService;
910
+ executionResult = svc.getValue();
911
+ }).pipe(Effect.asVoid),
912
+ properties: { label: `Test ${service.getValue()}` },
913
+ },
914
+ ];
915
+ }),
916
+ }).pipe(Effect.provideService(TestService, { getValue: () => 42 })),
917
+ );
918
+
919
+ GraphBuilder.addExtension(builder, extensions);
920
+
921
+ const writableGraph = graph as Graph.WritableGraph;
922
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
923
+ Graph.expand(graph, 'parent', 'child');
924
+ await GraphBuilder.flush(builder);
925
+
926
+ const actions = registry.get(graph.actions('parent'));
927
+ expect(actions).has.length(1);
928
+
929
+ // Verify _actionContext is captured.
930
+ const action = actions[0] as Node.Action;
931
+ expect(action._actionContext).to.not.be.undefined;
932
+
933
+ // Execute the action with the captured context.
934
+ const actionEffect = action.data();
935
+ const effectWithContext = action._actionContext
936
+ ? actionEffect.pipe(Effect.provide(action._actionContext))
937
+ : actionEffect;
938
+
939
+ Effect.runSync(effectWithContext);
940
+
941
+ // Verify the service was accessible during execution.
942
+ expect(executionResult).to.equal(42);
943
+ });
944
+
945
+ test('works with resolver', async () => {
946
+ const registry = Registry.make();
947
+ const builder = GraphBuilder.make({ registry });
948
+ const graph = builder.graph;
949
+
950
+ const extensions = Effect.runSync(
951
+ GraphBuilder.createExtension({
952
+ id: 'test-extension',
953
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
954
+ resolver: (id, get) => Effect.succeed({ id, type: EXAMPLE_TYPE, properties: {}, data: 'resolved' }),
955
+ }),
956
+ );
957
+
958
+ GraphBuilder.addExtension(builder, extensions);
959
+ await Graph.initialize(graph, EXAMPLE_ID);
960
+
961
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
962
+ expect(node).to.not.be.null;
963
+ expect(node?.id).to.equal(EXAMPLE_ID);
964
+ expect(node?.data).to.equal('resolved');
965
+ });
966
+
967
+ test('works with connector and actions together', async () => {
968
+ const registry = Registry.make();
969
+ const builder = GraphBuilder.make({ registry });
970
+ const graph = builder.graph;
971
+
972
+ const extensions = Effect.runSync(
973
+ GraphBuilder.createExtension({
974
+ id: 'test-extension',
975
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
976
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
977
+ actions: (node, get) =>
978
+ Effect.succeed([
979
+ {
980
+ id: 'test-action',
981
+ data: () => Effect.void,
982
+ properties: { label: 'Test' },
983
+ },
984
+ ]),
985
+ }),
986
+ );
987
+
988
+ GraphBuilder.addExtension(builder, extensions);
989
+
990
+ const writableGraph = graph as Graph.WritableGraph;
991
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
992
+ Graph.expand(graph, 'parent', 'child');
993
+ await GraphBuilder.flush(builder);
994
+
995
+ const connections = registry.get(graph.connections('parent', 'child'));
996
+ // Should have both the child node and the action node.
997
+ expect(connections.length).to.be.greaterThanOrEqual(1);
998
+ const childNode = connections.find((n) => n.id === 'parent/child');
999
+ expect(childNode).to.not.be.undefined;
1000
+ expect(childNode?.data).to.equal('test');
1001
+
1002
+ const actions = registry.get(graph.actions('parent'));
1003
+ expect(actions).has.length(1);
1004
+ expect(actions[0].id).to.equal('parent/test-action');
1005
+ });
1006
+
1007
+ test('works with reactive connector using get context', async () => {
1008
+ const registry = Registry.make();
1009
+ const builder = GraphBuilder.make({ registry });
1010
+ const graph = builder.graph;
1011
+
1012
+ const state = Atom.make('initial');
1013
+
1014
+ const extensions = Effect.runSync(
1015
+ GraphBuilder.createExtension({
1016
+ id: 'test-extension',
1017
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1018
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: get(state) }]),
1019
+ }),
1020
+ );
1021
+
1022
+ GraphBuilder.addExtension(builder, extensions);
1023
+
1024
+ const writableGraph = graph as Graph.WritableGraph;
1025
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1026
+ Graph.expand(graph, 'parent', 'child');
1027
+ await GraphBuilder.flush(builder);
1028
+
1029
+ {
1030
+ const connections = registry.get(graph.connections('parent', 'child'));
1031
+ expect(connections).has.length(1);
1032
+ expect(connections[0].data).to.equal('initial');
1033
+ }
1034
+
1035
+ registry.set(state, 'updated');
1036
+ await GraphBuilder.flush(builder);
1037
+
1038
+ {
1039
+ const connections = registry.get(graph.connections('parent', 'child'));
1040
+ expect(connections).has.length(1);
1041
+ expect(connections[0].data).to.equal('updated');
1042
+ }
1043
+ });
1044
+ });
1045
+
1046
+ describe('extension error handling', () => {
1047
+ test('connector failure is caught and logged, returns empty array', async () => {
1048
+ const registry = Registry.make();
1049
+ const builder = GraphBuilder.make({ registry });
1050
+ const graph = builder.graph;
1051
+
1052
+ const extensions = Effect.runSync(
1053
+ GraphBuilder.createExtension({
1054
+ id: 'failing-extension',
1055
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1056
+ connector: (node, get) => Effect.fail(new Error('Connector failed intentionally')),
1057
+ }),
1058
+ );
1059
+
1060
+ GraphBuilder.addExtension(builder, extensions);
1061
+
1062
+ const writableGraph = graph as Graph.WritableGraph;
1063
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1064
+
1065
+ // Should not throw, error is caught internally.
1066
+ Graph.expand(graph, 'parent', 'child');
1067
+ await GraphBuilder.flush(builder);
1068
+
1069
+ // Should return empty connections since the connector failed.
1070
+ const connections = registry.get(graph.connections('parent', 'child'));
1071
+ expect(connections).has.length(0);
1072
+ });
1073
+
1074
+ test('actions failure is caught and logged, returns empty array', async () => {
1075
+ const registry = Registry.make();
1076
+ const builder = GraphBuilder.make({ registry });
1077
+ const graph = builder.graph;
1078
+
1079
+ const extensions = Effect.runSync(
1080
+ GraphBuilder.createExtension({
1081
+ id: 'failing-actions-extension',
1082
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1083
+ actions: (node, get) => Effect.fail(new Error('Actions failed intentionally')),
1084
+ }),
1085
+ );
1086
+
1087
+ GraphBuilder.addExtension(builder, extensions);
1088
+
1089
+ const writableGraph = graph as Graph.WritableGraph;
1090
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1091
+
1092
+ // Should not throw, error is caught internally.
1093
+ Graph.expand(graph, 'parent', 'child');
1094
+ await GraphBuilder.flush(builder);
1095
+
1096
+ // Should return empty actions since the actions callback failed.
1097
+ const actions = registry.get(graph.actions('parent'));
1098
+ expect(actions).has.length(0);
1099
+ });
1100
+
1101
+ test('resolver failure is caught and logged, returns null', async () => {
1102
+ const registry = Registry.make();
1103
+ const builder = GraphBuilder.make({ registry });
1104
+ const graph = builder.graph;
1105
+
1106
+ const extensions = Effect.runSync(
1107
+ GraphBuilder.createExtension({
1108
+ id: 'failing-resolver-extension',
1109
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1110
+ resolver: (id, get) => Effect.fail(new Error('Resolver failed intentionally')),
1111
+ }),
1112
+ );
1113
+
1114
+ GraphBuilder.addExtension(builder, extensions);
1115
+
1116
+ // Should not throw, error is caught internally.
1117
+ await Graph.initialize(graph, EXAMPLE_ID);
1118
+
1119
+ // Should return null/none since the resolver failed.
1120
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
1121
+ expect(node).to.be.null;
1122
+ });
1123
+
1124
+ test('failing extension does not affect other extensions', async () => {
1125
+ const registry = Registry.make();
1126
+ const builder = GraphBuilder.make({ registry });
1127
+ const graph = builder.graph;
1128
+
1129
+ // Add a failing extension.
1130
+ const failingExtensions = Effect.runSync(
1131
+ GraphBuilder.createExtension({
1132
+ id: 'failing-extension',
1133
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1134
+ connector: (node, get) => Effect.fail(new Error('This one fails')),
1135
+ }),
1136
+ );
1137
+
1138
+ // Add a working extension.
1139
+ const workingExtensions = Effect.runSync(
1140
+ GraphBuilder.createExtension({
1141
+ id: 'working-extension',
1142
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
1143
+ connector: (node, get) =>
1144
+ Effect.succeed([{ id: 'child-from-working', type: EXAMPLE_TYPE, data: 'success' }]),
1145
+ }),
1146
+ );
1147
+
1148
+ GraphBuilder.addExtension(builder, failingExtensions);
1149
+ GraphBuilder.addExtension(builder, workingExtensions);
1150
+
1151
+ const writableGraph = graph as Graph.WritableGraph;
1152
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
1153
+ Graph.expand(graph, 'parent', 'child');
1154
+ await GraphBuilder.flush(builder);
1155
+
1156
+ // The working extension should still produce its node.
1157
+ const connections = registry.get(graph.connections('parent', 'child'));
1158
+ expect(connections).has.length(1);
1159
+ expect(connections[0].id).to.equal('parent/child-from-working');
1160
+ expect(connections[0].data).to.equal('success');
1161
+ });
1162
+ });
1163
+
1164
+ describe('createTypeExtension', () => {
1165
+ test('creates extension matching by schema type with inferred object type', async () => {
1166
+ const registry = Registry.make();
1167
+ const builder = GraphBuilder.make({ registry });
1168
+ const graph = builder.graph;
1169
+
1170
+ const extensions = Effect.runSync(
1171
+ GraphBuilder.createTypeExtension({
1172
+ id: 'type-extension',
1173
+ type: TestSchema.Person,
1174
+ connector: (object) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: object }]),
1175
+ }),
1176
+ );
1177
+
1178
+ GraphBuilder.addExtension(builder, extensions);
1179
+
1180
+ const writableGraph = graph as Graph.WritableGraph;
1181
+ const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
1182
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
1183
+ Graph.expand(graph, 'parent', 'child');
1184
+ await GraphBuilder.flush(builder);
1185
+
1186
+ const connections = registry.get(graph.connections('parent', 'child'));
1187
+ expect(connections).has.length(1);
1188
+ expect(connections[0].id).to.equal('parent/child');
1189
+ expect(connections[0].data).to.equal(testObject);
1190
+ });
1191
+ });
1192
+ });
1193
+ describe('path-based ID qualification', () => {
1194
+ test('rejects segment IDs containing slash', async () => {
1195
+ const registry = Registry.make();
1196
+ const builder = GraphBuilder.make({ registry });
1197
+ GraphBuilder.addExtension(
1198
+ builder,
1199
+ GraphBuilder.createExtensionRaw({
1200
+ id: 'bad-connector',
1201
+ connector: () => Atom.make([{ id: 'foo/bar', type: EXAMPLE_TYPE, data: null }]),
1202
+ }),
1203
+ );
1204
+
1205
+ expect(() => Graph.expand(builder.graph, Node.RootId, 'child')).toThrow(/must not contain/);
1206
+ });
1207
+
1208
+ test('multi-level path qualification', async () => {
1209
+ const registry = Registry.make();
1210
+ const builder = GraphBuilder.make({ registry });
1211
+ GraphBuilder.addExtension(builder, [
1212
+ GraphBuilder.createExtensionRaw({
1213
+ id: 'level1',
1214
+ connector: (node) =>
1215
+ Atom.make((get) =>
1216
+ Function.pipe(
1217
+ get(node),
1218
+ Option.filter((n) => n.id === 'root'),
1219
+ Option.map(() => [{ id: 'A', type: EXAMPLE_TYPE, data: 'a' }]),
1220
+ Option.getOrElse(() => []),
1221
+ ),
1222
+ ),
1223
+ }),
1224
+ GraphBuilder.createExtensionRaw({
1225
+ id: 'level2',
1226
+ connector: (node) =>
1227
+ Atom.make((get) =>
1228
+ Function.pipe(
1229
+ get(node),
1230
+ Option.filter((n) => n.id === 'root/A'),
1231
+ Option.map(() => [{ id: 'B', type: EXAMPLE_TYPE, data: 'b' }]),
1232
+ Option.getOrElse(() => []),
1233
+ ),
1234
+ ),
1235
+ }),
1236
+ ]);
1237
+
1238
+ const graph = builder.graph;
1239
+
1240
+ Graph.expand(graph, Node.RootId, 'child');
1241
+ await GraphBuilder.flush(builder);
1242
+
1243
+ const level1 = registry.get(graph.connections(Node.RootId, 'child'));
1244
+ expect(level1).has.length(1);
1245
+ expect(level1[0].id).to.equal('root/A');
1246
+
1247
+ Graph.expand(graph, 'root/A', 'child');
1248
+ await GraphBuilder.flush(builder);
1249
+
1250
+ const level2 = registry.get(graph.connections('root/A', 'child'));
1251
+ expect(level2).has.length(1);
1252
+ expect(level2[0].id).to.equal('root/A/B');
1253
+ });
1254
+
1255
+ test('inline nodes are recursively qualified', async () => {
1256
+ const registry = Registry.make();
1257
+ const builder = GraphBuilder.make({ registry });
1258
+ GraphBuilder.addExtension(
1259
+ builder,
1260
+ GraphBuilder.createExtensionRaw({
1261
+ id: 'inline-connector',
1262
+ connector: () =>
1263
+ Atom.make([
1264
+ {
1265
+ id: 'parent-node',
1266
+ type: EXAMPLE_TYPE,
1267
+ data: null,
1268
+ nodes: [
1269
+ {
1270
+ id: 'inline-child',
1271
+ type: EXAMPLE_TYPE,
1272
+ data: null,
1273
+ nodes: [{ id: 'deep-child', type: EXAMPLE_TYPE, data: null }],
1274
+ },
1275
+ ],
1276
+ },
1277
+ ]),
1278
+ }),
1279
+ );
1280
+
1281
+ const graph = builder.graph;
1282
+ Graph.expand(graph, Node.RootId, 'child');
1283
+ await GraphBuilder.flush(builder);
1284
+
1285
+ const connections = registry.get(graph.connections(Node.RootId, 'child'));
1286
+ expect(connections).has.length(1);
1287
+ expect(connections[0].id).to.equal('root/parent-node');
1288
+
1289
+ const inlineNode = Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
1290
+ expect(inlineNode).to.not.be.null;
1291
+ expect(inlineNode?.id).to.equal('root/parent-node/inline-child');
1292
+
1293
+ const deepNode = Graph.getNode(graph, 'root/parent-node/inline-child/deep-child').pipe(Option.getOrNull);
1294
+ expect(deepNode).to.not.be.null;
1295
+ expect(deepNode?.id).to.equal('root/parent-node/inline-child/deep-child');
1296
+ });
1297
+
1298
+ test('constant connector produces distinct nodes under different parents', async () => {
1299
+ const registry = Registry.make();
1300
+ const builder = GraphBuilder.make({ registry });
1301
+
1302
+ GraphBuilder.addExtension(builder, [
1303
+ GraphBuilder.createExtensionRaw({
1304
+ id: 'parents',
1305
+ connector: (node) =>
1306
+ Atom.make((get) =>
1307
+ Function.pipe(
1308
+ get(node),
1309
+ Option.filter((n) => n.id === 'root'),
1310
+ Option.map(() => [
1311
+ { id: 'A', type: EXAMPLE_TYPE, data: 'a' },
1312
+ { id: 'B', type: EXAMPLE_TYPE, data: 'b' },
1313
+ ]),
1314
+ Option.getOrElse(() => []),
1315
+ ),
1316
+ ),
1317
+ }),
1318
+ GraphBuilder.createExtensionRaw({
1319
+ id: 'constant-child',
1320
+ connector: () => Atom.make([{ id: 'shared', type: EXAMPLE_TYPE, data: 'constant' }]),
1321
+ }),
1322
+ ]);
1323
+
1324
+ const graph = builder.graph;
1325
+
1326
+ Graph.expand(graph, Node.RootId, 'child');
1327
+ await GraphBuilder.flush(builder);
1328
+
1329
+ Graph.expand(graph, 'root/A', 'child');
1330
+ Graph.expand(graph, 'root/B', 'child');
1331
+ await GraphBuilder.flush(builder);
1332
+
1333
+ const childrenOfA = registry.get(graph.connections('root/A', 'child'));
1334
+ const childrenOfB = registry.get(graph.connections('root/B', 'child'));
1335
+
1336
+ expect(childrenOfA).has.length(1);
1337
+ expect(childrenOfB).has.length(1);
1338
+ expect(childrenOfA[0].id).to.equal('root/A/shared');
1339
+ expect(childrenOfB[0].id).to.equal('root/B/shared');
1340
+
1341
+ const nodeA = Graph.getNode(graph, 'root/A/shared').pipe(Option.getOrNull);
1342
+ const nodeB = Graph.getNode(graph, 'root/B/shared').pipe(Option.getOrNull);
1343
+ expect(nodeA).to.not.be.null;
1344
+ expect(nodeB).to.not.be.null;
1345
+ expect(nodeA?.id).to.not.equal(nodeB?.id);
1346
+ });
1347
+
1348
+ test('explore qualifies node IDs', async () => {
1349
+ const builder = GraphBuilder.make();
1350
+ GraphBuilder.addExtension(
1351
+ builder,
1352
+ GraphBuilder.createExtensionRaw({
1353
+ id: 'connector',
1354
+ connector: (node) =>
1355
+ Atom.make((get) =>
1356
+ Function.pipe(
1357
+ get(node),
1358
+ Option.filter((n) => n.id === 'root'),
1359
+ Option.map(() => [
1360
+ { id: 'first', type: EXAMPLE_TYPE, data: 1 },
1361
+ { id: 'second', type: EXAMPLE_TYPE, data: 2 },
1362
+ ]),
1363
+ Option.getOrElse(() => []),
1364
+ ),
1365
+ ),
1366
+ }),
1367
+ );
1368
+
1369
+ const visited: Array<{ id: string; path: string[] }> = [];
1370
+ await GraphBuilder.explore(builder, {
1371
+ relation: 'child',
1372
+ visitor: (node, path) => {
1373
+ visited.push({ id: node.id, path });
1374
+ },
1375
+ });
1376
+
1377
+ expect(visited).has.length(3);
1378
+ expect(visited[0].id).to.equal('root');
1379
+ expect(visited[1].id).to.equal('root/first');
1380
+ expect(visited[2].id).to.equal('root/second');
1381
+ });
1382
+ });
442
1383
  });