@dxos/app-graph 0.8.2-main.f11618f → 0.8.2-main.fbd8ed0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/browser/index.mjs +541 -789
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +533 -780
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +541 -789
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/experimental/graph-projections.test.d.ts +25 -0
- package/dist/types/src/experimental/graph-projections.test.d.ts.map +1 -0
- package/dist/types/src/graph-builder.d.ts +48 -91
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +191 -98
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +2 -2
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/signals-integration.test.d.ts +2 -0
- package/dist/types/src/signals-integration.test.d.ts.map +1 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing.d.ts +5 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -16
- package/src/experimental/graph-projections.test.ts +56 -0
- package/src/graph-builder.test.ts +293 -310
- package/src/graph-builder.ts +209 -317
- package/src/graph.test.ts +314 -463
- package/src/graph.ts +452 -458
- package/src/node.ts +2 -2
- package/src/signals-integration.test.ts +218 -0
- package/src/stories/EchoGraph.stories.tsx +56 -77
- package/src/testing.ts +20 -0
package/src/graph-builder.ts
CHANGED
|
@@ -1,104 +1,121 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Copyright
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Registry, Rx } from '@effect-rx/rx-react';
|
|
6
|
+
import { effect } from '@preact/signals-core';
|
|
7
|
+
import { Array, type Option, pipe, Record } from 'effect';
|
|
6
8
|
|
|
7
|
-
import {
|
|
8
|
-
import { invariant } from '@dxos/invariant';
|
|
9
|
-
import { live } from '@dxos/live-object';
|
|
9
|
+
import { type MulticastObservable, type CleanupFn } from '@dxos/async';
|
|
10
10
|
import { log } from '@dxos/log';
|
|
11
|
-
import { byPosition,
|
|
11
|
+
import { byPosition, getDebugName, isNode, isNonNullable, type MaybePromise, type Position } from '@dxos/util';
|
|
12
12
|
|
|
13
|
-
import { ACTION_GROUP_TYPE, ACTION_TYPE, Graph, ROOT_ID, type GraphParams } from './graph';
|
|
14
|
-
import { type ActionData,
|
|
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;
|
|
13
|
+
import { ACTION_GROUP_TYPE, ACTION_TYPE, Graph, ROOT_ID, type GraphParams, type ExpandableGraph } from './graph';
|
|
14
|
+
import { actionGroupSymbol, type ActionData, type Node, type NodeArg, type Relation } from './node';
|
|
25
15
|
|
|
26
16
|
/**
|
|
27
17
|
* Graph builder extension for adding nodes to the graph based on a connection to an existing node.
|
|
28
18
|
*
|
|
29
19
|
* @param params.node The existing node the returned nodes will be connected to.
|
|
30
20
|
*/
|
|
31
|
-
export type ConnectorExtension
|
|
21
|
+
export type ConnectorExtension = (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
|
|
32
22
|
|
|
33
23
|
/**
|
|
34
24
|
* Constrained case of the connector extension for more easily adding actions to the graph.
|
|
35
25
|
*/
|
|
36
|
-
export type ActionsExtension
|
|
37
|
-
node: Node
|
|
38
|
-
|
|
26
|
+
export type ActionsExtension = (
|
|
27
|
+
node: Rx.Rx<Option.Option<Node>>,
|
|
28
|
+
) => Rx.Rx<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
|
|
39
29
|
|
|
40
30
|
/**
|
|
41
31
|
* Constrained case of the connector extension for more easily adding action groups to the graph.
|
|
42
32
|
*/
|
|
43
|
-
export type ActionGroupsExtension
|
|
44
|
-
node: Node
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
type GuardedNodeType<T> = T extends (value: any) => value is infer N ? (N extends Node<infer D> ? D : unknown) : never;
|
|
33
|
+
export type ActionGroupsExtension = (
|
|
34
|
+
node: Rx.Rx<Option.Option<Node>>,
|
|
35
|
+
) => Rx.Rx<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
|
|
48
36
|
|
|
49
37
|
/**
|
|
50
38
|
* A graph builder extension is used to add nodes to the graph.
|
|
51
39
|
*
|
|
52
40
|
* @param params.id The unique id of the extension.
|
|
53
41
|
* @param params.relation The relation the graph is being expanded from the existing node.
|
|
54
|
-
* @param params.
|
|
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.
|
|
42
|
+
* @param params.position Affects the order the extensions are processed in.
|
|
57
43
|
* @param params.resolver A function to add nodes to the graph based on just the node id.
|
|
58
44
|
* @param params.connector A function to add nodes to the graph based on a connection to an existing node.
|
|
59
45
|
* @param params.actions A function to add actions to the graph based on a connection to an existing node.
|
|
60
46
|
* @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
|
|
61
47
|
*/
|
|
62
|
-
export type CreateExtensionOptions
|
|
48
|
+
export type CreateExtensionOptions = {
|
|
63
49
|
id: string;
|
|
64
50
|
relation?: Relation;
|
|
65
|
-
type?: string;
|
|
66
51
|
position?: Position;
|
|
67
|
-
|
|
68
|
-
resolver?: ResolverExtension;
|
|
69
|
-
connector?: ConnectorExtension
|
|
70
|
-
actions?: ActionsExtension
|
|
71
|
-
actionGroups?: ActionGroupsExtension
|
|
52
|
+
// TODO(wittjosiah): On initialize to restore state from cache.
|
|
53
|
+
// resolver?: ResolverExtension;
|
|
54
|
+
connector?: ConnectorExtension;
|
|
55
|
+
actions?: ActionsExtension;
|
|
56
|
+
actionGroups?: ActionGroupsExtension;
|
|
72
57
|
};
|
|
73
58
|
|
|
74
59
|
/**
|
|
75
60
|
* Create a graph builder extension.
|
|
76
61
|
*/
|
|
77
|
-
export const createExtension =
|
|
78
|
-
const {
|
|
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;
|
|
79
71
|
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
|
+
|
|
80
83
|
return [
|
|
81
|
-
resolver ? { id: getId('resolver'), position, resolver } : undefined,
|
|
82
|
-
connector
|
|
84
|
+
// resolver ? { id: getId('resolver'), position, resolver } : undefined,
|
|
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,
|
|
83
93
|
actionGroups
|
|
84
94
|
? ({
|
|
85
|
-
...rest,
|
|
86
95
|
id: getId('actionGroups'),
|
|
87
96
|
position,
|
|
88
|
-
type: ACTION_GROUP_TYPE,
|
|
89
97
|
relation: 'outbound',
|
|
90
|
-
connector: (
|
|
91
|
-
|
|
98
|
+
connector: Rx.family((node) =>
|
|
99
|
+
Rx.make((get) =>
|
|
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
|
+
),
|
|
92
107
|
} satisfies BuilderExtension)
|
|
93
108
|
: undefined,
|
|
94
109
|
actions
|
|
95
110
|
? ({
|
|
96
|
-
...rest,
|
|
97
111
|
id: getId('actions'),
|
|
98
112
|
position,
|
|
99
|
-
type: ACTION_TYPE,
|
|
100
113
|
relation: 'outbound',
|
|
101
|
-
connector: (
|
|
114
|
+
connector: Rx.family((node) =>
|
|
115
|
+
Rx.make((get) => get(actions(node)).map((arg) => ({ ...arg, type: ACTION_TYPE }))).pipe(
|
|
116
|
+
Rx.withLabel(`graph-builder:connector:actions:${id}`),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
102
119
|
} satisfies BuilderExtension)
|
|
103
120
|
: undefined,
|
|
104
121
|
].filter(isNonNullable);
|
|
@@ -106,86 +123,22 @@ export const createExtension = <T = any>(extension: CreateExtensionOptions<T>):
|
|
|
106
123
|
|
|
107
124
|
export type GraphBuilderTraverseOptions = {
|
|
108
125
|
visitor: (node: Node, path: string[]) => MaybePromise<boolean | void>;
|
|
109
|
-
|
|
126
|
+
registry?: Registry.Registry;
|
|
127
|
+
source?: string;
|
|
110
128
|
relation?: Relation;
|
|
111
129
|
};
|
|
112
130
|
|
|
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
|
-
|
|
175
131
|
export type BuilderExtension = Readonly<{
|
|
176
132
|
id: string;
|
|
177
133
|
position: Position;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
relation?: Relation;
|
|
182
|
-
type?: string;
|
|
183
|
-
filter?: (node: Node) => boolean;
|
|
134
|
+
relation?: Relation; // Only for connector.
|
|
135
|
+
// resolver?: ResolverExtension;
|
|
136
|
+
connector?: (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
|
|
184
137
|
}>;
|
|
185
138
|
|
|
186
|
-
type
|
|
139
|
+
export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
|
|
187
140
|
|
|
188
|
-
export const flattenExtensions = (extension:
|
|
141
|
+
export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
189
142
|
if (Array.isArray(extension)) {
|
|
190
143
|
return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
|
|
191
144
|
} else {
|
|
@@ -200,106 +153,67 @@ export const flattenExtensions = (extension: ExtensionArg, acc: BuilderExtension
|
|
|
200
153
|
// Should unsubscribe from nodes that are not in the set/radius.
|
|
201
154
|
// Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
|
|
202
155
|
export class GraphBuilder {
|
|
203
|
-
|
|
204
|
-
private readonly _extensions = live<Record<string, BuilderExtension>>({});
|
|
205
|
-
private readonly _resolverSubscriptions = new Map<string, CleanupFn>();
|
|
156
|
+
// TODO(wittjosiah): Use Context.
|
|
206
157
|
private readonly _connectorSubscriptions = new Map<string, CleanupFn>();
|
|
207
|
-
private readonly
|
|
208
|
-
|
|
209
|
-
|
|
158
|
+
private readonly _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
|
|
159
|
+
Rx.keepAlive,
|
|
160
|
+
Rx.withLabel('graph-builder:extensions'),
|
|
161
|
+
);
|
|
210
162
|
|
|
211
|
-
|
|
163
|
+
private readonly _registry: Registry.Registry;
|
|
164
|
+
private readonly _graph: Graph;
|
|
165
|
+
|
|
166
|
+
constructor({ registry, ...params }: Pick<GraphParams, 'registry' | 'nodes' | 'edges'> = {}) {
|
|
167
|
+
this._registry = registry ?? Registry.make();
|
|
212
168
|
this._graph = new Graph({
|
|
213
169
|
...params,
|
|
214
|
-
|
|
215
|
-
|
|
170
|
+
registry: this._registry,
|
|
171
|
+
onExpand: (id, relation) => this._onExpand(id, relation),
|
|
172
|
+
// onInitialize: (id) => this._onInitialize(id),
|
|
216
173
|
onRemoveNode: (id) => this._onRemoveNode(id),
|
|
217
174
|
});
|
|
218
175
|
}
|
|
219
176
|
|
|
220
|
-
static from(pickle?: string) {
|
|
177
|
+
static from(pickle?: string, registry?: Registry.Registry) {
|
|
221
178
|
if (!pickle) {
|
|
222
|
-
return new GraphBuilder();
|
|
179
|
+
return new GraphBuilder({ registry });
|
|
223
180
|
}
|
|
224
181
|
|
|
225
182
|
const { nodes, edges } = JSON.parse(pickle);
|
|
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
|
-
);
|
|
183
|
+
return new GraphBuilder({ nodes, edges, registry });
|
|
249
184
|
}
|
|
250
185
|
|
|
251
|
-
get graph() {
|
|
186
|
+
get graph(): ExpandableGraph {
|
|
252
187
|
return this._graph;
|
|
253
188
|
}
|
|
254
189
|
|
|
255
|
-
/**
|
|
256
|
-
* @reactive
|
|
257
|
-
*/
|
|
258
190
|
get extensions() {
|
|
259
|
-
return
|
|
191
|
+
return this._extensions;
|
|
260
192
|
}
|
|
261
193
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const extensions = flattenExtensions(extension);
|
|
267
|
-
untracked(() => {
|
|
268
|
-
extensions.forEach((extension) => {
|
|
269
|
-
this._dispatcher.state[extension.id] = [];
|
|
270
|
-
this._extensions[extension.id] = extension;
|
|
271
|
-
});
|
|
194
|
+
addExtension(extensions: BuilderExtensions): GraphBuilder {
|
|
195
|
+
flattenExtensions(extensions).forEach((extension) => {
|
|
196
|
+
const extensions = this._registry.get(this._extensions);
|
|
197
|
+
this._registry.set(this._extensions, Record.set(extensions, extension.id, extension));
|
|
272
198
|
});
|
|
273
199
|
return this;
|
|
274
200
|
}
|
|
275
201
|
|
|
276
|
-
/**
|
|
277
|
-
* Remove a node builder from the graph builder.
|
|
278
|
-
*/
|
|
279
202
|
removeExtension(id: string): GraphBuilder {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
203
|
+
const extensions = this._registry.get(this._extensions);
|
|
204
|
+
this._registry.set(this._extensions, Record.remove(extensions, id));
|
|
283
205
|
return this;
|
|
284
206
|
}
|
|
285
207
|
|
|
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
|
-
*/
|
|
297
208
|
async explore(
|
|
298
|
-
|
|
209
|
+
// TODO(wittjosiah): Currently defaulting to new registry.
|
|
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,
|
|
299
213
|
path: string[] = [],
|
|
300
214
|
) {
|
|
301
215
|
// Break cycles.
|
|
302
|
-
if (path.includes(
|
|
216
|
+
if (path.includes(source)) {
|
|
303
217
|
return;
|
|
304
218
|
}
|
|
305
219
|
|
|
@@ -309,155 +223,133 @@ export class GraphBuilder {
|
|
|
309
223
|
const { yieldOrContinue } = await import('main-thread-scheduling');
|
|
310
224
|
await yieldOrContinue('idle');
|
|
311
225
|
}
|
|
226
|
+
|
|
227
|
+
const node = registry.get(this._graph.nodeOrThrow(source));
|
|
312
228
|
const shouldContinue = await visitor(node, [...path, node.id]);
|
|
313
229
|
if (shouldContinue === false) {
|
|
314
230
|
return;
|
|
315
231
|
}
|
|
316
232
|
|
|
317
|
-
const nodes = Object.values(this._extensions)
|
|
233
|
+
const nodes = Object.values(this._registry.get(this._extensions))
|
|
318
234
|
.filter((extension) => relation === (extension.relation ?? 'outbound'))
|
|
319
|
-
.
|
|
320
|
-
.
|
|
321
|
-
|
|
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
|
-
);
|
|
337
|
-
|
|
338
|
-
await Promise.all(nodes.map((n) => this.explore({ node: n, relation, visitor }, [...path, node.id])));
|
|
339
|
-
}
|
|
235
|
+
.map((extension) => extension.connector)
|
|
236
|
+
.filter(isNonNullable)
|
|
237
|
+
.flatMap((connector) => registry.get(connector(this._graph.node(source))));
|
|
340
238
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
}
|
|
239
|
+
await Promise.all(
|
|
240
|
+
nodes.map((nodeArg) => {
|
|
241
|
+
registry.set(this._graph._node(nodeArg.id), this._graph._constructNode(nodeArg));
|
|
242
|
+
return this.explore({ registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
|
|
379
243
|
}),
|
|
380
244
|
);
|
|
245
|
+
|
|
246
|
+
if (registry !== this._registry) {
|
|
247
|
+
registry.reset();
|
|
248
|
+
registry.dispose();
|
|
249
|
+
}
|
|
381
250
|
}
|
|
382
251
|
|
|
383
|
-
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
}
|
|
252
|
+
destroy() {
|
|
253
|
+
this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
254
|
+
this._connectorSubscriptions.clear();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private readonly _connectors = Rx.family<string, Rx.Rx<NodeArg<any>[]>>((key) => {
|
|
258
|
+
return Rx.make((get) => {
|
|
259
|
+
const [id, relation] = key.split('+');
|
|
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
|
+
});
|
|
427
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
|
+
|
|
279
|
+
let previous: string[] = [];
|
|
280
|
+
const cancel = this._registry.subscribe(
|
|
281
|
+
connectors,
|
|
282
|
+
(nodes) => {
|
|
428
283
|
const ids = nodes.map((n) => n.id);
|
|
429
284
|
const removed = previous.filter((id) => !ids.includes(id));
|
|
430
285
|
previous = ids;
|
|
431
286
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (this._nodeChanged[n.id]) {
|
|
450
|
-
this._nodeChanged[n.id].value = {};
|
|
451
|
-
}
|
|
287
|
+
log('update', { id, relation, ids, removed });
|
|
288
|
+
Rx.batch(() => {
|
|
289
|
+
this._graph.removeEdges(
|
|
290
|
+
removed.map((target) => ({ source: id, target })),
|
|
291
|
+
true,
|
|
292
|
+
);
|
|
293
|
+
this._graph.addNodes(nodes);
|
|
294
|
+
this._graph.addEdges(
|
|
295
|
+
nodes.map((node) =>
|
|
296
|
+
relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
this._graph.sortEdges(
|
|
300
|
+
id,
|
|
301
|
+
relation,
|
|
302
|
+
nodes.map(({ id }) => id),
|
|
303
|
+
);
|
|
452
304
|
});
|
|
453
|
-
}
|
|
305
|
+
},
|
|
306
|
+
{ immediate: true },
|
|
454
307
|
);
|
|
308
|
+
|
|
309
|
+
this._connectorSubscriptions.set(id, cancel);
|
|
455
310
|
}
|
|
456
311
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
312
|
+
// TODO(wittjosiah): On initialize to restore state from cache.
|
|
313
|
+
// private async _onInitialize(id: string) {
|
|
314
|
+
// log('onInitialize', { id });
|
|
315
|
+
// }
|
|
316
|
+
|
|
317
|
+
private _onRemoveNode(id: string) {
|
|
318
|
+
this._connectorSubscriptions.get(id)?.();
|
|
319
|
+
this._connectorSubscriptions.delete(id);
|
|
462
320
|
}
|
|
463
321
|
}
|
|
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
|
+
};
|