@dxos/app-graph 0.8.3 → 0.8.4-main.1068cf700f

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 (47) hide show
  1. package/dist/lib/browser/index.mjs +1135 -616
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1134 -616
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/atoms.d.ts +8 -0
  8. package/dist/types/src/atoms.d.ts.map +1 -0
  9. package/dist/types/src/graph-builder.d.ts +113 -60
  10. package/dist/types/src/graph-builder.d.ts.map +1 -1
  11. package/dist/types/src/graph.d.ts +183 -209
  12. package/dist/types/src/graph.d.ts.map +1 -1
  13. package/dist/types/src/index.d.ts +6 -3
  14. package/dist/types/src/index.d.ts.map +1 -1
  15. package/dist/types/src/node-matcher.d.ts +218 -0
  16. package/dist/types/src/node-matcher.d.ts.map +1 -0
  17. package/dist/types/src/node-matcher.test.d.ts +2 -0
  18. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  19. package/dist/types/src/node.d.ts +32 -3
  20. package/dist/types/src/node.d.ts.map +1 -1
  21. package/dist/types/src/stories/EchoGraph.stories.d.ts +6 -13
  22. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +37 -37
  25. package/src/atoms.ts +25 -0
  26. package/src/graph-builder.test.ts +571 -97
  27. package/src/graph-builder.ts +600 -258
  28. package/src/graph.test.ts +300 -107
  29. package/src/graph.ts +971 -400
  30. package/src/index.ts +9 -3
  31. package/src/node-matcher.test.ts +301 -0
  32. package/src/node-matcher.ts +284 -0
  33. package/src/node.ts +40 -5
  34. package/src/stories/EchoGraph.stories.tsx +128 -233
  35. package/src/stories/Tree.tsx +2 -2
  36. package/dist/lib/node/index.cjs +0 -816
  37. package/dist/lib/node/index.cjs.map +0 -7
  38. package/dist/lib/node/meta.json +0 -1
  39. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  40. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  41. package/dist/types/src/signals-integration.test.d.ts +0 -2
  42. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  43. package/dist/types/src/testing.d.ts +0 -5
  44. package/dist/types/src/testing.d.ts.map +0 -1
  45. package/src/experimental/graph-projections.test.ts +0 -56
  46. package/src/signals-integration.test.ts +0 -218
  47. package/src/testing.ts +0 -20
@@ -2,213 +2,136 @@
2
2
  // Copyright 2025 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';
8
-
9
- import { type MulticastObservable, type CleanupFn } from '@dxos/async';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Array from 'effect/Array';
7
+ import type * as Context from 'effect/Context';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Function from 'effect/Function';
10
+ import * as Option from 'effect/Option';
11
+ import * as Pipeable from 'effect/Pipeable';
12
+ import * as Record from 'effect/Record';
13
+ import type * as Schema from 'effect/Schema';
14
+
15
+ import { type CleanupFn, type Trigger } from '@dxos/async';
16
+ import { type Entity, type Type } from '@dxos/echo';
10
17
  import { log } from '@dxos/log';
11
- import { byPosition, getDebugName, isNode, isNonNullable, type MaybePromise, type Position } from '@dxos/util';
18
+ import { type MaybePromise, type Position, byPosition, getDebugName, isNode, isNonNullable } from '@dxos/util';
19
+
20
+ import * as Graph from './graph';
21
+ import * as Node from './node';
22
+ import * as NodeMatcher from './node-matcher';
12
23
 
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';
24
+ //
25
+ // Extension Types
26
+ //
27
+
28
+ /**
29
+ * Graph builder extension for adding nodes to the graph based on a node id.
30
+ */
31
+ export type ResolverExtension = (id: string) => Atom.Atom<Node.NodeArg<any> | null>;
15
32
 
16
33
  /**
17
34
  * Graph builder extension for adding nodes to the graph based on a connection to an existing node.
18
35
  *
19
36
  * @param params.node The existing node the returned nodes will be connected to.
20
37
  */
21
- export type ConnectorExtension = (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
38
+ export type ConnectorExtension = (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
22
39
 
23
40
  /**
24
41
  * Constrained case of the connector extension for more easily adding actions to the graph.
25
42
  */
26
43
  export type ActionsExtension = (
27
- node: Rx.Rx<Option.Option<Node>>,
28
- ) => Rx.Rx<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
44
+ node: Atom.Atom<Option.Option<Node.Node>>,
45
+ ) => Atom.Atom<Omit<Node.NodeArg<Node.ActionData<any>>, 'type' | 'nodes' | 'edges'>[]>;
29
46
 
30
47
  /**
31
48
  * Constrained case of the connector extension for more easily adding action groups to the graph.
32
49
  */
33
50
  export type ActionGroupsExtension = (
34
- node: Rx.Rx<Option.Option<Node>>,
35
- ) => Rx.Rx<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
51
+ node: Atom.Atom<Option.Option<Node.Node>>,
52
+ ) => Atom.Atom<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
36
53
 
