@dxos/app-graph 0.8.3 → 0.8.4-main.1068cf700f

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 (47) hide show
  1. package/dist/lib/browser/index.mjs +1135 -616
  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 +1134 -616
  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 +113 -60
  10. package/dist/types/src/graph-builder.d.ts.map +1 -1
  11. package/dist/types/src/graph.d.ts +183 -209
  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 +6 -13
  22. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +37 -37
  25. package/src/atoms.ts +25 -0
  26. package/src/graph-builder.test.ts +571 -97
  27. package/src/graph-builder.ts +600 -258
  28. package/src/graph.test.ts +300 -107
  29. package/src/graph.ts +971 -400
  30. package/src/index.ts +9 -3
  31. package/src/node-matcher.test.ts +301 -0
  32. package/src/node-matcher.ts +284 -0
  33. package/src/node.ts +40 -5
  34. package/src/stories/EchoGraph.stories.tsx +128 -233
  35. package/src/stories/Tree.tsx +2 -2
  36. package/dist/lib/node/index.cjs +0 -816
  37. package/dist/lib/node/index.cjs.map +0 -7
  38. package/dist/lib/node/meta.json +0 -1
  39. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  40. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  41. package/dist/types/src/signals-integration.test.d.ts +0 -2
  42. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  43. package/dist/types/src/testing.d.ts +0 -5
  44. package/dist/types/src/testing.d.ts.map +0 -1
  45. package/src/experimental/graph-projections.test.ts +0 -56
  46. package/src/signals-integration.test.ts +0 -218
  47. package/src/testing.ts +0 -20
@@ -2,45 +2,113 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
6
- import { Option, pipe } from 'effect';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Function from 'effect/Function';
9
+ import * as Option from 'effect/Option';
7
10
  import { describe, expect, onTestFinished, test } from 'vitest';
8
11
 
9
- import { sleep, Trigger } from '@dxos/async';
12
+ import { Trigger, sleep } from '@dxos/async';
13
+ import { Obj } from '@dxos/echo';
14
+ import { TestSchema } from '@dxos/echo/testing';
10
15
 
11
- import { ROOT_ID } from './graph';
12
- import { createExtension, GraphBuilder } from './graph-builder';
13
- import { type Node } from './node';
16
+ import * as Graph from './graph';
17
+ import * as GraphBuilder from './graph-builder';
18
+ import * as Node from './node';
19
+ import * as NodeMatcher from './node-matcher';
14
20
 
15
21
  const exampleId = (id: number) => `dx:test:${id}`;
16
22
  const EXAMPLE_ID = exampleId(1);
17
23
  const EXAMPLE_TYPE = 'dxos.org/type/example';
18
24
 
