@dxos/app-graph 0.8.4-main.ae835ea → 0.8.4-main.bc674ce

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 (43) hide show
  1. package/dist/lib/browser/index.mjs +1014 -553
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1013 -553
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/atoms.d.ts +8 -0
  8. package/dist/types/src/atoms.d.ts.map +1 -0
  9. package/dist/types/src/graph-builder.d.ts +108 -66
  10. package/dist/types/src/graph-builder.d.ts.map +1 -1
  11. package/dist/types/src/graph.d.ts +182 -212
  12. package/dist/types/src/graph.d.ts.map +1 -1
  13. package/dist/types/src/index.d.ts +6 -3
  14. package/dist/types/src/index.d.ts.map +1 -1
  15. package/dist/types/src/node-matcher.d.ts +218 -0
  16. package/dist/types/src/node-matcher.d.ts.map +1 -0
  17. package/dist/types/src/node-matcher.test.d.ts +2 -0
  18. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  19. package/dist/types/src/node.d.ts +32 -3
  20. package/dist/types/src/node.d.ts.map +1 -1
  21. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  22. package/dist/types/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +35 -33
  24. package/src/atoms.ts +25 -0
  25. package/src/graph-builder.test.ts +520 -104
  26. package/src/graph-builder.ts +550 -255
  27. package/src/graph.test.ts +299 -106
  28. package/src/graph.ts +964 -394
  29. package/src/index.ts +9 -3
  30. package/src/node-matcher.test.ts +301 -0
  31. package/src/node-matcher.ts +284 -0
  32. package/src/node.ts +39 -6
  33. package/src/stories/EchoGraph.stories.tsx +104 -95
  34. package/src/stories/Tree.tsx +2 -2
  35. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  36. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  37. package/dist/types/src/signals-integration.test.d.ts +0 -2
  38. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  39. package/dist/types/src/testing.d.ts +0 -5
  40. package/dist/types/src/testing.d.ts.map +0 -1
  41. package/src/experimental/graph-projections.test.ts +0 -56
  42. package/src/signals-integration.test.ts +0 -218
  43. package/src/testing.ts +0 -20
@@ -2,16 +2,21 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
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
12
  import { Trigger, sleep } 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';
15
20
 
16
21
  const exampleId = (id: number) => `dx:test:${id}`;
17
22
  const EXAMPLE_ID = exampleId(1);
