@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.
Files changed (33) hide show
  1. package/dist/lib/browser/index.mjs +789 -541
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +780 -533
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +789 -541
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/graph-builder.d.ts +91 -48
  11. package/dist/types/src/graph-builder.d.ts.map +1 -1
  12. package/dist/types/src/graph.d.ts +98 -191
  13. package/dist/types/src/graph.d.ts.map +1 -1
  14. package/dist/types/src/node.d.ts +3 -3
  15. package/dist/types/src/node.d.ts.map +1 -1
  16. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  17. package/dist/types/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +16 -23
  19. package/src/graph-builder.test.ts +310 -293
  20. package/src/graph-builder.ts +317 -209
  21. package/src/graph.test.ts +463 -314
  22. package/src/graph.ts +455 -452
  23. package/src/node.ts +4 -4
  24. package/src/stories/EchoGraph.stories.tsx +78 -57
  25. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  26. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  27. package/dist/types/src/signals-integration.test.d.ts +0 -2
  28. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  29. package/dist/types/src/testing.d.ts +0 -5
  30. package/dist/types/src/testing.d.ts.map +0 -1
  31. package/src/experimental/graph-projections.test.ts +0 -56
  32. package/src/signals-integration.test.ts +0 -218
  33. package/src/testing.ts +0 -20
@@ -1,121 +1,104 @@
1
1
  //
2
- // Copyright 2025 DXOS.org
2
+ // Copyright 2023 DXOS.org
3
3
  //
4
4
 
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';
5
+ import { effect, type Signal, signal, untracked } from '@preact/signals-core';
8
6
 
9
- import { type MulticastObservable, type CleanupFn } from '@dxos/async';
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, getDebugName, isNode, isNonNullable, type MaybePromise, type Position } from '@dxos/util';
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, type ExpandableGraph } from './graph';
14
- import { actionGroupSymbol, type ActionData, type Node, type NodeArg, type Relation } from './node';
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: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
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: Rx.Rx<Option.Option<Node>>,
28
- ) => Rx.Rx<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
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: Rx.Rx<Option.Option<Node>>,
35
- ) => Rx.Rx<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
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.position Affects the order the extensions are processed in.
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
- // TODO(wittjosiah): On initialize to restore state from cache.
53
- // resolver?: ResolverExtension;
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
- // 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,
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: 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
- ),
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: 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
- ),
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
- registry?: Registry.Registry;
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
- relation?: Relation; // Only for connector.
135
- // resolver?: ResolverExtension;
136
- connector?: (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
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
- export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
186
+ type ExtensionArg = BuilderExtension | BuilderExtension[] | ExtensionArg[];
140
187
 
141
- export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
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
- // TODO(wittjosiah): Use Context.
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 _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
159
- Rx.keepAlive,
160
- Rx.withLabel('graph-builder:extensions'),
161
- );
207
+ private readonly _nodeChanged: Record<string, Signal<{}>> = {};
208
+ private readonly _initialized: Record<string, Trigger> = {};
209
+ private _graph: Graph;
162
210
 
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();
211
+ constructor(params: Pick<GraphParams, 'nodes' | 'edges'> = {}) {
168
212
  this._graph = new Graph({
169
213
  ...params,
170
- registry: this._registry,
171
- onExpand: (id, relation) => this._onExpand(id, relation),
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, registry?: Registry.Registry) {
220
+ static from(pickle?: string) {
178
221
  if (!pickle) {
179
- return new GraphBuilder({ registry });
222
+ return new GraphBuilder();
180
223
  }
181
224
 
182
225
  const { nodes, edges } = JSON.parse(pickle);
183
- return new GraphBuilder({ nodes, edges, registry });
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(): ExpandableGraph {
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
- 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));
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
- const extensions = this._registry.get(this._extensions);
204
- this._registry.set(this._extensions, Record.remove(extensions, id));
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
- // 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,
298
+ { node = this._graph.root, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
213
299
  path: string[] = [],
214
300
  ) {
215
301
  // Break cycles.
216
- if (path.includes(source)) {
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._registry.get(this._extensions))
317
+ const nodes = Object.values(this._extensions)
234
318
  .filter((extension) => relation === (extension.relation ?? 'outbound'))
235
- .map((extension) => extension.connector)
236
- .filter(isNonNullable)
237
- .flatMap((connector) => registry.get(connector(this._graph.node(source))));
238
-
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]);
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
- if (registry !== this._registry) {
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
- destroy() {
253
- this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
254
- this._connectorSubscriptions.clear();
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 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
- });
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
- const cancel = this._registry.subscribe(
281
- connectors,
282
- (nodes) => {
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
- 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
- );
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
- // 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);
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
- };