@dxos/app-graph 0.8.2-main.fbd8ed0 → 0.8.2-staging.7ac8446
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/browser/index.mjs +789 -541
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +780 -533
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +789 -541
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/graph-builder.d.ts +91 -48
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +98 -191
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +3 -3
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -23
- package/src/graph-builder.test.ts +310 -293
- package/src/graph-builder.ts +317 -209
- package/src/graph.test.ts +463 -314
- package/src/graph.ts +455 -452
- package/src/node.ts +4 -4
- package/src/stories/EchoGraph.stories.tsx +78 -57
- 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
package/src/graph-builder.ts
CHANGED
|
@@ -1,121 +1,104 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Copyright
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { effect } from '@preact/signals-core';
|
|
7
|
-
import { Array, type Option, pipe, Record } from 'effect';
|
|
5
|
+
import { effect, type Signal, signal, untracked } from '@preact/signals-core';
|
|
8
6
|
|
|
9
|
-
import {
|
|
7
|
+
import { Trigger, type CleanupFn } from '@dxos/async';
|
|
8
|
+
import { invariant } from '@dxos/invariant';
|
|
9
|
+
import { create } from '@dxos/live-object';
|
|
10
10
|
import { log } from '@dxos/log';
|
|
11
|
-
import { byPosition,
|
|
11
|
+
import { byPosition, type Position, isNode, type MaybePromise, isNonNullable } from '@dxos/util';
|
|
12
12
|
|
|
13
|
-
import { ACTION_GROUP_TYPE, ACTION_TYPE, Graph, ROOT_ID, type GraphParams
|
|
14
|
-
import {
|
|
13
|
+
import { ACTION_GROUP_TYPE, ACTION_TYPE, Graph, ROOT_ID, type GraphParams } from './graph';
|
|
14
|
+
import { type ActionData, actionGroupSymbol, type Node, type NodeArg, type Relation } from './node';
|
|
15
|
+
|
|
16
|
+
const NODE_RESOLVER_TIMEOUT = 1_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Graph builder extension for adding nodes to the graph based on just the node id.
|
|
20
|
+
* This is useful for creating the first node in a graph or for hydrating cached nodes with data.
|
|
21
|
+
*
|
|
22
|
+
* @param params.id The id of the node to resolve.
|
|
23
|
+
*/
|
|
24
|
+
export type ResolverExtension = (params: { id: string }) => NodeArg<any> | false | undefined;
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
27
|
* Graph builder extension for adding nodes to the graph based on a connection to an existing node.
|
|
18
28
|
*
|
|
19
29
|
* @param params.node The existing node the returned nodes will be connected to.
|
|
20
30
|
*/
|
|
21
|
-
export type ConnectorExtension = (node:
|
|
31
|
+
export type ConnectorExtension<T = any> = (params: { node: Node<T> }) => NodeArg<any>[] | undefined;
|
|
22
32
|
|
|
23
33
|
/**
|
|
24
34
|
* Constrained case of the connector extension for more easily adding actions to the graph.
|
|
25
35
|
*/
|
|
26
|
-
export type ActionsExtension = (
|
|
27
|
-
node:
|
|
28
|
-
) =>
|
|
36
|
+
export type ActionsExtension<T = any> = (params: {
|
|
37
|
+
node: Node<T>;
|
|
38
|
+
}) => Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[] | undefined;
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
41
|
* Constrained case of the connector extension for more easily adding action groups to the graph.
|
|
32
42
|
*/
|
|
33
|
-
export type ActionGroupsExtension = (
|
|
34
|
-
node:
|
|
35
|
-
) =>
|
|
43
|
+
export type ActionGroupsExtension<T = any> = (params: {
|
|
44
|
+
node: Node<T>;
|
|
45
|
+
}) => Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[] | undefined;
|
|
46
|
+
|
|
47
|
+
type GuardedNodeType<T> = T extends (value: any) => value is infer N ? (N extends Node<infer D> ? D : unknown) : never;
|
|
36
48
|
|
|
37
49
|
/**
|
|
38
50
|
* A graph builder extension is used to add nodes to the graph.
|
|
39
51
|
*
|
|
40
52
|
* @param params.id The unique id of the extension.
|
|
41
53
|
* @param params.relation The relation the graph is being expanded from the existing node.
|
|
42
|
-
* @param params.
|
|
54
|
+
* @param params.type If provided, all nodes returned are expected to have this type.
|
|
55
|
+
* @param params.disposition Affects the order the extensions are processed in.
|
|
56
|
+
* @param params.filter A filter function to determine if an extension should act on a node.
|
|
43
57
|
* @param params.resolver A function to add nodes to the graph based on just the node id.
|
|
44
58
|
* @param params.connector A function to add nodes to the graph based on a connection to an existing node.
|
|
45
59
|
* @param params.actions A function to add actions to the graph based on a connection to an existing node.
|
|
46
60
|
* @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
|
|
47
61
|
*/
|
|
48
|
-
export type CreateExtensionOptions = {
|
|
62
|
+
export type CreateExtensionOptions<T = any> = {
|
|
49
63
|
id: string;
|
|
50
64
|
relation?: Relation;
|
|
65
|
+
type?: string;
|
|
51
66
|
position?: Position;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
connector?: ConnectorExtension
|
|
55
|
-
actions?: ActionsExtension
|
|
56
|
-
actionGroups?: ActionGroupsExtension
|
|
67
|
+
filter?: (node: Node) => node is Node<T>;
|
|
68
|
+
resolver?: ResolverExtension;
|
|
69
|
+
connector?: ConnectorExtension<GuardedNodeType<CreateExtensionOptions<T>['filter']>>;
|
|
70
|
+
actions?: ActionsExtension<GuardedNodeType<CreateExtensionOptions<T>['filter']>>;
|
|
71
|
+
actionGroups?: ActionGroupsExtension<GuardedNodeType<CreateExtensionOptions<T>['filter']>>;
|
|
57
72
|
};
|
|
58
73
|
|
|
59
74
|
/**
|
|
60
75
|
* Create a graph builder extension.
|
|
61
76
|
*/
|
|
62
|
-
export const createExtension = (extension: CreateExtensionOptions): BuilderExtension[] => {
|
|
63
|
-
const {
|
|
64
|
-
id,
|
|
65
|
-
position = 'static',
|
|
66
|
-
relation = 'outbound',
|
|
67
|
-
connector,
|
|
68
|
-
actions: _actions,
|
|
69
|
-
actionGroups: _actionGroups,
|
|
70
|
-
} = extension;
|
|
77
|
+
export const createExtension = <T = any>(extension: CreateExtensionOptions<T>): BuilderExtension[] => {
|
|
78
|
+
const { id, position = 'static', resolver, connector, actions, actionGroups, ...rest } = extension;
|
|
71
79
|
const getId = (key: string) => `${id}/${key}`;
|
|
72
|
-
|
|
73
|
-
const actionGroups =
|
|
74
|
-
_actionGroups &&
|
|
75
|
-
Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
|
|
76
|
-
_actionGroups(node).pipe(Rx.withLabel(`graph-builder:actionGroups:${id}`)),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const actions =
|
|
80
|
-
_actions &&
|
|
81
|
-
Rx.family((node: Rx.Rx<Option.Option<Node>>) => _actions(node).pipe(Rx.withLabel(`graph-builder:actions:${id}`)));
|
|
82
|
-
|
|
83
80
|
return [
|
|
84
|
-
|
|
85
|
-
connector
|
|
86
|
-
? ({
|
|
87
|
-
id: getId('connector'),
|
|
88
|
-
position,
|
|
89
|
-
relation,
|
|
90
|
-
connector: Rx.family((key) => connector(key).pipe(Rx.withLabel(`graph-builder:connector:${id}`))),
|
|
91
|
-
} satisfies BuilderExtension)
|
|
92
|
-
: undefined,
|
|
81
|
+
resolver ? { id: getId('resolver'), position, resolver } : undefined,
|
|
82
|
+
connector ? { ...rest, id: getId('connector'), position, connector } : undefined,
|
|
93
83
|
actionGroups
|
|
94
84
|
? ({
|
|
85
|
+
...rest,
|
|
95
86
|
id: getId('actionGroups'),
|
|
96
87
|
position,
|
|
88
|
+
type: ACTION_GROUP_TYPE,
|
|
97
89
|
relation: 'outbound',
|
|
98
|
-
connector:
|
|
99
|
-
|
|
100
|
-
get(actionGroups(node)).map((arg) => ({
|
|
101
|
-
...arg,
|
|
102
|
-
data: actionGroupSymbol,
|
|
103
|
-
type: ACTION_GROUP_TYPE,
|
|
104
|
-
})),
|
|
105
|
-
).pipe(Rx.withLabel(`graph-builder:connector:actionGroups:${id}`)),
|
|
106
|
-
),
|
|
90
|
+
connector: ({ node }) =>
|
|
91
|
+
actionGroups({ node })?.map((arg) => ({ ...arg, data: actionGroupSymbol, type: ACTION_GROUP_TYPE })),
|
|
107
92
|
} satisfies BuilderExtension)
|
|
108
93
|
: undefined,
|
|
109
94
|
actions
|
|
110
95
|
? ({
|
|
96
|
+
...rest,
|
|
111
97
|
id: getId('actions'),
|
|
112
98
|
position,
|
|
99
|
+
type: ACTION_TYPE,
|
|
113
100
|
relation: 'outbound',
|
|
114
|
-
connector:
|
|
115
|
-
Rx.make((get) => get(actions(node)).map((arg) => ({ ...arg, type: ACTION_TYPE }))).pipe(
|
|
116
|
-
Rx.withLabel(`graph-builder:connector:actions:${id}`),
|
|
117
|
-
),
|
|
118
|
-
),
|
|
101
|
+
connector: ({ node }) => actions({ node })?.map((arg) => ({ ...arg, type: ACTION_TYPE })),
|
|
119
102
|
} satisfies BuilderExtension)
|
|
120
103
|
: undefined,
|
|
121
104
|
].filter(isNonNullable);
|
|
@@ -123,22 +106,86 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
|
|
|
123
106
|
|
|
124
107
|
export type GraphBuilderTraverseOptions = {
|
|
125
108
|
visitor: (node: Node, path: string[]) => MaybePromise<boolean | void>;
|
|
126
|
-
|
|
127
|
-
source?: string;
|
|
109
|
+
node?: Node;
|
|
128
110
|
relation?: Relation;
|
|
129
111
|
};
|
|
130
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The dispatcher is used to keep track of the current extension and state when memoizing functions.
|
|
115
|
+
*/
|
|
116
|
+
class Dispatcher {
|
|
117
|
+
currentExtension?: string;
|
|
118
|
+
stateIndex = 0;
|
|
119
|
+
state: Record<string, any[]> = {};
|
|
120
|
+
cleanup: (() => void)[] = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class BuilderInternal {
|
|
124
|
+
// This must be static to avoid passing the dispatcher instance to every memoized function.
|
|
125
|
+
// If the dispatcher is not set that means that the memoized function is being called outside of the graph builder.
|
|
126
|
+
static currentDispatcher?: Dispatcher;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Allows code to be memoized within the context of a graph builder extension.
|
|
131
|
+
* This is useful for creating instances which should be subscribed to rather than recreated.
|
|
132
|
+
*/
|
|
133
|
+
export const memoize = <T>(fn: () => T, key = 'result'): T => {
|
|
134
|
+
const dispatcher = BuilderInternal.currentDispatcher;
|
|
135
|
+
invariant(dispatcher?.currentExtension, 'memoize must be called within an extension');
|
|
136
|
+
const all = dispatcher.state[dispatcher.currentExtension][dispatcher.stateIndex] ?? {};
|
|
137
|
+
const current = all[key];
|
|
138
|
+
const result = current ? current.result : fn();
|
|
139
|
+
dispatcher.state[dispatcher.currentExtension][dispatcher.stateIndex] = { ...all, [key]: { result } };
|
|
140
|
+
dispatcher.stateIndex++;
|
|
141
|
+
return result;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Register a cleanup function to be called when the graph builder is destroyed.
|
|
146
|
+
*/
|
|
147
|
+
export const cleanup = (fn: () => void): void => {
|
|
148
|
+
memoize(() => {
|
|
149
|
+
const dispatcher = BuilderInternal.currentDispatcher;
|
|
150
|
+
invariant(dispatcher, 'cleanup must be called within an extension');
|
|
151
|
+
dispatcher.cleanup.push(fn);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Convert a subscribe/get pair into a signal.
|
|
157
|
+
*/
|
|
158
|
+
export const toSignal = <T>(
|
|
159
|
+
subscribe: (onChange: () => void) => () => void,
|
|
160
|
+
get: () => T | undefined,
|
|
161
|
+
key?: string,
|
|
162
|
+
) => {
|
|
163
|
+
const thisSignal = memoize(() => {
|
|
164
|
+
return signal(get());
|
|
165
|
+
}, key);
|
|
166
|
+
const unsubscribe = memoize(() => {
|
|
167
|
+
return subscribe(() => (thisSignal.value = get()));
|
|
168
|
+
}, key);
|
|
169
|
+
cleanup(() => {
|
|
170
|
+
unsubscribe();
|
|
171
|
+
});
|
|
172
|
+
return thisSignal.value;
|
|
173
|
+
};
|
|
174
|
+
|
|
131
175
|
export type BuilderExtension = Readonly<{
|
|
132
176
|
id: string;
|
|
133
177
|
position: Position;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
178
|
+
resolver?: ResolverExtension;
|
|
179
|
+
connector?: ConnectorExtension;
|
|
180
|
+
// Only for connector.
|
|
181
|
+
relation?: Relation;
|
|
182
|
+
type?: string;
|
|
183
|
+
filter?: (node: Node) => boolean;
|
|
137
184
|
}>;
|
|
138
185
|
|
|
139
|
-
|
|
186
|
+
type ExtensionArg = BuilderExtension | BuilderExtension[] | ExtensionArg[];
|
|
140
187
|
|
|
141
|
-
export const flattenExtensions = (extension:
|
|
188
|
+
export const flattenExtensions = (extension: ExtensionArg, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
142
189
|
if (Array.isArray(extension)) {
|
|
143
190
|
return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
|
|
144
191
|
} else {
|
|
@@ -153,67 +200,106 @@ export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExte
|
|
|
153
200
|
// Should unsubscribe from nodes that are not in the set/radius.
|
|
154
201
|
// Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
|
|
155
202
|
export class GraphBuilder {
|
|
156
|
-
|
|
203
|
+
private readonly _dispatcher = new Dispatcher();
|
|
204
|
+
private readonly _extensions = create<Record<string, BuilderExtension>>({});
|
|
205
|
+
private readonly _resolverSubscriptions = new Map<string, CleanupFn>();
|
|
157
206
|
private readonly _connectorSubscriptions = new Map<string, CleanupFn>();
|
|
158
|
-
private readonly
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
207
|
+
private readonly _nodeChanged: Record<string, Signal<{}>> = {};
|
|
208
|
+
private readonly _initialized: Record<string, Trigger> = {};
|
|
209
|
+
private _graph: Graph;
|
|
162
210
|
|
|
163
|
-
|
|
164
|
-
private readonly _graph: Graph;
|
|
165
|
-
|
|
166
|
-
constructor({ registry, ...params }: Pick<GraphParams, 'registry' | 'nodes' | 'edges'> = {}) {
|
|
167
|
-
this._registry = registry ?? Registry.make();
|
|
211
|
+
constructor(params: Pick<GraphParams, 'nodes' | 'edges'> = {}) {
|
|
168
212
|
this._graph = new Graph({
|
|
169
213
|
...params,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// onInitialize: (id) => this._onInitialize(id),
|
|
214
|
+
onInitialNode: async (id) => this._onInitialNode(id),
|
|
215
|
+
onInitialNodes: async (node, relation, type) => this._onInitialNodes(node, relation, type),
|
|
173
216
|
onRemoveNode: (id) => this._onRemoveNode(id),
|
|
174
217
|
});
|
|
175
218
|
}
|
|
176
219
|
|
|
177
|
-
static from(pickle?: string
|
|
220
|
+
static from(pickle?: string) {
|
|
178
221
|
if (!pickle) {
|
|
179
|
-
return new GraphBuilder(
|
|
222
|
+
return new GraphBuilder();
|
|
180
223
|
}
|
|
181
224
|
|
|
182
225
|
const { nodes, edges } = JSON.parse(pickle);
|
|
183
|
-
return new GraphBuilder({ nodes, edges
|
|
226
|
+
return new GraphBuilder({ nodes, edges });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* If graph is being restored from a pickle, the data will be null.
|
|
231
|
+
* Initialize the data of each node by calling resolvers.
|
|
232
|
+
* Wait until all of the initial nodes have resolved.
|
|
233
|
+
*/
|
|
234
|
+
async initialize() {
|
|
235
|
+
Object.keys(this._graph._nodes)
|
|
236
|
+
.filter((id) => id !== ROOT_ID)
|
|
237
|
+
.forEach((id) => (this._initialized[id] = new Trigger()));
|
|
238
|
+
Object.keys(this._graph._nodes).forEach((id) => this._onInitialNode(id));
|
|
239
|
+
await Promise.all(
|
|
240
|
+
Object.entries(this._initialized).map(async ([id, trigger]) => {
|
|
241
|
+
try {
|
|
242
|
+
await trigger.wait({ timeout: NODE_RESOLVER_TIMEOUT });
|
|
243
|
+
} catch {
|
|
244
|
+
log.error('node resolver timeout', { id });
|
|
245
|
+
this.graph._removeNodes([id]);
|
|
246
|
+
}
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
184
249
|
}
|
|
185
250
|
|
|
186
|
-
get graph()
|
|
251
|
+
get graph() {
|
|
187
252
|
return this._graph;
|
|
188
253
|
}
|
|
189
254
|
|
|
255
|
+
/**
|
|
256
|
+
* @reactive
|
|
257
|
+
*/
|
|
190
258
|
get extensions() {
|
|
191
|
-
return this._extensions;
|
|
259
|
+
return Object.values(this._extensions);
|
|
192
260
|
}
|
|
193
261
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Register a node builder which will be called in order to construct the graph.
|
|
264
|
+
*/
|
|
265
|
+
addExtension(extension: ExtensionArg): GraphBuilder {
|
|
266
|
+
const extensions = flattenExtensions(extension);
|
|
267
|
+
untracked(() => {
|
|
268
|
+
extensions.forEach((extension) => {
|
|
269
|
+
this._dispatcher.state[extension.id] = [];
|
|
270
|
+
this._extensions[extension.id] = extension;
|
|
271
|
+
});
|
|
198
272
|
});
|
|
199
273
|
return this;
|
|
200
274
|
}
|
|
201
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Remove a node builder from the graph builder.
|
|
278
|
+
*/
|
|
202
279
|
removeExtension(id: string): GraphBuilder {
|
|
203
|
-
|
|
204
|
-
|
|
280
|
+
untracked(() => {
|
|
281
|
+
delete this._extensions[id];
|
|
282
|
+
});
|
|
205
283
|
return this;
|
|
206
284
|
}
|
|
207
285
|
|
|
286
|
+
destroy() {
|
|
287
|
+
this._dispatcher.cleanup.forEach((fn) => fn());
|
|
288
|
+
this._resolverSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
289
|
+
this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
290
|
+
this._resolverSubscriptions.clear();
|
|
291
|
+
this._connectorSubscriptions.clear();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A graph traversal using just the connector extensions, without subscribing to any signals or persisting any nodes.
|
|
296
|
+
*/
|
|
208
297
|
async explore(
|
|
209
|
-
|
|
210
|
-
// Currently unsure about how to handle nodes which are expanded in the background.
|
|
211
|
-
// This seems like a good place to start.
|
|
212
|
-
{ registry = Registry.make(), source = ROOT_ID, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
|
|
298
|
+
{ node = this._graph.root, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
|
|
213
299
|
path: string[] = [],
|
|
214
300
|
) {
|
|
215
301
|
// Break cycles.
|
|
216
|
-
if (path.includes(
|
|
302
|
+
if (path.includes(node.id)) {
|
|
217
303
|
return;
|
|
218
304
|
}
|
|
219
305
|
|
|
@@ -223,133 +309,155 @@ export class GraphBuilder {
|
|
|
223
309
|
const { yieldOrContinue } = await import('main-thread-scheduling');
|
|
224
310
|
await yieldOrContinue('idle');
|
|
225
311
|
}
|
|
226
|
-
|
|
227
|
-
const node = registry.get(this._graph.nodeOrThrow(source));
|
|
228
312
|
const shouldContinue = await visitor(node, [...path, node.id]);
|
|
229
313
|
if (shouldContinue === false) {
|
|
230
314
|
return;
|
|
231
315
|
}
|
|
232
316
|
|
|
233
|
-
const nodes = Object.values(this.
|
|
317
|
+
const nodes = Object.values(this._extensions)
|
|
234
318
|
.filter((extension) => relation === (extension.relation ?? 'outbound'))
|
|
235
|
-
.
|
|
236
|
-
.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
})
|
|
244
|
-
|
|
319
|
+
.filter((extension) => !extension.filter || extension.filter(node))
|
|
320
|
+
.flatMap((extension) => {
|
|
321
|
+
this._dispatcher.currentExtension = extension.id;
|
|
322
|
+
this._dispatcher.stateIndex = 0;
|
|
323
|
+
BuilderInternal.currentDispatcher = this._dispatcher;
|
|
324
|
+
const result = extension.connector?.({ node }) ?? [];
|
|
325
|
+
BuilderInternal.currentDispatcher = undefined;
|
|
326
|
+
return result;
|
|
327
|
+
})
|
|
328
|
+
.map(
|
|
329
|
+
(arg): Node => ({
|
|
330
|
+
id: arg.id,
|
|
331
|
+
type: arg.type,
|
|
332
|
+
cacheable: arg.cacheable,
|
|
333
|
+
data: arg.data ?? null,
|
|
334
|
+
properties: arg.properties ?? {},
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
245
337
|
|
|
246
|
-
|
|
247
|
-
registry.reset();
|
|
248
|
-
registry.dispose();
|
|
249
|
-
}
|
|
338
|
+
await Promise.all(nodes.map((n) => this.explore({ node: n, relation, visitor }, [...path, node.id])));
|
|
250
339
|
}
|
|
251
340
|
|
|
252
|
-
|
|
253
|
-
this.
|
|
254
|
-
this.
|
|
341
|
+
private _onInitialNode(nodeId: string) {
|
|
342
|
+
this._nodeChanged[nodeId] = this._nodeChanged[nodeId] ?? signal({});
|
|
343
|
+
this._resolverSubscriptions.set(
|
|
344
|
+
nodeId,
|
|
345
|
+
effect(() => {
|
|
346
|
+
const extensions = Object.values(this._extensions).toSorted(byPosition);
|
|
347
|
+
for (const { id, resolver } of extensions) {
|
|
348
|
+
if (!resolver) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this._dispatcher.currentExtension = id;
|
|
353
|
+
this._dispatcher.stateIndex = 0;
|
|
354
|
+
BuilderInternal.currentDispatcher = this._dispatcher;
|
|
355
|
+
let node: NodeArg<any> | false | undefined;
|
|
356
|
+
try {
|
|
357
|
+
node = resolver({ id: nodeId });
|
|
358
|
+
} catch (err) {
|
|
359
|
+
log.catch(err, { extension: id });
|
|
360
|
+
log.error(`Previous error occurred in extension: ${id}`);
|
|
361
|
+
} finally {
|
|
362
|
+
BuilderInternal.currentDispatcher = undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const trigger = this._initialized[nodeId];
|
|
366
|
+
if (node) {
|
|
367
|
+
this.graph._addNodes([node]);
|
|
368
|
+
trigger?.wake();
|
|
369
|
+
if (this._nodeChanged[node.id]) {
|
|
370
|
+
this._nodeChanged[node.id].value = {};
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
} else if (node === false) {
|
|
374
|
+
this.graph._removeNodes([nodeId]);
|
|
375
|
+
trigger?.wake();
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
255
381
|
}
|
|
256
382
|
|
|
257
|
-
private
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const node = this._graph.node(id);
|
|
261
|
-
|
|
262
|
-
return pipe(
|
|
263
|
-
get(this._extensions),
|
|
264
|
-
Record.values,
|
|
265
|
-
// TODO(wittjosiah): Sort on write rather than read.
|
|
266
|
-
Array.sortBy(byPosition),
|
|
267
|
-
Array.filter(({ relation: _relation = 'outbound' }) => _relation === relation),
|
|
268
|
-
Array.map(({ connector }) => connector?.(node)),
|
|
269
|
-
Array.filter(isNonNullable),
|
|
270
|
-
Array.flatMap((result) => get(result)),
|
|
271
|
-
);
|
|
272
|
-
}).pipe(Rx.withLabel(`graph-builder:connectors:${key}`));
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
private _onExpand(id: string, relation: Relation) {
|
|
276
|
-
log('onExpand', { id, relation, registry: getDebugName(this._registry) });
|
|
277
|
-
const connectors = this._connectors(`${id}+${relation}`);
|
|
278
|
-
|
|
383
|
+
private _onInitialNodes(node: Node, nodesRelation: Relation, nodesType?: string) {
|
|
384
|
+
this._nodeChanged[node.id] = this._nodeChanged[node.id] ?? signal({});
|
|
385
|
+
let first = true;
|
|
279
386
|
let previous: string[] = [];
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
(
|
|
387
|
+
this._connectorSubscriptions.set(
|
|
388
|
+
node.id,
|
|
389
|
+
effect(() => {
|
|
390
|
+
// TODO(wittjosiah): This is a workaround for a race between the node removal and the effect re-running.
|
|
391
|
+
// To cause this case to happen, remove a collection and then undo the removal.
|
|
392
|
+
if (!first && !this._connectorSubscriptions.has(node.id)) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
first = false;
|
|
396
|
+
|
|
397
|
+
// Subscribe to extensions being added.
|
|
398
|
+
Object.keys(this._extensions);
|
|
399
|
+
// Subscribe to connected node changes.
|
|
400
|
+
this._nodeChanged[node.id].value;
|
|
401
|
+
|
|
402
|
+
// TODO(wittjosiah): Consider allowing extensions to collaborate on the same node by merging their results.
|
|
403
|
+
const nodes: NodeArg<any>[] = [];
|
|
404
|
+
const extensions = Object.values(this._extensions).toSorted(byPosition);
|
|
405
|
+
for (const { id, connector, filter, type, relation = 'outbound' } of extensions) {
|
|
406
|
+
if (
|
|
407
|
+
!connector ||
|
|
408
|
+
relation !== nodesRelation ||
|
|
409
|
+
(nodesType && type !== nodesType) ||
|
|
410
|
+
(filter && !filter(node))
|
|
411
|
+
) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this._dispatcher.currentExtension = id;
|
|
416
|
+
this._dispatcher.stateIndex = 0;
|
|
417
|
+
BuilderInternal.currentDispatcher = this._dispatcher;
|
|
418
|
+
try {
|
|
419
|
+
nodes.push(...(connector({ node }) ?? []));
|
|
420
|
+
} catch (err) {
|
|
421
|
+
log.catch(err, { extension: id });
|
|
422
|
+
log.error(`Previous error occurred in extension: ${id}`);
|
|
423
|
+
} finally {
|
|
424
|
+
BuilderInternal.currentDispatcher = undefined;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
283
428
|
const ids = nodes.map((n) => n.id);
|
|
284
429
|
const removed = previous.filter((id) => !ids.includes(id));
|
|
285
430
|
previous = ids;
|
|
286
431
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
432
|
+
// Remove edges and only remove nodes that are orphaned.
|
|
433
|
+
this.graph._removeEdges(
|
|
434
|
+
removed.map((target) => ({ source: node.id, target })),
|
|
435
|
+
true,
|
|
436
|
+
);
|
|
437
|
+
this.graph._addNodes(nodes);
|
|
438
|
+
this.graph._addEdges(
|
|
439
|
+
nodes.map(({ id }) =>
|
|
440
|
+
nodesRelation === 'outbound' ? { source: node.id, target: id } : { source: id, target: node.id },
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
this.graph._sortEdges(
|
|
444
|
+
node.id,
|
|
445
|
+
nodesRelation,
|
|
446
|
+
nodes.map(({ id }) => id),
|
|
447
|
+
);
|
|
448
|
+
nodes.forEach((n) => {
|
|
449
|
+
if (this._nodeChanged[n.id]) {
|
|
450
|
+
this._nodeChanged[n.id].value = {};
|
|
451
|
+
}
|
|
304
452
|
});
|
|
305
|
-
},
|
|
306
|
-
{ immediate: true },
|
|
453
|
+
}),
|
|
307
454
|
);
|
|
308
|
-
|
|
309
|
-
this._connectorSubscriptions.set(id, cancel);
|
|
310
455
|
}
|
|
311
456
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
private _onRemoveNode(id: string) {
|
|
318
|
-
this._connectorSubscriptions.get(id)?.();
|
|
319
|
-
this._connectorSubscriptions.delete(id);
|
|
457
|
+
private async _onRemoveNode(nodeId: string) {
|
|
458
|
+
this._resolverSubscriptions.get(nodeId)?.();
|
|
459
|
+
this._connectorSubscriptions.get(nodeId)?.();
|
|
460
|
+
this._resolverSubscriptions.delete(nodeId);
|
|
461
|
+
this._connectorSubscriptions.delete(nodeId);
|
|
320
462
|
}
|
|
321
463
|
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Creates an Rx.Rx<T> from a callback which accesses signals.
|
|
325
|
-
* Will return a new rx instance each time.
|
|
326
|
-
*/
|
|
327
|
-
export const rxFromSignal = <T>(cb: () => T): Rx.Rx<T> => {
|
|
328
|
-
return Rx.make((get) => {
|
|
329
|
-
const dispose = effect(() => {
|
|
330
|
-
get.setSelf(cb());
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
get.addFinalizer(() => dispose());
|
|
334
|
-
|
|
335
|
-
return cb();
|
|
336
|
-
});
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
const observableFamily = Rx.family((observable: MulticastObservable<any>) => {
|
|
340
|
-
return Rx.make((get) => {
|
|
341
|
-
const subscription = observable.subscribe((value) => get.setSelf(value));
|
|
342
|
-
|
|
343
|
-
get.addFinalizer(() => subscription.unsubscribe());
|
|
344
|
-
|
|
345
|
-
return observable.get();
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Creates an Rx.Rx<T> from a MulticastObservable<T>
|
|
351
|
-
* Will return the same rx instance for the same observable.
|
|
352
|
-
*/
|
|
353
|
-
export const rxFromObservable = <T>(observable: MulticastObservable<T>): Rx.Rx<T> => {
|
|
354
|
-
return observableFamily(observable) as Rx.Rx<T>;
|
|
355
|
-
};
|