@dxos/app-graph 0.8.4-main.c4373fc → 0.8.4-main.c85a9c8dae

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