19
25
  describe('GraphBuilder', () => {
26
+ describe('resolver', () => {
27
+ test('works', async () => {
28
+ const registry = Registry.make();
29
+ const builder = GraphBuilder.make({ registry });
30
+ const graph = builder.graph;
31
+
32
+ {
33
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
34
+ expect(node).to.be.null;
35
+ }
36
+
37
+ // Test direct API
38
+ GraphBuilder.addExtension(
39
+ builder,
40
+ GraphBuilder.createExtensionRaw({
41
+ id: 'resolver',
42
+ resolver: () => {
43
+ console.log('resolver');
44
+ return Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
45
+ },
46
+ }),
47
+ );
48
+ await Graph.initialize(graph, EXAMPLE_ID);
49
+
50
+ {
51
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
52
+ expect(node?.id).to.equal(EXAMPLE_ID);
53
+ expect(node?.type).to.equal(EXAMPLE_TYPE);
54
+ expect(node?.data).to.equal(1);
55
+ }
56
+ });
57
+
58
+ test('updates', async () => {
59
+ const registry = Registry.make();
60
+ const builder = GraphBuilder.make({ registry });
61
+ const name = Atom.make('default');
62
+ GraphBuilder.addExtension(
63
+ builder,
64
+ GraphBuilder.createExtensionRaw({
65
+ id: 'resolver',
66
+ resolver: () => Atom.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
67
+ }),
68
+ );
69
+ const graph = builder.graph;
70
+ await Graph.initialize(graph, EXAMPLE_ID);
71
+
72
+ {
73
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
74
+ expect(node?.data).to.equal('default');
75
+ }
76
+
77
+ registry.set(name, 'updated');
78
+
79
+ {
80
+ const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
81
+ expect(node?.data).to.equal('updated');
82
+ }
83
+ });
84
+ });
85
+
20
86
  describe('connector', () => {
21
87
  test('works', () => {
22
88
  const registry = Registry.make();
23
- const builder = new GraphBuilder({ registry });
24
- builder.addExtension(
25
- createExtension({
89
+ const builder = GraphBuilder.make({ registry });
90
+ GraphBuilder.addExtension(
91
+ builder,
92
+ GraphBuilder.createExtensionRaw({
26
93
  id: 'outbound-connector',
27
- connector: () => Rx.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
94
+ connector: () => Atom.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
28
95
  }),
29
96
  );
30
- builder.addExtension(
31
- createExtension({
97
+ GraphBuilder.addExtension(
98
+ builder,
99
+ GraphBuilder.createExtensionRaw({
32
100
  id: 'inbound-connector',
33
101
  relation: 'inbound',
34
- connector: () => Rx.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
102
+ connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
35
103
  }),
36
104
  );
37
105
 
38
106
  const graph = builder.graph;
39
- graph.expand(ROOT_ID);
40
- graph.expand(ROOT_ID, 'inbound');
107
+ Graph.expand(graph, Node.RootId);
108
+ Graph.expand(graph, Node.RootId, 'inbound');
41
109
 
42
- const outbound = registry.get(graph.connections(ROOT_ID));
43
- 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'));
44
112
 
45
113
  expect(outbound).has.length(1);
46
114
  expect(outbound[0].id).to.equal('child');
@@ -52,52 +120,54 @@ describe('GraphBuilder', () => {
52
120
 
53
121
  test('updates', () => {
54
122
  const registry = Registry.make();
55
- const builder = new GraphBuilder({ registry });
56
- const state = Rx.make(0);
57
- builder.addExtension(
58
- createExtension({
123
+ const builder = GraphBuilder.make({ registry });
124
+ const state = Atom.make(0);
125
+ GraphBuilder.addExtension(
126
+ builder,
127
+ GraphBuilder.createExtensionRaw({
59
128
  id: 'connector',
60
- 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) }]),
61
130
  }),
62
131
  );
63
132
  const graph = builder.graph;
64
- graph.expand(ROOT_ID);
133
+ Graph.expand(graph, Node.RootId);
65
134
 
66
135
  {
67
- const [node] = registry.get(graph.connections(ROOT_ID));
136
+ const [node] = registry.get(graph.connections(Node.RootId));
68
137
  expect(node.data).to.equal(0);
69
138
  }
70
139
 
71
140
  {
72
141
  registry.set(state, 1);
73
- const [node] = registry.get(graph.connections(ROOT_ID));
142
+ const [node] = registry.get(graph.connections(Node.RootId));
74
143
  expect(node.data).to.equal(1);
75
144
  }
76
145
  });
77
146
 
78
147
  test('subscribes to updates', () => {
79
148
  const registry = Registry.make();
80
- const builder = new GraphBuilder({ registry });
81
- const state = Rx.make(0);
82
- builder.addExtension(
83
- createExtension({
149
+ const builder = GraphBuilder.make({ registry });
150
+ const state = Atom.make(0);
151
+ GraphBuilder.addExtension(
152
+ builder,
153
+ GraphBuilder.createExtensionRaw({
84
154
  id: 'connector',
85
- 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) }]),
86
156
  }),
87
157
  );
88
158
  const graph = builder.graph;
89
159
 
90
160
  let count = 0;
91
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_) => {
161
+ const cancel = registry.subscribe(graph.connections(Node.RootId), (_) => {
92
162
  count++;
93
163
  });
94
164
  onTestFinished(() => cancel());
95
165
 
96
166
  expect(count).to.equal(0);
97
- expect(registry.get(graph.connections(ROOT_ID))).to.have.length(0);
167
+ expect(registry.get(graph.connections(Node.RootId))).to.have.length(0);
98
168
  expect(count).to.equal(1);
99
169
 
100
- graph.expand(ROOT_ID);
170
+ Graph.expand(graph, Node.RootId);
101
171
  expect(count).to.equal(2);
102
172
  registry.set(state, 1);
103
173
  expect(count).to.equal(3);
@@ -105,19 +175,20 @@ describe('GraphBuilder', () => {
105
175
 
106
176
  test('updates with new extensions', () => {
107
177
  const registry = Registry.make();
108
- const builder = new GraphBuilder({ registry });
109
- builder.addExtension(
110
- createExtension({
178
+ const builder = GraphBuilder.make({ registry });
179
+ GraphBuilder.addExtension(
180
+ builder,
181
+ GraphBuilder.createExtensionRaw({
111
182
  id: 'connector',
112
- connector: () => Rx.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
183
+ connector: () => Atom.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
113
184
  }),
114
185
  );
115
186
  const graph = builder.graph;
116
- graph.expand(ROOT_ID);
187
+ Graph.expand(graph, Node.RootId);
117
188
 
118
- let nodes: Node[] = [];
189
+ let nodes: Node.Node[] = [];
119
190
  let count = 0;
120
- const cancel = registry.subscribe(graph.connections(ROOT_ID), (_nodes) => {
191
+ const cancel = registry.subscribe(graph.connections(Node.RootId), (_nodes) => {
121
192
  count++;
122
193
  nodes = _nodes;
123
194
  });
@@ -125,14 +196,15 @@ describe('GraphBuilder', () => {
125
196
 
126
197
  expect(nodes).has.length(0);
127
198
  expect(count).to.equal(0);
128
- registry.get(graph.connections(ROOT_ID));
199
+ registry.get(graph.connections(Node.RootId));
129
200
  expect(nodes).has.length(1);
130
201
  expect(count).to.equal(1);
131
202
 
132
- builder.addExtension(
133
- createExtension({
203
+ GraphBuilder.addExtension(
204
+ builder,
205
+ GraphBuilder.createExtensionRaw({
134
206
  id: 'connector-2',
135
- connector: () => Rx.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
207
+ connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
136
208
  }),
137
209
  );
138
210
  expect(nodes).has.length(2);
@@ -141,22 +213,23 @@ describe('GraphBuilder', () => {
141
213
 
142
214
  test('removes', () => {
143
215
  const registry = Registry.make();
144
- const builder = new GraphBuilder({ registry });
145
- const nodes = Rx.make([
216
+ const builder = GraphBuilder.make({ registry });
217
+ const nodes = Atom.make([
146
218
  { id: exampleId(1), type: EXAMPLE_TYPE },
147
219
  { id: exampleId(2), type: EXAMPLE_TYPE },
148
220
  ]);
149
- builder.addExtension(
150
- createExtension({
221
+ GraphBuilder.addExtension(
222
+ builder,
223
+ GraphBuilder.createExtensionRaw({
151
224
  id: 'connector',
152
- connector: () => Rx.make((get) => get(nodes)),
225
+ connector: () => Atom.make((get) => get(nodes)),
153
226
  }),
154
227
  );
155
228
  const graph = builder.graph;
156
- graph.expand(ROOT_ID);
229
+ Graph.expand(graph, Node.RootId);
157
230
 
158
231
  {
159
- const nodes = registry.get(graph.connections(ROOT_ID));
232
+ const nodes = registry.get(graph.connections(Node.RootId));
160
233
  expect(nodes).has.length(2);
161
234
  expect(nodes[0].id).to.equal(exampleId(1));
162
235
  expect(nodes[1].id).to.equal(exampleId(2));
@@ -165,7 +238,7 @@ describe('GraphBuilder', () => {
165
238
  registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
166
239
 
167
240
  {
168
- const nodes = registry.get(graph.connections(ROOT_ID));
241
+ const nodes = registry.get(graph.connections(Node.RootId));
169
242
  expect(nodes).has.length(1);
170
243
  expect(nodes[0].id).to.equal(exampleId(3));
171
244
  }
@@ -173,15 +246,15 @@ describe('GraphBuilder', () => {
173
246
 
174
247
  test('nodes are updated when removed', () => {
175
248
  const registry = Registry.make();
176
- const builder = new GraphBuilder({ registry });
177
- const name = Rx.make('removed');
249
+ const builder = GraphBuilder.make({ registry });
250
+ const name = Atom.make('removed');
178
251
 
179
- builder.addExtension([
180
- createExtension({
252
+ GraphBuilder.addExtension(builder, [
253
+ GraphBuilder.createExtensionRaw({
181
254
  id: 'root',
182
255
  connector: (node) =>
183
- Rx.make((get) =>
184
- pipe(
256
+ Atom.make((get) =>
257
+ Function.pipe(
185
258
  get(node),
186
259
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
187
260
  Option.filter((name) => name !== 'removed'),
@@ -202,7 +275,7 @@ describe('GraphBuilder', () => {
202
275
  });
203
276
  onTestFinished(() => cancel());
204
277
 
205
- graph.expand(ROOT_ID);
278
+ Graph.expand(graph, Node.RootId);
206
279
  expect(count).to.equal(0);
207
280
  expect(exists).to.be.false;
208
281
 
@@ -221,23 +294,24 @@ describe('GraphBuilder', () => {
221
294
 
222
295
  test('sort edges', async () => {
223
296
  const registry = Registry.make();
224
- const builder = new GraphBuilder({ registry });
225
- const nodes = Rx.make([
297
+ const builder = GraphBuilder.make({ registry });
298
+ const nodes = Atom.make([
226
299
  { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
227
300
  { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
228
301
  { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
229
302
  ]);
230
- builder.addExtension(
231
- createExtension({
303
+ GraphBuilder.addExtension(
304
+ builder,
305
+ GraphBuilder.createExtensionRaw({
232
306
  id: 'connector',
233
- connector: () => Rx.make((get) => get(nodes)),
307
+ connector: () => Atom.make((get) => get(nodes)),
234
308
  }),
235
309
  );
236
310
  const graph = builder.graph;
237
- graph.expand(ROOT_ID);
311
+ Graph.expand(graph, Node.RootId);
238
312
 
239
313
  {
240
- const nodes = registry.get(graph.connections(ROOT_ID));
314
+ const nodes = registry.get(graph.connections(Node.RootId));
241
315
  expect(nodes).has.length(3);
242
316
  expect(nodes[0].id).to.equal(exampleId(1));
243
317
  expect(nodes[1].id).to.equal(exampleId(2));
@@ -254,7 +328,7 @@ describe('GraphBuilder', () => {
254
328
  await sleep(0);
255
329
 
256
330
  {
257
- const nodes = registry.get(graph.connections(ROOT_ID));
331
+ const nodes = registry.get(graph.connections(Node.RootId));
258
332
  expect(nodes).has.length(3);
259
333
  expect(nodes[0].id).to.equal(exampleId(3));
260
334
  expect(nodes[1].id).to.equal(exampleId(1));
@@ -264,16 +338,16 @@ describe('GraphBuilder', () => {
264
338
 
265
339
  test('updates are constrained', () => {
266
340
  const registry = Registry.make();
267
- const builder = new GraphBuilder({ registry });
268
- const name = Rx.make('default');
269
- const sub = Rx.make('default');
341
+ const builder = GraphBuilder.make({ registry });
342
+ const name = Atom.make('default');
343
+ const sub = Atom.make('default');
270
344
 
271
- builder.addExtension([
272
- createExtension({
345
+ GraphBuilder.addExtension(builder, [
346
+ GraphBuilder.createExtensionRaw({
273
347
  id: 'root',
274
348
  connector: (node) =>
275
- Rx.make((get) =>
276
- pipe(
349
+ Atom.make((get) =>
350
+ Function.pipe(
277
351
  get(node),
278
352
  Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
279
353
  Option.filter((name) => name !== 'removed'),
@@ -282,11 +356,11 @@ describe('GraphBuilder', () => {
282
356
  ),
283
357
  ),
284
358
  }),
285
- createExtension({
359
+ GraphBuilder.createExtensionRaw({
286
360
  id: 'connector1',
287
361
  connector: (node) =>
288
- Rx.make((get) =>
289
- pipe(
362
+ Atom.make((get) =>
363
+ Function.pipe(
290
364
  get(node),
291
365
  Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
292
366
  Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
@@ -294,11 +368,11 @@ describe('GraphBuilder', () => {
294
368
  ),
295
369
  ),
296
370
  }),
297
- createExtension({
371
+ GraphBuilder.createExtensionRaw({
298
372
  id: 'connector2',
299
373
  connector: (node) =>
300
- Rx.make((get) =>
301
- pipe(
374
+ Atom.make((get) =>
375
+ Function.pipe(
302
376
  get(node),
303
377
  Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
304
378
  Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
@@ -329,13 +403,13 @@ describe('GraphBuilder', () => {
329
403
  onTestFinished(() => dependentCancel());
330
404
 
331
405
  // Counts should not increment until the node is expanded.
332
- graph.expand(ROOT_ID);
406
+ Graph.expand(graph, Node.RootId);
333
407
  expect(parentCount).to.equal(1);
334
408
  expect(independentCount).to.equal(0);
335
409
  expect(dependentCount).to.equal(0);
336
410
 
337
411
  // Counts should increment when the node is expanded.
338
- graph.expand(EXAMPLE_ID);
412
+ Graph.expand(graph, EXAMPLE_ID);
339
413
  expect(parentCount).to.equal(1);
340
414
  expect(independentCount).to.equal(1);
341
415
  expect(dependentCount).to.equal(1);
@@ -353,7 +427,7 @@ describe('GraphBuilder', () => {
353
427
  expect(dependentCount).to.equal(2);
354
428
 
355
429
  // Independent count should update if its state changes even if the parent is removed.
356
- Rx.batch(() => {
430
+ Atom.batch(() => {
357
431
  registry.set(name, 'removed');
358
432
  registry.set(sub, 'batch');
359
433
  });
@@ -368,7 +442,7 @@ describe('GraphBuilder', () => {
368
442
  expect(dependentCount).to.equal(3);
369
443
 
370
444
  // Counts should not increment when the node is expanded again.
371
- graph.expand(EXAMPLE_ID);
445
+ Graph.expand(graph, EXAMPLE_ID);
372
446
  expect(parentCount).to.equal(3);
373
447
  expect(independentCount).to.equal(3);
374
448
  expect(dependentCount).to.equal(3);
@@ -376,13 +450,14 @@ describe('GraphBuilder', () => {
376
450
 
377
451
  test('eager graph expansion', async () => {
378
452
  const registry = Registry.make();
379
- const builder = new GraphBuilder({ registry });
380
- builder.addExtension(
381
- createExtension({
453
+ const builder = GraphBuilder.make({ registry });
454
+ GraphBuilder.addExtension(
455
+ builder,
456
+ GraphBuilder.createExtensionRaw({
382
457
  id: 'connector',
383
458
  connector: (node) => {
384
- return Rx.make((get) =>
385
- pipe(
459
+ return Atom.make((get) =>
460
+ Function.pipe(
386
461
  get(node),
387
462
  Option.map((node) => (node.data ? node.data + 1 : 1)),
388
463
  Option.filter((data) => data <= 5),
@@ -397,14 +472,14 @@ describe('GraphBuilder', () => {
397
472
  let count = 0;
398
473
  const trigger = new Trigger();
399
474
  builder.graph.onNodeChanged.on(({ id }) => {
400
- builder.graph.expand(id);
475
+ Graph.expand(builder.graph, id);
401
476
  count++;
402
477
  if (count === 5) {
403
478
  trigger.wake();
404
479
  }
405
480
  });
406
481
 
407
- builder.graph.expand(ROOT_ID);
482
+ Graph.expand(builder.graph, Node.RootId);
408
483
  await trigger.wait();
409
484
  expect(count).to.equal(5);
410
485
  });
@@ -412,13 +487,14 @@ describe('GraphBuilder', () => {
412
487
 
413
488
  describe('explore', () => {
414
489
  test('works', async () => {
415
- const builder = new GraphBuilder();
416
- builder.addExtension(
417
- createExtension({
490
+ const builder = GraphBuilder.make();
491
+ GraphBuilder.addExtension(
492
+ builder,
493
+ GraphBuilder.createExtensionRaw({
418
494
  id: 'connector',
419
495
  connector: (node) =>
420
- Rx.make((get) =>
421
- pipe(
496
+ Atom.make((get) =>
497
+ Function.pipe(
422
498
  get(node),
423
499
  Option.map((node) => (node.data ? node.data + 1 : 1)),
424
500
  Option.filter((data) => data <= 5),
@@ -430,7 +506,7 @@ describe('GraphBuilder', () => {
430
506
  );
431
507
 
432
508
  let count = 0;
433
- await builder.explore({
509
+ await GraphBuilder.explore(builder, {
434
510
  visitor: () => {
435
511
  count++;
436
512
  },
@@ -439,4 +515,402 @@ describe('GraphBuilder', () => {
439
515
  expect(count).to.equal(6);
440
516
  });
441
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
+ });
442
916
  });