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