@dxos/app-graph 0.8.4-main.72ec0f3 → 0.8.4-main.74a063c4e0

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 (60) hide show
  1. package/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
  2. package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +29 -827
  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-HR5S4XYH.mjs +1604 -0
  9. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +29 -828
  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 +111 -65
  18. package/dist/types/src/graph-builder.d.ts.map +1 -1
  19. package/dist/types/src/graph.d.ts +179 -213
  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.map +1 -1
  30. package/dist/types/src/testing/index.d.ts +2 -0
  31. package/dist/types/src/testing/index.d.ts.map +1 -0
  32. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  33. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  34. package/dist/types/src/util.d.ts +39 -0
  35. package/dist/types/src/util.d.ts.map +1 -0
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +47 -35
  38. package/src/atoms.ts +25 -0
  39. package/src/graph-builder.test.ts +995 -112
  40. package/src/graph-builder.ts +705 -284
  41. package/src/graph.test.ts +448 -120
  42. package/src/graph.ts +1019 -413
  43. package/src/index.ts +10 -3
  44. package/src/node-matcher.test.ts +301 -0
  45. package/src/node-matcher.ts +314 -0
  46. package/src/node.ts +82 -8
  47. package/src/stories/EchoGraph.stories.tsx +151 -121
  48. package/src/stories/Tree.tsx +2 -2
  49. package/src/testing/index.ts +5 -0
  50. package/src/testing/setup-graph-builder.ts +41 -0
  51. package/src/util.ts +95 -0
  52. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  53. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  54. package/dist/types/src/signals-integration.test.d.ts +0 -2
  55. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  56. package/dist/types/src/testing.d.ts +0 -5
  57. package/dist/types/src/testing.d.ts.map +0 -1
  58. package/src/experimental/graph-projections.test.ts +0 -56
  59. package/src/signals-integration.test.ts +0 -219
  60. package/src/testing.ts +0 -20
@@ -3,45 +3,50 @@
3
3
  //
4
4
 
5
5
  import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
6
8
  import * as Function from 'effect/Function';
7
9
  import * as Option from 'effect/Option';
8
10
  import { describe, expect, onTestFinished, test } from 'vitest';
9
11
 
10
- import { Trigger, sleep } from '@dxos/async';
12
+ import { Trigger } from '@dxos/async';
13
+ import { Obj } from '@dxos/echo';
14
+ import { TestSchema } from '@dxos/echo/testing';
11
15
 
12
- import { ROOT_ID } from './graph';
13
- import { GraphBuilder, createExtension } from './graph-builder';
14
- import { type Node } from './node';
16
+ import * as Graph from './graph';
17
+ import * as GraphBuilder from './graph-builder';
18
+ import * as Node from './node';
19
+ import * as NodeMatcher from './node-matcher';
20
+ import { qualifyId } from './util';
15
21
 
16
22
  const exampleId = (id: number) => `dx:test:${id}`;
17
23
  const EXAMPLE_ID = exampleId(1);
18
- const EXAMPLE_TYPE = 'dxos.org/type/example';
24
+ const EXAMPLE_TYPE = 'org.dxos.type.example';
19
25
 
