@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.
Files changed (33) hide show
  1. package/dist/lib/browser/index.mjs +593 -794
  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 +585 -785
  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 +593 -794
  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/experimental/graph-projections.test.d.ts +25 -0
  11. package/dist/types/src/experimental/graph-projections.test.d.ts.map +1 -0
  12. package/dist/types/src/graph-builder.d.ts +48 -91
  13. package/dist/types/src/graph-builder.d.ts.map +1 -1
  14. package/dist/types/src/graph.d.ts +191 -98
  15. package/dist/types/src/graph.d.ts.map +1 -1
  16. package/dist/types/src/node.d.ts +3 -3
  17. package/dist/types/src/node.d.ts.map +1 -1
  18. package/dist/types/src/signals-integration.test.d.ts +2 -0
  19. package/dist/types/src/signals-integration.test.d.ts.map +1 -0
  20. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  21. package/dist/types/src/testing.d.ts +5 -0
  22. package/dist/types/src/testing.d.ts.map +1 -0
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +24 -17
  25. package/src/experimental/graph-projections.test.ts +56 -0
  26. package/src/graph-builder.test.ts +293 -310
  27. package/src/graph-builder.ts +235 -318
  28. package/src/graph.test.ts +314 -463
  29. package/src/graph.ts +452 -455
  30. package/src/node.ts +4 -4
  31. package/src/signals-integration.test.ts +218 -0
  32. package/src/stories/EchoGraph.stories.tsx +67 -76
  33. package/src/testing.ts +20 -0
@@ -1,104 +1,146 @@
1
1
  //
2
- // Copyright 2023 DXOS.org
2
+ // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { effect, type Signal, signal, untracked } from '@preact/signals-core';
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 { Trigger, type CleanupFn } from '@dxos/async';
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, type Position, isNode, type MaybePromise, isNonNullable } from '@dxos/util';
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, 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;
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<T = any> = (params: { node: Node<T> }) => NodeArg<any>[] | undefined;
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<T = any> = (params: {
37
- node: Node<T>;
38
- }) => Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[] | undefined;
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<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;
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.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.
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<T = any> = {
48
+ export type CreateExtensionOptions = {
63
49
  id: string;
64
50
  relation?: Relation;
65
- type?: string;
66
51
  position?: Position;
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']>>;
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 = <T = any>(extension: CreateExtensionOptions<T>): BuilderExtension[] => {
78
- const { id, position = 'static', resolver, connector, actions, actionGroups, ...rest } = extension;
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 ? { ...rest, id: getId('connector'), position, connector } : undefined,
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: ({ node }) =>
91
- actionGroups({ node })?.map((arg) => ({ ...arg, data: actionGroupSymbol, type: ACTION_GROUP_TYPE })),
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: ({ node }) => actions({ node })?.map((arg) => ({ ...arg, type: ACTION_TYPE })),
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
- node?: Node;
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
- resolver?: ResolverExtension;
179
- connector?: ConnectorExtension;
180
- // Only for connector.
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 ExtensionArg = BuilderExtension | BuilderExtension[] | ExtensionArg[];
164
+ export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
187
165
 
188
- export const flattenExtensions = (extension: ExtensionArg, acc: BuilderExtension[] = []): BuilderExtension[] => {
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
- private readonly _dispatcher = new Dispatcher();
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 _nodeChanged: Record<string, Signal<{}>> = {};
208
- private readonly _initialized: Record<string, Trigger> = {};
209
- private _graph: Graph;
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
- onInitialNode: async (id) => this._onInitialNode(id),
215
- onInitialNodes: async (node, relation, type) => this._onInitialNodes(node, relation, type),
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 Object.values(this._extensions);
216
+ return this._extensions;
260
217
  }
261
218
 
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
- });
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
- untracked(() => {
281
- delete this._extensions[id];
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
- { node = this._graph.root, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
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(node.id)) {
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
- .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
- );
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
- 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
- }
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
- private _onInitialNodes(node: Node, nodesRelation: Relation, nodesType?: string) {
384
- this._nodeChanged[node.id] = this._nodeChanged[node.id] ?? signal({});
385
- let first = true;
386
- let previous: string[] = [];
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
- }
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
- // 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
- }
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
- 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);
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
+ };