@@ -21,27 +26,29 @@ describe('GraphBuilder', () => {
21
26
  describe('resolver', () => {
22
27
  test('works', async () => {
23
28
  const registry = Registry.make();
24
- const builder = new GraphBuilder({ registry });
29
+ const builder = GraphBuilder.make({ registry });
25
30
  const graph = builder.graph;
26
31
 
27
32
  {
28
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
33
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
29
34
  expect(node).to.be.null;
30
35
  }
31
36
 
32
- builder.addExtension(
33
- createExtension({
37
+ // Test direct API
38
+ GraphBuilder.addExtension(
39
+ builder,
40
+ GraphBuilder.createExtensionRaw({
34
41
  id: 'resolver',
35
42
  resolver: () => {
36
43
  console.log('resolver');
37
- return Rx.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
44
+ return Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
38
45
  },
39
46
  }),
40
47
  );
41
- await graph.initialize(EXAMPLE_ID);
48
+ await Graph.initialize(graph, EXAMPLE_ID);
42
49
 
43
50
  {
44
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
51
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
45
52
  expect(node?.id).to.equal(EXAMPLE_ID);
46
53
  expect(node?.type).to.equal(EXAMPLE_TYPE);
47
54
  expect(node?.data).to.equal(1);
@@ -50,26 +57,27 @@ describe('GraphBuilder', () => {
50
57
 
51
58
  test('updates', async () => {
52
59
  const registry = Registry.make();
53
- const builder = new GraphBuilder({ registry });
54
- const name = Rx.make('default');
55
- builder.addExtension(
56
- createExtension({
60
+ const builder = GraphBuilder.make({ registry });
61
+ const name = Atom.make('default');
62
+ GraphBuilder.addExtension(
63
+ builder,
64
+ GraphBuilder.createExtensionRaw({
57
65
  id: 'resolver',
58
- resolver: () => Rx.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
66
+ resolver: () => Atom.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
59
67
  }),
60
68
  );
61
69
  const graph = builder.graph;
62
- await graph.initialize(EXAMPLE_ID);
70
+ await Graph.initialize(graph, EXAMPLE_ID);
63
71
 
64
72
  {
65
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
73
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
66
74
  expect(node?.data).to.equal('default');
67
75
  }
68
76
 
69
77
  registry.set(name, 'updated');
70
78
 
71
79
  {
72
- const node = graph.getNode(EXAMPLE_ID).pipe(Option.getOrNull);
80
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
73
81
  expect(node?.data).to.equal('updated');
74
82
  }
75
83
  });
@@ -78,27 +86,29 @@ describe('GraphBuilder', () => {
78
86
  describe('connector', () => {
79
87
  test('works', () => {
80
88
  const registry = Registry.make();
81
- const builder = new GraphBuilder({ registry });
82
- builder.addExtension(
83
- createExtension({
89
+ const builder = GraphBuilder.make({ registry });
90
+ GraphBuilder.addExtension(
91
+ builder,
92
+ GraphBuilder.createExtensionRaw({
84
93
  id: 'outbound-connector',
85
- connector: () => Rx.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
94
+ connector: () => Atom.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
86
95
  }),
87
96
  );
88
- builder.addExtension(
89
- createExtension({
97
+ GraphBuilder.addExtension(
98
+ builder,
99
+ GraphBuilder.createExtensionRaw({
90
100
  id: 'inbound-connector',
91
101
  relation: 'inbound',
92
- connector: () => Rx.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
102
+ connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
93
103
  }),
94
104
  );
95
105
 
96
106
  const graph = builder.graph;
97
- graph.expand(ROOT_ID);
98
- graph.expand(ROOT_ID, 'inbound');
107
+ Graph.expand(graph, Node.RootId);
108
+ Graph.expand(graph, Node.RootId, 'inbound');
99
109
 
100
- const outbound = registry.get(graph.connections(ROOT_ID));
101
- const inbound = registry.get(graph.connections(ROOT_ID, 'inbound'));
110
+ const outbound = registry.get(graph.connections(Node.RootId));
111
+ const inbound = registry.get(graph.connections(Node.RootId, 'inbound'));
102
112
 
103
113
  expect(outbound).has.length(1);
104
114
  expect(outbound[0].id).to.equal('child');
@@ -110,52 +120,54 @@ describe('GraphBuilder', () => {
110
120
 
111
121
  test('updates', () => {
112
122
  const registry = Registry.make();
113
- const builder = new GraphBuilder({ registry });
114
- const state = Rx.make(0);
115
- builder.addExtension(
116
- createExtension({
123
+ const builder = GraphBuilder.make({ registry });
124
+ const state = Atom.make(0);
125
+ GraphBuilder.addExtension(
126
+ builder,
127
+ GraphBuilder.createExtensionRaw({
117
128
  id: 'connector',
118
- connector: () => Rx.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
129
+ connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
119
130
  }),
120
131
  );
121
132
  const graph = builder.graph;
122
- graph.expand(ROOT_ID);
133
+ Graph.expand(graph, Node.RootId);
123
134
 
124
135
  {
125
- const [node] = registry.get(graph.connections(ROOT_ID));
136
+ const [node] = registry.get(graph.connections(Node.RootId));
126
137
  expect(node.data).to.equal(0);
127
138
  }
128
139
 
129
140
  {
130
141
  registry.set(state, 1);
131
- const [node] = registry.get(graph.connections(ROOT_ID));
142
+ const [node] = registry.get(graph.connections(Node.RootId));
132
143
  expect(node.data).to.equal(1);
133
144
  }
134
145
  });
135
146
 
136
147
  test('subscribes to updates', () => {
137
148
  const registry = Registry.make();
138
- const builder = new GraphBuilder({ registry });
139
- const state = Rx.make(0);
140
- builder.addExtension(
141
- createExtension({
149
+ const builder = GraphBuilder.make({ registry });
150
+ const state = Atom.make(0);
151
+ GraphBuilder.addExtension(
152
+ builder,
153
+ GraphBuilder.createExtensionRaw({
142
154
  id: 'connector',
143
- connector: () => Rx.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
155
+ connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
144
156
  }),
145
157
  );
146
158
  const graph = builder.graph;
147
159
 
148
160
  let count = 0;
149
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_) => {
161
+ const cancel = registry.subscribe(graph.connections(Node.RootId), (_) => {
150
162
  count++;
151
163
  });
152
164
  onTestFinished(() => cancel());
153
165
 
154
166
  expect(count).to.equal(0);
155
- expect(registry.get(graph.connections(ROOT_ID))).to.have.length(0);
167
+ expect(registry.get(graph.connections(Node.RootId))).to.have.length(0);
156
168
  expect(count).to.equal(1);
157
169
 
158
- graph.expand(ROOT_ID);
170
+ Graph.expand(graph, Node.RootId);
159
171
  expect(count).to.equal(2);
160
172
  registry.set(state, 1);
161
173
  expect(count).to.equal(3);
@@ -163,19 +175,20 @@ describe('GraphBuilder', () => {
163
175
 
164
176
  test('updates with new extensions', () => {
165
177
  const registry = Registry.make();
166
- const builder = new GraphBuilder({ registry });
167
- builder.addExtension(
168
- createExtension({
178
+ const builder = GraphBuilder.make({ registry });
179
+ GraphBuilder.addExtension(
180
+ builder,
181
+ GraphBuilder.createExtensionRaw({
169
182
  id: 'connector',
170
- connector: () => Rx.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
183
+ connector: () => Atom.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
171
184
  }),
172
185
  );
173
186
  const graph = builder.graph;
174
- graph.expand(ROOT_ID);
187
+ Graph.expand(graph, Node.RootId);
175
188
 
176
- let nodes: Node[] = [];
189
+ let nodes: Node.Node[] = [];
177
190
  let count = 0;
178
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_nodes) => {
191
+ const cancel = registry.subscribe(graph.connections(Node.RootId), (_nodes) => {
179
192
  count++;
180
193
  nodes = _nodes;
181
194
  });
@@ -183,14 +196,15 @@ describe('GraphBuilder', () => {
183
196
 
184
197
  expect(nodes).has.length(0);
185
198
  expect(count).to.equal(0);
186
- registry.get(graph.connections(ROOT_ID));
199
+ registry.get(graph.connections(Node.RootId));
187
200
  expect(nodes).has.length(1);
188
201
  expect(count).to.equal(1);
189
202
 
190
- builder.addExtension(
191
- createExtension({
203
+ GraphBuilder.addExtension(
204
+ builder,
205
+ GraphBuilder.createExtensionRaw({
192
206
  id: 'connector-2',
193
- connector: () => Rx.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
207
+ connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
194
208
  }),
195
209
  );
196
210
  expect(nodes).has.length(2);
@@ -199,22 +213,23 @@ describe('GraphBuilder', () => {
199
213
 
200
214
  test('removes', () => {
201
215
  const registry = Registry.make();
202
- const builder = new GraphBuilder({ registry });
203
- const nodes = Rx.make([
216
+ const builder = GraphBuilder.make({ registry });
217
+ const nodes = Atom.make([
204
218
  { id: exampleId(1), type: EXAMPLE_TYPE },
205
219
  { id: exampleId(2), type: EXAMPLE_TYPE },
206
220
  ]);
207
- builder.addExtension(
208
- createExtension({
221
+ GraphBuilder.addExtension(
222
+ builder,
223
+ GraphBuilder.createExtensionRaw({
209
224
  id: 'connector',
210
- connector: () => Rx.make((get) => get(nodes)),
225
+ connector: () => Atom.make((get) => get(nodes)),
211
226
  }),
212
227
  );
213
228
  const graph = builder.graph;
214
- graph.expand(ROOT_ID);
229
+ Graph.expand(graph, Node.RootId);
215
230
 
216
231
  {
217
- const nodes = registry.get(graph.connections(ROOT_ID));
232
+ const nodes = registry.get(graph.connections(Node.RootId));
218
233
  expect(nodes).has.length(2);
219
234
  expect(nodes[0].id).to.equal(exampleId(1));
220
235
  expect(nodes[1].id).to.equal(exampleId(2));
@@ -223,7 +238,7 @@ describe('GraphBuilder', () => {
223
238
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
224
239
 
225
240
  {
226
- const nodes = registry.get(graph.connections(ROOT_ID));
241
+ const nodes = registry.get(graph.connections(Node.RootId));
227
242
  expect(nodes).has.length(1);
228
243
  expect(nodes[0].id).to.equal(exampleId(3));
229
244
  }
@@ -231,14 +246,14 @@ describe('GraphBuilder', () => {
231
246
 
232
247
  test('nodes are updated when removed', () => {
233
248
  const registry = Registry.make();
234
- const builder = new GraphBuilder({ registry });
235
- const name = Rx.make('removed');
249
+ const builder = GraphBuilder.make({ registry });
250
+ const name = Atom.make('removed');
236
251
 
237
- builder.addExtension([
238
- createExtension({
252
+ GraphBuilder.addExtension(builder, [
253
+ GraphBuilder.createExtensionRaw({
239
254
  id: 'root',
240
255
  connector: (node) =>
241
- Rx.make((get) =>
256
+ Atom.make((get) =>
242
257
  Function.pipe(
243
258
  get(node),
244
259
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
@@ -260,7 +275,7 @@ describe('GraphBuilder', () => {
260
275
  });
261
276
  onTestFinished(() => cancel());
262
277
 
263
- graph.expand(ROOT_ID);
278
+ Graph.expand(graph, Node.RootId);
264
279
  expect(count).to.equal(0);
265
280
  expect(exists).to.be.false;
266
281
 
@@ -279,23 +294,24 @@ describe('GraphBuilder', () => {
279
294
 
280
295
  test('sort edges', async () => {
281
296
  const registry = Registry.make();
282
- const builder = new GraphBuilder({ registry });
283
- const nodes = Rx.make([
297
+ const builder = GraphBuilder.make({ registry });
298
+ const nodes = Atom.make([
284
299
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
285
300
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
286
301
  { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
287
302
  ]);
288
- builder.addExtension(
289
- createExtension({
303
+ GraphBuilder.addExtension(
304
+ builder,
305
+ GraphBuilder.createExtensionRaw({
290
306
  id: 'connector',
291
- connector: () => Rx.make((get) => get(nodes)),
307
+ connector: () => Atom.make((get) => get(nodes)),
292
308
  }),
293
309
  );
294
310
  const graph = builder.graph;
295
- graph.expand(ROOT_ID);
311
+ Graph.expand(graph, Node.RootId);
296
312
 
297
313
  {
298
- const nodes = registry.get(graph.connections(ROOT_ID));
314
+ const nodes = registry.get(graph.connections(Node.RootId));
299
315
  expect(nodes).has.length(3);
300
316
  expect(nodes[0].id).to.equal(exampleId(1));
301
317
  expect(nodes[1].id).to.equal(exampleId(2));
@@ -312,7 +328,7 @@ describe('GraphBuilder', () => {
312
328
  await sleep(0);
313
329
 
314
330
  {
315
- const nodes = registry.get(graph.connections(ROOT_ID));
331
+ const nodes = registry.get(graph.connections(Node.RootId));
316
332
  expect(nodes).has.length(3);
317
333
  expect(nodes[0].id).to.equal(exampleId(3));
318
334
  expect(nodes[1].id).to.equal(exampleId(1));
@@ -322,15 +338,15 @@ describe('GraphBuilder', () => {
322
338
 
323
339
  test('updates are constrained', () => {
324
340
  const registry = Registry.make();
325
- const builder = new GraphBuilder({ registry });
326
- const name = Rx.make('default');
327
- const sub = Rx.make('default');
341
+ const builder = GraphBuilder.make({ registry });
342
+ const name = Atom.make('default');
343
+ const sub = Atom.make('default');
328
344
 
329
- builder.addExtension([
330
- createExtension({
345
+ GraphBuilder.addExtension(builder, [
346
+ GraphBuilder.createExtensionRaw({
331
347
  id: 'root',
332
348
  connector: (node) =>
333
- Rx.make((get) =>
349
+ Atom.make((get) =>
334
350
  Function.pipe(
335
351
  get(node),
336
352
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
@@ -340,10 +356,10 @@ describe('GraphBuilder', () => {
340
356
  ),
341
357
  ),
342
358
  }),
343
- createExtension({
359
+ GraphBuilder.createExtensionRaw({
344
360
  id: 'connector1',
345
361
  connector: (node) =>
346
- Rx.make((get) =>
362
+ Atom.make((get) =>
347
363
  Function.pipe(
348
364
  get(node),
349
365
  Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
@@ -352,10 +368,10 @@ describe('GraphBuilder', () => {
352
368
  ),
353
369
  ),
354
370
  }),
355
- createExtension({
371
+ GraphBuilder.createExtensionRaw({
356
372
  id: 'connector2',
357
373
  connector: (node) =>
358
- Rx.make((get) =>
374
+ Atom.make((get) =>
359
375
  Function.pipe(
360
376
  get(node),
361
377
  Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
@@ -387,13 +403,13 @@ describe('GraphBuilder', () => {
387
403
  onTestFinished(() => dependentCancel());
388
404
 
389
405
  // Counts should not increment until the node is expanded.
390
- graph.expand(ROOT_ID);
406
+ Graph.expand(graph, Node.RootId);
391
407
  expect(parentCount).to.equal(1);
392
408
  expect(independentCount).to.equal(0);
393
409
  expect(dependentCount).to.equal(0);
394
410
 
395
411
  // Counts should increment when the node is expanded.
396
- graph.expand(EXAMPLE_ID);
412
+ Graph.expand(graph, EXAMPLE_ID);
397
413
  expect(parentCount).to.equal(1);
398
414
  expect(independentCount).to.equal(1);
399
415
  expect(dependentCount).to.equal(1);
@@ -411,7 +427,7 @@ describe('GraphBuilder', () => {
411
427
  expect(dependentCount).to.equal(2);
412
428
 
413
429
  // Independent count should update if its state changes even if the parent is removed.
414
- Rx.batch(() => {
430
+ Atom.batch(() => {
415
431
  registry.set(name, 'removed');
416
432
  registry.set(sub, 'batch');
417
433
  });
@@ -426,7 +442,7 @@ describe('GraphBuilder', () => {
426
442
  expect(dependentCount).to.equal(3);
427
443
 
428
444
  // Counts should not increment when the node is expanded again.
429
- graph.expand(EXAMPLE_ID);
445
+ Graph.expand(graph, EXAMPLE_ID);
430
446
  expect(parentCount).to.equal(3);
431
447
  expect(independentCount).to.equal(3);
432
448
  expect(dependentCount).to.equal(3);
@@ -434,12 +450,13 @@ describe('GraphBuilder', () => {
434
450
 
435
451
  test('eager graph expansion', async () => {
436
452
  const registry = Registry.make();
437
- const builder = new GraphBuilder({ registry });
438
- builder.addExtension(
439
- createExtension({
453
+ const builder = GraphBuilder.make({ registry });
454
+ GraphBuilder.addExtension(
455
+ builder,
456
+ GraphBuilder.createExtensionRaw({
440
457
  id: 'connector',
441
458
  connector: (node) => {
442
- return Rx.make((get) =>
459
+ return Atom.make((get) =>
443
460
  Function.pipe(
444
461
  get(node),
445
462
  Option.map((node) => (node.data ? node.data + 1 : 1)),
@@ -455,14 +472,14 @@ describe('GraphBuilder', () => {
455
472
  let count = 0;
456
473
  const trigger = new Trigger();
457
474
  builder.graph.onNodeChanged.on(({ id }) => {
458
- builder.graph.expand(id);
475
+ Graph.expand(builder.graph, id);
459
476
  count++;
460
477
  if (count === 5) {
461
478
  trigger.wake();
462
479
  }
463
480
  });
464
481
 
465
- builder.graph.expand(ROOT_ID);
482
+ Graph.expand(builder.graph, Node.RootId);
466
483
  await trigger.wait();
467
484
  expect(count).to.equal(5);
468
485
  });
@@ -470,12 +487,13 @@ describe('GraphBuilder', () => {
470
487
 
471
488
  describe('explore', () => {
472
489
  test('works', async () => {
473
- const builder = new GraphBuilder();
474
- builder.addExtension(
475
- createExtension({
490
+ const builder = GraphBuilder.make();
491
+ GraphBuilder.addExtension(
492
+ builder,
493
+ GraphBuilder.createExtensionRaw({
476
494
  id: 'connector',
477
495
  connector: (node) =>
478
- Rx.make((get) =>
496
+ Atom.make((get) =>
479
497
  Function.pipe(
480
498
  get(node),
481
499
  Option.map((node) => (node.data ? node.data + 1 : 1)),
@@ -488,7 +506,7 @@ describe('GraphBuilder', () => {
488
506
  );
489
507
 
490
508
  let count = 0;
491
- await builder.explore({
509
+ await GraphBuilder.explore(builder, {
492
510
  visitor: () => {
493
511
  count++;
494
512
  },
@@ -497,4 +515,402 @@ describe('GraphBuilder', () => {
497
515
  expect(count).to.equal(6);
498
516
  });
499
517
  });
518
+
519
+ describe('helpers', () => {
520
+ describe('createConnector', () => {
521
+ test('creates connector with type inference', () => {
522
+ const registry = Registry.make();
523
+ const builder = GraphBuilder.make({ registry });
524
+ const graph = builder.graph;
525
+
526
+ const matcher = (node: Node.Node) => NodeMatcher.whenId('root')(node);
527
+ const factory = (node: Node.Node) => [{ id: 'child', type: EXAMPLE_TYPE, data: node.id }];
528
+
529
+ const connector = GraphBuilder.createConnector(matcher, factory);
530
+
531
+ GraphBuilder.addExtension(
532
+ builder,
533
+ GraphBuilder.createExtensionRaw({
534
+ id: 'test-connector',
535
+ connector,
536
+ }),
537
+ );
538
+
539
+ Graph.expand(graph, Node.RootId);
540
+
541
+ const connections = registry.get(graph.connections(Node.RootId));
542
+ expect(connections).has.length(1);
543
+ expect(connections[0].id).to.equal('child');
544
+ });
545
+ });
546
+
547
+ describe('createExtension', () => {
548
+ test('works with Effect connector', () => {
549
+ const registry = Registry.make();
550
+ const builder = GraphBuilder.make({ registry });
551
+ const graph = builder.graph;
552
+
553
+ const extensions = Effect.runSync(
554
+ GraphBuilder.createExtension({
555
+ id: 'test-extension',
556
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
557
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
558
+ }),
559
+ );
560
+
561
+ GraphBuilder.addExtension(builder, extensions);
562
+
563
+ const writableGraph = graph as Graph.WritableGraph;
564
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
565
+ Graph.expand(graph, 'parent');
566
+
567
+ const connections = registry.get(graph.connections('parent'));
568
+ expect(connections).has.length(1);
569
+ expect(connections[0].id).to.equal('child');
570
+ expect(connections[0].data).to.equal('test');
571
+ });
572
+
573
+ test('works with Effect actions', () => {
574
+ const registry = Registry.make();
575
+ const builder = GraphBuilder.make({ registry });
576
+ const graph = builder.graph;
577
+
578
+ const extensions = Effect.runSync(
579
+ GraphBuilder.createExtension({
580
+ id: 'test-extension',
581
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
582
+ actions: (node, get) =>
583
+ Effect.succeed([
584
+ {
585
+ id: 'test-action',
586
+ data: () =>
587
+ Effect.sync(() => {
588
+ console.log('TestAction');
589
+ }),
590
+ properties: { label: 'Test' },
591
+ },
592
+ ]),
593
+ }),
594
+ );
595
+
596
+ GraphBuilder.addExtension(builder, extensions);
597
+
598
+ const writableGraph = graph as Graph.WritableGraph;
599
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
600
+ Graph.expand(graph, 'parent');
601
+
602
+ const actions = registry.get(graph.actions('parent'));
603
+ expect(actions).has.length(1);
604
+ expect(actions[0].id).to.equal('test-action');
605
+ });
606
+
607
+ test('_actionContext captures and provides services to action execution', () => {
608
+ const registry = Registry.make();
609
+ const builder = GraphBuilder.make({ registry });
610
+ const graph = builder.graph;
611
+
612
+ // Define a test service using Context.GenericTag pattern.
613
+ interface TestServiceInterface {
614
+ getValue(): number;
615
+ }
616
+ const TestService = Context.GenericTag<TestServiceInterface>('TestService');
617
+
618
+ // Track whether the action was executed with the correct context.
619
+ let executionResult: number | null = null;
620
+
621
+ // Create extension with service requirement.
622
+ // Note: The actions callback must USE the service for R to be inferred correctly.
623
+ const extensions = Effect.runSync(
624
+ GraphBuilder.createExtension({
625
+ id: 'test-extension',
626
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
627
+ actions: (node, get) =>
628
+ // Use TestService in the callback to include it in R.
629
+ Effect.gen(function* () {
630
+ const service = yield* TestService;
631
+ return [
632
+ {
633
+ id: 'test-action',
634
+ data: () =>
635
+ Effect.gen(function* () {
636
+ // Action can use the same service from captured context.
637
+ const svc = yield* TestService;
638
+ executionResult = svc.getValue();
639
+ }).pipe(Effect.asVoid),
640
+ properties: { label: `Test ${service.getValue()}` },
641
+ },
642
+ ];
643
+ }),
644
+ }).pipe(Effect.provideService(TestService, { getValue: () => 42 })),
645
+ );
646
+
647
+ GraphBuilder.addExtension(builder, extensions);
648
+
649
+ const writableGraph = graph as Graph.WritableGraph;
650
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
651
+ Graph.expand(graph, 'parent');
652
+
653
+ const actions = registry.get(graph.actions('parent'));
654
+ expect(actions).has.length(1);
655
+
656
+ // Verify _actionContext is captured.
657
+ const action = actions[0] as Node.Action;
658
+ expect(action._actionContext).to.not.be.undefined;
659
+
660
+ // Execute the action with the captured context.
661
+ const actionEffect = action.data();
662
+ const effectWithContext = action._actionContext
663
+ ? actionEffect.pipe(Effect.provide(action._actionContext))
664
+ : actionEffect;
665
+
666
+ Effect.runSync(effectWithContext);
667
+
668
+ // Verify the service was accessible during execution.
669
+ expect(executionResult).to.equal(42);
670
+ });
671
+
672
+ test('works with resolver', async () => {
673
+ const registry = Registry.make();
674
+ const builder = GraphBuilder.make({ registry });
675
+ const graph = builder.graph;
676
+
677
+ const extensions = Effect.runSync(
678
+ GraphBuilder.createExtension({
679
+ id: 'test-extension',
680
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
681
+ resolver: (id, get) => Effect.succeed({ id, type: EXAMPLE_TYPE, properties: {}, data: 'resolved' }),
682
+ }),
683
+ );
684
+
685
+ GraphBuilder.addExtension(builder, extensions);
686
+ await Graph.initialize(graph, EXAMPLE_ID);
687
+
688
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
689
+ expect(node).to.not.be.null;
690
+ expect(node?.id).to.equal(EXAMPLE_ID);
691
+ expect(node?.data).to.equal('resolved');
692
+ });
693
+
694
+ test('works with connector and actions together', () => {
695
+ const registry = Registry.make();
696
+ const builder = GraphBuilder.make({ registry });
697
+ const graph = builder.graph;
698
+
699
+ const extensions = Effect.runSync(
700
+ GraphBuilder.createExtension({
701
+ id: 'test-extension',
702
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
703
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
704
+ actions: (node, get) =>
705
+ Effect.succeed([
706
+ {
707
+ id: 'test-action',
708
+ data: () =>
709
+ Effect.sync(() => {
710
+ console.log('TestAction');
711
+ }),
712
+ properties: { label: 'Test' },
713
+ },
714
+ ]),
715
+ }),
716
+ );
717
+
718
+ GraphBuilder.addExtension(builder, extensions);
719
+
720
+ const writableGraph = graph as Graph.WritableGraph;
721
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
722
+ Graph.expand(graph, 'parent');
723
+
724
+ const connections = registry.get(graph.connections('parent'));
725
+ // Should have both the child node and the action node.
726
+ expect(connections.length).to.be.greaterThanOrEqual(1);
727
+ const childNode = connections.find((n) => n.id === 'child');
728
+ expect(childNode).to.not.be.undefined;
729
+ expect(childNode?.data).to.equal('test');
730
+
731
+ const actions = registry.get(graph.actions('parent'));
732
+ expect(actions).has.length(1);
733
+ expect(actions[0].id).to.equal('test-action');
734
+ });
735
+
736
+ test('works with reactive connector using get context', () => {
737
+ const registry = Registry.make();
738
+ const builder = GraphBuilder.make({ registry });
739
+ const graph = builder.graph;
740
+
741
+ const state = Atom.make('initial');
742
+
743
+ const extensions = Effect.runSync(
744
+ GraphBuilder.createExtension({
745
+ id: 'test-extension',
746
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
747
+ connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: get(state) }]),
748
+ }),
749
+ );
750
+
751
+ GraphBuilder.addExtension(builder, extensions);
752
+
753
+ const writableGraph = graph as Graph.WritableGraph;
754
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
755
+ Graph.expand(graph, 'parent');
756
+
757
+ {
758
+ const connections = registry.get(graph.connections('parent'));
759
+ expect(connections).has.length(1);
760
+ expect(connections[0].data).to.equal('initial');
761
+ }
762
+
763
+ registry.set(state, 'updated');
764
+
765
+ {
766
+ const connections = registry.get(graph.connections('parent'));
767
+ expect(connections).has.length(1);
768
+ expect(connections[0].data).to.equal('updated');
769
+ }
770
+ });
771
+ });
772
+
773
+ describe('extension error handling', () => {
774
+ test('connector failure is caught and logged, returns empty array', () => {
775
+ const registry = Registry.make();
776
+ const builder = GraphBuilder.make({ registry });
777
+ const graph = builder.graph;
778
+
779
+ const extensions = Effect.runSync(
780
+ GraphBuilder.createExtension({
781
+ id: 'failing-extension',
782
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
783
+ connector: (node, get) => Effect.fail(new Error('Connector failed intentionally')),
784
+ }),
785
+ );
786
+
787
+ GraphBuilder.addExtension(builder, extensions);
788
+
789
+ const writableGraph = graph as Graph.WritableGraph;
790
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
791
+
792
+ // Should not throw, error is caught internally.
793
+ Graph.expand(graph, 'parent');
794
+
795
+ // Should return empty connections since the connector failed.
796
+ const connections = registry.get(graph.connections('parent'));
797
+ expect(connections).has.length(0);
798
+ });
799
+
800
+ test('actions failure is caught and logged, returns empty array', () => {
801
+ const registry = Registry.make();
802
+ const builder = GraphBuilder.make({ registry });
803
+ const graph = builder.graph;
804
+
805
+ const extensions = Effect.runSync(
806
+ GraphBuilder.createExtension({
807
+ id: 'failing-actions-extension',
808
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
809
+ actions: (node, get) => Effect.fail(new Error('Actions failed intentionally')),
810
+ }),
811
+ );
812
+
813
+ GraphBuilder.addExtension(builder, extensions);
814
+
815
+ const writableGraph = graph as Graph.WritableGraph;
816
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
817
+
818
+ // Should not throw, error is caught internally.
819
+ Graph.expand(graph, 'parent');
820
+
821
+ // Should return empty actions since the actions callback failed.
822
+ const actions = registry.get(graph.actions('parent'));
823
+ expect(actions).has.length(0);
824
+ });
825
+
826
+ test('resolver failure is caught and logged, returns null', async () => {
827
+ const registry = Registry.make();
828
+ const builder = GraphBuilder.make({ registry });
829
+ const graph = builder.graph;
830
+
831
+ const extensions = Effect.runSync(
832
+ GraphBuilder.createExtension({
833
+ id: 'failing-resolver-extension',
834
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
835
+ resolver: (id, get) => Effect.fail(new Error('Resolver failed intentionally')),
836
+ }),
837
+ );
838
+
839
+ GraphBuilder.addExtension(builder, extensions);
840
+
841
+ // Should not throw, error is caught internally.
842
+ await Graph.initialize(graph, EXAMPLE_ID);
843
+
844
+ // Should return null/none since the resolver failed.
845
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
846
+ expect(node).to.be.null;
847
+ });
848
+
849
+ test('failing extension does not affect other extensions', () => {
850
+ const registry = Registry.make();
851
+ const builder = GraphBuilder.make({ registry });
852
+ const graph = builder.graph;
853
+
854
+ // Add a failing extension.
855
+ const failingExtensions = Effect.runSync(
856
+ GraphBuilder.createExtension({
857
+ id: 'failing-extension',
858
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
859
+ connector: (node, get) => Effect.fail(new Error('This one fails')),
860
+ }),
861
+ );
862
+
863
+ // Add a working extension.
864
+ const workingExtensions = Effect.runSync(
865
+ GraphBuilder.createExtension({
866
+ id: 'working-extension',
867
+ match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
868
+ connector: (node, get) =>
869
+ Effect.succeed([{ id: 'child-from-working', type: EXAMPLE_TYPE, data: 'success' }]),
870
+ }),
871
+ );
872
+
873
+ GraphBuilder.addExtension(builder, failingExtensions);
874
+ GraphBuilder.addExtension(builder, workingExtensions);
875
+
876
+ const writableGraph = graph as Graph.WritableGraph;
877
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
878
+ Graph.expand(graph, 'parent');
879
+
880
+ // The working extension should still produce its node.
881
+ const connections = registry.get(graph.connections('parent'));
882
+ expect(connections).has.length(1);
883
+ expect(connections[0].id).to.equal('child-from-working');
884
+ expect(connections[0].data).to.equal('success');
885
+ });
886
+ });
887
+
888
+ describe('createTypeExtension', () => {
889
+ test('creates extension matching by schema type with inferred object type', () => {
890
+ const registry = Registry.make();
891
+ const builder = GraphBuilder.make({ registry });
892
+ const graph = builder.graph;
893
+
894
+ const extensions = Effect.runSync(
895
+ GraphBuilder.createTypeExtension({
896
+ id: 'type-extension',
897
+ type: TestSchema.Person,
898
+ connector: (object) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: object }]),
899
+ }),
900
+ );
901
+
902
+ GraphBuilder.addExtension(builder, extensions);
903
+
904
+ const writableGraph = graph as Graph.WritableGraph;
905
+ const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
906
+ Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
907
+ Graph.expand(graph, 'parent');
908
+
909
+ const connections = registry.get(graph.connections('parent'));
910
+ expect(connections).has.length(1);
911
+ expect(connections[0].id).to.equal('child');
912
+ expect(connections[0].data).to.equal(testObject);
913
+ });
914
+ });
915
+ });
500
916
  });