37
- /**
38
- * A graph builder extension is used to add nodes to the graph.
39
- *
40
- * @param params.id The unique id of the extension.
41
- * @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.
43
- * @param params.resolver A function to add nodes to the graph based on just the node id.
44
- * @param params.connector A function to add nodes to the graph based on a connection to an existing node.
45
- * @param params.actions A function to add actions to the graph based on a connection to an existing node.
46
- * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
47
- */
48
- export type CreateExtensionOptions = {
54
+ export type BuilderExtension = Readonly<{
49
55
  id: string;
50
- relation?: Relation;
51
- position?: Position;
52
- // TODO(wittjosiah): On initialize to restore state from cache.
53
- // resolver?: ResolverExtension;
54
- connector?: ConnectorExtension;
55
- actions?: ActionsExtension;
56
- actionGroups?: ActionGroupsExtension;
57
- };
58
-
59
- /**
60
- * Create a graph builder extension.
61
- */
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;
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
- );
56
+ position: Position;
57
+ relation?: Node.Relation; // Only for connector.
58
+ resolver?: ResolverExtension;
59
+ connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
60
+ }>;
84
61
 
85
- const actions =
86
- _actions &&
87
- Rx.family((node: Rx.Rx<Option.Option<Node>>) => _actions(node).pipe(Rx.withLabel(`graph-builder:_actions:${id}`)));
62
+ export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
88
63
 
89
- return [
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,
108
- actionGroups
109
- ? ({
110
- id: getId('actionGroups'),
111
- position,
112
- relation: 'outbound',
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
- ),
127
- } satisfies BuilderExtension)
128
- : undefined,
129
- actions
130
- ? ({
131
- id: getId('actions'),
132
- position,
133
- relation: 'outbound',
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
- ),
144
- } satisfies BuilderExtension)
145
- : undefined,
146
- ].filter(isNonNullable);
147
- };
64
+ //
65
+ // GraphBuilder Core
66
+ //
148
67
 
149
68
  export type GraphBuilderTraverseOptions = {
150
- visitor: (node: Node, path: string[]) => MaybePromise<boolean | void>;
69
+ visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
151
70
  registry?: Registry.Registry;
152
71
  source?: string;
153
- relation?: Relation;
72
+ relation?: Node.Relation;
154
73
  };
155
74
 
156
- export type BuilderExtension = Readonly<{
157
- id: string;
158
- position: Position;
159
- relation?: Relation; // Only for connector.
160
- // resolver?: ResolverExtension;
161
- connector?: (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
162
- }>;
163
-
164
- export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
75
+ /**
76
+ * Identifier denoting a GraphBuilder.
77
+ */
78
+ export const GraphBuilderTypeId: unique symbol = Symbol.for('@dxos/app-graph/GraphBuilder');
79
+ export type GraphBuilderTypeId = typeof GraphBuilderTypeId;
165
80
 
166
- export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
167
- if (Array.isArray(extension)) {
168
- return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
169
- } else {
170
- return [...acc, extension];
171
- }
172
- };
81
+ /**
82
+ * GraphBuilder interface.
83
+ */
84
+ export interface GraphBuilder extends Pipeable.Pipeable {
85
+ readonly [GraphBuilderTypeId]: GraphBuilderTypeId;
86
+ readonly graph: Graph.ExpandableGraph;
87
+ readonly extensions: Atom.Atom<Record<string, BuilderExtension>>;
88
+ }
173
89
 
174
90
  /**
175
91
  * The builder provides an extensible way to compose the construction of the graph.
92
+ * @internal
176
93
  */
177
94
  // TODO(wittjosiah): Add api for setting subscription set and/or radius.
178
95
  // Should unsubscribe from nodes that are not in the set/radius.
179
96
  // Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
