@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.
Files changed (33) hide show
  1. package/dist/lib/browser/index.mjs +541 -789
  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 +533 -780
  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 +541 -789
  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 +2 -2
  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 +23 -16
  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 +209 -317
  28. package/src/graph.test.ts +314 -463
  29. package/src/graph.ts +452 -458
  30. package/src/node.ts +2 -2
  31. package/src/signals-integration.test.ts +218 -0
  32. package/src/stories/EchoGraph.stories.tsx +56 -77
  33. package/src/testing.ts +20 -0
@@ -1,104 +1,121 @@
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 { live } 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,
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 ? { ...rest, id: getId('connector'), position, connector } : undefined,
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: ({ node }) =>
91
- actionGroups({ node })?.map((arg) => ({ ...arg, data: actionGroupSymbol, type: ACTION_GROUP_TYPE })),
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: ({ node }) => actions({ node })?.map((arg) => ({ ...arg, type: ACTION_TYPE })),
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
- node?: Node;
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
- resolver?: ResolverExtension;
179
- connector?: ConnectorExtension;
180
- // Only for connector.
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 ExtensionArg = BuilderExtension | BuilderExtension[] | ExtensionArg[];
139
+ export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
187
140
 
188
- export const flattenExtensions = (extension: ExtensionArg, acc: BuilderExtension[] = []): BuilderExtension[] => {
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
- private readonly _dispatcher = new Dispatcher();
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 _nodeChanged: Record<string, Signal<{}>> = {};
208
- private readonly _initialized: Record<string, Trigger> = {};
209
- private _graph: Graph;
158
+ private readonly _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
159
+ Rx.keepAlive,
160
+ Rx.withLabel('graph-builder:extensions'),
161
+ );
210
162
 
211
- constructor(params: Pick<GraphParams, 'nodes' | 'edges'> = {}) {
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
- onInitialNode: async (id) => this._onInitialNode(id),
215
- onInitialNodes: async (node, relation, type) => this._onInitialNodes(node, relation, type),
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 Object.values(this._extensions);
191
+ return this._extensions;
260
192
  }
261
193
 
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
- });
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
- untracked(() => {
281
- delete this._extensions[id];
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
- { node = this._graph.root, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
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(node.id)) {
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
- .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
- }
235
+ .map((extension) => extension.connector)
236
+ .filter(isNonNullable)
237
+ .flatMap((connector) => registry.get(connector(this._graph.node(source))));
340
238
 
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
- }
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
- 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
- }
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
- // 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
- }
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
- 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);
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
+ };