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

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