20
26
  describe('GraphBuilder', () => {
21
27
  describe('resolver', () => {
22
28
  test('works', async () => {
23
29
  const registry = Registry.make();
24
- const builder = new GraphBuilder({ registry });
30
+ const builder = GraphBuilder.make({ registry });
25
31
  const graph = builder.graph;
26
32
 
27
33
  {
28
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
34
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
29
35
  expect(node).to.be.null;
30
36
  }
31
37
 
32
- builder.addExtension(
33
- createExtension({
38
+ // Test direct API
39
+ GraphBuilder.addExtension(
40
+ builder,
41
+ GraphBuilder.createExtensionRaw({
34
42
  id: 'resolver',
35
- resolver: () => {
36
- console.log('resolver');
37
- return Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
38
- },
43
+ resolver: () => Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }),
39
44
  }),
40
45
  );
41
- await graph.initialize(EXAMPLE_ID);
46
+ await Graph.initialize(graph, EXAMPLE_ID);
42
47
 
43
48
  {
44
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
49
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
45
50
  expect(node?.id).to.equal(EXAMPLE_ID);
46
51
  expect(node?.type).to.equal(EXAMPLE_TYPE);
47
52
  expect(node?.data).to.equal(1);
@@ -50,95 +55,284 @@ describe('GraphBuilder', () => {
50
55
 
51
56
  test('updates', async () => {
52
57
  const registry = Registry.make();
53
- const builder = new GraphBuilder({ registry });
58
+ const builder = GraphBuilder.make({ registry });
54
59
  const name = Atom.make('default');
55
- builder.addExtension(
56
- createExtension({
60
+ GraphBuilder.addExtension(
61
+ builder,
62
+ GraphBuilder.createExtensionRaw({
57
63
  id: 'resolver',
58
64
  resolver: () => Atom.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
59
65
  }),
60
66
  );
61
67
  const graph = builder.graph;
62
- await graph.initialize(EXAMPLE_ID);
68
+ await Graph.initialize(graph, EXAMPLE_ID);
63
69
 
64
70
  {
65
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
71
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
66
72
  expect(node?.data).to.equal('default');
67
73
  }
68
74
 
69
75
  registry.set(name, 'updated');
70
76
 
71
77
  {
72
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
78
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
73
79
  expect(node?.data).to.equal('updated');
74
80
  }
75
81
  });
82
+
83
+ test('connects resolved node to parent via child edge', async ({ expect }) => {
84
+ const registry = Registry.make();
85
+ const builder = GraphBuilder.make({ registry });
86
+ const childId = qualifyId('root', '~child');
87
+
88
+ GraphBuilder.addExtension(
89
+ builder,
90
+ GraphBuilder.createExtensionRaw({
91
+ id: 'resolver',
92
+ resolver: (id) =>
93
+ id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved' }) : Atom.make(null),
94
+ }),
95
+ );
96
+
97
+ const graph = builder.graph;
98
+ await Graph.initialize(graph, childId);
99
+
100
+ {
101
+ const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
102
+ expect(node?.id).to.equal(childId);
103
+ expect(node?.data).to.equal('resolved');
104
+ }
105
+
106
+ // Verify the resolved node is a child of root.
107
+ {
108
+ const children = registry.get(graph.connections('root', 'child'));
109
+ expect(children.some((n) => n.id === childId)).to.be.true;
110
+ }
111
+ });
112
+
113
+ test('out-of-order: resolver fires before parent exists', async ({ expect }) => {
114
+ const registry = Registry.make();
115
+ const builder = GraphBuilder.make({ registry });
116
+ const parentId = qualifyId('root', 'parent');
117
+ const childId = qualifyId('root', 'parent', '~child');
118
+
119
+ GraphBuilder.addExtension(builder, [
120
+ GraphBuilder.createExtensionRaw({
121
+ id: 'resolver',
122
+ resolver: (id) =>
123
+ id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved-child' }) : Atom.make(null),
124
+ }),
125
+ GraphBuilder.createExtensionRaw({
126
+ id: 'connector',
127
+ connector: (node) =>
128
+ Atom.make((get) =>
129
+ Function.pipe(
130
+ get(node),
131
+ Option.filter((n) => n.id === 'root'),
132
+ Option.map(() => [{ id: 'parent', type: EXAMPLE_TYPE, data: 'parent-data' }]),
133
+ Option.getOrElse(() => []),
134
+ ),
135
+ ),
136
+ }),
137
+ ]);
138
+
139
+ const graph = builder.graph;
140
+
141
+ // Resolve child BEFORE parent exists in the graph.
142
+ await Graph.initialize(graph, childId);
143
+
144
+ {
145
+ const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
146
+ expect(node?.id).to.equal(childId);
147
+ expect(node?.data).to.equal('resolved-child');
148
+ }
149
+
150
+ // Now expand root to create parent via connector.
151
+ Graph.expand(graph, Node.RootId, 'child');
152
+ await GraphBuilder.flush(builder);
153
+
154
+ {
155
+ const parent = Graph.getNode(graph, parentId).pipe(Option.getOrNull);
156
+ expect(parent?.data).to.equal('parent-data');
157
+ }
158
+
159
+ // The resolved child should be connected to the parent.
160
+ {
161
+ const children = registry.get(graph.connections(parentId, 'child'));
162
+ expect(children.some((n) => n.id === childId)).to.be.true;
163
+ }
164
+ });
165
+
166
+ test('onNone does not remove connector-owned node', async ({ expect }) => {
167
+ const registry = Registry.make();
168
+ const builder = GraphBuilder.make({ registry });
169
+ const nodeId = qualifyId('root', 'shared');
170
+
171
+ // Connector that produces root/shared. No resolver matches root/shared.
172
+ GraphBuilder.addExtension(
173
+ builder,
174
+ GraphBuilder.createExtensionRaw({
175
+ id: 'connector',
176
+ connector: (node) =>
177
+ Atom.make((get) =>
178
+ Function.pipe(
179
+ get(node),
180
+ Option.filter((n) => n.id === 'root'),
181
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
182
+ Option.getOrElse(() => []),
183
+ ),
184
+ ),
185
+ }),
186
+ );
187
+
188
+ const graph = builder.graph;
189
+
190
+ // Connector produces root/shared.
191
+ Graph.expand(graph, Node.RootId, 'child');
192
+ await GraphBuilder.flush(builder);
193
+
194
+ {
195
+ const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
196
+ expect(node?.data).to.equal('from-connector');
197
+ }
198
+
199
+ // Initialize fires for the same ID. No resolver matches, so onNone fires.
200
+ // The connector-owned node should NOT be removed.
201
+ await Graph.initialize(graph, nodeId);
202
+
203
+ {
204
+ const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
205
+ expect(node?.data).to.equal('from-connector');
206
+ }
207
+ });
208
+
209
+ test('does not overwrite connector-produced node', async () => {
210
+ const registry = Registry.make();
211
+ const builder = GraphBuilder.make({ registry });
212
+ const resolverData = Atom.make('from-resolver');
213
+
214
+ GraphBuilder.addExtension(builder, [
215
+ GraphBuilder.createExtensionRaw({
216
+ id: 'resolver',
217
+ resolver: (id) =>
218
+ id === qualifyId('root', 'shared')
219
+ ? Atom.make((get) => ({ id: qualifyId('root', 'shared'), type: EXAMPLE_TYPE, data: get(resolverData) }))
220
+ : Atom.make(null),
221
+ }),
222
+ GraphBuilder.createExtensionRaw({
223
+ id: 'connector',
224
+ connector: (node) =>
225
+ Atom.make((get) =>
226
+ Function.pipe(
227
+ get(node),
228
+ Option.filter((n) => n.id === 'root'),
229
+ Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
230
+ Option.getOrElse(() => []),
231
+ ),
232
+ ),
233
+ }),
234
+ ]);
235
+
236
+ const graph = builder.graph;
237
+
238
+ // Connector produces root/shared.
239
+ Graph.expand(graph, Node.RootId, 'child');
240
+ await GraphBuilder.flush(builder);
241
+
242
+ {
243
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
244
+ expect(node?.data).to.equal('from-connector');
245
+ }
246
+
247
+ // Resolver fires for the same ID but should not overwrite.
248
+ await Graph.initialize(graph, qualifyId('root', 'shared'));
249
+
250
+ {
251
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
252
+ expect(node?.data).to.equal('from-connector');
253
+ }
254
+
255
+ // Updating the resolver's atom should still not overwrite.
256
+ registry.set(resolverData, 'updated-resolver');
257
+
258
+ {
259
+ const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
260
+ expect(node?.data).to.equal('from-connector');
261
+ }
262
+ });
76
263
  });
77
264
 
78
265
  describe('connector', () => {
79
- test('works', () => {
266
+ test('works', async () => {
80
267
  const registry = Registry.make();
81
- const builder = new GraphBuilder({ registry });
82
- builder.addExtension(
83
- createExtension({
268
+ const builder = GraphBuilder.make({ registry });
269
+ GraphBuilder.addExtension(
270
+ builder,
271
+ GraphBuilder.createExtensionRaw({
84
272
  id: 'outbound-connector',
85
273
  connector: () => Atom.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
86
274
  }),
87
275
  );
88
- builder.addExtension(
89
- createExtension({
276
+ GraphBuilder.addExtension(
277
+ builder,
278
+ GraphBuilder.createExtensionRaw({
90
279
  id: 'inbound-connector',
91
- relation: 'inbound',
280
+ relation: Node.childRelation('inbound'),
92
281
  connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
93
282
  }),
94
283
  );
95
284
 
96
285
  const graph = builder.graph;
97
- graph.expand(ROOT_ID);
98
- graph.expand(ROOT_ID, 'inbound');
286
+ Graph.expand(graph, Node.RootId, 'child');
287
+ Graph.expand(graph, Node.RootId, Node.childRelation('inbound'));
288
+ await GraphBuilder.flush(builder);
99
289
 
100
- const outbound = registry.get(graph.connections(ROOT_ID));
101
- const inbound = registry.get(graph.connections(ROOT_ID, 'inbound'));
290
+ const outbound = registry.get(graph.connections(Node.RootId, 'child'));
291
+ const inbound = registry.get(graph.connections(Node.RootId, Node.childRelation('inbound')));
102
292
 
103
293
  expect(outbound).has.length(1);
104
- expect(outbound[0].id).to.equal('child');
294
+ expect(outbound[0].id).to.equal('root/child');
105
295
  expect(outbound[0].data).to.equal(2);
106
296
  expect(inbound).has.length(1);
107
- expect(inbound[0].id).to.equal('parent');
297
+ expect(inbound[0].id).to.equal('root/parent');
108
298
  expect(inbound[0].data).to.equal(0);
109
299
  });
110
300
 
111
- test('updates', () => {
301
+ test('updates', async () => {
112
302
  const registry = Registry.make();
113
- const builder = new GraphBuilder({ registry });
303
+ const builder = GraphBuilder.make({ registry });
114
304
  const state = Atom.make(0);
115
- builder.addExtension(
116
- createExtension({
305
+ GraphBuilder.addExtension(
306
+ builder,
307
+ GraphBuilder.createExtensionRaw({
117
308
  id: 'connector',
118
309
  connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
119
310
  }),
120
311
  );
121
312
  const graph = builder.graph;
122
- graph.expand(ROOT_ID);
313
+ Graph.expand(graph, Node.RootId, 'child');
314
+ await GraphBuilder.flush(builder);
123
315
 
124
316
  {
125
- const [node] = registry.get(graph.connections(ROOT_ID));
317
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
126
318
  expect(node.data).to.equal(0);
127
319
  }
128
320
 
129
321
  {
130
322
  registry.set(state, 1);
131
- const [node] = registry.get(graph.connections(ROOT_ID));
323
+ await GraphBuilder.flush(builder);
324
+ const [node] = registry.get(graph.connections(Node.RootId, 'child'));
132
325
  expect(node.data).to.equal(1);
133
326
  }
134
327
  });
135
328
 
136
- test('subscribes to updates', () => {
329
+ test('subscribes to updates', async () => {
137
330
  const registry = Registry.make();
138
- const builder = new GraphBuilder({ registry });
331
+ const builder = GraphBuilder.make({ registry });
139
332
  const state = Atom.make(0);
140
- builder.addExtension(
141
- createExtension({
333
+ GraphBuilder.addExtension(
334
+ builder,
335
+ GraphBuilder.createExtensionRaw({
142
336
  id: 'connector',
143
337
  connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
144
338
  }),
@@ -146,36 +340,41 @@ describe('GraphBuilder', () => {
146
340
  const graph = builder.graph;
147
341
 
148
342
  let count = 0;
149
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_) => {
343
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_) => {
150
344
  count++;
151
345
  });
152
346
  onTestFinished(() => cancel());
153
347
 
154
348
  expect(count).to.equal(0);
155
- expect(registry.get(graph.connections(ROOT_ID))).to.have.length(0);
349
+ expect(registry.get(graph.connections(Node.RootId, 'child'))).to.have.length(0);
156
350
  expect(count).to.equal(1);
157
351
 
158
- graph.expand(ROOT_ID);
352
+ Graph.expand(graph, Node.RootId, 'child');
353
+ await GraphBuilder.flush(builder);
159
354
  expect(count).to.equal(2);
355
+
160
356
  registry.set(state, 1);
357
+ await GraphBuilder.flush(builder);
161
358
  expect(count).to.equal(3);
162
359
  });
163
360
 
164
- test('updates with new extensions', () => {
361
+ test('updates with new extensions', async () => {
165
362
  const registry = Registry.make();
166
- const builder = new GraphBuilder({ registry });
167
- builder.addExtension(
168
- createExtension({
363
+ const builder = GraphBuilder.make({ registry });
364
+ GraphBuilder.addExtension(
365
+ builder,
366
+ GraphBuilder.createExtensionRaw({
169
367
  id: 'connector',
170
368
  connector: () => Atom.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
171
369
  }),
172
370
  );
173
371
  const graph = builder.graph;
174
- graph.expand(ROOT_ID);
372
+ Graph.expand(graph, Node.RootId, 'child');
373
+ await GraphBuilder.flush(builder);
175
374
 
176
- let nodes: Node[] = [];
375
+ let nodes: Node.Node[] = [];
177
376
  let count = 0;
178
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_nodes) => {
377
+ const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_nodes) => {
179
378
  count++;
180
379
  nodes = _nodes;
181
380
  });
@@ -183,59 +382,64 @@ describe('GraphBuilder', () => {
183
382
 
184
383
  expect(nodes).has.length(0);
185
384
  expect(count).to.equal(0);
186
- registry.get(graph.connections(ROOT_ID));
385
+ registry.get(graph.connections(Node.RootId, 'child'));
187
386
  expect(nodes).has.length(1);
188
387
  expect(count).to.equal(1);
189
388
 
190
- builder.addExtension(
191
- createExtension({
389
+ GraphBuilder.addExtension(
390
+ builder,
391
+ GraphBuilder.createExtensionRaw({
192
392
  id: 'connector-2',
193
393
  connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
194
394
  }),
195
395
  );
396
+ await GraphBuilder.flush(builder);
196
397
  expect(nodes).has.length(2);
197
398
  expect(count).to.equal(2);
198
399
  });
199
400
 
200
- test('removes', () => {
401
+ test('removes', async () => {
201
402
  const registry = Registry.make();
202
- const builder = new GraphBuilder({ registry });
403
+ const builder = GraphBuilder.make({ registry });
203
404
  const nodes = Atom.make([
204
405
  { id: exampleId(1), type: EXAMPLE_TYPE },
205
406
  { id: exampleId(2), type: EXAMPLE_TYPE },
206
407
  ]);
207
- builder.addExtension(
208
- createExtension({
408
+ GraphBuilder.addExtension(
409
+ builder,
410
+ GraphBuilder.createExtensionRaw({
209
411
  id: 'connector',
210
412
  connector: () => Atom.make((get) => get(nodes)),
211
413
  }),
212
414
  );
213
415
  const graph = builder.graph;
214
- graph.expand(ROOT_ID);
416
+ Graph.expand(graph, Node.RootId, 'child');
417
+ await GraphBuilder.flush(builder);
215
418
 
216
419
  {
217
- const nodes = registry.get(graph.connections(ROOT_ID));
420
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
218
421
  expect(nodes).has.length(2);
219
- expect(nodes[0].id).to.equal(exampleId(1));
220
- expect(nodes[1].id).to.equal(exampleId(2));
422
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
423
+ expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
221
424
  }
222
425
 
223
426
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
427
+ await GraphBuilder.flush(builder);
224
428
 
225
429
  {
226
- const nodes = registry.get(graph.connections(ROOT_ID));
430
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
227
431
  expect(nodes).has.length(1);
228
- expect(nodes[0].id).to.equal(exampleId(3));
432
+ expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
229
433
  }
230
434
  });
231
435
 
232
- test('nodes are updated when removed', () => {
436
+ test('nodes are updated when removed', async () => {
233
437
  const registry = Registry.make();
234
- const builder = new GraphBuilder({ registry });
438
+ const builder = GraphBuilder.make({ registry });
235
439
  const name = Atom.make('removed');
236
440
 
237
- builder.addExtension([
238
- createExtension({
441
+ GraphBuilder.addExtension(builder, [
442
+ GraphBuilder.createExtensionRaw({
239
443
  id: 'root',
240
444
  connector: (node) =>
241
445
  Atom.make((get) =>
@@ -254,52 +458,58 @@ describe('GraphBuilder', () => {
254
458
 
255
459
  let count = 0;
256
460
  let exists = false;
257
- const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
461
+ const cancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (node) => {
258
462
  count++;
259
463
  exists = Option.isSome(node);
260
464
  });
261
465
  onTestFinished(() => cancel());
262
466
 
263
- graph.expand(ROOT_ID);
467
+ Graph.expand(graph, Node.RootId, 'child');
468
+ await GraphBuilder.flush(builder);
264
469
  expect(count).to.equal(0);
265
470
  expect(exists).to.be.false;
266
471
 
267
472
  registry.set(name, 'default');
473
+ await GraphBuilder.flush(builder);
268
474
  expect(count).to.equal(1);
269
475
  expect(exists).to.be.true;
270
476
 
271
477
  registry.set(name, 'removed');
478
+ await GraphBuilder.flush(builder);
272
479
  expect(count).to.equal(2);
273
480
  expect(exists).to.be.false;
274
481
 
275
482
  registry.set(name, 'added');
483
+ await GraphBuilder.flush(builder);
276
484
  expect(count).to.equal(3);
277
485
  expect(exists).to.be.true;
278
486
  });
279
487
 
280
488
  test('sort edges', async () => {
281
489
  const registry = Registry.make();
282
- const builder = new GraphBuilder({ registry });
490
+ const builder = GraphBuilder.make({ registry });
283
491
  const nodes = Atom.make([
284
492
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
285
493
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
286
494
  { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
287
495
  ]);
288
- builder.addExtension(
289
- createExtension({
496
+ GraphBuilder.addExtension(
497
+ builder,
498
+ GraphBuilder.createExtensionRaw({
290
499
  id: 'connector',
291
500
  connector: () => Atom.make((get) => get(nodes)),
292
501
  }),
293
502
  );
294
503
  const graph = builder.graph;
295
- graph.expand(ROOT_ID);
504
+ Graph.expand(graph, Node.RootId, 'child');
505
+ await GraphBuilder.flush(builder);
296
506
 
297
507
  {
298
- const nodes = registry.get(graph.connections(ROOT_ID));
508
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
299
509
  expect(nodes).has.length(3);
300
- expect(nodes[0].id).to.equal(exampleId(1));
301
- expect(nodes[1].id).to.equal(exampleId(2));
302
- expect(nodes[2].id).to.equal(exampleId(3));
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)));
303
513
  }
304
514
 
305
515
  registry.set(nodes, [
@@ -307,27 +517,25 @@ describe('GraphBuilder', () => {
307
517
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
308
518
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
309
519
  ]);
310
-
311
- // TODO(wittjosiah): Why is this needed for the following conditions to pass?
312
- await sleep(0);
520
+ await GraphBuilder.flush(builder);
313
521
 
314
522
  {
315
- const nodes = registry.get(graph.connections(ROOT_ID));
523
+ const nodes = registry.get(graph.connections(Node.RootId, 'child'));
316
524
  expect(nodes).has.length(3);
317
- expect(nodes[0].id).to.equal(exampleId(3));
318
- expect(nodes[1].id).to.equal(exampleId(1));
319
- expect(nodes[2].id).to.equal(exampleId(2));
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)));
320
528
  }
321
529
  });
322
530
 
323
- test('updates are constrained', () => {
531
+ test('updates are constrained', async () => {
324
532
  const registry = Registry.make();
325
- const builder = new GraphBuilder({ registry });
533
+ const builder = GraphBuilder.make({ registry });
326
534
  const name = Atom.make('default');
327
535
  const sub = Atom.make('default');
328
536
 
329
- builder.addExtension([
330
- createExtension({
537
+ GraphBuilder.addExtension(builder, [
538
+ GraphBuilder.createExtensionRaw({
331
539
  id: 'root',
332
540
  connector: (node) =>
333
541
  Atom.make((get) =>
@@ -340,25 +548,29 @@ describe('GraphBuilder', () => {
340
548
  ),
341
549
  ),
342
550
  }),
343
- createExtension({
551
+ GraphBuilder.createExtensionRaw({
344
552
  id: 'connector1',
345
553
  connector: (node) =>
346
554
  Atom.make((get) =>
347
555
  Function.pipe(
348
556
  get(node),
349
- 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
+ ),
350
560
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
351
561
  Option.getOrElse(() => []),
352
562
  ),
353
563
  ),
354
564
  }),
355
- createExtension({
565
+ GraphBuilder.createExtensionRaw({
356
566
  id: 'connector2',
357
567
  connector: (node) =>
358
568
  Atom.make((get) =>
359
569
  Function.pipe(
360
570
  get(node),
361
- 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
+ ),
362
574
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
363
575
  Option.getOrElse(() => []),
364
576
  ),
@@ -369,43 +581,47 @@ describe('GraphBuilder', () => {
369
581
  const graph = builder.graph;
370
582
 
371
583
  let parentCount = 0;
372
- const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
584
+ const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
373
585
  parentCount++;
374
586
  });
375
587
  onTestFinished(() => parentCancel());
376
588
 
377
589
  let independentCount = 0;
378
- const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
590
+ const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
379
591
  independentCount++;
380
592
  });
381
593
  onTestFinished(() => independentCancel());
382
594
 
383
595
  let dependentCount = 0;
384
- const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
596
+ const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
385
597
  dependentCount++;
386
598
  });
387
599
  onTestFinished(() => dependentCancel());
388
600
 
389
601
  // Counts should not increment until the node is expanded.
390
- graph.expand(ROOT_ID);
602
+ Graph.expand(graph, Node.RootId, 'child');
603
+ await GraphBuilder.flush(builder);
391
604
  expect(parentCount).to.equal(1);
392
605
  expect(independentCount).to.equal(0);
393
606
  expect(dependentCount).to.equal(0);
394
607
 
395
608
  // Counts should increment when the node is expanded.
396
- graph.expand(EXAMPLE_ID);
609
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
610
+ await GraphBuilder.flush(builder);
397
611
  expect(parentCount).to.equal(1);
398
612
  expect(independentCount).to.equal(1);
399
613
  expect(dependentCount).to.equal(1);
400
614
 
401
615
  // Only dependent count should increment when the parent changes.
402
616
  registry.set(name, 'updated');
617
+ await GraphBuilder.flush(builder);
403
618
  expect(parentCount).to.equal(2);
404
619
  expect(independentCount).to.equal(1);
405
620
  expect(dependentCount).to.equal(2);
406
621
 
407
622
  // Only independent count should increment when its state changes.
408
623
  registry.set(sub, 'updated');
624
+ await GraphBuilder.flush(builder);
409
625
  expect(parentCount).to.equal(2);
410
626
  expect(independentCount).to.equal(2);
411
627
  expect(dependentCount).to.equal(2);
@@ -415,18 +631,21 @@ describe('GraphBuilder', () => {
415
631
  registry.set(name, 'removed');
416
632
  registry.set(sub, 'batch');
417
633
  });
634
+ await GraphBuilder.flush(builder);
418
635
  expect(parentCount).to.equal(2);
419
636
  expect(independentCount).to.equal(3);
420
637
  expect(dependentCount).to.equal(2);
421
638
 
422
639
  // Dependent count should increment when the node is added back.
423
640
  registry.set(name, 'added');
641
+ await GraphBuilder.flush(builder);
424
642
  expect(parentCount).to.equal(3);
425
643
  expect(independentCount).to.equal(3);
426
644
  expect(dependentCount).to.equal(3);
427
645
 
428
646
  // Counts should not increment when the node is expanded again.
429
- graph.expand(EXAMPLE_ID);
647
+ Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
648
+ await GraphBuilder.flush(builder);
430
649
  expect(parentCount).to.equal(3);
431
650
  expect(independentCount).to.equal(3);
432
651
  expect(dependentCount).to.equal(3);
@@ -434,9 +653,10 @@ describe('GraphBuilder', () => {
434
653
 
435
654
  test('eager graph expansion', async () => {
436
655
  const registry = Registry.make();
437
- const builder = new GraphBuilder({ registry });
438
- builder.addExtension(
439
- createExtension({
656
+ const builder = GraphBuilder.make({ registry });
657
+ GraphBuilder.addExtension(
658
+ builder,
659
+ GraphBuilder.createExtensionRaw({
440
660
  id: 'connector',
441
661
  connector: (node) => {
442
662
  return Atom.make((get) =>
@@ -455,14 +675,14 @@ describe('GraphBuilder', () => {
455
675
  let count = 0;
456
676
  const trigger = new Trigger();
457
677
  builder.graph.onNodeChanged.on(({ id }) => {
458
- builder.graph.expand(id);
678
+ Graph.expand(builder.graph, id, 'child');
459
679
  count++;
460
680
  if (count === 5) {
461
681
  trigger.wake();
462
682
  }
463
683
  });
464
684
 
465
- builder.graph.expand(ROOT_ID);
685
+ Graph.expand(builder.graph, Node.RootId, 'child');
466
686
  await trigger.wait();
467
687
  expect(count).to.equal(5);
468
688
  });
@@ -470,9 +690,10 @@ describe('GraphBuilder', () => {
470
690
 
471
691
  describe('explore', () => {
472
692
  test('works', async () => {
473
- const builder = new GraphBuilder();
474
- builder.addExtension(
475
- createExtension({
693
+ const builder = GraphBuilder.make();
694
+ GraphBuilder.addExtension(
695
+ builder,
696
+ GraphBuilder.createExtensionRaw({
476
697
  id: 'connector',
477
698
  connector: (node) =>
478
699
  Atom.make((get) =>
@@ -488,7 +709,8 @@ describe('GraphBuilder', () => {
488
709
  );
489
710
 
490
711
  let count = 0;
491
- await builder.explore({
712
+ await GraphBuilder.explore(builder, {
713
+ relation: 'child',
492
714
  visitor: () => {
493
715
  count++;
494
716
  },
@@ -497,4 +719,665 @@ describe('GraphBuilder', () => {
497
719
  expect(count).to.equal(6);
498
720
  });
499
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
+ });
500
1383
  });