@dxos/app-graph 0.8.4-main.dedc0f3 → 0.8.4-main.dfabb4ec29
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.
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
- package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
- package/dist/lib/neutral/chunk-WJJ5KEOH.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +40 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/scheduler.mjs +15 -0
- package/dist/lib/neutral/scheduler.mjs.map +7 -0
- package/dist/lib/neutral/testing/index.mjs +40 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/atoms.d.ts +8 -0
- package/dist/types/src/atoms.d.ts.map +1 -0
- package/dist/types/src/graph-builder.d.ts +113 -67
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +188 -222
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +7 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +244 -0
- package/dist/types/src/node-matcher.d.ts.map +1 -0
- package/dist/types/src/node-matcher.test.d.ts +2 -0
- package/dist/types/src/node-matcher.test.d.ts.map +1 -0
- package/dist/types/src/node.d.ts +50 -5
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/scheduler.browser.d.ts +2 -0
- package/dist/types/src/scheduler.browser.d.ts.map +1 -0
- package/dist/types/src/scheduler.d.ts +8 -0
- package/dist/types/src/scheduler.d.ts.map +1 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts +0 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
- package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +40 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +52 -40
- package/src/atoms.ts +25 -0
- package/src/graph-builder.test.ts +1154 -144
- package/src/graph-builder.ts +738 -293
- package/src/graph.test.ts +451 -123
- package/src/graph.ts +1054 -403
- package/src/index.ts +10 -3
- package/src/node-matcher.test.ts +301 -0
- package/src/node-matcher.ts +314 -0
- package/src/node.ts +82 -8
- package/src/scheduler.browser.ts +5 -0
- package/src/scheduler.ts +17 -0
- package/src/stories/EchoGraph.stories.tsx +167 -131
- package/src/stories/Tree.tsx +1 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/setup-graph-builder.ts +41 -0
- package/src/util.ts +101 -0
- package/dist/lib/browser/index.mjs +0 -853
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -855
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
- package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
- package/dist/types/src/signals-integration.test.d.ts +0 -2
- package/dist/types/src/signals-integration.test.d.ts.map +0 -1
- package/dist/types/src/testing.d.ts +0 -5
- package/dist/types/src/testing.d.ts.map +0 -1
- package/src/experimental/graph-projections.test.ts +0 -56
- package/src/signals-integration.test.ts +0 -218
- package/src/testing.ts +0 -20
|
@@ -2,45 +2,51 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
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 { Trigger
|
|
12
|
+
import { Trigger } from '@dxos/async';
|
|
13
|
+
import { Obj } from '@dxos/echo';
|
|
14
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
10
15
|
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import
|
|
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';
|
|
20
|
+
import { qualifyId } from './util';
|
|
14
21
|
|
|
15
22
|
const exampleId = (id: number) => `dx:test:${id}`;
|
|
16
23
|
const EXAMPLE_ID = exampleId(1);
|
|
17
|
-
const EXAMPLE_TYPE = 'dxos.
|
|
24
|
+
const EXAMPLE_TYPE = 'org.dxos.type.example';
|
|
18
25
|
|
|
19
26
|
describe('GraphBuilder', () => {
|
|
20
27
|
describe('resolver', () => {
|
|
21
28
|
test('works', async () => {
|
|
22
29
|
const registry = Registry.make();
|
|
23
|
-
const builder =
|
|
30
|
+
const builder = GraphBuilder.make({ registry });
|
|
24
31
|
const graph = builder.graph;
|
|
25
32
|
|
|
26
33
|
{
|
|
27
|
-
const node =
|
|
34
|
+
const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
|
|
28
35
|
expect(node).to.be.null;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
// Test direct API
|
|
39
|
+
GraphBuilder.addExtension(
|
|
40
|
+
builder,
|
|
41
|
+
GraphBuilder.createExtensionRaw({
|
|
33
42
|
id: 'resolver',
|
|
34
|
-
resolver: () => {
|
|
35
|
-
console.log('resolver');
|
|
36
|
-
return Rx.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 });
|
|
37
|
-
},
|
|
43
|
+
resolver: () => Atom.make({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: 1 }),
|
|
38
44
|
}),
|
|
39
45
|
);
|
|
40
|
-
await
|
|
46
|
+
await Graph.initialize(graph, EXAMPLE_ID);
|
|
41
47
|
|
|
42
48
|
{
|
|
43
|
-
const node =
|
|
49
|
+
const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
|
|
44
50
|
expect(node?.id).to.equal(EXAMPLE_ID);
|
|
45
51
|
expect(node?.type).to.equal(EXAMPLE_TYPE);
|
|
46
52
|
expect(node?.data).to.equal(1);
|
|
@@ -49,132 +55,326 @@ describe('GraphBuilder', () => {
|
|
|
49
55
|
|
|
50
56
|
test('updates', async () => {
|
|
51
57
|
const registry = Registry.make();
|
|
52
|
-
const builder =
|
|
53
|
-
const name =
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const builder = GraphBuilder.make({ registry });
|
|
59
|
+
const name = Atom.make('default');
|
|
60
|
+
GraphBuilder.addExtension(
|
|
61
|
+
builder,
|
|
62
|
+
GraphBuilder.createExtensionRaw({
|
|
56
63
|
id: 'resolver',
|
|
57
|
-
resolver: () =>
|
|
64
|
+
resolver: () => Atom.make((get) => ({ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(name) })),
|
|
58
65
|
}),
|
|
59
66
|
);
|
|
60
67
|
const graph = builder.graph;
|
|
61
|
-
await
|
|
68
|
+
await Graph.initialize(graph, EXAMPLE_ID);
|
|
62
69
|
|
|
63
70
|
{
|
|
64
|
-
const node =
|
|
71
|
+
const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
|
|
65
72
|
expect(node?.data).to.equal('default');
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
registry.set(name, 'updated');
|
|
69
76
|
|
|
70
77
|
{
|
|
71
|
-
const node =
|
|
78
|
+
const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
|
|
72
79
|
expect(node?.data).to.equal('updated');
|
|
73
80
|
}
|
|
74
81
|
});
|
|
82
|
+
|
|
83
|
+
test('connects resolved node to parent via child edge', async ({ expect }) => {
|
|
84
|
+
const registry = Registry.make();
|
|
85
|
+
const builder = GraphBuilder.make({ registry });
|
|
86
|
+
const childId = qualifyId('root', '~child');
|
|
87
|
+
|
|
88
|
+
GraphBuilder.addExtension(
|
|
89
|
+
builder,
|
|
90
|
+
GraphBuilder.createExtensionRaw({
|
|
91
|
+
id: 'resolver',
|
|
92
|
+
resolver: (id) =>
|
|
93
|
+
id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved' }) : Atom.make(null),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const graph = builder.graph;
|
|
98
|
+
await Graph.initialize(graph, childId);
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
|
|
102
|
+
expect(node?.id).to.equal(childId);
|
|
103
|
+
expect(node?.data).to.equal('resolved');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Verify the resolved node is a child of root.
|
|
107
|
+
{
|
|
108
|
+
const children = registry.get(graph.connections('root', 'child'));
|
|
109
|
+
expect(children.some((n) => n.id === childId)).to.be.true;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('out-of-order: resolver fires before parent exists', async ({ expect }) => {
|
|
114
|
+
const registry = Registry.make();
|
|
115
|
+
const builder = GraphBuilder.make({ registry });
|
|
116
|
+
const parentId = qualifyId('root', 'parent');
|
|
117
|
+
const childId = qualifyId('root', 'parent', '~child');
|
|
118
|
+
|
|
119
|
+
GraphBuilder.addExtension(builder, [
|
|
120
|
+
GraphBuilder.createExtensionRaw({
|
|
121
|
+
id: 'resolver',
|
|
122
|
+
resolver: (id) =>
|
|
123
|
+
id === childId ? Atom.make({ id: childId, type: EXAMPLE_TYPE, data: 'resolved-child' }) : Atom.make(null),
|
|
124
|
+
}),
|
|
125
|
+
GraphBuilder.createExtensionRaw({
|
|
126
|
+
id: 'connector',
|
|
127
|
+
connector: (node) =>
|
|
128
|
+
Atom.make((get) =>
|
|
129
|
+
Function.pipe(
|
|
130
|
+
get(node),
|
|
131
|
+
Option.filter((n) => n.id === 'root'),
|
|
132
|
+
Option.map(() => [{ id: 'parent', type: EXAMPLE_TYPE, data: 'parent-data' }]),
|
|
133
|
+
Option.getOrElse(() => []),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const graph = builder.graph;
|
|
140
|
+
|
|
141
|
+
// Resolve child BEFORE parent exists in the graph.
|
|
142
|
+
await Graph.initialize(graph, childId);
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
const node = Graph.getNode(graph, childId).pipe(Option.getOrNull);
|
|
146
|
+
expect(node?.id).to.equal(childId);
|
|
147
|
+
expect(node?.data).to.equal('resolved-child');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Now expand root to create parent via connector.
|
|
151
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
152
|
+
await GraphBuilder.flush(builder);
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
const parent = Graph.getNode(graph, parentId).pipe(Option.getOrNull);
|
|
156
|
+
expect(parent?.data).to.equal('parent-data');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The resolved child should be connected to the parent.
|
|
160
|
+
{
|
|
161
|
+
const children = registry.get(graph.connections(parentId, 'child'));
|
|
162
|
+
expect(children.some((n) => n.id === childId)).to.be.true;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('onNone does not remove connector-owned node', async ({ expect }) => {
|
|
167
|
+
const registry = Registry.make();
|
|
168
|
+
const builder = GraphBuilder.make({ registry });
|
|
169
|
+
const nodeId = qualifyId('root', 'shared');
|
|
170
|
+
|
|
171
|
+
// Connector that produces root/shared. No resolver matches root/shared.
|
|
172
|
+
GraphBuilder.addExtension(
|
|
173
|
+
builder,
|
|
174
|
+
GraphBuilder.createExtensionRaw({
|
|
175
|
+
id: 'connector',
|
|
176
|
+
connector: (node) =>
|
|
177
|
+
Atom.make((get) =>
|
|
178
|
+
Function.pipe(
|
|
179
|
+
get(node),
|
|
180
|
+
Option.filter((n) => n.id === 'root'),
|
|
181
|
+
Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
|
|
182
|
+
Option.getOrElse(() => []),
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const graph = builder.graph;
|
|
189
|
+
|
|
190
|
+
// Connector produces root/shared.
|
|
191
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
192
|
+
await GraphBuilder.flush(builder);
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
|
|
196
|
+
expect(node?.data).to.equal('from-connector');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Initialize fires for the same ID. No resolver matches, so onNone fires.
|
|
200
|
+
// The connector-owned node should NOT be removed.
|
|
201
|
+
await Graph.initialize(graph, nodeId);
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
const node = Graph.getNode(graph, nodeId).pipe(Option.getOrNull);
|
|
205
|
+
expect(node?.data).to.equal('from-connector');
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('does not overwrite connector-produced node', async () => {
|
|
210
|
+
const registry = Registry.make();
|
|
211
|
+
const builder = GraphBuilder.make({ registry });
|
|
212
|
+
const resolverData = Atom.make('from-resolver');
|
|
213
|
+
|
|
214
|
+
GraphBuilder.addExtension(builder, [
|
|
215
|
+
GraphBuilder.createExtensionRaw({
|
|
216
|
+
id: 'resolver',
|
|
217
|
+
resolver: (id) =>
|
|
218
|
+
id === qualifyId('root', 'shared')
|
|
219
|
+
? Atom.make((get) => ({ id: qualifyId('root', 'shared'), type: EXAMPLE_TYPE, data: get(resolverData) }))
|
|
220
|
+
: Atom.make(null),
|
|
221
|
+
}),
|
|
222
|
+
GraphBuilder.createExtensionRaw({
|
|
223
|
+
id: 'connector',
|
|
224
|
+
connector: (node) =>
|
|
225
|
+
Atom.make((get) =>
|
|
226
|
+
Function.pipe(
|
|
227
|
+
get(node),
|
|
228
|
+
Option.filter((n) => n.id === 'root'),
|
|
229
|
+
Option.map(() => [{ id: 'shared', type: EXAMPLE_TYPE, data: 'from-connector' }]),
|
|
230
|
+
Option.getOrElse(() => []),
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
}),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const graph = builder.graph;
|
|
237
|
+
|
|
238
|
+
// Connector produces root/shared.
|
|
239
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
240
|
+
await GraphBuilder.flush(builder);
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
|
|
244
|
+
expect(node?.data).to.equal('from-connector');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Resolver fires for the same ID but should not overwrite.
|
|
248
|
+
await Graph.initialize(graph, qualifyId('root', 'shared'));
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
|
|
252
|
+
expect(node?.data).to.equal('from-connector');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Updating the resolver's atom should still not overwrite.
|
|
256
|
+
registry.set(resolverData, 'updated-resolver');
|
|
257
|
+
|
|
258
|
+
{
|
|
259
|
+
const node = Graph.getNode(graph, qualifyId('root', 'shared')).pipe(Option.getOrNull);
|
|
260
|
+
expect(node?.data).to.equal('from-connector');
|
|
261
|
+
}
|
|
262
|
+
});
|
|
75
263
|
});
|
|
76
264
|
|
|
77
265
|
describe('connector', () => {
|
|
78
|
-
test('works', () => {
|
|
266
|
+
test('works', async () => {
|
|
79
267
|
const registry = Registry.make();
|
|
80
|
-
const builder =
|
|
81
|
-
|
|
82
|
-
|
|
268
|
+
const builder = GraphBuilder.make({ registry });
|
|
269
|
+
GraphBuilder.addExtension(
|
|
270
|
+
builder,
|
|
271
|
+
GraphBuilder.createExtensionRaw({
|
|
83
272
|
id: 'outbound-connector',
|
|
84
|
-
connector: () =>
|
|
273
|
+
connector: () => Atom.make([{ id: 'child', type: EXAMPLE_TYPE, data: 2 }]),
|
|
85
274
|
}),
|
|
86
275
|
);
|
|
87
|
-
|
|
88
|
-
|
|
276
|
+
GraphBuilder.addExtension(
|
|
277
|
+
builder,
|
|
278
|
+
GraphBuilder.createExtensionRaw({
|
|
89
279
|
id: 'inbound-connector',
|
|
90
|
-
relation: 'inbound',
|
|
91
|
-
connector: () =>
|
|
280
|
+
relation: Node.childRelation('inbound'),
|
|
281
|
+
connector: () => Atom.make([{ id: 'parent', type: EXAMPLE_TYPE, data: 0 }]),
|
|
92
282
|
}),
|
|
93
283
|
);
|
|
94
284
|
|
|
95
285
|
const graph = builder.graph;
|
|
96
|
-
|
|
97
|
-
|
|
286
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
287
|
+
Graph.expand(graph, Node.RootId, Node.childRelation('inbound'));
|
|
288
|
+
await GraphBuilder.flush(builder);
|
|
98
289
|
|
|
99
|
-
const outbound = registry.get(graph.connections(
|
|
100
|
-
const inbound = registry.get(graph.connections(
|
|
290
|
+
const outbound = registry.get(graph.connections(Node.RootId, 'child'));
|
|
291
|
+
const inbound = registry.get(graph.connections(Node.RootId, Node.childRelation('inbound')));
|
|
101
292
|
|
|
102
293
|
expect(outbound).has.length(1);
|
|
103
|
-
expect(outbound[0].id).to.equal('child');
|
|
294
|
+
expect(outbound[0].id).to.equal('root/child');
|
|
104
295
|
expect(outbound[0].data).to.equal(2);
|
|
105
296
|
expect(inbound).has.length(1);
|
|
106
|
-
expect(inbound[0].id).to.equal('parent');
|
|
297
|
+
expect(inbound[0].id).to.equal('root/parent');
|
|
107
298
|
expect(inbound[0].data).to.equal(0);
|
|
108
299
|
});
|
|
109
300
|
|
|
110
|
-
test('updates', () => {
|
|
301
|
+
test('updates', async () => {
|
|
111
302
|
const registry = Registry.make();
|
|
112
|
-
const builder =
|
|
113
|
-
const state =
|
|
114
|
-
|
|
115
|
-
|
|
303
|
+
const builder = GraphBuilder.make({ registry });
|
|
304
|
+
const state = Atom.make(0);
|
|
305
|
+
GraphBuilder.addExtension(
|
|
306
|
+
builder,
|
|
307
|
+
GraphBuilder.createExtensionRaw({
|
|
116
308
|
id: 'connector',
|
|
117
|
-
connector: () =>
|
|
309
|
+
connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
|
|
118
310
|
}),
|
|
119
311
|
);
|
|
120
312
|
const graph = builder.graph;
|
|
121
|
-
|
|
313
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
314
|
+
await GraphBuilder.flush(builder);
|
|
122
315
|
|
|
123
316
|
{
|
|
124
|
-
const [node] = registry.get(graph.connections(
|
|
317
|
+
const [node] = registry.get(graph.connections(Node.RootId, 'child'));
|
|
125
318
|
expect(node.data).to.equal(0);
|
|
126
319
|
}
|
|
127
320
|
|
|
128
321
|
{
|
|
129
322
|
registry.set(state, 1);
|
|
130
|
-
|
|
323
|
+
await GraphBuilder.flush(builder);
|
|
324
|
+
const [node] = registry.get(graph.connections(Node.RootId, 'child'));
|
|
131
325
|
expect(node.data).to.equal(1);
|
|
132
326
|
}
|
|
133
327
|
});
|
|
134
328
|
|
|
135
|
-
test('subscribes to updates', () => {
|
|
329
|
+
test('subscribes to updates', async () => {
|
|
136
330
|
const registry = Registry.make();
|
|
137
|
-
const builder =
|
|
138
|
-
const state =
|
|
139
|
-
|
|
140
|
-
|
|
331
|
+
const builder = GraphBuilder.make({ registry });
|
|
332
|
+
const state = Atom.make(0);
|
|
333
|
+
GraphBuilder.addExtension(
|
|
334
|
+
builder,
|
|
335
|
+
GraphBuilder.createExtensionRaw({
|
|
141
336
|
id: 'connector',
|
|
142
|
-
connector: () =>
|
|
337
|
+
connector: () => Atom.make((get) => [{ id: EXAMPLE_ID, type: EXAMPLE_TYPE, data: get(state) }]),
|
|
143
338
|
}),
|
|
144
339
|
);
|
|
145
340
|
const graph = builder.graph;
|
|
146
341
|
|
|
147
342
|
let count = 0;
|
|
148
|
-
const cancel = registry.subscribe(graph.connections(
|
|
343
|
+
const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_) => {
|
|
149
344
|
count++;
|
|
150
345
|
});
|
|
151
346
|
onTestFinished(() => cancel());
|
|
152
347
|
|
|
153
348
|
expect(count).to.equal(0);
|
|
154
|
-
expect(registry.get(graph.connections(
|
|
349
|
+
expect(registry.get(graph.connections(Node.RootId, 'child'))).to.have.length(0);
|
|
155
350
|
expect(count).to.equal(1);
|
|
156
351
|
|
|
157
|
-
|
|
352
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
353
|
+
await GraphBuilder.flush(builder);
|
|
158
354
|
expect(count).to.equal(2);
|
|
355
|
+
|
|
159
356
|
registry.set(state, 1);
|
|
357
|
+
await GraphBuilder.flush(builder);
|
|
160
358
|
expect(count).to.equal(3);
|
|
161
359
|
});
|
|
162
360
|
|
|
163
|
-
test('updates with new extensions', () => {
|
|
361
|
+
test('updates with new extensions', async () => {
|
|
164
362
|
const registry = Registry.make();
|
|
165
|
-
const builder =
|
|
166
|
-
|
|
167
|
-
|
|
363
|
+
const builder = GraphBuilder.make({ registry });
|
|
364
|
+
GraphBuilder.addExtension(
|
|
365
|
+
builder,
|
|
366
|
+
GraphBuilder.createExtensionRaw({
|
|
168
367
|
id: 'connector',
|
|
169
|
-
connector: () =>
|
|
368
|
+
connector: () => Atom.make([{ id: EXAMPLE_ID, type: EXAMPLE_TYPE }]),
|
|
170
369
|
}),
|
|
171
370
|
);
|
|
172
371
|
const graph = builder.graph;
|
|
173
|
-
|
|
372
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
373
|
+
await GraphBuilder.flush(builder);
|
|
174
374
|
|
|
175
|
-
let nodes: Node[] = [];
|
|
375
|
+
let nodes: Node.Node[] = [];
|
|
176
376
|
let count = 0;
|
|
177
|
-
const cancel = registry.subscribe(graph.connections(
|
|
377
|
+
const cancel = registry.subscribe(graph.connections(Node.RootId, 'child'), (_nodes) => {
|
|
178
378
|
count++;
|
|
179
379
|
nodes = _nodes;
|
|
180
380
|
});
|
|
@@ -182,63 +382,68 @@ describe('GraphBuilder', () => {
|
|
|
182
382
|
|
|
183
383
|
expect(nodes).has.length(0);
|
|
184
384
|
expect(count).to.equal(0);
|
|
185
|
-
registry.get(graph.connections(
|
|
385
|
+
registry.get(graph.connections(Node.RootId, 'child'));
|
|
186
386
|
expect(nodes).has.length(1);
|
|
187
387
|
expect(count).to.equal(1);
|
|
188
388
|
|
|
189
|
-
|
|
190
|
-
|
|
389
|
+
GraphBuilder.addExtension(
|
|
390
|
+
builder,
|
|
391
|
+
GraphBuilder.createExtensionRaw({
|
|
191
392
|
id: 'connector-2',
|
|
192
|
-
connector: () =>
|
|
393
|
+
connector: () => Atom.make([{ id: exampleId(2), type: EXAMPLE_TYPE }]),
|
|
193
394
|
}),
|
|
194
395
|
);
|
|
396
|
+
await GraphBuilder.flush(builder);
|
|
195
397
|
expect(nodes).has.length(2);
|
|
196
398
|
expect(count).to.equal(2);
|
|
197
399
|
});
|
|
198
400
|
|
|
199
|
-
test('removes', () => {
|
|
401
|
+
test('removes', async () => {
|
|
200
402
|
const registry = Registry.make();
|
|
201
|
-
const builder =
|
|
202
|
-
const nodes =
|
|
403
|
+
const builder = GraphBuilder.make({ registry });
|
|
404
|
+
const nodes = Atom.make([
|
|
203
405
|
{ id: exampleId(1), type: EXAMPLE_TYPE },
|
|
204
406
|
{ id: exampleId(2), type: EXAMPLE_TYPE },
|
|
205
407
|
]);
|
|
206
|
-
|
|
207
|
-
|
|
408
|
+
GraphBuilder.addExtension(
|
|
409
|
+
builder,
|
|
410
|
+
GraphBuilder.createExtensionRaw({
|
|
208
411
|
id: 'connector',
|
|
209
|
-
connector: () =>
|
|
412
|
+
connector: () => Atom.make((get) => get(nodes)),
|
|
210
413
|
}),
|
|
211
414
|
);
|
|
212
415
|
const graph = builder.graph;
|
|
213
|
-
|
|
416
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
417
|
+
await GraphBuilder.flush(builder);
|
|
214
418
|
|
|
215
419
|
{
|
|
216
|
-
const nodes = registry.get(graph.connections(
|
|
420
|
+
const nodes = registry.get(graph.connections(Node.RootId, 'child'));
|
|
217
421
|
expect(nodes).has.length(2);
|
|
218
|
-
expect(nodes[0].id).to.equal(exampleId(1));
|
|
219
|
-
expect(nodes[1].id).to.equal(exampleId(2));
|
|
422
|
+
expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
|
|
423
|
+
expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
|
|
220
424
|
}
|
|
221
425
|
|
|
222
426
|
registry.set(nodes, [{ id: exampleId(3), type: EXAMPLE_TYPE }]);
|
|
427
|
+
await GraphBuilder.flush(builder);
|
|
223
428
|
|
|
224
429
|
{
|
|
225
|
-
const nodes = registry.get(graph.connections(
|
|
430
|
+
const nodes = registry.get(graph.connections(Node.RootId, 'child'));
|
|
226
431
|
expect(nodes).has.length(1);
|
|
227
|
-
expect(nodes[0].id).to.equal(exampleId(3));
|
|
432
|
+
expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
|
|
228
433
|
}
|
|
229
434
|
});
|
|
230
435
|
|
|
231
|
-
test('nodes are updated when removed', () => {
|
|
436
|
+
test('nodes are updated when removed', async () => {
|
|
232
437
|
const registry = Registry.make();
|
|
233
|
-
const builder =
|
|
234
|
-
const name =
|
|
438
|
+
const builder = GraphBuilder.make({ registry });
|
|
439
|
+
const name = Atom.make('removed');
|
|
235
440
|
|
|
236
|
-
|
|
237
|
-
|
|
441
|
+
GraphBuilder.addExtension(builder, [
|
|
442
|
+
GraphBuilder.createExtensionRaw({
|
|
238
443
|
id: 'root',
|
|
239
444
|
connector: (node) =>
|
|
240
|
-
|
|
241
|
-
pipe(
|
|
445
|
+
Atom.make((get) =>
|
|
446
|
+
Function.pipe(
|
|
242
447
|
get(node),
|
|
243
448
|
Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
|
|
244
449
|
Option.filter((name) => name !== 'removed'),
|
|
@@ -253,52 +458,184 @@ describe('GraphBuilder', () => {
|
|
|
253
458
|
|
|
254
459
|
let count = 0;
|
|
255
460
|
let exists = false;
|
|
256
|
-
const cancel = registry.subscribe(graph.node(EXAMPLE_ID), (node) => {
|
|
461
|
+
const cancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (node) => {
|
|
257
462
|
count++;
|
|
258
463
|
exists = Option.isSome(node);
|
|
259
464
|
});
|
|
260
465
|
onTestFinished(() => cancel());
|
|
261
466
|
|
|
262
|
-
|
|
467
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
468
|
+
await GraphBuilder.flush(builder);
|
|
263
469
|
expect(count).to.equal(0);
|
|
264
470
|
expect(exists).to.be.false;
|
|
265
471
|
|
|
266
472
|
registry.set(name, 'default');
|
|
473
|
+
await GraphBuilder.flush(builder);
|
|
267
474
|
expect(count).to.equal(1);
|
|
268
475
|
expect(exists).to.be.true;
|
|
269
476
|
|
|
270
477
|
registry.set(name, 'removed');
|
|
478
|
+
await GraphBuilder.flush(builder);
|
|
271
479
|
expect(count).to.equal(2);
|
|
272
480
|
expect(exists).to.be.false;
|
|
273
481
|
|
|
274
482
|
registry.set(name, 'added');
|
|
483
|
+
await GraphBuilder.flush(builder);
|
|
275
484
|
expect(count).to.equal(3);
|
|
276
485
|
expect(exists).to.be.true;
|
|
277
486
|
});
|
|
278
487
|
|
|
488
|
+
describe('inline nodes', () => {
|
|
489
|
+
const parent = (child?: Node.NodeArg<any>): Node.NodeArg<any> => ({
|
|
490
|
+
id: 'parent-node',
|
|
491
|
+
type: EXAMPLE_TYPE,
|
|
492
|
+
data: null,
|
|
493
|
+
nodes: child ? [child] : [],
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const inlineChild = (overrides?: Partial<Node.NodeArg<any>>): Node.NodeArg<any> => ({
|
|
497
|
+
id: 'inline-child',
|
|
498
|
+
type: EXAMPLE_TYPE,
|
|
499
|
+
data: null,
|
|
500
|
+
...overrides,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const makeGraph = () => {
|
|
504
|
+
const registry = Registry.make();
|
|
505
|
+
const builder = GraphBuilder.make({ registry });
|
|
506
|
+
const nodesAtom = Atom.make<Node.NodeArg<any>[]>([]);
|
|
507
|
+
GraphBuilder.addExtension(
|
|
508
|
+
builder,
|
|
509
|
+
GraphBuilder.createExtensionRaw({
|
|
510
|
+
id: 'inline-connector',
|
|
511
|
+
connector: () => Atom.make((get) => get(nodesAtom)),
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
const graph = builder.graph;
|
|
515
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
516
|
+
return { registry, builder, graph, nodesAtom };
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const getInlineChild = (graph: Graph.ExpandableGraph) =>
|
|
520
|
+
Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
|
|
521
|
+
|
|
522
|
+
test('are removed when connector re-runs with data change', async () => {
|
|
523
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
524
|
+
registry.set(nodesAtom, [parent(inlineChild())]);
|
|
525
|
+
await GraphBuilder.flush(builder);
|
|
526
|
+
|
|
527
|
+
expect(getInlineChild(graph)).to.not.be.null;
|
|
528
|
+
|
|
529
|
+
// Remove inline child while also changing parent data to trigger the update.
|
|
530
|
+
registry.set(nodesAtom, [{ ...parent(), data: 'v2' }]);
|
|
531
|
+
await GraphBuilder.flush(builder);
|
|
532
|
+
|
|
533
|
+
expect(getInlineChild(graph)).to.be.null;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('are removed when only inline children change', async () => {
|
|
537
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
538
|
+
registry.set(nodesAtom, [parent(inlineChild())]);
|
|
539
|
+
await GraphBuilder.flush(builder);
|
|
540
|
+
|
|
541
|
+
expect(getInlineChild(graph)).to.not.be.null;
|
|
542
|
+
|
|
543
|
+
// Remove inline child without touching parent — tests change detection covers inline children.
|
|
544
|
+
registry.set(nodesAtom, [parent()]);
|
|
545
|
+
await GraphBuilder.flush(builder);
|
|
546
|
+
|
|
547
|
+
expect(getInlineChild(graph)).to.be.null;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('are added when connector re-runs', async () => {
|
|
551
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
552
|
+
registry.set(nodesAtom, [parent()]);
|
|
553
|
+
await GraphBuilder.flush(builder);
|
|
554
|
+
|
|
555
|
+
expect(getInlineChild(graph)).to.be.null;
|
|
556
|
+
|
|
557
|
+
registry.set(nodesAtom, [parent(inlineChild())]);
|
|
558
|
+
await GraphBuilder.flush(builder);
|
|
559
|
+
|
|
560
|
+
expect(getInlineChild(graph)).to.not.be.null;
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('reactively update data', async ({ expect }) => {
|
|
564
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
565
|
+
registry.set(nodesAtom, [parent(inlineChild({ data: 'v1' }))]);
|
|
566
|
+
await GraphBuilder.flush(builder);
|
|
567
|
+
|
|
568
|
+
expect(getInlineChild(graph)?.data).to.equal('v1');
|
|
569
|
+
|
|
570
|
+
// Change only the inline child's data — parent is unchanged.
|
|
571
|
+
registry.set(nodesAtom, [parent(inlineChild({ data: 'v2' }))]);
|
|
572
|
+
await GraphBuilder.flush(builder);
|
|
573
|
+
|
|
574
|
+
expect(getInlineChild(graph)?.data).to.equal('v2');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('reactively update properties', async ({ expect }) => {
|
|
578
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
579
|
+
registry.set(nodesAtom, [parent(inlineChild({ properties: { label: 'before' } }))]);
|
|
580
|
+
await GraphBuilder.flush(builder);
|
|
581
|
+
|
|
582
|
+
expect(getInlineChild(graph)?.properties.label).to.equal('before');
|
|
583
|
+
|
|
584
|
+
registry.set(nodesAtom, [parent(inlineChild({ properties: { label: 'after' } }))]);
|
|
585
|
+
await GraphBuilder.flush(builder);
|
|
586
|
+
|
|
587
|
+
expect(getInlineChild(graph)?.properties.label).to.equal('after');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('deeply nested inline nodes reactively update', async ({ expect }) => {
|
|
591
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
592
|
+
|
|
593
|
+
const withGrandchild = (data: string) =>
|
|
594
|
+
parent({
|
|
595
|
+
id: 'child',
|
|
596
|
+
type: EXAMPLE_TYPE,
|
|
597
|
+
data: null,
|
|
598
|
+
nodes: [{ id: 'grandchild', type: EXAMPLE_TYPE, data }],
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
registry.set(nodesAtom, [withGrandchild('v1')]);
|
|
602
|
+
await GraphBuilder.flush(builder);
|
|
603
|
+
|
|
604
|
+
expect(Graph.getNode(graph, 'root/parent-node/child/grandchild').pipe(Option.getOrNull)?.data).to.equal('v1');
|
|
605
|
+
|
|
606
|
+
// Change only the grandchild's data — all ancestors unchanged.
|
|
607
|
+
registry.set(nodesAtom, [withGrandchild('v2')]);
|
|
608
|
+
await GraphBuilder.flush(builder);
|
|
609
|
+
|
|
610
|
+
expect(Graph.getNode(graph, 'root/parent-node/child/grandchild').pipe(Option.getOrNull)?.data).to.equal('v2');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
279
614
|
test('sort edges', async () => {
|
|
280
615
|
const registry = Registry.make();
|
|
281
|
-
const builder =
|
|
282
|
-
const nodes =
|
|
616
|
+
const builder = GraphBuilder.make({ registry });
|
|
617
|
+
const nodes = Atom.make([
|
|
283
618
|
{ id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
|
|
284
619
|
{ id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
|
|
285
620
|
{ id: exampleId(3), type: EXAMPLE_TYPE, data: 3 },
|
|
286
621
|
]);
|
|
287
|
-
|
|
288
|
-
|
|
622
|
+
GraphBuilder.addExtension(
|
|
623
|
+
builder,
|
|
624
|
+
GraphBuilder.createExtensionRaw({
|
|
289
625
|
id: 'connector',
|
|
290
|
-
connector: () =>
|
|
626
|
+
connector: () => Atom.make((get) => get(nodes)),
|
|
291
627
|
}),
|
|
292
628
|
);
|
|
293
629
|
const graph = builder.graph;
|
|
294
|
-
|
|
630
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
631
|
+
await GraphBuilder.flush(builder);
|
|
295
632
|
|
|
296
633
|
{
|
|
297
|
-
const nodes = registry.get(graph.connections(
|
|
634
|
+
const nodes = registry.get(graph.connections(Node.RootId, 'child'));
|
|
298
635
|
expect(nodes).has.length(3);
|
|
299
|
-
expect(nodes[0].id).to.equal(exampleId(1));
|
|
300
|
-
expect(nodes[1].id).to.equal(exampleId(2));
|
|
301
|
-
expect(nodes[2].id).to.equal(exampleId(3));
|
|
636
|
+
expect(nodes[0].id).to.equal(qualifyId('root', exampleId(1)));
|
|
637
|
+
expect(nodes[1].id).to.equal(qualifyId('root', exampleId(2)));
|
|
638
|
+
expect(nodes[2].id).to.equal(qualifyId('root', exampleId(3)));
|
|
302
639
|
}
|
|
303
640
|
|
|
304
641
|
registry.set(nodes, [
|
|
@@ -306,31 +643,29 @@ describe('GraphBuilder', () => {
|
|
|
306
643
|
{ id: exampleId(1), type: EXAMPLE_TYPE, data: 1 },
|
|
307
644
|
{ id: exampleId(2), type: EXAMPLE_TYPE, data: 2 },
|
|
308
645
|
]);
|
|
309
|
-
|
|
310
|
-
// TODO(wittjosiah): Why is this needed for the following conditions to pass?
|
|
311
|
-
await sleep(0);
|
|
646
|
+
await GraphBuilder.flush(builder);
|
|
312
647
|
|
|
313
648
|
{
|
|
314
|
-
const nodes = registry.get(graph.connections(
|
|
649
|
+
const nodes = registry.get(graph.connections(Node.RootId, 'child'));
|
|
315
650
|
expect(nodes).has.length(3);
|
|
316
|
-
expect(nodes[0].id).to.equal(exampleId(3));
|
|
317
|
-
expect(nodes[1].id).to.equal(exampleId(1));
|
|
318
|
-
expect(nodes[2].id).to.equal(exampleId(2));
|
|
651
|
+
expect(nodes[0].id).to.equal(qualifyId('root', exampleId(3)));
|
|
652
|
+
expect(nodes[1].id).to.equal(qualifyId('root', exampleId(1)));
|
|
653
|
+
expect(nodes[2].id).to.equal(qualifyId('root', exampleId(2)));
|
|
319
654
|
}
|
|
320
655
|
});
|
|
321
656
|
|
|
322
|
-
test('updates are constrained', () => {
|
|
657
|
+
test('updates are constrained', async () => {
|
|
323
658
|
const registry = Registry.make();
|
|
324
|
-
const builder =
|
|
325
|
-
const name =
|
|
326
|
-
const sub =
|
|
659
|
+
const builder = GraphBuilder.make({ registry });
|
|
660
|
+
const name = Atom.make('default');
|
|
661
|
+
const sub = Atom.make('default');
|
|
327
662
|
|
|
328
|
-
|
|
329
|
-
|
|
663
|
+
GraphBuilder.addExtension(builder, [
|
|
664
|
+
GraphBuilder.createExtensionRaw({
|
|
330
665
|
id: 'root',
|
|
331
666
|
connector: (node) =>
|
|
332
|
-
|
|
333
|
-
pipe(
|
|
667
|
+
Atom.make((get) =>
|
|
668
|
+
Function.pipe(
|
|
334
669
|
get(node),
|
|
335
670
|
Option.flatMap((node) => (node.id === 'root' ? Option.some(get(name)) : Option.none())),
|
|
336
671
|
Option.filter((name) => name !== 'removed'),
|
|
@@ -339,25 +674,29 @@ describe('GraphBuilder', () => {
|
|
|
339
674
|
),
|
|
340
675
|
),
|
|
341
676
|
}),
|
|
342
|
-
|
|
677
|
+
GraphBuilder.createExtensionRaw({
|
|
343
678
|
id: 'connector1',
|
|
344
679
|
connector: (node) =>
|
|
345
|
-
|
|
346
|
-
pipe(
|
|
680
|
+
Atom.make((get) =>
|
|
681
|
+
Function.pipe(
|
|
347
682
|
get(node),
|
|
348
|
-
Option.flatMap((node) =>
|
|
683
|
+
Option.flatMap((node) =>
|
|
684
|
+
node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(get(sub)) : Option.none(),
|
|
685
|
+
),
|
|
349
686
|
Option.map((sub) => [{ id: exampleId(2), type: EXAMPLE_TYPE, data: sub }]),
|
|
350
687
|
Option.getOrElse(() => []),
|
|
351
688
|
),
|
|
352
689
|
),
|
|
353
690
|
}),
|
|
354
|
-
|
|
691
|
+
GraphBuilder.createExtensionRaw({
|
|
355
692
|
id: 'connector2',
|
|
356
693
|
connector: (node) =>
|
|
357
|
-
|
|
358
|
-
pipe(
|
|
694
|
+
Atom.make((get) =>
|
|
695
|
+
Function.pipe(
|
|
359
696
|
get(node),
|
|
360
|
-
Option.flatMap((node) =>
|
|
697
|
+
Option.flatMap((node) =>
|
|
698
|
+
node.id === qualifyId('root', EXAMPLE_ID) ? Option.some(node.data) : Option.none(),
|
|
699
|
+
),
|
|
361
700
|
Option.map((data) => [{ id: exampleId(3), type: EXAMPLE_TYPE, data }]),
|
|
362
701
|
Option.getOrElse(() => []),
|
|
363
702
|
),
|
|
@@ -368,64 +707,71 @@ describe('GraphBuilder', () => {
|
|
|
368
707
|
const graph = builder.graph;
|
|
369
708
|
|
|
370
709
|
let parentCount = 0;
|
|
371
|
-
const parentCancel = registry.subscribe(graph.node(EXAMPLE_ID), (_) => {
|
|
710
|
+
const parentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID)), (_) => {
|
|
372
711
|
parentCount++;
|
|
373
712
|
});
|
|
374
713
|
onTestFinished(() => parentCancel());
|
|
375
714
|
|
|
376
715
|
let independentCount = 0;
|
|
377
|
-
const independentCancel = registry.subscribe(graph.node(exampleId(2)), (_) => {
|
|
716
|
+
const independentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(2))), (_) => {
|
|
378
717
|
independentCount++;
|
|
379
718
|
});
|
|
380
719
|
onTestFinished(() => independentCancel());
|
|
381
720
|
|
|
382
721
|
let dependentCount = 0;
|
|
383
|
-
const dependentCancel = registry.subscribe(graph.node(exampleId(3)), (_) => {
|
|
722
|
+
const dependentCancel = registry.subscribe(graph.node(qualifyId('root', EXAMPLE_ID, exampleId(3))), (_) => {
|
|
384
723
|
dependentCount++;
|
|
385
724
|
});
|
|
386
725
|
onTestFinished(() => dependentCancel());
|
|
387
726
|
|
|
388
727
|
// Counts should not increment until the node is expanded.
|
|
389
|
-
|
|
728
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
729
|
+
await GraphBuilder.flush(builder);
|
|
390
730
|
expect(parentCount).to.equal(1);
|
|
391
731
|
expect(independentCount).to.equal(0);
|
|
392
732
|
expect(dependentCount).to.equal(0);
|
|
393
733
|
|
|
394
734
|
// Counts should increment when the node is expanded.
|
|
395
|
-
|
|
735
|
+
Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
|
|
736
|
+
await GraphBuilder.flush(builder);
|
|
396
737
|
expect(parentCount).to.equal(1);
|
|
397
738
|
expect(independentCount).to.equal(1);
|
|
398
739
|
expect(dependentCount).to.equal(1);
|
|
399
740
|
|
|
400
741
|
// Only dependent count should increment when the parent changes.
|
|
401
742
|
registry.set(name, 'updated');
|
|
743
|
+
await GraphBuilder.flush(builder);
|
|
402
744
|
expect(parentCount).to.equal(2);
|
|
403
745
|
expect(independentCount).to.equal(1);
|
|
404
746
|
expect(dependentCount).to.equal(2);
|
|
405
747
|
|
|
406
748
|
// Only independent count should increment when its state changes.
|
|
407
749
|
registry.set(sub, 'updated');
|
|
750
|
+
await GraphBuilder.flush(builder);
|
|
408
751
|
expect(parentCount).to.equal(2);
|
|
409
752
|
expect(independentCount).to.equal(2);
|
|
410
753
|
expect(dependentCount).to.equal(2);
|
|
411
754
|
|
|
412
755
|
// Independent count should update if its state changes even if the parent is removed.
|
|
413
|
-
|
|
756
|
+
Atom.batch(() => {
|
|
414
757
|
registry.set(name, 'removed');
|
|
415
758
|
registry.set(sub, 'batch');
|
|
416
759
|
});
|
|
760
|
+
await GraphBuilder.flush(builder);
|
|
417
761
|
expect(parentCount).to.equal(2);
|
|
418
762
|
expect(independentCount).to.equal(3);
|
|
419
763
|
expect(dependentCount).to.equal(2);
|
|
420
764
|
|
|
421
765
|
// Dependent count should increment when the node is added back.
|
|
422
766
|
registry.set(name, 'added');
|
|
767
|
+
await GraphBuilder.flush(builder);
|
|
423
768
|
expect(parentCount).to.equal(3);
|
|
424
769
|
expect(independentCount).to.equal(3);
|
|
425
770
|
expect(dependentCount).to.equal(3);
|
|
426
771
|
|
|
427
772
|
// Counts should not increment when the node is expanded again.
|
|
428
|
-
|
|
773
|
+
Graph.expand(graph, qualifyId('root', EXAMPLE_ID), 'child');
|
|
774
|
+
await GraphBuilder.flush(builder);
|
|
429
775
|
expect(parentCount).to.equal(3);
|
|
430
776
|
expect(independentCount).to.equal(3);
|
|
431
777
|
expect(dependentCount).to.equal(3);
|
|
@@ -433,13 +779,14 @@ describe('GraphBuilder', () => {
|
|
|
433
779
|
|
|
434
780
|
test('eager graph expansion', async () => {
|
|
435
781
|
const registry = Registry.make();
|
|
436
|
-
const builder =
|
|
437
|
-
|
|
438
|
-
|
|
782
|
+
const builder = GraphBuilder.make({ registry });
|
|
783
|
+
GraphBuilder.addExtension(
|
|
784
|
+
builder,
|
|
785
|
+
GraphBuilder.createExtensionRaw({
|
|
439
786
|
id: 'connector',
|
|
440
787
|
connector: (node) => {
|
|
441
|
-
return
|
|
442
|
-
pipe(
|
|
788
|
+
return Atom.make((get) =>
|
|
789
|
+
Function.pipe(
|
|
443
790
|
get(node),
|
|
444
791
|
Option.map((node) => (node.data ? node.data + 1 : 1)),
|
|
445
792
|
Option.filter((data) => data <= 5),
|
|
@@ -454,14 +801,14 @@ describe('GraphBuilder', () => {
|
|
|
454
801
|
let count = 0;
|
|
455
802
|
const trigger = new Trigger();
|
|
456
803
|
builder.graph.onNodeChanged.on(({ id }) => {
|
|
457
|
-
builder.graph
|
|
804
|
+
Graph.expand(builder.graph, id, 'child');
|
|
458
805
|
count++;
|
|
459
806
|
if (count === 5) {
|
|
460
807
|
trigger.wake();
|
|
461
808
|
}
|
|
462
809
|
});
|
|
463
810
|
|
|
464
|
-
builder.graph.
|
|
811
|
+
Graph.expand(builder.graph, Node.RootId, 'child');
|
|
465
812
|
await trigger.wait();
|
|
466
813
|
expect(count).to.equal(5);
|
|
467
814
|
});
|
|
@@ -469,13 +816,14 @@ describe('GraphBuilder', () => {
|
|
|
469
816
|
|
|
470
817
|
describe('explore', () => {
|
|
471
818
|
test('works', async () => {
|
|
472
|
-
const builder =
|
|
473
|
-
|
|
474
|
-
|
|
819
|
+
const builder = GraphBuilder.make();
|
|
820
|
+
GraphBuilder.addExtension(
|
|
821
|
+
builder,
|
|
822
|
+
GraphBuilder.createExtensionRaw({
|
|
475
823
|
id: 'connector',
|
|
476
824
|
connector: (node) =>
|
|
477
|
-
|
|
478
|
-
pipe(
|
|
825
|
+
Atom.make((get) =>
|
|
826
|
+
Function.pipe(
|
|
479
827
|
get(node),
|
|
480
828
|
Option.map((node) => (node.data ? node.data + 1 : 1)),
|
|
481
829
|
Option.filter((data) => data <= 5),
|
|
@@ -487,7 +835,8 @@ describe('GraphBuilder', () => {
|
|
|
487
835
|
);
|
|
488
836
|
|
|
489
837
|
let count = 0;
|
|
490
|
-
await
|
|
838
|
+
await GraphBuilder.explore(builder, {
|
|
839
|
+
relation: 'child',
|
|
491
840
|
visitor: () => {
|
|
492
841
|
count++;
|
|
493
842
|
},
|
|
@@ -496,4 +845,665 @@ describe('GraphBuilder', () => {
|
|
|
496
845
|
expect(count).to.equal(6);
|
|
497
846
|
});
|
|
498
847
|
});
|
|
848
|
+
|
|
849
|
+
describe('helpers', () => {
|
|
850
|
+
describe('createConnector', () => {
|
|
851
|
+
test('creates connector with type inference', async () => {
|
|
852
|
+
const registry = Registry.make();
|
|
853
|
+
const builder = GraphBuilder.make({ registry });
|
|
854
|
+
const graph = builder.graph;
|
|
855
|
+
|
|
856
|
+
const matcher = (node: Node.Node) => NodeMatcher.whenId('root')(node);
|
|
857
|
+
const factory = (node: Node.Node) => [{ id: 'child', type: EXAMPLE_TYPE, data: node.id }];
|
|
858
|
+
|
|
859
|
+
const connector = GraphBuilder.createConnector(matcher, factory);
|
|
860
|
+
|
|
861
|
+
GraphBuilder.addExtension(
|
|
862
|
+
builder,
|
|
863
|
+
GraphBuilder.createExtensionRaw({
|
|
864
|
+
id: 'test-connector',
|
|
865
|
+
connector,
|
|
866
|
+
}),
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
870
|
+
await GraphBuilder.flush(builder);
|
|
871
|
+
|
|
872
|
+
const connections = registry.get(graph.connections(Node.RootId, 'child'));
|
|
873
|
+
expect(connections).has.length(1);
|
|
874
|
+
expect(connections[0].id).to.equal('root/child');
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe('createExtension', () => {
|
|
879
|
+
test('works with Effect connector', async () => {
|
|
880
|
+
const registry = Registry.make();
|
|
881
|
+
const builder = GraphBuilder.make({ registry });
|
|
882
|
+
const graph = builder.graph;
|
|
883
|
+
|
|
884
|
+
const extensions = Effect.runSync(
|
|
885
|
+
GraphBuilder.createExtension({
|
|
886
|
+
id: 'test-extension',
|
|
887
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
888
|
+
connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
|
|
889
|
+
}),
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
893
|
+
|
|
894
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
895
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
896
|
+
Graph.expand(graph, 'parent', 'child');
|
|
897
|
+
await GraphBuilder.flush(builder);
|
|
898
|
+
|
|
899
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
900
|
+
expect(connections).has.length(1);
|
|
901
|
+
expect(connections[0].id).to.equal('parent/child');
|
|
902
|
+
expect(connections[0].data).to.equal('test');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test('works with Effect actions', async () => {
|
|
906
|
+
const registry = Registry.make();
|
|
907
|
+
const builder = GraphBuilder.make({ registry });
|
|
908
|
+
const graph = builder.graph;
|
|
909
|
+
|
|
910
|
+
const extensions = Effect.runSync(
|
|
911
|
+
GraphBuilder.createExtension({
|
|
912
|
+
id: 'test-extension',
|
|
913
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
914
|
+
actions: (node, get) =>
|
|
915
|
+
Effect.succeed([
|
|
916
|
+
{
|
|
917
|
+
id: 'test-action',
|
|
918
|
+
data: () => Effect.void,
|
|
919
|
+
properties: { label: 'Test' },
|
|
920
|
+
},
|
|
921
|
+
]),
|
|
922
|
+
}),
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
926
|
+
|
|
927
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
928
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
929
|
+
Graph.expand(graph, 'parent', 'child');
|
|
930
|
+
await GraphBuilder.flush(builder);
|
|
931
|
+
|
|
932
|
+
const edges = registry.get(graph.edges('parent'));
|
|
933
|
+
expect(edges[Graph.relationKey('action')] ?? []).to.have.length(1);
|
|
934
|
+
expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/test-action');
|
|
935
|
+
expect(edges[Graph.relationKey('child')] ?? []).to.have.length(0);
|
|
936
|
+
const actions = registry.get(graph.actions('parent'));
|
|
937
|
+
expect(actions).has.length(1);
|
|
938
|
+
expect(actions[0].id).to.equal('parent/test-action');
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test('actions expand automatically with child relation', async ({ expect }) => {
|
|
942
|
+
const registry = Registry.make();
|
|
943
|
+
const builder = GraphBuilder.make({ registry });
|
|
944
|
+
const graph = builder.graph;
|
|
945
|
+
|
|
946
|
+
const extensions = Effect.runSync(
|
|
947
|
+
GraphBuilder.createExtension({
|
|
948
|
+
id: 'test-extension',
|
|
949
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
950
|
+
connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: 'c' }]),
|
|
951
|
+
actions: (node, get) =>
|
|
952
|
+
Effect.succeed([{ id: 'act1', data: () => Effect.void, properties: { label: 'A' } }]),
|
|
953
|
+
}),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
957
|
+
|
|
958
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
959
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
960
|
+
Graph.expand(graph, 'parent', 'child');
|
|
961
|
+
await GraphBuilder.flush(builder);
|
|
962
|
+
|
|
963
|
+
const edges = registry.get(graph.edges('parent'));
|
|
964
|
+
expect(edges[Graph.relationKey('child')] ?? []).to.include('parent/child');
|
|
965
|
+
expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/act1');
|
|
966
|
+
const actions = registry.get(graph.actions('parent'));
|
|
967
|
+
expect(actions).has.length(1);
|
|
968
|
+
expect(actions[0].id).to.equal('parent/act1');
|
|
969
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
970
|
+
expect(connections).has.length(1);
|
|
971
|
+
expect(connections[0].id).to.equal('parent/child');
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test('actions appear when extension registered after expand', async ({ expect }) => {
|
|
975
|
+
const registry = Registry.make();
|
|
976
|
+
const builder = GraphBuilder.make({ registry });
|
|
977
|
+
const graph = builder.graph;
|
|
978
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
979
|
+
|
|
980
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
981
|
+
Graph.expand(graph, 'parent', 'child');
|
|
982
|
+
await GraphBuilder.flush(builder);
|
|
983
|
+
|
|
984
|
+
expect(registry.get(graph.actions('parent'))).to.have.length(0);
|
|
985
|
+
|
|
986
|
+
const extensions = Effect.runSync(
|
|
987
|
+
GraphBuilder.createExtension({
|
|
988
|
+
id: 'late-extension',
|
|
989
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
990
|
+
actions: (node, get) =>
|
|
991
|
+
Effect.succeed([{ id: 'late-act', data: () => Effect.void, properties: { label: 'Late' } }]),
|
|
992
|
+
}),
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
996
|
+
await GraphBuilder.flush(builder);
|
|
997
|
+
|
|
998
|
+
const edges = registry.get(graph.edges('parent'));
|
|
999
|
+
expect(edges[Graph.relationKey('action')] ?? []).to.include('parent/late-act');
|
|
1000
|
+
const actions = registry.get(graph.actions('parent'));
|
|
1001
|
+
expect(actions).has.length(1);
|
|
1002
|
+
expect(actions[0].id).to.equal('parent/late-act');
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test('_actionContext captures and provides services to action execution', async () => {
|
|
1006
|
+
const registry = Registry.make();
|
|
1007
|
+
const builder = GraphBuilder.make({ registry });
|
|
1008
|
+
const graph = builder.graph;
|
|
1009
|
+
|
|
1010
|
+
// Define a test service using Context.GenericTag pattern.
|
|
1011
|
+
interface TestServiceInterface {
|
|
1012
|
+
getValue(): number;
|
|
1013
|
+
}
|
|
1014
|
+
const TestService = Context.GenericTag<TestServiceInterface>('TestService');
|
|
1015
|
+
|
|
1016
|
+
// Track whether the action was executed with the correct context.
|
|
1017
|
+
let executionResult: number | null = null;
|
|
1018
|
+
|
|
1019
|
+
// Create extension with service requirement.
|
|
1020
|
+
// Note: The actions callback must USE the service for R to be inferred correctly.
|
|
1021
|
+
const extensions = Effect.runSync(
|
|
1022
|
+
GraphBuilder.createExtension({
|
|
1023
|
+
id: 'test-extension',
|
|
1024
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1025
|
+
actions: (node, get) =>
|
|
1026
|
+
// Use TestService in the callback to include it in R.
|
|
1027
|
+
Effect.gen(function* () {
|
|
1028
|
+
const service = yield* TestService;
|
|
1029
|
+
return [
|
|
1030
|
+
{
|
|
1031
|
+
id: 'test-action',
|
|
1032
|
+
data: () =>
|
|
1033
|
+
Effect.gen(function* () {
|
|
1034
|
+
// Action can use the same service from captured context.
|
|
1035
|
+
const svc = yield* TestService;
|
|
1036
|
+
executionResult = svc.getValue();
|
|
1037
|
+
}).pipe(Effect.asVoid),
|
|
1038
|
+
properties: { label: `Test ${service.getValue()}` },
|
|
1039
|
+
},
|
|
1040
|
+
];
|
|
1041
|
+
}),
|
|
1042
|
+
}).pipe(Effect.provideService(TestService, { getValue: () => 42 })),
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1046
|
+
|
|
1047
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1048
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
1049
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1050
|
+
await GraphBuilder.flush(builder);
|
|
1051
|
+
|
|
1052
|
+
const actions = registry.get(graph.actions('parent'));
|
|
1053
|
+
expect(actions).has.length(1);
|
|
1054
|
+
|
|
1055
|
+
// Verify _actionContext is captured.
|
|
1056
|
+
const action = actions[0] as Node.Action;
|
|
1057
|
+
expect(action._actionContext).to.not.be.undefined;
|
|
1058
|
+
|
|
1059
|
+
// Execute the action with the captured context.
|
|
1060
|
+
const actionEffect = action.data();
|
|
1061
|
+
const effectWithContext = action._actionContext
|
|
1062
|
+
? actionEffect.pipe(Effect.provide(action._actionContext))
|
|
1063
|
+
: actionEffect;
|
|
1064
|
+
|
|
1065
|
+
Effect.runSync(effectWithContext);
|
|
1066
|
+
|
|
1067
|
+
// Verify the service was accessible during execution.
|
|
1068
|
+
expect(executionResult).to.equal(42);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('works with resolver', async () => {
|
|
1072
|
+
const registry = Registry.make();
|
|
1073
|
+
const builder = GraphBuilder.make({ registry });
|
|
1074
|
+
const graph = builder.graph;
|
|
1075
|
+
|
|
1076
|
+
const extensions = Effect.runSync(
|
|
1077
|
+
GraphBuilder.createExtension({
|
|
1078
|
+
id: 'test-extension',
|
|
1079
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1080
|
+
resolver: (id, get) => Effect.succeed({ id, type: EXAMPLE_TYPE, properties: {}, data: 'resolved' }),
|
|
1081
|
+
}),
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1085
|
+
await Graph.initialize(graph, EXAMPLE_ID);
|
|
1086
|
+
|
|
1087
|
+
const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
|
|
1088
|
+
expect(node).to.not.be.null;
|
|
1089
|
+
expect(node?.id).to.equal(EXAMPLE_ID);
|
|
1090
|
+
expect(node?.data).to.equal('resolved');
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test('works with connector and actions together', async () => {
|
|
1094
|
+
const registry = Registry.make();
|
|
1095
|
+
const builder = GraphBuilder.make({ registry });
|
|
1096
|
+
const graph = builder.graph;
|
|
1097
|
+
|
|
1098
|
+
const extensions = Effect.runSync(
|
|
1099
|
+
GraphBuilder.createExtension({
|
|
1100
|
+
id: 'test-extension',
|
|
1101
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1102
|
+
connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: node.data }]),
|
|
1103
|
+
actions: (node, get) =>
|
|
1104
|
+
Effect.succeed([
|
|
1105
|
+
{
|
|
1106
|
+
id: 'test-action',
|
|
1107
|
+
data: () => Effect.void,
|
|
1108
|
+
properties: { label: 'Test' },
|
|
1109
|
+
},
|
|
1110
|
+
]),
|
|
1111
|
+
}),
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1115
|
+
|
|
1116
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1117
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
1118
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1119
|
+
await GraphBuilder.flush(builder);
|
|
1120
|
+
|
|
1121
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
1122
|
+
// Should have both the child node and the action node.
|
|
1123
|
+
expect(connections.length).to.be.greaterThanOrEqual(1);
|
|
1124
|
+
const childNode = connections.find((n) => n.id === 'parent/child');
|
|
1125
|
+
expect(childNode).to.not.be.undefined;
|
|
1126
|
+
expect(childNode?.data).to.equal('test');
|
|
1127
|
+
|
|
1128
|
+
const actions = registry.get(graph.actions('parent'));
|
|
1129
|
+
expect(actions).has.length(1);
|
|
1130
|
+
expect(actions[0].id).to.equal('parent/test-action');
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test('works with reactive connector using get context', async () => {
|
|
1134
|
+
const registry = Registry.make();
|
|
1135
|
+
const builder = GraphBuilder.make({ registry });
|
|
1136
|
+
const graph = builder.graph;
|
|
1137
|
+
|
|
1138
|
+
const state = Atom.make('initial');
|
|
1139
|
+
|
|
1140
|
+
const extensions = Effect.runSync(
|
|
1141
|
+
GraphBuilder.createExtension({
|
|
1142
|
+
id: 'test-extension',
|
|
1143
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1144
|
+
connector: (node, get) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: get(state) }]),
|
|
1145
|
+
}),
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1149
|
+
|
|
1150
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1151
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
1152
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1153
|
+
await GraphBuilder.flush(builder);
|
|
1154
|
+
|
|
1155
|
+
{
|
|
1156
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
1157
|
+
expect(connections).has.length(1);
|
|
1158
|
+
expect(connections[0].data).to.equal('initial');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
registry.set(state, 'updated');
|
|
1162
|
+
await GraphBuilder.flush(builder);
|
|
1163
|
+
|
|
1164
|
+
{
|
|
1165
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
1166
|
+
expect(connections).has.length(1);
|
|
1167
|
+
expect(connections[0].data).to.equal('updated');
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
describe('extension error handling', () => {
|
|
1173
|
+
test('connector failure is caught and logged, returns empty array', async () => {
|
|
1174
|
+
const registry = Registry.make();
|
|
1175
|
+
const builder = GraphBuilder.make({ registry });
|
|
1176
|
+
const graph = builder.graph;
|
|
1177
|
+
|
|
1178
|
+
const extensions = Effect.runSync(
|
|
1179
|
+
GraphBuilder.createExtension({
|
|
1180
|
+
id: 'failing-extension',
|
|
1181
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1182
|
+
connector: (node, get) => Effect.fail(new Error('Connector failed intentionally')),
|
|
1183
|
+
}),
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1187
|
+
|
|
1188
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1189
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
1190
|
+
|
|
1191
|
+
// Should not throw, error is caught internally.
|
|
1192
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1193
|
+
await GraphBuilder.flush(builder);
|
|
1194
|
+
|
|
1195
|
+
// Should return empty connections since the connector failed.
|
|
1196
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
1197
|
+
expect(connections).has.length(0);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
test('actions failure is caught and logged, returns empty array', async () => {
|
|
1201
|
+
const registry = Registry.make();
|
|
1202
|
+
const builder = GraphBuilder.make({ registry });
|
|
1203
|
+
const graph = builder.graph;
|
|
1204
|
+
|
|
1205
|
+
const extensions = Effect.runSync(
|
|
1206
|
+
GraphBuilder.createExtension({
|
|
1207
|
+
id: 'failing-actions-extension',
|
|
1208
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1209
|
+
actions: (node, get) => Effect.fail(new Error('Actions failed intentionally')),
|
|
1210
|
+
}),
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1214
|
+
|
|
1215
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1216
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
1217
|
+
|
|
1218
|
+
// Should not throw, error is caught internally.
|
|
1219
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1220
|
+
await GraphBuilder.flush(builder);
|
|
1221
|
+
|
|
1222
|
+
// Should return empty actions since the actions callback failed.
|
|
1223
|
+
const actions = registry.get(graph.actions('parent'));
|
|
1224
|
+
expect(actions).has.length(0);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test('resolver failure is caught and logged, returns null', async () => {
|
|
1228
|
+
const registry = Registry.make();
|
|
1229
|
+
const builder = GraphBuilder.make({ registry });
|
|
1230
|
+
const graph = builder.graph;
|
|
1231
|
+
|
|
1232
|
+
const extensions = Effect.runSync(
|
|
1233
|
+
GraphBuilder.createExtension({
|
|
1234
|
+
id: 'failing-resolver-extension',
|
|
1235
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1236
|
+
resolver: (id, get) => Effect.fail(new Error('Resolver failed intentionally')),
|
|
1237
|
+
}),
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1241
|
+
|
|
1242
|
+
// Should not throw, error is caught internally.
|
|
1243
|
+
await Graph.initialize(graph, EXAMPLE_ID);
|
|
1244
|
+
|
|
1245
|
+
// Should return null/none since the resolver failed.
|
|
1246
|
+
const node = Graph.getNode(graph, EXAMPLE_ID).pipe(Option.getOrNull);
|
|
1247
|
+
expect(node).to.be.null;
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
test('failing extension does not affect other extensions', async () => {
|
|
1251
|
+
const registry = Registry.make();
|
|
1252
|
+
const builder = GraphBuilder.make({ registry });
|
|
1253
|
+
const graph = builder.graph;
|
|
1254
|
+
|
|
1255
|
+
// Add a failing extension.
|
|
1256
|
+
const failingExtensions = Effect.runSync(
|
|
1257
|
+
GraphBuilder.createExtension({
|
|
1258
|
+
id: 'failing-extension',
|
|
1259
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1260
|
+
connector: (node, get) => Effect.fail(new Error('This one fails')),
|
|
1261
|
+
}),
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
// Add a working extension.
|
|
1265
|
+
const workingExtensions = Effect.runSync(
|
|
1266
|
+
GraphBuilder.createExtension({
|
|
1267
|
+
id: 'working-extension',
|
|
1268
|
+
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1269
|
+
connector: (node, get) =>
|
|
1270
|
+
Effect.succeed([{ id: 'child-from-working', type: EXAMPLE_TYPE, data: 'success' }]),
|
|
1271
|
+
}),
|
|
1272
|
+
);
|
|
1273
|
+
|
|
1274
|
+
GraphBuilder.addExtension(builder, failingExtensions);
|
|
1275
|
+
GraphBuilder.addExtension(builder, workingExtensions);
|
|
1276
|
+
|
|
1277
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1278
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: 'test' });
|
|
1279
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1280
|
+
await GraphBuilder.flush(builder);
|
|
1281
|
+
|
|
1282
|
+
// The working extension should still produce its node.
|
|
1283
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
1284
|
+
expect(connections).has.length(1);
|
|
1285
|
+
expect(connections[0].id).to.equal('parent/child-from-working');
|
|
1286
|
+
expect(connections[0].data).to.equal('success');
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
describe('createTypeExtension', () => {
|
|
1291
|
+
test('creates extension matching by schema type with inferred object type', async () => {
|
|
1292
|
+
const registry = Registry.make();
|
|
1293
|
+
const builder = GraphBuilder.make({ registry });
|
|
1294
|
+
const graph = builder.graph;
|
|
1295
|
+
|
|
1296
|
+
const extensions = Effect.runSync(
|
|
1297
|
+
GraphBuilder.createTypeExtension({
|
|
1298
|
+
id: 'type-extension',
|
|
1299
|
+
type: TestSchema.Person,
|
|
1300
|
+
connector: (object) => Effect.succeed([{ id: 'child', type: EXAMPLE_TYPE, data: object }]),
|
|
1301
|
+
}),
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
1305
|
+
|
|
1306
|
+
const writableGraph = graph as Graph.WritableGraph;
|
|
1307
|
+
const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
|
|
1308
|
+
Graph.addNode(writableGraph, { id: 'parent', type: EXAMPLE_TYPE, properties: {}, data: testObject });
|
|
1309
|
+
Graph.expand(graph, 'parent', 'child');
|
|
1310
|
+
await GraphBuilder.flush(builder);
|
|
1311
|
+
|
|
1312
|
+
const connections = registry.get(graph.connections('parent', 'child'));
|
|
1313
|
+
expect(connections).has.length(1);
|
|
1314
|
+
expect(connections[0].id).to.equal('parent/child');
|
|
1315
|
+
expect(connections[0].data).to.equal(testObject);
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
});
|
|
1319
|
+
describe('path-based ID qualification', () => {
|
|
1320
|
+
test('rejects segment IDs containing slash', async () => {
|
|
1321
|
+
const registry = Registry.make();
|
|
1322
|
+
const builder = GraphBuilder.make({ registry });
|
|
1323
|
+
GraphBuilder.addExtension(
|
|
1324
|
+
builder,
|
|
1325
|
+
GraphBuilder.createExtensionRaw({
|
|
1326
|
+
id: 'bad-connector',
|
|
1327
|
+
connector: () => Atom.make([{ id: 'foo/bar', type: EXAMPLE_TYPE, data: null }]),
|
|
1328
|
+
}),
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
expect(() => Graph.expand(builder.graph, Node.RootId, 'child')).toThrow(/must not contain/);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
test('multi-level path qualification', async () => {
|
|
1335
|
+
const registry = Registry.make();
|
|
1336
|
+
const builder = GraphBuilder.make({ registry });
|
|
1337
|
+
GraphBuilder.addExtension(builder, [
|
|
1338
|
+
GraphBuilder.createExtensionRaw({
|
|
1339
|
+
id: 'level1',
|
|
1340
|
+
connector: (node) =>
|
|
1341
|
+
Atom.make((get) =>
|
|
1342
|
+
Function.pipe(
|
|
1343
|
+
get(node),
|
|
1344
|
+
Option.filter((n) => n.id === 'root'),
|
|
1345
|
+
Option.map(() => [{ id: 'A', type: EXAMPLE_TYPE, data: 'a' }]),
|
|
1346
|
+
Option.getOrElse(() => []),
|
|
1347
|
+
),
|
|
1348
|
+
),
|
|
1349
|
+
}),
|
|
1350
|
+
GraphBuilder.createExtensionRaw({
|
|
1351
|
+
id: 'level2',
|
|
1352
|
+
connector: (node) =>
|
|
1353
|
+
Atom.make((get) =>
|
|
1354
|
+
Function.pipe(
|
|
1355
|
+
get(node),
|
|
1356
|
+
Option.filter((n) => n.id === 'root/A'),
|
|
1357
|
+
Option.map(() => [{ id: 'B', type: EXAMPLE_TYPE, data: 'b' }]),
|
|
1358
|
+
Option.getOrElse(() => []),
|
|
1359
|
+
),
|
|
1360
|
+
),
|
|
1361
|
+
}),
|
|
1362
|
+
]);
|
|
1363
|
+
|
|
1364
|
+
const graph = builder.graph;
|
|
1365
|
+
|
|
1366
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
1367
|
+
await GraphBuilder.flush(builder);
|
|
1368
|
+
|
|
1369
|
+
const level1 = registry.get(graph.connections(Node.RootId, 'child'));
|
|
1370
|
+
expect(level1).has.length(1);
|
|
1371
|
+
expect(level1[0].id).to.equal('root/A');
|
|
1372
|
+
|
|
1373
|
+
Graph.expand(graph, 'root/A', 'child');
|
|
1374
|
+
await GraphBuilder.flush(builder);
|
|
1375
|
+
|
|
1376
|
+
const level2 = registry.get(graph.connections('root/A', 'child'));
|
|
1377
|
+
expect(level2).has.length(1);
|
|
1378
|
+
expect(level2[0].id).to.equal('root/A/B');
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
test('inline nodes are recursively qualified', async () => {
|
|
1382
|
+
const registry = Registry.make();
|
|
1383
|
+
const builder = GraphBuilder.make({ registry });
|
|
1384
|
+
GraphBuilder.addExtension(
|
|
1385
|
+
builder,
|
|
1386
|
+
GraphBuilder.createExtensionRaw({
|
|
1387
|
+
id: 'inline-connector',
|
|
1388
|
+
connector: () =>
|
|
1389
|
+
Atom.make([
|
|
1390
|
+
{
|
|
1391
|
+
id: 'parent-node',
|
|
1392
|
+
type: EXAMPLE_TYPE,
|
|
1393
|
+
data: null,
|
|
1394
|
+
nodes: [
|
|
1395
|
+
{
|
|
1396
|
+
id: 'inline-child',
|
|
1397
|
+
type: EXAMPLE_TYPE,
|
|
1398
|
+
data: null,
|
|
1399
|
+
nodes: [{ id: 'deep-child', type: EXAMPLE_TYPE, data: null }],
|
|
1400
|
+
},
|
|
1401
|
+
],
|
|
1402
|
+
},
|
|
1403
|
+
]),
|
|
1404
|
+
}),
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
const graph = builder.graph;
|
|
1408
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
1409
|
+
await GraphBuilder.flush(builder);
|
|
1410
|
+
|
|
1411
|
+
const connections = registry.get(graph.connections(Node.RootId, 'child'));
|
|
1412
|
+
expect(connections).has.length(1);
|
|
1413
|
+
expect(connections[0].id).to.equal('root/parent-node');
|
|
1414
|
+
|
|
1415
|
+
const inlineNode = Graph.getNode(graph, 'root/parent-node/inline-child').pipe(Option.getOrNull);
|
|
1416
|
+
expect(inlineNode).to.not.be.null;
|
|
1417
|
+
expect(inlineNode?.id).to.equal('root/parent-node/inline-child');
|
|
1418
|
+
|
|
1419
|
+
const deepNode = Graph.getNode(graph, 'root/parent-node/inline-child/deep-child').pipe(Option.getOrNull);
|
|
1420
|
+
expect(deepNode).to.not.be.null;
|
|
1421
|
+
expect(deepNode?.id).to.equal('root/parent-node/inline-child/deep-child');
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
test('constant connector produces distinct nodes under different parents', async () => {
|
|
1425
|
+
const registry = Registry.make();
|
|
1426
|
+
const builder = GraphBuilder.make({ registry });
|
|
1427
|
+
|
|
1428
|
+
GraphBuilder.addExtension(builder, [
|
|
1429
|
+
GraphBuilder.createExtensionRaw({
|
|
1430
|
+
id: 'parents',
|
|
1431
|
+
connector: (node) =>
|
|
1432
|
+
Atom.make((get) =>
|
|
1433
|
+
Function.pipe(
|
|
1434
|
+
get(node),
|
|
1435
|
+
Option.filter((n) => n.id === 'root'),
|
|
1436
|
+
Option.map(() => [
|
|
1437
|
+
{ id: 'A', type: EXAMPLE_TYPE, data: 'a' },
|
|
1438
|
+
{ id: 'B', type: EXAMPLE_TYPE, data: 'b' },
|
|
1439
|
+
]),
|
|
1440
|
+
Option.getOrElse(() => []),
|
|
1441
|
+
),
|
|
1442
|
+
),
|
|
1443
|
+
}),
|
|
1444
|
+
GraphBuilder.createExtensionRaw({
|
|
1445
|
+
id: 'constant-child',
|
|
1446
|
+
connector: () => Atom.make([{ id: 'shared', type: EXAMPLE_TYPE, data: 'constant' }]),
|
|
1447
|
+
}),
|
|
1448
|
+
]);
|
|
1449
|
+
|
|
1450
|
+
const graph = builder.graph;
|
|
1451
|
+
|
|
1452
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
1453
|
+
await GraphBuilder.flush(builder);
|
|
1454
|
+
|
|
1455
|
+
Graph.expand(graph, 'root/A', 'child');
|
|
1456
|
+
Graph.expand(graph, 'root/B', 'child');
|
|
1457
|
+
await GraphBuilder.flush(builder);
|
|
1458
|
+
|
|
1459
|
+
const childrenOfA = registry.get(graph.connections('root/A', 'child'));
|
|
1460
|
+
const childrenOfB = registry.get(graph.connections('root/B', 'child'));
|
|
1461
|
+
|
|
1462
|
+
expect(childrenOfA).has.length(1);
|
|
1463
|
+
expect(childrenOfB).has.length(1);
|
|
1464
|
+
expect(childrenOfA[0].id).to.equal('root/A/shared');
|
|
1465
|
+
expect(childrenOfB[0].id).to.equal('root/B/shared');
|
|
1466
|
+
|
|
1467
|
+
const nodeA = Graph.getNode(graph, 'root/A/shared').pipe(Option.getOrNull);
|
|
1468
|
+
const nodeB = Graph.getNode(graph, 'root/B/shared').pipe(Option.getOrNull);
|
|
1469
|
+
expect(nodeA).to.not.be.null;
|
|
1470
|
+
expect(nodeB).to.not.be.null;
|
|
1471
|
+
expect(nodeA?.id).to.not.equal(nodeB?.id);
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
test('explore qualifies node IDs', async () => {
|
|
1475
|
+
const builder = GraphBuilder.make();
|
|
1476
|
+
GraphBuilder.addExtension(
|
|
1477
|
+
builder,
|
|
1478
|
+
GraphBuilder.createExtensionRaw({
|
|
1479
|
+
id: 'connector',
|
|
1480
|
+
connector: (node) =>
|
|
1481
|
+
Atom.make((get) =>
|
|
1482
|
+
Function.pipe(
|
|
1483
|
+
get(node),
|
|
1484
|
+
Option.filter((n) => n.id === 'root'),
|
|
1485
|
+
Option.map(() => [
|
|
1486
|
+
{ id: 'first', type: EXAMPLE_TYPE, data: 1 },
|
|
1487
|
+
{ id: 'second', type: EXAMPLE_TYPE, data: 2 },
|
|
1488
|
+
]),
|
|
1489
|
+
Option.getOrElse(() => []),
|
|
1490
|
+
),
|
|
1491
|
+
),
|
|
1492
|
+
}),
|
|
1493
|
+
);
|
|
1494
|
+
|
|
1495
|
+
const visited: Array<{ id: string; path: string[] }> = [];
|
|
1496
|
+
await GraphBuilder.explore(builder, {
|
|
1497
|
+
relation: 'child',
|
|
1498
|
+
visitor: (node, path) => {
|
|
1499
|
+
visited.push({ id: node.id, path });
|
|
1500
|
+
},
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
expect(visited).has.length(3);
|
|
1504
|
+
expect(visited[0].id).to.equal('root');
|
|
1505
|
+
expect(visited[1].id).to.equal('root/first');
|
|
1506
|
+
expect(visited[2].id).to.equal('root/second');
|
|
1507
|
+
});
|
|
1508
|
+
});
|
|
499
1509
|
});
|