@dxos/app-graph 0.8.4-main.e8ec1fe → 0.8.4-main.ef1bc66f44

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 +1012 -551
  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 +1011 -551
  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 +107 -65
  10. package/dist/types/src/graph-builder.d.ts.map +1 -1
  11. package/dist/types/src/graph.d.ts +174 -204
  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 +494 -78
  26. package/src/graph-builder.ts +543 -250
  27. package/src/graph.test.ts +297 -104
  28. package/src/graph.ts +934 -404
  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 +94 -92
  34. package/src/stories/Tree.tsx +1 -1
  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 -219
  43. package/src/testing.ts +0 -20
@@ -3,15 +3,20 @@
3
3
  //
4
4
 
5
5
  import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
6
8
  import * as Function from 'effect/Function';
7
9
  import * as Option from 'effect/Option';
8
10
  import { describe, expect, onTestFinished, test } from 'vitest';
9
11
 
10
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,16 +26,18 @@ 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');
@@ -38,10 +45,10 @@ describe('GraphBuilder', () => {
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 });
60
+ const builder = GraphBuilder.make({ registry });
54
61
  const name = Atom.make('default');
55
- builder.addExtension(
56
- createExtension({
62
+ GraphBuilder.addExtension(
63
+ builder,
64
+ GraphBuilder.createExtensionRaw({
57
65
  id: 'resolver',
58
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,15 +86,17 @@ 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
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
102
  connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
@@ -94,11 +104,11 @@ describe('GraphBuilder', () => {
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,35 +120,37 @@ describe('GraphBuilder', () => {
110
120
 
111
121
  test('updates', () => {
112
122
  const registry = Registry.make();
113
- const builder = new GraphBuilder({ registry });
123
+ const builder = GraphBuilder.make({ registry });
114
124
  const state = Atom.make(0);
115
- builder.addExtension(
116
- createExtension({
125
+ GraphBuilder.addExtension(
126
+ builder,
127
+ GraphBuilder.createExtensionRaw({
117
128
  id: 'connector',
118
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 });
149
+ const builder = GraphBuilder.make({ registry });
139
150
  const state = Atom.make(0);
140
- builder.addExtension(
141
- createExtension({
151
+ GraphBuilder.addExtension(
152
+ builder,
153
+ GraphBuilder.createExtensionRaw({
142
154
  id: 'connector',
143
155
  connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
144
156
  }),
@@ -146,16 +158,16 @@ describe('GraphBuilder', () => {
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
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,12 +196,13 @@ 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
207
  connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
194
208
  }),
@@ -199,22 +213,23 @@ describe('GraphBuilder', () => {
199
213
 
200
214
  test('removes', () => {
201
215
  const registry = Registry.make();
202
- const builder = new GraphBuilder({ registry });
216
+ const builder = GraphBuilder.make({ registry });
203
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
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,11 +246,11 @@ describe('GraphBuilder', () => {
231
246
 
232
247
  test('nodes are updated when removed', () => {
233
248
  const registry = Registry.make();
234
- const builder = new GraphBuilder({ registry });
249
+ const builder = GraphBuilder.make({ registry });
235
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
256
  Atom.make((get) =>
@@ -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 });
297
+ const builder = GraphBuilder.make({ registry });
283
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
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,12 +338,12 @@ describe('GraphBuilder', () => {
322
338
 
323
339
  test('updates are constrained', () => {
324
340
  const registry = Registry.make();
325
- const builder = new GraphBuilder({ registry });
341
+ const builder = GraphBuilder.make({ registry });
326
342
  const name = Atom.make('default');
327
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
349
  Atom.make((get) =>
@@ -340,7 +356,7 @@ describe('GraphBuilder', () => {
340
356
  ),
341
357
  ),
342
358
  }),
343
- createExtension({
359
+ GraphBuilder.createExtensionRaw({
344
360
  id: 'connector1',
345
361
  connector: (node) =>
346
362
  Atom.make((get) =>
@@ -352,7 +368,7 @@ describe('GraphBuilder', () => {
352
368
  ),
353
369
  ),
354
370
  }),
355
- createExtension({
371
+ GraphBuilder.createExtensionRaw({
356
372
  id: 'connector2',
357
373
  connector: (node) =>
358
374
  Atom.make((get) =>
@@ -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);
@@ -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,9 +450,10 @@ 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
459
  return Atom.make((get) =>
@@ -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,9 +487,10 @@ 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
496
  Atom.make((get) =>
@@ -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
  });