180
- export class GraphBuilder {
181
- // TODO(wittjosiah): Use Context.
182
- private readonly _connectorSubscriptions = new Map<string, CleanupFn>();
183
- private readonly _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
184
- Rx.keepAlive,
185
- Rx.withLabel('graph-builder:extensions'),
186
- );
97
+ class GraphBuilderImpl implements GraphBuilder {
98
+ readonly [GraphBuilderTypeId]: GraphBuilderTypeId = GraphBuilderTypeId;
187
99
 
188
- private readonly _registry: Registry.Registry;
189
- private readonly _graph: Graph;
100
+ pipe() {
101
+ // eslint-disable-next-line prefer-rest-params
102
+ return Pipeable.pipeArguments(this, arguments);
103
+ }
190
104
 
191
- constructor({ registry, ...params }: Pick<GraphParams, 'registry' | 'nodes' | 'edges'> = {}) {
105
+ // TODO(wittjosiah): Use Context.
106
+ readonly _subscriptions = new Map<string, CleanupFn>();
107
+ readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
108
+ Atom.keepAlive,
109
+ Atom.withLabel('graph-builder:extensions'),
110
+ );
111
+ readonly _initialized: Record<string, Trigger> = {};
112
+ readonly _registry: Registry.Registry;
113
+ readonly _graph: Graph.Graph & {
114
+ _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
115
+ _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
116
+ };
117
+
118
+ constructor({ registry, ...params }: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'> = {}) {
192
119
  this._registry = registry ?? Registry.make();
193
- this._graph = new Graph({
120
+ const graph = Graph.make({
194
121
  ...params,
195
122
  registry: this._registry,
196
123
  onExpand: (id, relation) => this._onExpand(id, relation),
197
- // onInitialize: (id) => this._onInitialize(id),
124
+ onInitialize: (id) => this._onInitialize(id),
198
125
  onRemoveNode: (id) => this._onRemoveNode(id),
199
126
  });
127
+ // Access internal methods via type assertion since GraphBuilder needs them
128
+ this._graph = graph as Graph.Graph & {
129
+ _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
130
+ _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
131
+ };
200
132
  }
201
133
 
202
- static from(pickle?: string, registry?: Registry.Registry): GraphBuilder {
203
- if (!pickle) {
204
- return new GraphBuilder({ registry });
205
- }
206
-
207
- const { nodes, edges } = JSON.parse(pickle);
208
- return new GraphBuilder({ nodes, edges, registry });
209
- }
210
-
211
- get graph(): ExpandableGraph {
134
+ get graph(): Graph.ExpandableGraph {
212
135
  return this._graph;
213
136
  }
214
137
 
@@ -216,75 +139,27 @@ export class GraphBuilder {
216
139
  return this._extensions;
217
140
  }
218
141
 
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));
142
+ private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
143
+ return Atom.make((get) => {
144
+ return Function.pipe(
145
+ get(this._extensions),
146
+ Record.values,
147
+ Array.sortBy(byPosition),
148
+ Array.map(({ resolver }) => resolver),
149
+ Array.filter(isNonNullable),
150
+ Array.map((resolver) => get(resolver(id))),
151
+ Array.filter(isNonNullable),
152
+ Array.head,
153
+ );
223
154
  });
224
- return this;
225
- }
226
-
227
- removeExtension(id: string): GraphBuilder {
228
- const extensions = this._registry.get(this._extensions);
229
- this._registry.set(this._extensions, Record.remove(extensions, id));
230
- return this;
231
- }
232
-
233
- async explore(
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,
238
- path: string[] = [],
239
- ): Promise<void> {
240
- // Break cycles.
241
- if (path.includes(source)) {
242
- return;
243
- }
244
-
245
- // TODO(wittjosiah): This is a workaround for esm not working in the test runner.
246
- // Switching to vitest is blocked by having node esm versions of echo-schema & echo-signals.
247
- if (!isNode()) {
248
- const { yieldOrContinue } = await import('main-thread-scheduling');
249
- await yieldOrContinue('idle');
250
- }
251
-
252
- const node = registry.get(this._graph.nodeOrThrow(source));
253
- const shouldContinue = await visitor(node, [...path, node.id]);
254
- if (shouldContinue === false) {
255
- return;
256
- }
257
-
258
- const nodes = Object.values(this._registry.get(this._extensions))
259
- .filter((extension) => relation === (extension.relation ?? 'outbound'))
260
- .map((extension) => extension.connector)
261
- .filter(isNonNullable)
262
- .flatMap((connector) => registry.get(connector(this._graph.node(source))));
263
-
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]);
268
- }),
269
- );
270
-
271
- if (registry !== this._registry) {
272
- registry.reset();
273
- registry.dispose();
274
- }
275
- }
276
-
277
- destroy(): void {
278
- this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
279
- this._connectorSubscriptions.clear();
280
- }
155
+ });
281
156
 
