@dxos/app-graph 0.8.2-main.f11618f → 0.8.2-main.fbd8ed0

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 (33) hide show
  1. package/dist/lib/browser/index.mjs +541 -789
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +533 -780
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +541 -789
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/experimental/graph-projections.test.d.ts +25 -0
  11. package/dist/types/src/experimental/graph-projections.test.d.ts.map +1 -0
  12. package/dist/types/src/graph-builder.d.ts +48 -91
  13. package/dist/types/src/graph-builder.d.ts.map +1 -1
  14. package/dist/types/src/graph.d.ts +191 -98
  15. package/dist/types/src/graph.d.ts.map +1 -1
  16. package/dist/types/src/node.d.ts +2 -2
  17. package/dist/types/src/node.d.ts.map +1 -1
  18. package/dist/types/src/signals-integration.test.d.ts +2 -0
  19. package/dist/types/src/signals-integration.test.d.ts.map +1 -0
  20. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  21. package/dist/types/src/testing.d.ts +5 -0
  22. package/dist/types/src/testing.d.ts.map +1 -0
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +23 -16
  25. package/src/experimental/graph-projections.test.ts +56 -0
  26. package/src/graph-builder.test.ts +293 -310
  27. package/src/graph-builder.ts +209 -317
  28. package/src/graph.test.ts +314 -463
  29. package/src/graph.ts +452 -458
  30. package/src/node.ts +2 -2
  31. package/src/signals-integration.test.ts +218 -0
  32. package/src/stories/EchoGraph.stories.tsx +56 -77
  33. package/src/testing.ts +20 -0
@@ -1,14 +1,15 @@
1
1
  //
2
- // Copyright 2024 DXOS.org
2
+ // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { batch, signal } from '@preact/signals-core';
6
- import { describe, expect, test } from 'vitest';
5
+ import { Registry, Rx } from '@effect-rx/rx-react';
6
+ import { Option, pipe } from 'effect';
7
+ import { describe, expect, onTestFinished, test } from 'vitest';
7
8
 
8
- import { updateCounter } from '@dxos/echo-schema/testing';
9
+ import { sleep, Trigger } from '@dxos/async';
9
10
 
10
- import { ACTION_TYPE, ROOT_ID, ROOT_TYPE } from './graph';
11
- import { GraphBuilder, createExtension, memoize } from './graph-builder';
11
+ import { ROOT_ID } from './graph';
12
+ import { createExtension, GraphBuilder } from './graph-builder';
12
13
  import { type Node } from './node';
13
14
 
14
15
  const exampleId = (id: number) => `dx:test:${id}`;
@@ -16,379 +17,396 @@ const EXAMPLE_ID = exampleId(1);
16
17
  const EXAMPLE_TYPE = 'dxos.org/type/example';
17
18
 
