@dxos/app-graph 0.8.2-staging.7ac8446 → 0.8.2
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 +593 -794
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +585 -785
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +593 -794
- 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 +3 -3
- 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 +24 -17
- package/src/experimental/graph-projections.test.ts +56 -0
- package/src/graph-builder.test.ts +293 -310
- package/src/graph-builder.ts +235 -318
- package/src/graph.test.ts +314 -463
- package/src/graph.ts +452 -455
- package/src/node.ts +4 -4
- package/src/signals-integration.test.ts +218 -0
- package/src/stories/EchoGraph.stories.tsx +67 -76
- package/src/testing.ts +20 -0
package/src/graph-builder.ts
CHANGED
|
@@ -1,104 +1,146 @@
|
|
|
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 { create } 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: _connector,
|
|
68
|
+
actions: _actions,
|
|
69
|
+
actionGroups: _actionGroups,
|
|
70
|
+
} = extension;
|
|
79
71
|
const getId = (key: string) => `${id}/${key}`;
|
|
72
|
+
|
|
73
|
+
const connector =
|
|
74
|
+
_connector &&
|
|
75
|
+
Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
|
|
76
|
+
_connector(node).pipe(Rx.withLabel(`graph-builder:_connector:${id}`)),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const actionGroups =
|
|
80
|
+
_actionGroups &&
|
|
81
|
+
Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
|
|
82
|
+
_actionGroups(node).pipe(Rx.withLabel(`graph-builder:_actionGroups:${id}`)),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const actions =
|
|
86
|
+
_actions &&
|
|
87
|
+
Rx.family((node: Rx.Rx<Option.Option<Node>>) => _actions(node).pipe(Rx.withLabel(`graph-builder:_actions:${id}`)));
|
|
88
|
+
|
|
80
89
|
return [
|
|
81
|
-
resolver ? { id: getId('resolver'), position, resolver } : undefined,
|
|
82
|
-
connector
|
|
90
|
+
// resolver ? { id: getId('resolver'), position, resolver } : undefined,
|
|
91
|
+
connector
|
|
92
|
+
? ({
|
|
93
|
+
id: getId('connector'),
|
|
94
|
+
position,
|
|
95
|
+
relation,
|
|
96
|
+
connector: Rx.family((node) =>
|
|
97
|
+
Rx.make((get) => {
|
|
98
|
+
try {
|
|
99
|
+
return get(connector(node));
|
|
100
|
+
} catch {
|
|
101
|
+
log.warn('Error in connector', { id: getId('connector'), node });
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}).pipe(Rx.withLabel(`graph-builder:connector:${id}`)),
|
|
105
|
+
),
|
|
106
|
+
} satisfies BuilderExtension)
|
|
107
|
+
: undefined,
|
|
83
108
|
actionGroups
|
|
84
109
|
? ({
|
|
85
|
-
...rest,
|
|
86
110
|
id: getId('actionGroups'),
|
|
87
111
|
position,
|
|
88
|
-
type: ACTION_GROUP_TYPE,
|
|
89
112
|
relation: 'outbound',
|
|
90
|
-
connector: (
|
|
91
|
-
|
|
113
|
+
connector: Rx.family((node) =>
|
|
114
|
+
Rx.make((get) => {
|
|
115
|
+
try {
|
|
116
|
+
return get(actionGroups(node)).map((arg) => ({
|
|
117
|
+
...arg,
|
|
118
|
+
data: actionGroupSymbol,
|
|
119
|
+
type: ACTION_GROUP_TYPE,
|
|
120
|
+
}));
|
|
121
|
+
} catch {
|
|
122
|
+
log.warn('Error in actionGroups', { id: getId('actionGroups'), node });
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}).pipe(Rx.withLabel(`graph-builder:connector:actionGroups:${id}`)),
|
|
126
|
+
),
|
|
92
127
|
} satisfies BuilderExtension)
|
|
93
128
|
: undefined,
|
|
94
129
|
actions
|
|
95
130
|
? ({
|
|
96
|
-
...rest,
|
|
97
131
|
id: getId('actions'),
|
|
98
132
|
position,
|
|
99
|
-
type: ACTION_TYPE,
|
|
100
133
|
relation: 'outbound',
|
|
101
|
-
connector: (
|
|
134
|
+
connector: Rx.family((node) =>
|
|
135
|
+
Rx.make((get) => {
|
|
136
|
+
try {
|
|
137
|
+
return get(actions(node)).map((arg) => ({ ...arg, type: ACTION_TYPE }));
|
|
138
|
+
} catch {
|
|
139
|
+
log.warn('Error in actions', { id: getId('actions'), node });
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}).pipe(Rx.withLabel(`graph-builder:connector:actions:${id}`)),
|
|
143
|
+
),
|
|
102
144
|
} satisfies BuilderExtension)
|
|
103
145
|
: undefined,
|
|
104
146
|
].filter(isNonNullable);
|
|
@@ -106,86 +148,22 @@ export const createExtension = <T = any>(extension: CreateExtensionOptions<T>):
|
|
|
106
148
|
|
|
107
149
|
export type GraphBuilderTraverseOptions = {
|
|
108
150
|
visitor: (node: Node, path: string[]) => MaybePromise<boolean | void>;
|
|
109
|
-
|
|
151
|
+
registry?: Registry.Registry;
|
|
152
|
+
source?: string;
|
|
110
153
|
relation?: Relation;
|
|
111
154
|
};
|
|
112
155
|
|
|
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
156
|
export type BuilderExtension = Readonly<{
|
|
176
157
|
id: string;
|
|
177
158
|
position: Position;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
relation?: Relation;
|
|
182
|
-
type?: string;
|
|
183
|
-
filter?: (node: Node) => boolean;
|
|
159
|
+
relation?: Relation; // Only for connector.
|
|
160
|
+
// resolver?: ResolverExtension;
|
|
161
|
+
connector?: (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
|
|
184
162
|
}>;
|
|
185
163
|
|
|
186
|
-
type
|
|
164
|
+
export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
|
|
187
165
|
|
|
188
|
-
export const flattenExtensions = (extension:
|
|
166
|
+
export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
189
167
|
if (Array.isArray(extension)) {
|
|
190
168
|
return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
|
|
191
169
|
} else {
|
|
@@ -200,106 +178,67 @@ export const flattenExtensions = (extension: ExtensionArg, acc: BuilderExtension
|
|
|
200
178
|
// Should unsubscribe from nodes that are not in the set/radius.
|
|
201
179
|
// Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
|
|
202
180
|
export class GraphBuilder {
|
|
203
|
-
|
|
204
|
-
private readonly _extensions = create<Record<string, BuilderExtension>>({});
|
|
205
|
-
private readonly _resolverSubscriptions = new Map<string, CleanupFn>();
|
|
181
|
+
// TODO(wittjosiah): Use Context.
|
|
206
182
|
private readonly _connectorSubscriptions = new Map<string, CleanupFn>();
|
|
207
|
-
private readonly
|
|
208
|
-
|
|
209
|
-
|
|
183
|
+
private readonly _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
|
|
184
|
+
Rx.keepAlive,
|
|
185
|
+
Rx.withLabel('graph-builder:extensions'),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
private readonly _registry: Registry.Registry;
|
|
189
|
+
private readonly _graph: Graph;
|
|
210
190
|
|
|
211
|
-
constructor(params: Pick<GraphParams, 'nodes' | 'edges'> = {}) {
|
|
191
|
+
constructor({ registry, ...params }: Pick<GraphParams, 'registry' | 'nodes' | 'edges'> = {}) {
|
|
192
|
+
this._registry = registry ?? Registry.make();
|
|
212
193
|
this._graph = new Graph({
|
|
213
194
|
...params,
|
|
214
|
-
|
|
215
|
-
|
|
195
|
+
registry: this._registry,
|
|
196
|
+
onExpand: (id, relation) => this._onExpand(id, relation),
|
|
197
|
+
// onInitialize: (id) => this._onInitialize(id),
|
|
216
198
|
onRemoveNode: (id) => this._onRemoveNode(id),
|
|
217
199
|
});
|
|
218
200
|
}
|
|
219
201
|
|
|
220
|
-
static from(pickle?: string) {
|
|
202
|
+
static from(pickle?: string, registry?: Registry.Registry): GraphBuilder {
|
|
221
203
|
if (!pickle) {
|
|
222
|
-
return new GraphBuilder();
|
|
204
|
+
return new GraphBuilder({ registry });
|
|
223
205
|
}
|
|
224
206
|
|
|
225
207
|
const { nodes, edges } = JSON.parse(pickle);
|
|
226
|
-
return new GraphBuilder({ nodes, edges });
|
|
208
|
+
return new GraphBuilder({ nodes, edges, registry });
|
|
227
209
|
}
|
|
228
210
|
|
|
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
|
-
);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
get graph() {
|
|
211
|
+
get graph(): ExpandableGraph {
|
|
252
212
|
return this._graph;
|
|
253
213
|
}
|
|
254
214
|
|
|
255
|
-
/**
|
|
256
|
-
* @reactive
|
|
257
|
-
*/
|
|
258
215
|
get extensions() {
|
|
259
|
-
return
|
|
216
|
+
return this._extensions;
|
|
260
217
|
}
|
|
261
218
|
|
|
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
|
-
});
|
|
219
|
+
addExtension(extensions: BuilderExtensions): GraphBuilder {
|
|
220
|
+
flattenExtensions(extensions).forEach((extension) => {
|
|
221
|
+
const extensions = this._registry.get(this._extensions);
|
|
222
|
+
this._registry.set(this._extensions, Record.set(extensions, extension.id, extension));
|
|
272
223
|
});
|
|
273
224
|
return this;
|
|
274
225
|
}
|
|
275
226
|
|
|
276
|
-
/**
|
|
277
|
-
* Remove a node builder from the graph builder.
|
|
278
|
-
*/
|
|
279
227
|
removeExtension(id: string): GraphBuilder {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
228
|
+
const extensions = this._registry.get(this._extensions);
|
|
229
|
+
this._registry.set(this._extensions, Record.remove(extensions, id));
|
|
283
230
|
return this;
|
|
284
231
|
}
|
|
285
232
|
|
|
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
233
|
async explore(
|
|
298
|
-
|
|
234
|
+
// TODO(wittjosiah): Currently defaulting to new registry.
|
|
235
|
+
// Currently unsure about how to handle nodes which are expanded in the background.
|
|
236
|
+
// This seems like a good place to start.
|
|
237
|
+
{ registry = Registry.make(), source = ROOT_ID, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
|
|
299
238
|
path: string[] = [],
|
|
300
|
-
) {
|
|
239
|
+
): Promise<void> {
|
|
301
240
|
// Break cycles.
|
|
302
|
-
if (path.includes(
|
|
241
|
+
if (path.includes(source)) {
|
|
303
242
|
return;
|
|
304
243
|
}
|
|
305
244
|
|
|
@@ -309,155 +248,133 @@ export class GraphBuilder {
|
|
|
309
248
|
const { yieldOrContinue } = await import('main-thread-scheduling');
|
|
310
249
|
await yieldOrContinue('idle');
|
|
311
250
|
}
|
|
251
|
+
|
|
252
|
+
const node = registry.get(this._graph.nodeOrThrow(source));
|
|
312
253
|
const shouldContinue = await visitor(node, [...path, node.id]);
|
|
313
254
|
if (shouldContinue === false) {
|
|
314
255
|
return;
|
|
315
256
|
}
|
|
316
257
|
|
|
317
|
-
const nodes = Object.values(this._extensions)
|
|
258
|
+
const nodes = Object.values(this._registry.get(this._extensions))
|
|
318
259
|
.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
|
-
}
|
|
260
|
+
.map((extension) => extension.connector)
|
|
261
|
+
.filter(isNonNullable)
|
|
262
|
+
.flatMap((connector) => registry.get(connector(this._graph.node(source))));
|
|
340
263
|
|
|
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
|
-
}
|
|
264
|
+
await Promise.all(
|
|
265
|
+
nodes.map((nodeArg) => {
|
|
266
|
+
registry.set(this._graph._node(nodeArg.id), this._graph._constructNode(nodeArg));
|
|
267
|
+
return this.explore({ registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
|
|
379
268
|
}),
|
|
380
269
|
);
|
|
270
|
+
|
|
271
|
+
if (registry !== this._registry) {
|
|
272
|
+
registry.reset();
|
|
273
|
+
registry.dispose();
|
|
274
|
+
}
|
|
381
275
|
}
|
|
382
276
|
|
|
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
|
-
}
|
|
277
|
+
destroy(): void {
|
|
278
|
+
this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
279
|
+
this._connectorSubscriptions.clear();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private readonly _connectors = Rx.family<string, Rx.Rx<NodeArg<any>[]>>((key) => {
|
|
283
|
+
return Rx.make((get) => {
|
|
284
|
+
const [id, relation] = key.split('+');
|
|
285
|
+
const node = this._graph.node(id);
|
|
286
|
+
|
|
287
|
+
return pipe(
|
|
288
|
+
get(this._extensions),
|
|
289
|
+
Record.values,
|
|
290
|
+
// TODO(wittjosiah): Sort on write rather than read.
|
|
291
|
+
Array.sortBy(byPosition),
|
|
292
|
+
Array.filter(({ relation: _relation = 'outbound' }) => _relation === relation),
|
|
293
|
+
Array.map(({ connector }) => connector?.(node)),
|
|
294
|
+
Array.filter(isNonNullable),
|
|
295
|
+
Array.flatMap((result) => get(result)),
|
|
296
|
+
);
|
|
297
|
+
}).pipe(Rx.withLabel(`graph-builder:connectors:${key}`));
|
|
298
|
+
});
|
|
427
299
|
|
|
300
|
+
private _onExpand(id: string, relation: Relation): void {
|
|
301
|
+
log('onExpand', { id, relation, registry: getDebugName(this._registry) });
|
|
302
|
+
const connectors = this._connectors(`${id}+${relation}`);
|
|
303
|
+
|
|
304
|
+
let previous: string[] = [];
|
|
305
|
+
const cancel = this._registry.subscribe(
|
|
306
|
+
connectors,
|
|
307
|
+
(nodes) => {
|
|
428
308
|
const ids = nodes.map((n) => n.id);
|
|
429
309
|
const removed = previous.filter((id) => !ids.includes(id));
|
|
430
310
|
previous = ids;
|
|
431
311
|
|
|
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
|
-
}
|
|
312
|
+
log('update', { id, relation, ids, removed });
|
|
313
|
+
Rx.batch(() => {
|
|
314
|
+
this._graph.removeEdges(
|
|
315
|
+
removed.map((target) => ({ source: id, target })),
|
|
316
|
+
true,
|
|
317
|
+
);
|
|
318
|
+
this._graph.addNodes(nodes);
|
|
319
|
+
this._graph.addEdges(
|
|
320
|
+
nodes.map((node) =>
|
|
321
|
+
relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
this._graph.sortEdges(
|
|
325
|
+
id,
|
|
326
|
+
relation,
|
|
327
|
+
nodes.map(({ id }) => id),
|
|
328
|
+
);
|
|
452
329
|
});
|
|
453
|
-
}
|
|
330
|
+
},
|
|
331
|
+
{ immediate: true },
|
|
454
332
|
);
|
|
333
|
+
|
|
334
|
+
this._connectorSubscriptions.set(id, cancel);
|
|
455
335
|
}
|
|
456
336
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
337
|
+
// TODO(wittjosiah): On initialize to restore state from cache.
|
|
338
|
+
// private async _onInitialize(id: string) {
|
|
339
|
+
// log('onInitialize', { id });
|
|
340
|
+
// }
|
|
341
|
+
|
|
342
|
+
private _onRemoveNode(id: string): void {
|
|
343
|
+
this._connectorSubscriptions.get(id)?.();
|
|
344
|
+
this._connectorSubscriptions.delete(id);
|
|
462
345
|
}
|
|
463
346
|
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Creates an Rx.Rx<T> from a callback which accesses signals.
|
|
350
|
+
* Will return a new rx instance each time.
|
|
351
|
+
*/
|
|
352
|
+
export const rxFromSignal = <T>(cb: () => T): Rx.Rx<T> => {
|
|
353
|
+
return Rx.make((get) => {
|
|
354
|
+
const dispose = effect(() => {
|
|
355
|
+
get.setSelf(cb());
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
get.addFinalizer(() => dispose());
|
|
359
|
+
|
|
360
|
+
return cb();
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const observableFamily = Rx.family((observable: MulticastObservable<any>) => {
|
|
365
|
+
return Rx.make((get) => {
|
|
366
|
+
const subscription = observable.subscribe((value) => get.setSelf(value));
|
|
367
|
+
|
|
368
|
+
get.addFinalizer(() => subscription.unsubscribe());
|
|
369
|
+
|
|
370
|
+
return observable.get();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Creates an Rx.Rx<T> from a MulticastObservable<T>
|
|
376
|
+
* Will return the same rx instance for the same observable.
|
|
377
|
+
*/
|
|
378
|
+
export const rxFromObservable = <T>(observable: MulticastObservable<T>): Rx.Rx<T> => {
|
|
379
|
+
return observableFamily(observable) as Rx.Rx<T>;
|
|
380
|
+
};
|