282
- private readonly _connectors = Rx.family<string, Rx.Rx<NodeArg<any>[]>>((key) => {
283
- return Rx.make((get) => {
157
+ private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
158
+ return Atom.make((get) => {
284
159
  const [id, relation] = key.split('+');
285
160
  const node = this._graph.node(id);
286
161
 
287
- return pipe(
162
+ return Function.pipe(
288
163
  get(this._extensions),
289
164
  Record.values,
290
165
  // TODO(wittjosiah): Sort on write rather than read.
@@ -294,10 +169,10 @@ export class GraphBuilder {
294
169
  Array.filter(isNonNullable),
295
170
  Array.flatMap((result) => get(result)),
296
171
  );
297
- }).pipe(Rx.withLabel(`graph-builder:connectors:${key}`));
172
+ }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
298
173
  });
299
174
 
300
- private _onExpand(id: string, relation: Relation): void {
175
+ private _onExpand(id: string, relation: Node.Relation): void {
301
176
  log('onExpand', { id, relation, registry: getDebugName(this._registry) });
302
177
  const connectors = this._connectors(`${id}+${relation}`);
303
178
 
@@ -311,18 +186,21 @@ export class GraphBuilder {
311
186
 
312
187
  log('update', { id, relation, ids, removed });
313
188
  const update = () => {
314
- Rx.batch(() => {
315
- this._graph.removeEdges(
189
+ Atom.batch(() => {
190
+ Graph.removeEdges(
191
+ this._graph,
316
192
  removed.map((target) => ({ source: id, target })),
317
193
  true,
318
194
  );
319
- this._graph.addNodes(nodes);
320
- this._graph.addEdges(
195
+ Graph.addNodes(this._graph, nodes);
196
+ Graph.addEdges(
197
+ this._graph,
321
198
  nodes.map((node) =>
322
199
  relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
323
200
  ),
324
201
  );
325
- this._graph.sortEdges(
202
+ Graph.sortEdges(
203
+ this._graph,
326
204
  id,
327
205
  relation,
328
206
  nodes.map(({ id }) => id),
@@ -341,50 +219,514 @@ export class GraphBuilder {
341
219
  { immediate: true },
342
220
  );
343
221
 
344
- this._connectorSubscriptions.set(id, cancel);
222
+ this._subscriptions.set(id, cancel);
345
223
  }
346
224
 
347
- // TODO(wittjosiah): On initialize to restore state from cache.
348
- // private async _onInitialize(id: string) {
349
- // log('onInitialize', { id });
350
- // }
225
+ // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
226
+ private async _onInitialize(id: string) {
227
+ log('onInitialize', { id });
228
+ const resolver = this._resolvers(id);
229
+
230
+ const cancel = this._registry.subscribe(
231
+ resolver,
232
+ (node) => {
233
+ const trigger = this._initialized[id];
234
+ Option.match(node, {
235
+ onSome: (node) => {
236
+ Graph.addNodes(this._graph, [node]);
237
+ trigger?.wake();
238
+ },
239
+ onNone: () => {
240
+ trigger?.wake();
241
+ Graph.removeNodes(this._graph, [id]);
242
+ },
243
+ });
244
+ },
245
+ { immediate: true },
246
+ );
247
+
248
+ this._subscriptions.set(id, cancel);
249
+ }
351
250
 
352
251
  private _onRemoveNode(id: string): void {
353
- this._connectorSubscriptions.get(id)?.();
354
- this._connectorSubscriptions.delete(id);
252
+ this._subscriptions.get(id)?.();
253
+ this._subscriptions.delete(id);
355
254
  }
356
255
  }
357
256
 
358
257
  /**
359
- * Creates an Rx.Rx<T> from a callback which accesses signals.
360
- * Will return a new rx instance each time.
258
+ * Creates a new GraphBuilder instance.
361
259
  */
362
- export const rxFromSignal = <T>(cb: () => T): Rx.Rx<T> => {
363
- return Rx.make((get) => {
364
- const dispose = effect(() => {
365
- get.setSelf(cb());
366
- });
260
+ export const make = (params?: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'>): GraphBuilder => {
261
+ return new GraphBuilderImpl(params);
262
+ };
367
263
 
368
- get.addFinalizer(() => dispose());
264
+ /**
265
+ * Creates a GraphBuilder from a serialized pickle string.
266
+ */
267
+ export const from = (pickle?: string, registry?: Registry.Registry): GraphBuilder => {
268
+ if (!pickle) {
269
+ return make({ registry });
270
+ }
271
+
272
+ const { nodes, edges } = JSON.parse(pickle);
273
+ return make({ nodes, edges, registry });
274
+ };
369
275
 
370
- return cb();
276
+ /**
277
+ * Implementation helper for addExtension.
278
+ */
279
+ const addExtensionImpl = (builder: GraphBuilder, extensions: BuilderExtensions): GraphBuilder => {
280
+ const internal = builder as GraphBuilderImpl;
281
+ flattenExtensions(extensions).forEach((extension) => {
282
+ const extensions = internal._registry.get(internal._extensions);
283
+ internal._registry.set(internal._extensions, Record.set(extensions, extension.id, extension));
371
284
  });
285
+ return builder;
372
286
  };
373
287
 
374
- const observableFamily = Rx.family((observable: MulticastObservable<any>) => {
375
- return Rx.make((get) => {
376
- const subscription = observable.subscribe((value) => get.setSelf(value));
288
+ /**
289
+ * Add extensions to the graph builder.
290
+ */
291
+ export function addExtension(builder: GraphBuilder, extensions: BuilderExtensions): GraphBuilder;
292
+ export function addExtension(extensions: BuilderExtensions): (builder: GraphBuilder) => GraphBuilder;
293
+ export function addExtension(
294
+ builderOrExtensions: GraphBuilder | BuilderExtensions,
295
+ extensions?: BuilderExtensions,
296
+ ): GraphBuilder | ((builder: GraphBuilder) => GraphBuilder) {
297
+ if (extensions === undefined) {
298
+ // Curried: addExtension(extensions)
299
+ const extensions = builderOrExtensions as BuilderExtensions;
300
+ return (builder: GraphBuilder) => addExtensionImpl(builder, extensions);
301
+ } else {
302
+ // Direct: addExtension(builder, extensions)
303
+ const builder = builderOrExtensions as GraphBuilder;
304
+ return addExtensionImpl(builder, extensions);
305
+ }
306
+ }
377
307
 
378
- get.addFinalizer(() => subscription.unsubscribe());
308
+ /**
309
+ * Implementation helper for removeExtension.
310
+ */
311
+ const removeExtensionImpl = (builder: GraphBuilder, id: string): GraphBuilder => {
312
+ const internal = builder as GraphBuilderImpl;
313
+ const extensions = internal._registry.get(internal._extensions);
314
+ internal._registry.set(internal._extensions, Record.remove(extensions, id));
315
+ return builder;
316
+ };
379
317
 
380
- return observable.get();
318
+ /**
319
+ * Remove an extension from the graph builder.
320
+ */
321
+ export function removeExtension(builder: GraphBuilder, id: string): GraphBuilder;
322
+ export function removeExtension(id: string): (builder: GraphBuilder) => GraphBuilder;
323
+ export function removeExtension(
324
+ builderOrId: GraphBuilder | string,
325
+ id?: string,
326
+ ): GraphBuilder | ((builder: GraphBuilder) => GraphBuilder) {
327
+ if (typeof builderOrId === 'string') {
328
+ // Curried: removeExtension(id)
329
+ const id = builderOrId;
330
+ return (builder: GraphBuilder) => removeExtensionImpl(builder, id);
331
+ } else {
332
+ // Direct: removeExtension(builder, id)
333
+ const builder = builderOrId;
334
+ return removeExtensionImpl(builder, id!);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Implementation helper for explore.
340
+ */
341
+ const exploreImpl = async (
342
+ builder: GraphBuilder,
343
+ options: GraphBuilderTraverseOptions,
344
+ path: string[] = [],
345
+ ): Promise<void> => {
346
+ const internal = builder as GraphBuilderImpl;
347
+ const { registry = Registry.make(), source = Node.RootId, relation = 'outbound', visitor } = options;
348
+ // Break cycles.
349
+ if (path.includes(source)) {
350
+ return;
351
+ }
352
+
353
+ // TODO(wittjosiah): This is a workaround for esm not working in the test runner.
354
+ // Switching to vitest is blocked by having node esm versions of echo-schema & echo-signals.
355
+ if (!isNode()) {
356
+ const { yieldOrContinue } = await import('main-thread-scheduling');
357
+ await yieldOrContinue('idle');
358
+ }
359
+
360
+ const node = registry.get(internal._graph.nodeOrThrow(source));
361
+ const shouldContinue = await visitor(node, [...path, node.id]);
362
+ if (shouldContinue === false) {
363
+ return;
364
+ }
365
+
366
+ const nodes = Object.values(internal._registry.get(internal._extensions))
367
+ .filter((extension) => relation === (extension.relation ?? 'outbound'))
368
+ .map((extension) => extension.connector)
369
+ .filter(isNonNullable)
370
+ .flatMap((connector) => registry.get(connector(internal._graph.node(source))));
371
+
372
+ await Promise.all(
373
+ nodes.map((nodeArg) => {
374
+ registry.set(internal._graph._node(nodeArg.id), internal._graph._constructNode(nodeArg));
375
+ return exploreImpl(builder, { registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
376
+ }),
377
+ );
378
+
379
+ if (registry !== internal._registry) {
380
+ registry.reset();
381
+ registry.dispose();
382
+ }
383
+ };
384
+
385
+ /**
386
+ * Explore the graph by traversing it with the given options.
387
+ */
388
+ export function explore(builder: GraphBuilder, options: GraphBuilderTraverseOptions, path?: string[]): Promise<void>;
389
+ export function explore(
390
+ options: GraphBuilderTraverseOptions,
391
+ path?: string[],
392
+ ): (builder: GraphBuilder) => Promise<void>;
393
+ export function explore(
394
+ builderOrOptions: GraphBuilder | GraphBuilderTraverseOptions,
395
+ optionsOrPath?: GraphBuilderTraverseOptions | string[],
396
+ path?: string[],
397
+ ): Promise<void> | ((builder: GraphBuilder) => Promise<void>) {
398
+ if (typeof builderOrOptions === 'object' && 'visitor' in builderOrOptions) {
399
+ // Curried: explore(options, path?)
400
+ const options = builderOrOptions as GraphBuilderTraverseOptions;
401
+ const path = Array.isArray(optionsOrPath) ? optionsOrPath : undefined;
402
+ return (builder: GraphBuilder) => exploreImpl(builder, options, path);
403
+ } else {
404
+ // Direct: explore(builder, options, path?)
405
+ const builder = builderOrOptions as GraphBuilder;
406
+ const options = optionsOrPath as GraphBuilderTraverseOptions;
407
+ const pathArg = path ?? (Array.isArray(optionsOrPath) ? optionsOrPath : undefined);
408
+ return exploreImpl(builder, options, pathArg);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Implementation helper for destroy.
414
+ */
415
+ const destroyImpl = (builder: GraphBuilder): void => {
416
+ const internal = builder as GraphBuilderImpl;
417
+ internal._subscriptions.forEach((unsubscribe) => unsubscribe());
418
+ internal._subscriptions.clear();
419
+ };
420
+
421
+ /**
422
+ * Destroy the graph builder and clean up resources.
423
+ */
424
+ export function destroy(builder: GraphBuilder): void;
425
+ export function destroy(): (builder: GraphBuilder) => void;
426
+ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder) => void) {
427
+ if (builder === undefined) {
428
+ // Curried: destroy()
429
+ return (builder: GraphBuilder) => destroyImpl(builder);
430
+ } else {
431
+ // Direct: destroy(builder)
432
+ return destroyImpl(builder);
433
+ }
434
+ }
435
+
436
+ //
437
+ // Extension Creation
438
+ //
439
+
440
+ /**
441
+ * A graph builder extension is used to add nodes to the graph.
442
+ *
443
+ * @param params.id The unique id of the extension.
444
+ * @param params.relation The relation the graph is being expanded from the existing node.
445
+ * @param params.position Affects the order the extensions are processed in.
446
+ * @param params.resolver A function to add nodes to the graph based on just the node id.
447
+ * @param params.connector A function to add nodes to the graph based on a connection to an existing node.
448
+ * @param params.actions A function to add actions to the graph based on a connection to an existing node.
449
+ * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
450
+ */
451
+ export type CreateExtensionRawOptions = {
452
+ id: string;
453
+ relation?: Node.Relation;
454
+ position?: Position;
455
+ resolver?: ResolverExtension;
456
+ connector?: ConnectorExtension;
457
+ actions?: ActionsExtension;
458
+ actionGroups?: ActionGroupsExtension;
459
+ };
460
+
461
+ /**
462
+ * Create a graph builder extension (low-level API that works directly with Atoms).
463
+ */
464
+ export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
465
+ const {
466
+ id,
467
+ position = 'static',
468
+ relation = 'outbound',
469
+ resolver: _resolver,
470
+ connector: _connector,
471
+ actions: _actions,
472
+ actionGroups: _actionGroups,
473
+ } = extension;
474
+ const getId = (key: string) => `${id}/${key}`;
475
+
476
+ const resolver =
477
+ _resolver && Atom.family((id: string) => _resolver(id).pipe(Atom.withLabel(`graph-builder:_resolver:${id}`)));
478
+
479
+ const connector =
480
+ _connector &&
481
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
482
+ _connector(node).pipe(Atom.withLabel(`graph-builder:_connector:${id}`)),
483
+ );
484
+
485
+ const actionGroups =
486
+ _actionGroups &&
487
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
488
+ _actionGroups(node).pipe(Atom.withLabel(`graph-builder:_actionGroups:${id}`)),
489
+ );
490
+
491
+ const actions =
492
+ _actions &&
493
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
494
+ _actions(node).pipe(Atom.withLabel(`graph-builder:_actions:${id}`)),
495
+ );
496
+
497
+ return [
498
+ resolver ? { id: getId('resolver'), position, resolver } : undefined,
499
+ connector
500
+ ? ({
501
+ id: getId('connector'),
502
+ position,
503
+ relation,
504
+ connector: Atom.family((node) =>
505
+ Atom.make((get) => {
506
+ try {
507
+ return get(connector(node));
508
+ } catch (error) {
509
+ log.warn('Error in connector', { id: getId('connector'), node, error });
510
+ return [];
511
+ }
512
+ }).pipe(Atom.withLabel(`graph-builder:connector:${id}`)),
513
+ ),
514
+ } satisfies BuilderExtension)
515
+ : undefined,
516
+ actionGroups
517
+ ? ({
518
+ id: getId('actionGroups'),
519
+ position,
520
+ relation: 'outbound',
521
+ connector: Atom.family((node) =>
522
+ Atom.make((get) => {
523
+ try {
524
+ return get(actionGroups(node)).map((arg) => ({
525
+ ...arg,
526
+ data: Node.actionGroupSymbol,
527
+ type: Node.ActionGroupType,
528
+ }));
529
+ } catch (error) {
530
+ log.warn('Error in actionGroups', { id: getId('actionGroups'), node, error });
531
+ return [];
532
+ }
533
+ }).pipe(Atom.withLabel(`graph-builder:connector:actionGroups:${id}`)),
534
+ ),
535
+ } satisfies BuilderExtension)
536
+ : undefined,
537
+ actions
538
+ ? ({
539
+ id: getId('actions'),
540
+ position,
541
+ relation: 'outbound',
542
+ connector: Atom.family((node) =>
543
+ Atom.make((get) => {
544
+ try {
545
+ return get(actions(node)).map((arg) => ({ ...arg, type: Node.ActionType }));
546
+ } catch (error) {
547
+ log.warn('Error in actions', { id: getId('actions'), node, error });
548
+ return [];
549
+ }
550
+ }).pipe(Atom.withLabel(`graph-builder:connector:actions:${id}`)),
551
+ ),
552
+ } satisfies BuilderExtension)
553
+ : undefined,
554
+ ].filter(isNonNullable);
555
+ };
556
+
557
+ /**
558
+ * Options for creating a graph builder extension with simplified API.
559
+ * All callbacks must return Effects for dependency injection.
560
+ * Effects may fail - errors are caught, logged, and the extension returns empty results.
561
+ */
562
+ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
563
+ id: string;
564
+ match: (node: Node.Node) => Option.Option<TMatched>;
565
+ actions?: (
566
+ matched: TMatched,
567
+ get: Atom.Context,
568
+ ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], Error, R>;
569
+ connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
570
+ resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
571
+ relation?: Node.Relation;
572
+ position?: Position;
573
+ };
574
+
575
+ /**
576
+ * Run an Effect synchronously with the provided context.
577
+ * If the effect fails, logs the error and returns the fallback value.
578
+ * @internal
579
+ */
580
+ const runEffectSyncWithFallback = <T, R>(
581
+ effect: Effect.Effect<T, Error, R>,
582
+ context: Context.Context<R>,
583
+ extensionId: string,
584
+ fallback: T,
585
+ ): T => {
586
+ return Effect.runSync(
587
+ effect.pipe(
588
+ Effect.provide(context),
589
+ Effect.catchAll((error) => {
590
+ log.warn('Extension failed', { extension: extensionId, error });
591
+ return Effect.succeed(fallback);
592
+ }),
593
+ ),
594
+ );
595
+ };
596
+
597
+ /**
598
+ * Create a graph builder extension with simplified API.
599
+ * Returns an Effect to allow callbacks to access services via dependency injection.
600
+ */
601
+ export const createExtension = <TMatched = Node.Node, R = never>(
602
+ options: CreateExtensionOptions<TMatched, R>,
603
+ ): Effect.Effect<BuilderExtension[], never, R> =>
604
+ Effect.map(Effect.context<R>(), (context) => {
605
+ const { id, match, actions, connector, resolver, relation, position } = options;
606
+
607
+ const connectorExtension = connector ? createConnectorWithRuntime(id, match, connector, context) : undefined;
608
+
609
+ const actionsExtension = actions
610
+ ? (node: Atom.Atom<Option.Option<Node.Node>>) =>
611
+ Atom.make((get) =>
612
+ Function.pipe(
613
+ get(node),
614
+ Option.flatMap(match),
615
+ Option.map((matched) =>
616
+ runEffectSyncWithFallback(actions(matched, get), context, id, []).map((action) => ({
617
+ ...action,
618
+ // Attach captured context for action execution.
619
+ _actionContext: context,
620
+ })),
621
+ ),
622
+ Option.getOrElse(() => []),
623
+ ),
624
+ )
625
+ : undefined;
626
+
627
+ const resolverExtension = resolver
628
+ ? (nodeId: string) =>
629
+ Atom.make((get) => runEffectSyncWithFallback(resolver(nodeId, get), context, id, null) ?? null)
630
+ : undefined;
631
+
632
+ return createExtensionRaw({
633
+ id,
634
+ relation,
635
+ position,
636
+ connector: connectorExtension,
637
+ actions: actionsExtension,
638
+ resolver: resolverExtension,
639
+ });
381
640
  });
382
- });
383
641
 
384
642
  /**
385
- * Creates an Rx.Rx<T> from a MulticastObservable<T>
386
- * Will return the same rx instance for the same observable.
643
+ * Create a connector extension from a matcher and factory function.
644
+ * The factory's data type is inferred from the matcher's return type.
645
+ */
646
+ export const createConnector = <TData>(
647
+ matcher: (node: Node.Node) => Option.Option<TData>,
648
+ factory: (data: TData, get: Atom.Context) => Node.NodeArg<any>[],
649
+ ): ConnectorExtension => {
650
+ return (node: Atom.Atom<Option.Option<Node.Node>>) =>
651
+ Atom.make((get) =>
652
+ Function.pipe(
653
+ get(node),
654
+ Option.flatMap(matcher),
655
+ Option.map((data) => factory(data, get)),
656
+ Option.getOrElse(() => []),
657
+ ),
658
+ );
659
+ };
660
+
661
+ /**
662
+ * Create a connector extension from a matcher and factory function with Effect support.
663
+ * The factory must return an Effect. Errors are caught and logged.
664
+ * @internal
665
+ */
666
+ const createConnectorWithRuntime = <TData, R>(
667
+ extensionId: string,
668
+ matcher: (node: Node.Node) => Option.Option<TData>,
669
+ factory: (data: TData, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], Error, R>,
670
+ context: Context.Context<R>,
671
+ ): ConnectorExtension => {
672
+ return (node: Atom.Atom<Option.Option<Node.Node>>) =>
673
+ Atom.make((get) =>
674
+ Function.pipe(
675
+ get(node),
676
+ Option.flatMap(matcher),
677
+ Option.map((data) => runEffectSyncWithFallback(factory(data, get), context, extensionId, [])),
678
+ Option.getOrElse(() => []),
679
+ ),
680
+ );
681
+ };
682
+
683
+ /**
684
+ * Options for creating a type-based extension.
685
+ * All callbacks must return Effects for dependency injection.
686
+ * Effects may fail - errors are caught, logged, and the extension returns empty results.
687
+ */
688
+ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.Any, R = never> = {
689
+ id: string;
690
+ type: T;
691
+ actions?: (
692
+ object: Entity.Entity<Schema.Schema.Type<T>>,
693
+ get: Atom.Context,
694
+ ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>>, 'type'>[], Error, R>;
695
+ connector?: (
696
+ object: Entity.Entity<Schema.Schema.Type<T>>,
697
+ get: Atom.Context,
698
+ ) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
699
+ relation?: Node.Relation;
700
+ position?: Position;
701
+ };
702
+
703
+ /**
704
+ * Create an extension that matches nodes by schema type.
705
+ * The entity type is inferred from the schema type and works for both object and relation schemas.
706
+ * Returns an Effect to allow callbacks to access services via dependency injection.
387
707
  */
388
- export const rxFromObservable = <T>(observable: MulticastObservable<T>): Rx.Rx<T> => {
389
- return observableFamily(observable) as Rx.Rx<T>;
708
+ export const createTypeExtension = <T extends Type.Entity.Any, R = never>(
709
+ options: CreateTypeExtensionOptions<T, R>,
710
+ ): Effect.Effect<BuilderExtension[], never, R> => {
711
+ const { id, type, actions, connector, relation, position } = options;
712
+ return createExtension<Entity.Entity<Schema.Schema.Type<T>>, R>({
713
+ id,
714
+ match: NodeMatcher.whenEchoType(type),
715
+ actions,
716
+ connector,
717
+ relation,
718
+ position,
719
+ });
720
+ };
721
+
722
+ //
723
+ // Extension Utilities
724
+ //
725
+
726
+ export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
727
+ if (Array.isArray(extension)) {
728
+ return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
729
+ } else {
730
+ return [...acc, extension];
731
+ }
390
732
  };