18
19
  describe('GraphBuilder', () => {
19
- describe('resolver', () => {
20
- test('works', async () => {
21
- const builder = new GraphBuilder();
22
- const graph = builder.graph;
23
-
24
- {
25
- const node = graph.findNode(EXAMPLE_ID);
26
- expect(node).to.be.undefined;
27
- }
28
-
29
- builder.addExtension(
30
- createExtension({ id: 'resolver', resolver: () => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }) }),
31
- );
32
-
33
- {
34
- const node = await graph.waitForNode(EXAMPLE_ID);
35
- expect(node?.id).to.equal(EXAMPLE_ID);
36
- expect(node?.type).to.equal(EXAMPLE_TYPE);
37
- expect(node?.data).to.equal(1);
38
- }
39
- });
40
-
41
- test('updates', async () => {
42
- const builder = new GraphBuilder();
43
- const name = signal('default');
20
+ describe('connector', () => {
21
+ test('works', () => {
22
+ const registry = Registry.make();
23
+ const builder = new GraphBuilder({ registry });
44
24
  builder.addExtension(
45
- createExtension({ id: 'resolver', resolver: () => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name.value }) }),
25
+ createExtension({
26
+ id: 'outbound-connector',
27
+ connector: () => Rx.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
28
+ }),
46
29
  );
47
- const graph = builder.graph;
48
-
49
- const node = await graph.waitForNode(EXAMPLE_ID);
50
- expect(node?.data).to.equal('default');
51
-
52
- name.value = 'updated';
53
- expect(node?.data).to.equal('updated');
54
- });
55
-
56
- test('memoize', async () => {
57
- const builder = new GraphBuilder();
58
- const name = signal('default');
59
- let count = 0;
60
- let memoizedCount = 0;
61
30
  builder.addExtension(
62
31
  createExtension({
63
- id: 'resolver',
64
- resolver: () => {
65
- count++;
66
- memoize(() => {
67
- memoizedCount++;
68
- });
69
-
70
- return { id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name.value };
71
- },
32
+ id: 'inbound-connector',
33
+ relation: 'inbound',
34
+ connector: () => Rx.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
72
35
  }),
73
36
  );
74
- const graph = builder.graph;
75
37
 
76
- const node = await graph.waitForNode(EXAMPLE_ID);
77
- expect(node?.data).to.equal('default');
78
- expect(count).to.equal(1);
79
- expect(memoizedCount).to.equal(1);
38
+ const graph = builder.graph;
39
+ graph.expand(ROOT_ID);
40
+ graph.expand(ROOT_ID, 'inbound');
80
41
 
81
- name!.value = 'one';
82
- name!.value = 'two';
83
- name!.value = 'three';
42
+ const outbound = registry.get(graph.connections(ROOT_ID));
43
+ const inbound = registry.get(graph.connections(ROOT_ID, 'inbound'));
84
44
 
85
- expect(node?.data).to.equal('three');
86
- expect(count).to.equal(4);
87
- expect(memoizedCount).to.equal(1);
45
+ expect(outbound).has.length(1);
46
+ expect(outbound[0].id).to.equal('child');
47
+ expect(outbound[0].data).to.equal(2);
48
+ expect(inbound).has.length(1);
49
+ expect(inbound[0].id).to.equal('parent');
50
+ expect(inbound[0].data).to.equal(0);
88
51
  });
89
52
 
90
- test('resolving pickled graph', async () => {
91
- const pickle =
92
- '{"nodes":[{"id":"root","type":"dxos.org/type/GraphRoot","properties":{}},{"id":"test1","type":"test","properties":{"value":1}},{"id":"test2","type":"test","properties":{"value":2}}],"edges":{"root":["test1","test2"],"test1":["test2"],"test2":[]}}';
93
- const builder = GraphBuilder.from(pickle);
94
- const graph = builder.graph;
95
-
53
+ test('updates', () => {
54
+ const registry = Registry.make();
55
+ const builder = new GraphBuilder({ registry });
56
+ const state = Rx.make(0);
96
57
  builder.addExtension(
97
58
  createExtension({
98
- id: 'resolver',
99
- resolver: ({ id }) => {
100
- if (id === ROOT_ID) {
101
- return { id: ROOT_ID, type: ROOT_TYPE };
102
- } else {
103
- return { id, type: EXAMPLE_TYPE, data: id, properties: { value: parseInt(id.replace('test', '')) } };
104
- }
105
- },
59
+ id: 'connector',
60
+ connector: () => Rx.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
106
61
  }),
107
62
  );
63
+ const graph = builder.graph;
64
+ graph.expand(ROOT_ID);
108
65
 
109
66
  {
110
- expect(graph.findNode('test1', false)).toBeDefined();
111
- expect(graph.findNode('test1', false)?.data).to.equal(null);
112
- expect(graph.findNode('test1', false)?.properties.value).to.equal(1);
113
- expect(graph.findNode('test2', false)).toBeDefined();
114
- expect(graph.findNode('test2', false)?.data).to.equal(null);
115
- expect(graph.findNode('test2', false)?.properties.value).to.equal(2);
67
+ const [node] = registry.get(graph.connections(ROOT_ID));
68
+ expect(node.data).to.equal(0);
116
69
  }
117
70
 
118
- await builder.initialize();
119
-
120
71
  {
121
- expect(graph.findNode('test1', false)?.data).to.equal('test1');
122
- expect(graph.findNode('test1', false)?.properties.value).to.equal(1);
123
- expect(graph.findNode('test2', false)?.data).to.equal('test2');
124
- expect(graph.findNode('test2', false)?.properties.value).to.equal(2);
72
+ registry.set(state, 1);
73
+ const [node] = registry.get(graph.connections(ROOT_ID));
74
+ expect(node.data).to.equal(1);
125
75
  }
126
76
  });
127
- });
128
-
129
- describe('connector', () => {
130
- test('works', async () => {
131
- const builder = new GraphBuilder();
132
- builder.addExtension(
133
- createExtension({
134
- id: 'outbound-connector',
135
- connector: () => [{ id: 'child', type: EXAMPLE_TYPE, data: 2 }],
136
- }),
137
- );
138
- builder.addExtension(
139
- createExtension({
140
- id: 'inbound-connector',
141
- relation: 'inbound',
142
- connector: () => [{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }],
143
- }),
144
- );
145
-
146
- const graph = builder.graph;
147
- await graph.expand(graph.root);
148
- await graph.expand(graph.root, 'inbound');
149
-
150
- const outbound = graph.nodes(graph.root);
151
- const inbound = graph.nodes(graph.root, { relation: 'inbound' });
152
-
153
- expect(outbound).has.length(1);
154
- expect(outbound?.[0].id).to.equal('child');
155
- expect(outbound?.[0].data).to.equal(2);
156
- expect(inbound).has.length(1);
157
- expect(inbound?.[0].id).to.equal('parent');
158
- expect(inbound?.[0].data).to.equal(0);
159
- });
160
77
 
161
- test('updates', async () => {
162
- const name = signal('default');
163
- const builder = new GraphBuilder();
78
+ test('subscribes to updates', () => {
79
+ const registry = Registry.make();
80
+ const builder = new GraphBuilder({ registry });
81
+ const state = Rx.make(0);
164
82
  builder.addExtension(
165
83
  createExtension({
166
84
  id: 'connector',
167
- connector: () => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name, properties: { label: name.value } }],
85
+ connector: () => Rx.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
168
86
  }),
169
87
  );
170
88
  const graph = builder.graph;
171
- await graph.expand(graph.root);
172
89
 
173
- const [node] = graph.nodes(graph.root);
174
- expect(node.properties.label).to.equal('default');
90
+ let count = 0;
91
+ const cancel = registry.subscribe(graph.connections(ROOT_ID), (_) => {
92
+ count++;
93
+ });
94
+ onTestFinished(() => cancel());
95
+
96
+ expect(count).to.equal(0);
97
+ expect(registry.get(graph.connections(ROOT_ID))).to.have.length(0);
98
+ expect(count).to.equal(1);
175
99
 
176
- name.value = 'updated';
177
- expect(node.properties.label).to.equal('updated');
100
+ graph.expand(ROOT_ID);
101
+ expect(count).to.equal(2);
102
+ registry.set(state, 1);
103
+ expect(count).to.equal(3);
178
104
  });
179
105
 
180
- test('updates with new extensions', async () => {
181
- const name = signal('default');
182
- const builder = new GraphBuilder();
106
+ test('updates with new extensions', () => {
107
+ const registry = Registry.make();
108
+ const builder = new GraphBuilder({ registry });
183
109
  builder.addExtension(
184
110
  createExtension({
185
111
  id: 'connector',
186
- connector: () => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name, properties: { label: name.value } }],
112
+ connector: () => Rx.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
187
113
  }),
188
114
  );
189
115
  const graph = builder.graph;
190
- await graph.expand(graph.root);
116
+ graph.expand(ROOT_ID);
191
117
 
192
118
  let nodes: Node[] = [];
193
- using updates = updateCounter(() => {
194
- nodes = graph.nodes(graph.root);
119
+ let count = 0;
120
+ const cancel = registry.subscribe(graph.connections(ROOT_ID), (_nodes) => {
121
+ count++;
122
+ nodes = _nodes;
195
123
  });
124
+ onTestFinished(() => cancel());
196
125
 
197
- expect(updates.count).to.equal(0);
126
+ expect(nodes).has.length(0);
127
+ expect(count).to.equal(0);
128
+ registry.get(graph.connections(ROOT_ID));
198
129
  expect(nodes).has.length(1);
199
- expect(nodes[0].id).to.equal(EXAMPLE_ID);
130
+ expect(count).to.equal(1);
200
131
 
201
132
  builder.addExtension(
202
133
  createExtension({
203
134
  id: 'connector-2',
204
- connector: () => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: 0 }],
135
+ connector: () => Rx.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
205
136
  }),
206
137
  );
207
-
208
- expect(updates.count).to.equal(1);
209
138
  expect(nodes).has.length(2);
210
- expect(nodes[0].id).to.equal(EXAMPLE_ID);
211
- expect(nodes[1].id).to.equal(exampleId(2));
139
+ expect(count).to.equal(2);
212
140
  });
213
141
 
214
- test('removes', async () => {
215
- const nodes = signal([
216
- { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
217
- { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
142
+ test('removes', () => {
143
+ const registry = Registry.make();
144
+ const builder = new GraphBuilder({ registry });
145
+ const nodes = Rx.make([
146
+ { id: exampleId(1), type: EXAMPLE_TYPE },
147
+ { id: exampleId(2), type: EXAMPLE_TYPE },
218
148
  ]);
219
-
220
- const builder = new GraphBuilder();
221
149
  builder.addExtension(
222
150
  createExtension({
223
151
  id: 'connector',
224
- connector: () => nodes.value,
152
+ connector: () => Rx.make((get) => get(nodes)),
225
153
  }),
226
154
  );
227
155
  const graph = builder.graph;
228
- await graph.expand(graph.root);
156
+ graph.expand(ROOT_ID);
229
157
 
230
158
  {
231
- const nodes = graph.nodes(graph.root);
159
+ const nodes = registry.get(graph.connections(ROOT_ID));
232
160
  expect(nodes).has.length(2);
233
161
  expect(nodes[0].id).to.equal(exampleId(1));
162
+ expect(nodes[1].id).to.equal(exampleId(2));
234
163
  }
235
164
 
236
- nodes.value = [{ id: exampleId(3), type: EXAMPLE_TYPE, data: 3 }];
165
+ registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
237
166
 
238
167
  {
239
- const nodes = graph.nodes(graph.root);
168
+ const nodes = registry.get(graph.connections(ROOT_ID));
240
169
  expect(nodes).has.length(1);
241
170
  expect(nodes[0].id).to.equal(exampleId(3));
242
- expect(graph.findNode(exampleId(1))).to.be.undefined;
243
171
  }
244
172
  });
245
173
 
246
- test('unsubscribes', async () => {
247
- let count = 0;
248
- const name = signal('default');
249
- const sub = signal('default');
250
- const builder = new GraphBuilder();
174
+ test('nodes are updated when removed', () => {
175
+ const registry = Registry.make();
176
+ const builder = new GraphBuilder({ registry });
177
+ const name = Rx.make('removed');
178
+
251
179
  builder.addExtension([
252
180
  createExtension({
253
181
  id: 'root',
254
- filter: (node): node is Node<null> => node.id === 'root',
255
- connector: () =>
256
- name.value === 'removed'
257
- ? []
258
- : [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name, properties: { label: name.value } }],
259
- }),
260
- createExtension({
261
- id: 'connector',
262
- filter: (node): node is Node<string> => node.id === EXAMPLE_ID,
263
- connector: () => {
264
- count++;
265
- sub.value;
266
-
267
- return [];
268
- },
182
+ connector: (node) =>
183
+ Rx.make((get) =>
184
+ pipe(
185
+ get(node),
186
+ Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
187
+ Option.filter((name) => name !== 'removed'),
188
+ Option.map((name) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name }]),
189
+ Option.getOrElse(() => []),
190
+ ),
191
+ ),
269
192
  }),
270
193
  ]);
271
194
 
272
- // Count should not increment until the node is expanded.
273
195
  const graph = builder.graph;
274
- await graph.expand(graph.root);
196
+
197
+ let count = 0;
198
+ let exists = false;
199
+ const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
200
+ count++;
201
+ exists = Option.isSome(node);
202
+ });
203
+ onTestFinished(() => cancel());
204
+
205
+ graph.expand(ROOT_ID);
275
206
  expect(count).to.equal(0);
207
+ expect(exists).to.be.false;
276
208
 
277
- // Count should increment when the node is expanded.
278
- const [node] = graph.nodes(graph.root);
279
- await graph.expand(node!);
209
+ registry.set(name, 'default');
280
210
  expect(count).to.equal(1);
211
+ expect(exists).to.be.true;
281
212
 
282
- // Count should increment when the parent changes.
283
- name.value = 'updated';
213
+ registry.set(name, 'removed');
284
214
  expect(count).to.equal(2);
215
+ expect(exists).to.be.false;
285
216
 
286
- // Count should increment when the signal changes.
287
- sub.value = 'updated';
217
+ registry.set(name, 'added');
288
218
  expect(count).to.equal(3);
289
-
290
- // Count will still increment if the node is removed in a batch.
291
- batch(() => {
292
- name.value = 'removed';
293
- sub.value = 'batch';
294
- });
295
- expect(count).to.equal(4);
296
-
297
- // Count should not increment after the node is removed.
298
- sub.value = 'removed';
299
- expect(count).to.equal(4);
300
-
301
- // Count will not increment when node is added back.
302
- name.value = 'added';
303
- expect(count).to.equal(4);
304
-
305
- // Count should increment when the node is expanded again.
306
- await graph.expand(node!);
307
- expect(count).to.equal(5);
308
-
309
- // Count should increment when signal changes again.
310
- sub.value = 'added';
311
- expect(count).to.equal(6);
219
+ expect(exists).to.be.true;
312
220
  });
313
221
 
314
- test('filters by type', async () => {
315
- const builder = new GraphBuilder();
222
+ test('sort edges', async () => {
223
+ const registry = Registry.make();
224
+ const builder = new GraphBuilder({ registry });
225
+ const nodes = Rx.make([
226
+ { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
227
+ { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
228
+ { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
229
+ ]);
316
230
  builder.addExtension(
317
231
  createExtension({
318
- id: 'actions',
319
- connector: () => [{ id: 'not-action', type: EXAMPLE_TYPE, data: 1 }],
320
- actions: () => [{ id: 'action', data: () => {} }],
232
+ id: 'connector',
233
+ connector: () => Rx.make((get) => get(nodes)),
321
234
  }),
322
235
  );
323
236
  const graph = builder.graph;
237
+ graph.expand(ROOT_ID);
238
+
239
+ {
240
+ const nodes = registry.get(graph.connections(ROOT_ID));
241
+ expect(nodes).has.length(3);
242
+ expect(nodes[0].id).to.equal(exampleId(1));
243
+ expect(nodes[1].id).to.equal(exampleId(2));
244
+ expect(nodes[2].id).to.equal(exampleId(3));
245
+ }
324
246
 
325
- await graph.expand(graph.root, 'outbound', ACTION_TYPE);
326
- const actions = graph.actions(graph.root);
327
- expect(actions).has.length(1);
328
- expect(actions?.[0].id).to.equal('action');
329
- expect(actions?.[0].type).to.equal(ACTION_TYPE);
247
+ registry.set(nodes, [
248
+ { id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
249
+ { id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
250
+ { id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
251
+ ]);
330
252
 
331
- await expect(graph.waitForNode('not-action', 10)).rejects.toBeInstanceOf(Error);
253
+ // TODO(wittjosiah): Why is this needed for the following conditions to pass?
254
+ await sleep(0);
332
255
 
333
- await graph.expand(graph.root);
334
- const nodes = graph.nodes(graph.root);
335
- expect(nodes).has.length(1);
336
- expect(nodes?.[0].id).to.equal('not-action');
337
- expect(nodes?.[0].data).to.equal(1);
256
+ {
257
+ const nodes = registry.get(graph.connections(ROOT_ID));
258
+ expect(nodes).has.length(3);
259
+ expect(nodes[0].id).to.equal(exampleId(3));
260
+ expect(nodes[1].id).to.equal(exampleId(1));
261
+ expect(nodes[2].id).to.equal(exampleId(2));
262
+ }
338
263
  });
339
264
 
340
- test('filters by callback', async () => {
341
- const builder = new GraphBuilder();
342
- builder.addExtension(
265
+ test('updates are constrained', () => {
266
+ const registry = Registry.make();
267
+ const builder = new GraphBuilder({ registry });
268
+ const name = Rx.make('default');
269
+ const sub = Rx.make('default');
270
+
271
+ builder.addExtension([
343
272
  createExtension({
344
- id: 'filtered-connector',
345
- filter: (node): node is Node<null> => node.id === 'root',
346
- connector: () => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }],
273
+ id: 'root',
274
+ connector: (node) =>
275
+ Rx.make((get) =>
276
+ pipe(
277
+ get(node),
278
+ Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
279
+ Option.filter((name) => name !== 'removed'),
280
+ Option.map((name) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name }]),
281
+ Option.getOrElse(() => []),
282
+ ),
283
+ ),
347
284
  }),
348
- );
285
+ createExtension({
286
+ id: 'connector1',
287
+ connector: (node) =>
288
+ Rx.make((get) =>
289
+ pipe(
290
+ get(node),
291
+ Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(get(sub)) : Option.none())),
292
+ Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
293
+ Option.getOrElse(() => []),
294
+ ),
295
+ ),
296
+ }),
297
+ createExtension({
298
+ id: 'connector2',
299
+ connector: (node) =>
300
+ Rx.make((get) =>
301
+ pipe(
302
+ get(node),
303
+ Option.flatMap((node) => (node.id === EXAMPLE_ID ? Option.some(node.data) : Option.none())),
304
+ Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
305
+ Option.getOrElse(() => []),
306
+ ),
307
+ ),
308
+ }),
309
+ ]);
310
+
349
311
  const graph = builder.graph;
350
- await graph.expand(graph.root);
351
312
 
352
- const [node1] = graph.nodes(graph.root);
353
- expect(node1?.id).to.equal(EXAMPLE_ID);
313
+ let parentCount = 0;
314
+ const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
315
+ parentCount++;
316
+ });
317
+ onTestFinished(() => parentCancel());
354
318
 
355
- const nodes = graph.nodes(node1);
356
- expect(nodes).has.length(0);
319
+ let independentCount = 0;
320
+ const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
321
+ independentCount++;
322
+ });
323
+ onTestFinished(() => independentCancel());
324
+
325
+ let dependentCount = 0;
326
+ const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
327
+ dependentCount++;
328
+ });
329
+ onTestFinished(() => dependentCancel());
330
+
331
+ // Counts should not increment until the node is expanded.
332
+ graph.expand(ROOT_ID);
333
+ expect(parentCount).to.equal(1);
334
+ expect(independentCount).to.equal(0);
335
+ expect(dependentCount).to.equal(0);
336
+
337
+ // Counts should increment when the node is expanded.
338
+ graph.expand(EXAMPLE_ID);
339
+ expect(parentCount).to.equal(1);
340
+ expect(independentCount).to.equal(1);
341
+ expect(dependentCount).to.equal(1);
342
+
343
+ // Only dependent count should increment when the parent changes.
344
+ registry.set(name, 'updated');
345
+ expect(parentCount).to.equal(2);
346
+ expect(independentCount).to.equal(1);
347
+ expect(dependentCount).to.equal(2);
348
+
349
+ // Only independent count should increment when its state changes.
350
+ registry.set(sub, 'updated');
351
+ expect(parentCount).to.equal(2);
352
+ expect(independentCount).to.equal(2);
353
+ expect(dependentCount).to.equal(2);
354
+
355
+ // Independent count should update if its state changes even if the parent is removed.
356
+ Rx.batch(() => {
357
+ registry.set(name, 'removed');
358
+ registry.set(sub, 'batch');
359
+ });
360
+ expect(parentCount).to.equal(2);
361
+ expect(independentCount).to.equal(3);
362
+ expect(dependentCount).to.equal(2);
363
+
364
+ // Dependent count should increment when the node is added back.
365
+ registry.set(name, 'added');
366
+ expect(parentCount).to.equal(3);
367
+ expect(independentCount).to.equal(3);
368
+ expect(dependentCount).to.equal(3);
369
+
370
+ // Counts should not increment when the node is expanded again.
371
+ graph.expand(EXAMPLE_ID);
372
+ expect(parentCount).to.equal(3);
373
+ expect(independentCount).to.equal(3);
374
+ expect(dependentCount).to.equal(3);
357
375
  });
358
376
 
359
- test('memoize', async () => {
360
- const builder = new GraphBuilder();
361
- const name = signal('default');
362
- let count = 0;
363
- let memoizedCount = 0;
377
+ test('eager graph expansion', async () => {
378
+ const registry = Registry.make();
379
+ const builder = new GraphBuilder({ registry });
364
380
  builder.addExtension(
365
381
  createExtension({
366
382
  id: 'connector',
367
- connector: () => {
368
- count++;
369
- memoize(() => {
370
- memoizedCount++;
371
- });
372
-
373
- return [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: name, properties: { label: name.value } }];
383
+ connector: (node) => {
384
+ return Rx.make((get) =>
385
+ pipe(
386
+ get(node),
387
+ Option.map((node) => (node.data ? node.data + 1 : 1)),
388
+ Option.filter((data) => data <= 5),
389
+ Option.map((data) => [{ id: `node-${data}`, type: EXAMPLE_TYPE, data }]),
390
+ Option.getOrElse(() => []),
391
+ ),
392
+ );
374
393
  },
375
394
  }),
376
395
  );
377
- const graph = builder.graph;
378
- await graph.expand(graph.root);
379
396
 
380
- const [node] = graph.nodes(graph.root);
381
- expect(node.properties.label).to.equal('default');
382
- expect(count).to.equal(1);
383
- expect(memoizedCount).to.equal(1);
384
-
385
- name!.value = 'one';
386
- name!.value = 'two';
387
- name!.value = 'three';
397
+ let count = 0;
398
+ const trigger = new Trigger();
399
+ builder.graph.onNodeChanged.on(({ id }) => {
400
+ builder.graph.expand(id);
401
+ count++;
402
+ if (count === 5) {
403
+ trigger.wake();
404
+ }
405
+ });
388
406
 
389
- expect(node.properties.label).to.equal('three');
390
- expect(count).to.equal(4);
391
- expect(memoizedCount).to.equal(1);
407
+ builder.graph.expand(ROOT_ID);
408
+ await trigger.wait();
409
+ expect(count).to.equal(5);
392
410
  });
393
411
  });
394
412
 
@@ -398,17 +416,21 @@ describe('GraphBuilder', () => {
398
416
  builder.addExtension(
399
417
  createExtension({
400
418
  id: 'connector',
401
- connector: ({ node }) => {
402
- const data = node.data ? node.data + 1 : 1;
403
- return data > 5 ? [] : [{ id: `node-${data}`, type: EXAMPLE_TYPE, data }];
404
- },
419
+ connector: (node) =>
420
+ Rx.make((get) =>
421
+ pipe(
422
+ get(node),
423
+ Option.map((node) => (node.data ? node.data + 1 : 1)),
424
+ Option.filter((data) => data <= 5),
425
+ Option.map((data) => [{ id: `node-${data}`, type: EXAMPLE_TYPE, data }]),
426
+ Option.getOrElse(() => []),
427
+ ),
428
+ ),
405
429
  }),
406
430
  );
407
- const graph = builder.graph;
408
431
 
409
432
  let count = 0;
410
433
  await builder.explore({
411
- node: graph.root,
412
434
  visitor: () => {
413
435
  count++;
414
436
  },
@@ -417,43 +439,4 @@ describe('GraphBuilder', () => {
417
439
  expect(count).to.equal(6);
418
440
  });
419
441
  });
420
-
421
- describe('multiples', () => {
422
- test('one of each with multiple memos', async () => {
423
- const name = signal('default');
424
- const builder = new GraphBuilder();
425
- builder.addExtension(
426
- createExtension({
427
- id: 'extension',
428
- resolver: () => {
429
- const data = memoize(() => Math.random());
430
- return { id: EXAMPLE_ID, type: EXAMPLE_TYPE, data, properties: { name: name.value } };
431
- },
432
- connector: () => {
433
- const a = memoize(() => Math.random());
434
- const b = memoize(() => Math.random());
435
- const c = Math.random();
436
- return [{ id: `${EXAMPLE_ID}-child`, type: EXAMPLE_TYPE, data: { a, b, c } }];
437
- },
438
- }),
439
- );
440
- const graph = builder.graph;
441
-
442
- const one = await graph.waitForNode(EXAMPLE_ID);
443
- const initialData = one!.data;
444
- await graph.expand(one!);
445
- const two = graph.nodes(one!)[0];
446
- const initialA = two?.data.a;
447
- const initialB = two?.data.b;
448
- const initialC = two?.data.c;
449
-
450
- name.value = 'updated';
451
-
452
- expect(one?.properties.name).to.equal('updated');
453
- expect(one?.data).to.equal(initialData);
454
- expect(two?.data.a).to.equal(initialA);
455
- expect(two?.data.b).to.equal(initialB);
456
- expect(two?.data.c).not.to.equal(initialC);
457
- });
458
- });
459
442
  });