@dxos/app-graph 0.8.4-main.ae835ea → 0.8.4-main.bc2380dfbc

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 (71) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  4. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
  6. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs.map +7 -0
  7. package/dist/lib/neutral/index.mjs +40 -0
  8. package/dist/lib/neutral/index.mjs.map +7 -0
  9. package/dist/lib/neutral/meta.json +1 -0
  10. package/dist/lib/neutral/scheduler.mjs +15 -0
  11. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  12. package/dist/lib/neutral/testing/index.mjs +40 -0
  13. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  14. package/dist/types/src/atoms.d.ts +8 -0
  15. package/dist/types/src/atoms.d.ts.map +1 -0
  16. package/dist/types/src/graph-builder.d.ts +112 -66
  17. package/dist/types/src/graph-builder.d.ts.map +1 -1
  18. package/dist/types/src/graph.d.ts +187 -221
  19. package/dist/types/src/graph.d.ts.map +1 -1
  20. package/dist/types/src/index.d.ts +7 -3
  21. package/dist/types/src/index.d.ts.map +1 -1
  22. package/dist/types/src/node-matcher.d.ts +244 -0
  23. package/dist/types/src/node-matcher.d.ts.map +1 -0
  24. package/dist/types/src/node-matcher.test.d.ts +2 -0
  25. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  26. package/dist/types/src/node.d.ts +50 -5
  27. package/dist/types/src/node.d.ts.map +1 -1
  28. package/dist/types/src/scheduler.browser.d.ts +2 -0
  29. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  30. package/dist/types/src/scheduler.d.ts +8 -0
  31. package/dist/types/src/scheduler.d.ts.map +1 -0
  32. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  33. package/dist/types/src/testing/index.d.ts +2 -0
  34. package/dist/types/src/testing/index.d.ts.map +1 -0
  35. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  36. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  37. package/dist/types/src/util.d.ts +40 -0
  38. package/dist/types/src/util.d.ts.map +1 -0
  39. package/dist/types/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +53 -40
  41. package/src/atoms.ts +25 -0
  42. package/src/graph-builder.test.ts +1146 -137
  43. package/src/graph-builder.ts +732 -290
  44. package/src/graph.test.ts +450 -122
  45. package/src/graph.ts +1050 -401
  46. package/src/index.ts +10 -3
  47. package/src/node-matcher.test.ts +301 -0
  48. package/src/node-matcher.ts +314 -0
  49. package/src/node.ts +82 -8
  50. package/src/scheduler.browser.ts +5 -0
  51. package/src/scheduler.ts +17 -0
  52. package/src/stories/EchoGraph.stories.tsx +160 -123
  53. package/src/stories/Tree.tsx +1 -1
  54. package/src/testing/index.ts +5 -0
  55. package/src/testing/setup-graph-builder.ts +41 -0
  56. package/src/util.ts +101 -0
  57. package/dist/lib/browser/index.mjs +0 -836
  58. package/dist/lib/browser/index.mjs.map +0 -7
  59. package/dist/lib/browser/meta.json +0 -1
  60. package/dist/lib/node-esm/index.mjs +0 -838
  61. package/dist/lib/node-esm/index.mjs.map +0 -7
  62. package/dist/lib/node-esm/meta.json +0 -1
  63. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  64. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  65. package/dist/types/src/signals-integration.test.d.ts +0 -2
  66. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  67. package/dist/types/src/testing.d.ts +0 -5
  68. package/dist/types/src/testing.d.ts.map +0 -1
  69. package/src/experimental/graph-projections.test.ts +0 -56
  70. package/src/signals-integration.test.ts +0 -218
  71. package/src/testing.ts +0 -20
@@ -2,224 +2,170 @@
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';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
7
6
  import * as Array from 'effect/Array';
7
+ import type * as Context from 'effect/Context';
8
+ import * as Effect from 'effect/Effect';
8
9
  import * as Function from 'effect/Function';
9
10
  import * as Option from 'effect/Option';
11
+ import * as Pipeable from 'effect/Pipeable';
10
12
  import * as Record from 'effect/Record';
13
+ import type * as Schema from 'effect/Schema';
11
14
 
12
- import { type CleanupFn, type MulticastObservable, type Trigger } from '@dxos/async';
15
+ import { type CleanupFn, type Trigger } from '@dxos/async';
16
+ import { type Entity, type Type } from '@dxos/echo';
13
17
  import { log } from '@dxos/log';
14
- import { type MaybePromise, type Position, byPosition, getDebugName, isNode, isNonNullable } from '@dxos/util';
18
+ import { type MaybePromise, type Position, byPosition, getDebugName, isNonNullable } from '@dxos/util';
19
+
20
+ import { scheduleTask, yieldOrContinue } from '#scheduler';
21
+
22
+ import * as Graph from './graph';
23
+ import * as Node from './node';
24
+ import * as NodeMatcher from './node-matcher';
25
+ import {
26
+ getParentId,
27
+ nodeArgsUnchanged,
28
+ normalizeRelation,
29
+ primaryKey,
30
+ primaryParts,
31
+ qualifyId,
32
+ validateSegmentId,
33
+ } from './util';
15
34
 
16
- import { ACTION_GROUP_TYPE, ACTION_TYPE, type ExpandableGraph, Graph, type GraphParams, ROOT_ID } from './graph';
17
- import { type ActionData, type Node, type NodeArg, type Relation, actionGroupSymbol } from './node';
35
+ //
36
+ // Extension Types
37
+ //
18
38
 
19
39
  /**
20
40
  * Graph builder extension for adding nodes to the graph based on a node id.
21
41
  */
22
- export type ResolverExtension = (id: string) => Rx.Rx<NodeArg<any> | null>;
42
+ export type ResolverExtension = (id: string) => Atom.Atom<Node.NodeArg<any> | null>;
23
43
 
24
44
  /**
25
45
  * Graph builder extension for adding nodes to the graph based on a connection to an existing node.
26
46
  *
27
47
  * @param params.node The existing node the returned nodes will be connected to.
28
48
  */
29
- export type ConnectorExtension = (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
49
+ export type ConnectorExtension = (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
30
50
 
31
51
  /**
32
52
  * Constrained case of the connector extension for more easily adding actions to the graph.
33
53
  */
34
54
  export type ActionsExtension = (
35
- node: Rx.Rx<Option.Option<Node>>,
36
- ) => Rx.Rx<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
55
+ node: Atom.Atom<Option.Option<Node.Node>>,
56
+ ) => Atom.Atom<Omit<Node.NodeArg<Node.ActionData<any>>, 'type' | 'nodes' | 'edges'>[]>;
37
57
 
38
58
  /**
39
59
  * Constrained case of the connector extension for more easily adding action groups to the graph.
40
60
  */
41
61
  export type ActionGroupsExtension = (
42
- node: Rx.Rx<Option.Option<Node>>,
43
- ) => Rx.Rx<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
62
+ node: Atom.Atom<Option.Option<Node.Node>>,
63
+ ) => Atom.Atom<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
44
64
 
45
- /**
46
- * A graph builder extension is used to add nodes to the graph.
47
- *
48
- * @param params.id The unique id of the extension.
49
- * @param params.relation The relation the graph is being expanded from the existing node.
50
- * @param params.position Affects the order the extensions are processed in.
51
- * @param params.resolver A function to add nodes to the graph based on just the node id.
52
- * @param params.connector A function to add nodes to the graph based on a connection to an existing node.
53
- * @param params.actions A function to add actions to the graph based on a connection to an existing node.
54
- * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
55
- */
56
- export type CreateExtensionOptions = {
65
+ export type BuilderExtension = Readonly<{
57
66
  id: string;
58
- relation?: Relation;
59
- position?: Position;
67
+ position: Position;
68
+ relation?: Node.RelationInput;
60
69
  resolver?: ResolverExtension;
61
- connector?: ConnectorExtension;
62
- actions?: ActionsExtension;
63
- actionGroups?: ActionGroupsExtension;
64
- };
65
-
66
- /**
67
- * Create a graph builder extension.
68
- */
69
- export const createExtension = (extension: CreateExtensionOptions): BuilderExtension[] => {
70
- const {
71
- id,
72
- position = 'static',
73
- relation = 'outbound',
74
- resolver: _resolver,
75
- connector: _connector,
76
- actions: _actions,
77
- actionGroups: _actionGroups,
78
- } = extension;
79
- const getId = (key: string) => `${id}/${key}`;
80
-
81
- const resolver =
82
- _resolver && Rx.family((id: string) => _resolver(id).pipe(Rx.withLabel(`graph-builder:_resolver:${id}`)));
83
-
84
- const connector =
85
- _connector &&
86
- Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
87
- _connector(node).pipe(Rx.withLabel(`graph-builder:_connector:${id}`)),
88
- );
89
-
90
- const actionGroups =
91
- _actionGroups &&
92
- Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
93
- _actionGroups(node).pipe(Rx.withLabel(`graph-builder:_actionGroups:${id}`)),
94
- );
70
+ connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
71
+ }>;
95
72
 
96
- const actions =
97
- _actions &&
98
- Rx.family((node: Rx.Rx<Option.Option<Node>>) => _actions(node).pipe(Rx.withLabel(`graph-builder:_actions:${id}`)));
73
+ export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
99
74
 
100
- return [
101
- resolver ? { id: getId('resolver'), position, resolver } : undefined,
102
- connector
103
- ? ({
104
- id: getId('connector'),
105
- position,
106
- relation,
107
- connector: Rx.family((node) =>
108
- Rx.make((get) => {
109
- try {
110
- return get(connector(node));
111
- } catch {
112
- log.warn('Error in connector', { id: getId('connector'), node });
113
- return [];
114
- }
115
- }).pipe(Rx.withLabel(`graph-builder:connector:${id}`)),
116
- ),
117
- } satisfies BuilderExtension)
118
- : undefined,
119
- actionGroups
120
- ? ({
121
- id: getId('actionGroups'),
122
- position,
123
- relation: 'outbound',
124
- connector: Rx.family((node) =>
125
- Rx.make((get) => {
126
- try {
127
- return get(actionGroups(node)).map((arg) => ({
128
- ...arg,
129
- data: actionGroupSymbol,
130
- type: ACTION_GROUP_TYPE,
131
- }));
132
- } catch {
133
- log.warn('Error in actionGroups', { id: getId('actionGroups'), node });
134
- return [];
135
- }
136
- }).pipe(Rx.withLabel(`graph-builder:connector:actionGroups:${id}`)),
137
- ),
138
- } satisfies BuilderExtension)
139
- : undefined,
140
- actions
141
- ? ({
142
- id: getId('actions'),
143
- position,
144
- relation: 'outbound',
145
- connector: Rx.family((node) =>
146
- Rx.make((get) => {
147
- try {
148
- return get(actions(node)).map((arg) => ({ ...arg, type: ACTION_TYPE }));
149
- } catch {
150
- log.warn('Error in actions', { id: getId('actions'), node });
151
- return [];
152
- }
153
- }).pipe(Rx.withLabel(`graph-builder:connector:actions:${id}`)),
154
- ),
155
- } satisfies BuilderExtension)
156
- : undefined,
157
- ].filter(isNonNullable);
158
- };
75
+ //
76
+ // GraphBuilder Core
77
+ //
159
78
 
160
79
  export type GraphBuilderTraverseOptions = {
161
- visitor: (node: Node, path: string[]) => MaybePromise<boolean | void>;
80
+ visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
162
81
  registry?: Registry.Registry;
163
82
  source?: string;
164
- relation?: Relation;
83
+ relation: Node.RelationInput | Node.RelationInput[];
165
84
  };
166
85
 
167
- export type BuilderExtension = Readonly<{
168
- id: string;
169
- position: Position;
170
- relation?: Relation; // Only for connector.
171
- resolver?: ResolverExtension;
172
- connector?: (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
173
- }>;
174
-
175
- export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
86
+ /**
87
+ * Identifier denoting a GraphBuilder.
88
+ */
89
+ export const GraphBuilderTypeId: unique symbol = Symbol.for('@dxos/app-graph/GraphBuilder');
90
+ export type GraphBuilderTypeId = typeof GraphBuilderTypeId;
176
91
 
177
- export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
178
- if (Array.isArray(extension)) {
179
- return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
180
- } else {
181
- return [...acc, extension];
182
- }
183
- };
92
+ /**
93
+ * GraphBuilder interface.
94
+ */
95
+ export interface GraphBuilder extends Pipeable.Pipeable {
96
+ readonly [GraphBuilderTypeId]: GraphBuilderTypeId;
97
+ readonly graph: Graph.ExpandableGraph;
98
+ readonly extensions: Atom.Atom<Record<string, BuilderExtension>>;
99
+ }
184
100
 
185
101
  /**
186
102
  * The builder provides an extensible way to compose the construction of the graph.
103
+ * @internal
187
104
  */
188
105
  // TODO(wittjosiah): Add api for setting subscription set and/or radius.
189
106
  // Should unsubscribe from nodes that are not in the set/radius.
190
107
  // Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
191
- export class GraphBuilder {
108
+ class GraphBuilderImpl implements GraphBuilder {
109
+ readonly [GraphBuilderTypeId]: GraphBuilderTypeId = GraphBuilderTypeId;
110
+
111
+ pipe() {
112
+ // eslint-disable-next-line prefer-rest-params
113
+ return Pipeable.pipeArguments(this, arguments);
114
+ }
115
+
192
116
  // TODO(wittjosiah): Use Context.
193
- private readonly _subscriptions = new Map<string, CleanupFn>();
194
- private readonly _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
195
- Rx.keepAlive,
196
- Rx.withLabel('graph-builder:extensions'),
117
+ /** Active subscriptions keyed by composite ID, cleaned up on node removal. */
118
+ readonly _subscriptions = new Map<string, CleanupFn>();
119
+ /** Connector updates pending flush, keyed by connector key. */
120
+ readonly _dirtyConnectors = new Map<
121
+ string,
122
+ {
123
+ nodes: Node.NodeArg<any>[];
124
+ previous: string[];
125
+ }
126
+ >();
127
+ /** Last-flushed node IDs per connector key, used for edge removal on update. */
128
+ readonly _connectorPrevious = new Map<string, string[]>();
129
+ /** All inline-descendant IDs per connector key, used to remove stale inline nodes on update. */
130
+ readonly _connectorPreviousInlineIds = new Map<string, string[]>();
131
+ /** Last-flushed node args per connector key, used for change detection. */
132
+ readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
133
+ /** Whether a dirty-flush task is already scheduled. */
134
+ _flushScheduled = false;
135
+ /** Resolves when the current flush completes. */
136
+ _flushPromise: Promise<void> = Promise.resolve();
137
+ /** Registered builder extensions keyed by extension ID. */
138
+ readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
139
+ Atom.keepAlive,
140
+ Atom.withLabel('graph-builder:extensions'),
197
141
  );
198
- private readonly _initialized: Record<string, Trigger> = {};
199
- private readonly _registry: Registry.Registry;
200
- private readonly _graph: Graph;
201
-
202
- constructor({ registry, ...params }: Pick<GraphParams, 'registry' | 'nodes' | 'edges'> = {}) {
142
+ /** Triggers signalling that a node's resolver has fired at least once. */
143
+ readonly _initialized: Record<string, Trigger> = {};
144
+ /** Shared atom registry for reactive subscriptions. */
145
+ readonly _registry: Registry.Registry;
146
+ /** Backing graph with internal accessors for node atoms and construction. */
147
+ readonly _graph: Graph.Graph & {
148
+ _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
149
+ _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
150
+ };
151
+
152
+ constructor({ registry, ...params }: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'> = {}) {
203
153
  this._registry = registry ?? Registry.make();
204
- this._graph = new Graph({
154
+ const graph = Graph.make({
205
155
  ...params,
206
156
  registry: this._registry,
207
157
  onExpand: (id, relation) => this._onExpand(id, relation),
208
158
  onInitialize: (id) => this._onInitialize(id),
209
159
  onRemoveNode: (id) => this._onRemoveNode(id),
210
160
  });
161
+ // Access internal methods via type assertion since GraphBuilder needs them
162
+ this._graph = graph as Graph.Graph & {
163
+ _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
164
+ _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
165
+ };
211
166
  }
212
167
 
213
- static from(pickle?: string, registry?: Registry.Registry): GraphBuilder {
214
- if (!pickle) {
215
- return new GraphBuilder({ registry });
216
- }
217
-
218
- const { nodes, edges } = JSON.parse(pickle);
219
- return new GraphBuilder({ nodes, edges, registry });
220
- }
221
-
222
- get graph(): ExpandableGraph {
168
+ get graph(): Graph.ExpandableGraph {
223
169
  return this._graph;
224
170
  }
225
171
 
@@ -227,71 +173,64 @@ export class GraphBuilder {
227
173
  return this._extensions;
228
174
  }
229
175
 
230
- addExtension(extensions: BuilderExtensions): GraphBuilder {
231
- flattenExtensions(extensions).forEach((extension) => {
232
- const extensions = this._registry.get(this._extensions);
233
- this._registry.set(this._extensions, Record.set(extensions, extension.id, extension));
234
- });
235
- return this;
236
- }
237
-
238
- removeExtension(id: string): GraphBuilder {
239
- const extensions = this._registry.get(this._extensions);
240
- this._registry.set(this._extensions, Record.remove(extensions, id));
241
- return this;
242
- }
243
-
244
- async explore(
245
- // TODO(wittjosiah): Currently defaulting to new registry.
246
- // Currently unsure about how to handle nodes which are expanded in the background.
247
- // This seems like a good place to start.
248
- { registry = Registry.make(), source = ROOT_ID, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
249
- path: string[] = [],
250
- ): Promise<void> {
251
- // Break cycles.
252
- if (path.includes(source)) {
253
- return;
254
- }
255
-
256
- // TODO(wittjosiah): This is a workaround for esm not working in the test runner.
257
- // Switching to vitest is blocked by having node esm versions of echo-schema & echo-signals.
258
- if (!isNode()) {
259
- const { yieldOrContinue } = await import('main-thread-scheduling');
260
- await yieldOrContinue('idle');
261
- }
262
-
263
- const node = registry.get(this._graph.nodeOrThrow(source));
264
- const shouldContinue = await visitor(node, [...path, node.id]);
265
- if (shouldContinue === false) {
266
- return;
267
- }
268
-
269
- const nodes = Object.values(this._registry.get(this._extensions))
270
- .filter((extension) => relation === (extension.relation ?? 'outbound'))
271
- .map((extension) => extension.connector)
272
- .filter(isNonNullable)
273
- .flatMap((connector) => registry.get(connector(this._graph.node(source))));
274
-
275
- await Promise.all(
276
- nodes.map((nodeArg) => {
277
- registry.set(this._graph._node(nodeArg.id), this._graph._constructNode(nodeArg));
278
- return this.explore({ registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
279
- }),
176
+ /** Apply a set of node changes for a single connector key. */
177
+ private _applyConnectorUpdate(key: string, nodes: Node.NodeArg<any>[], previous: string[]): void {
178
+ const { id, relation } = relationFromConnectorKey(key);
179
+ const ids = nodes.map((node) => node.id);
180
+ const removed = previous.filter((pid) => !ids.includes(pid));
181
+ this._connectorPrevious.set(key, ids);
182
+ this._connectorPreviousArgs.set(key, nodes);
183
+
184
+ const currentInlineIds = collectAllInlineIds(nodes);
185
+ const previousInlineIds = this._connectorPreviousInlineIds.get(key) ?? [];
186
+ const staleInlineIds = previousInlineIds.filter((pid) => !currentInlineIds.includes(pid));
187
+ this._connectorPreviousInlineIds.set(key, currentInlineIds);
188
+
189
+ Graph.removeNodes(this._graph, staleInlineIds, true);
190
+ Graph.removeEdges(
191
+ this._graph,
192
+ removed.map((target) => ({ source: id, target, relation })),
193
+ true,
280
194
  );
281
-
282
- if (registry !== this._registry) {
283
- registry.reset();
284
- registry.dispose();
195
+ Graph.addNodes(this._graph, nodes);
196
+ Graph.addEdges(
197
+ this._graph,
198
+ nodes.map((node) => ({ source: id, target: node.id, relation })),
199
+ );
200
+ if (ids.length > 0) {
201
+ const sortedIds = [...nodes]
202
+ .sort((a, b) =>
203
+ byPosition(a.properties ?? ({} as { position?: Position }), b.properties ?? ({} as { position?: Position })),
204
+ )
205
+ .map((n) => n.id);
206
+ Graph.sortEdges(this._graph, id, relation, sortedIds);
285
207
  }
286
208
  }
287
209
 
288
- destroy(): void {
289
- this._subscriptions.forEach((unsubscribe) => unsubscribe());
290
- this._subscriptions.clear();
210
+ private _scheduleDirtyFlush(): void {
211
+ if (!this._flushScheduled) {
212
+ this._flushScheduled = true;
213
+ this._flushPromise = scheduleTask(
214
+ () => {
215
+ this._flushScheduled = false;
216
+ while (this._dirtyConnectors.size > 0) {
217
+ const entries = [...this._dirtyConnectors.entries()];
218
+ this._dirtyConnectors.clear();
219
+
220
+ Atom.batch(() => {
221
+ for (const [key, { nodes, previous }] of entries) {
222
+ this._applyConnectorUpdate(key, nodes, previous);
223
+ }
224
+ });
225
+ }
226
+ },
227
+ { strategy: 'smooth' },
228
+ );
229
+ }
291
230
  }
292
231
 
293
- private readonly _resolvers = Rx.family<string, Rx.Rx<Option.Option<NodeArg<any>>>>((id) => {
294
- return Rx.make((get) => {
232
+ private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
233
+ return Atom.make((get) => {
295
234
  return Function.pipe(
296
235
  get(this._extensions),
297
236
  Record.values,
@@ -305,72 +244,74 @@ export class GraphBuilder {
305
244
  });
306
245
  });
307
246
 
308
- private readonly _connectors = Rx.family<string, Rx.Rx<NodeArg<any>[]>>((key) => {
309
- return Rx.make((get) => {
310
- const [id, relation] = key.split('+');
247
+ private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
248
+ return Atom.make((get) => {
249
+ const { id, relation } = relationFromConnectorKey(key);
311
250
  const node = this._graph.node(id);
312
251
 
313
- return Function.pipe(
252
+ const sourceNode = Option.getOrElse(get(node), () => undefined);
253
+ if (!sourceNode) {
254
+ return [];
255
+ }
256
+
257
+ const extensions = Function.pipe(
314
258
  get(this._extensions),
315
259
  Record.values,
316
- // TODO(wittjosiah): Sort on write rather than read.
317
260
  Array.sortBy(byPosition),
318
- Array.filter(({ relation: _relation = 'outbound' }) => _relation === relation),
319
- Array.map(({ connector }) => connector?.(node)),
320
- Array.filter(isNonNullable),
321
- Array.flatMap((result) => get(result)),
261
+ Array.filter(
262
+ (ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
263
+ Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
264
+ ),
322
265
  );
323
- }).pipe(Rx.withLabel(`graph-builder:connectors:${key}`));
266
+
267
+ const nodes: Node.NodeArg<any>[] = [];
268
+ for (const ext of extensions) {
269
+ const result = get(ext.connector(node));
270
+ nodes.push(...result);
271
+ }
272
+
273
+ return nodes;
274
+ }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
324
275
  });
325
276
 
326
- private _onExpand(id: string, relation: Relation): void {
277
+ private _onExpand(id: string, relation: Node.Relation): void {
327
278
  log('onExpand', { id, relation, registry: getDebugName(this._registry) });
328
- const connectors = this._connectors(`${id}+${relation}`);
279
+ this._expandRelation(id, relation);
280
+
281
+ // TODO(wittjosiah): Remove. This is for backwards compatibility.
282
+ if (relation.kind === 'child' && relation.direction === 'outbound') {
283
+ Graph.expand(this._graph, id, 'action');
284
+ }
285
+ }
286
+
287
+ private _expandRelation(id: string, relation: Node.RelationInput): void {
288
+ const key = connectorKey(id, relation);
289
+ const connectors = this._connectors(key);
329
290
 
330
- let previous: string[] = [];
331
291
  const cancel = this._registry.subscribe(
332
292
  connectors,
333
- (nodes) => {
293
+ (rawNodes) => {
294
+ const nodes = qualifyNodeArgs(id)(rawNodes);
295
+ const previous = this._connectorPrevious.get(key) ?? [];
334
296
  const ids = nodes.map((n) => n.id);
335
- const removed = previous.filter((id) => !ids.includes(id));
336
- previous = ids;
337
-
338
- log('update', { id, relation, ids, removed });
339
- const update = () => {
340
- Rx.batch(() => {
341
- this._graph.removeEdges(
342
- removed.map((target) => ({ source: id, target })),
343
- true,
344
- );
345
- this._graph.addNodes(nodes);
346
- this._graph.addEdges(
347
- nodes.map((node) =>
348
- relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
349
- ),
350
- );
351
- this._graph.sortEdges(
352
- id,
353
- relation,
354
- nodes.map(({ id }) => id),
355
- );
356
- });
357
- };
358
-
359
- // TODO(wittjosiah): Remove `requestAnimationFrame` once we have a better solution.
360
- // This is a workaround to avoid a race condition where the graph is updated during React render.
361
- if (typeof requestAnimationFrame === 'function') {
362
- requestAnimationFrame(update);
363
- } else {
364
- update();
297
+
298
+ if (ids.length === previous.length && ids.every((nodeId, idx) => nodeId === previous[idx])) {
299
+ const prevArgs = this._connectorPreviousArgs.get(key);
300
+ if (prevArgs && nodeArgsUnchanged(prevArgs, nodes)) {
301
+ return;
302
+ }
365
303
  }
304
+
305
+ log('update', { id, relation, ids });
306
+ this._dirtyConnectors.set(key, { nodes, previous });
307
+ this._scheduleDirtyFlush();
366
308
  },
367
309
  { immediate: true },
368
310
  );
369
311
 
370
- this._subscriptions.set(id, cancel);
312
+ this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
371
313
  }
372
314
 
373
- // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
374
315
  private async _onInitialize(id: string) {
375
316
  log('onInitialize', { id });
376
317
  const resolver = this._resolvers(id);
@@ -379,59 +320,560 @@ export class GraphBuilder {
379
320
  resolver,
380
321
  (node) => {
381
322
  const trigger = this._initialized[id];
323
+ const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
382
324
  Option.match(node, {
383
325
  onSome: (node) => {
384
- this._graph.addNodes([node]);
326
+ if (!connectorOwned) {
327
+ Graph.addNodes(this._graph, [node]);
328
+ // Connect resolved node to its parent via a child edge.
329
+ const parentId = getParentId(id);
330
+ if (parentId) {
331
+ Graph.addEdges(this._graph, [{ source: parentId, target: id, relation: 'child' }]);
332
+ }
333
+ }
385
334
  trigger?.wake();
386
335
  },
387
336
  onNone: () => {
388
337
  trigger?.wake();
389
- this._graph.removeNodes([id]);
338
+ if (!connectorOwned) {
339
+ Graph.removeNodes(this._graph, [id]);
340
+ }
390
341
  },
391
342
  });
392
343
  },
393
344
  { immediate: true },
394
345
  );
395
346
 
396
- this._subscriptions.set(id, cancel);
347
+ this._subscriptions.set(subscriptionKey(id, 'init'), cancel);
397
348
  }
398
349
 
399
350
  private _onRemoveNode(id: string): void {
400
- this._subscriptions.get(id)?.();
401
- this._subscriptions.delete(id);
351
+ for (const [key, cleanup] of this._subscriptions) {
352
+ if (primaryParts(key)[0] === id) {
353
+ cleanup();
354
+ this._subscriptions.delete(key);
355
+ }
356
+ }
402
357
  }
403
358
  }
404
359
 
405
360
  /**
406
- * Creates an Rx.Rx<T> from a callback which accesses signals.
407
- * Will return a new rx instance each time.
361
+ * Creates a new GraphBuilder instance.
408
362
  */
409
- export const rxFromSignal = <T>(cb: () => T): Rx.Rx<T> => {
410
- return Rx.make((get) => {
411
- const dispose = effect(() => {
412
- get.setSelf(cb());
413
- });
363
+ export const make = (params?: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'>): GraphBuilder => {
364
+ return new GraphBuilderImpl(params);
365
+ };
414
366
 
415
- get.addFinalizer(() => dispose());
367
+ /**
368
+ * Creates a GraphBuilder from a serialized pickle string.
369
+ */
370
+ export const from = (pickle?: string, registry?: Registry.Registry): GraphBuilder => {
371
+ if (!pickle) {
372
+ return make({ registry });
373
+ }
374
+
375
+ const { nodes, edges } = JSON.parse(pickle);
376
+ return make({ nodes, edges, registry });
377
+ };
416
378
 
417
- return cb();
379
+ /**
380
+ * Implementation helper for addExtension.
381
+ */
382
+ const addExtensionImpl = (builder: GraphBuilder, extensions: BuilderExtensions): GraphBuilder => {
383
+ const internal = builder as GraphBuilderImpl;
384
+ flattenExtensions(extensions).forEach((extension) => {
385
+ const extensions = internal._registry.get(internal._extensions);
386
+ internal._registry.set(internal._extensions, Record.set(extensions, extension.id, extension));
418
387
  });
388
+ return builder;
419
389
  };
420
390
 
421
- const observableFamily = Rx.family((observable: MulticastObservable<any>) => {
422
- return Rx.make((get) => {
423
- const subscription = observable.subscribe((value) => get.setSelf(value));
391
+ /**
392
+ * Add extensions to the graph builder.
393
+ */
394
+ export function addExtension(builder: GraphBuilder, extensions: BuilderExtensions): GraphBuilder;
395
+ export function addExtension(extensions: BuilderExtensions): (builder: GraphBuilder) => GraphBuilder;
396
+ export function addExtension(
397
+ builderOrExtensions: GraphBuilder | BuilderExtensions,
398
+ extensions?: BuilderExtensions,
399
+ ): GraphBuilder | ((builder: GraphBuilder) => GraphBuilder) {
400
+ if (extensions === undefined) {
401
+ // Curried: addExtension(extensions)
402
+ const extensions = builderOrExtensions as BuilderExtensions;
403
+ return (builder: GraphBuilder) => addExtensionImpl(builder, extensions);
404
+ } else {
405
+ // Direct: addExtension(builder, extensions)
406
+ const builder = builderOrExtensions as GraphBuilder;
407
+ return addExtensionImpl(builder, extensions);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Implementation helper for removeExtension.
413
+ */
414
+ const removeExtensionImpl = (builder: GraphBuilder, id: string): GraphBuilder => {
415
+ const internal = builder as GraphBuilderImpl;
416
+ const extensions = internal._registry.get(internal._extensions);
417
+ internal._registry.set(internal._extensions, Record.remove(extensions, id));
418
+ return builder;
419
+ };
420
+
421
+ /**
422
+ * Remove an extension from the graph builder.
423
+ */
424
+ export function removeExtension(builder: GraphBuilder, id: string): GraphBuilder;
425
+ export function removeExtension(id: string): (builder: GraphBuilder) => GraphBuilder;
426
+ export function removeExtension(
427
+ builderOrId: GraphBuilder | string,
428
+ id?: string,
429
+ ): GraphBuilder | ((builder: GraphBuilder) => GraphBuilder) {
430
+ if (typeof builderOrId === 'string') {
431
+ // Curried: removeExtension(id)
432
+ const id = builderOrId;
433
+ return (builder: GraphBuilder) => removeExtensionImpl(builder, id);
434
+ } else {
435
+ // Direct: removeExtension(builder, id)
436
+ const builder = builderOrId;
437
+ return removeExtensionImpl(builder, id!);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Implementation helper for explore.
443
+ */
444
+ const exploreImpl = async (
445
+ builder: GraphBuilder,
446
+ options: GraphBuilderTraverseOptions,
447
+ path: string[] = [],
448
+ ): Promise<void> => {
449
+ const internal = builder as GraphBuilderImpl;
450
+ const { registry = Registry.make(), source = Node.RootId, relation, visitor } = options;
451
+ // Break cycles.
452
+ if (path.includes(source)) {
453
+ return;
454
+ }
424
455
 
425
- get.addFinalizer(() => subscription.unsubscribe());
456
+ await yieldOrContinue('idle');
426
457
 
427
- return observable.get();
458
+ const node = registry.get(internal._graph.nodeOrThrow(source));
459
+ const shouldContinue = await visitor(node, [...path, node.id]);
460
+ if (shouldContinue === false) {
461
+ return;
462
+ }
463
+
464
+ const nodes = Function.pipe(
465
+ internal._registry.get(internal._extensions),
466
+ Record.values,
467
+ Array.map((extension) => extension.connector),
468
+ Array.filter(isNonNullable),
469
+ Array.flatMap((connector) => registry.get(connector(internal._graph.node(source)))),
470
+ qualifyNodeArgs(source),
471
+ );
472
+
473
+ await Promise.all(
474
+ nodes.map((nodeArg) => {
475
+ registry.set(internal._graph._node(nodeArg.id), internal._graph._constructNode(nodeArg));
476
+ return exploreImpl(builder, { registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
477
+ }),
478
+ );
479
+
480
+ if (registry !== internal._registry) {
481
+ registry.reset();
482
+ registry.dispose();
483
+ }
484
+ };
485
+
486
+ /**
487
+ * Explore the graph by traversing it with the given options.
488
+ */
489
+ export function explore(builder: GraphBuilder, options: GraphBuilderTraverseOptions, path?: string[]): Promise<void>;
490
+ export function explore(
491
+ options: GraphBuilderTraverseOptions,
492
+ path?: string[],
493
+ ): (builder: GraphBuilder) => Promise<void>;
494
+ export function explore(
495
+ builderOrOptions: GraphBuilder | GraphBuilderTraverseOptions,
496
+ optionsOrPath?: GraphBuilderTraverseOptions | string[],
497
+ path?: string[],
498
+ ): Promise<void> | ((builder: GraphBuilder) => Promise<void>) {
499
+ if (typeof builderOrOptions === 'object' && 'visitor' in builderOrOptions) {
500
+ // Curried: explore(options, path?)
501
+ const options = builderOrOptions as GraphBuilderTraverseOptions;
502
+ const path = Array.isArray(optionsOrPath) ? optionsOrPath : undefined;
503
+ return (builder: GraphBuilder) => exploreImpl(builder, options, path);
504
+ } else {
505
+ // Direct: explore(builder, options, path?)
506
+ const builder = builderOrOptions as GraphBuilder;
507
+ const options = optionsOrPath as GraphBuilderTraverseOptions;
508
+ const pathArg = path ?? (Array.isArray(optionsOrPath) ? optionsOrPath : undefined);
509
+ return exploreImpl(builder, options, pathArg);
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Implementation helper for destroy.
515
+ */
516
+ const destroyImpl = (builder: GraphBuilder): void => {
517
+ const internal = builder as GraphBuilderImpl;
518
+ internal._subscriptions.forEach((unsubscribe) => unsubscribe());
519
+ internal._subscriptions.clear();
520
+ };
521
+
522
+ /**
523
+ * Destroy the graph builder and clean up resources.
524
+ */
525
+ export function destroy(builder: GraphBuilder): void;
526
+ export function destroy(): (builder: GraphBuilder) => void;
527
+ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder) => void) {
528
+ if (builder === undefined) {
529
+ // Curried: destroy()
530
+ return (builder: GraphBuilder) => destroyImpl(builder);
531
+ } else {
532
+ // Direct: destroy(builder)
533
+ return destroyImpl(builder);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Wait for all pending connector updates to be flushed.
539
+ */
540
+ export const flush = (builder: GraphBuilder): Promise<void> => {
541
+ return (builder as GraphBuilderImpl)._flushPromise;
542
+ };
543
+
544
+ //
545
+ // Extension Creation
546
+ //
547
+
548
+ /**
549
+ * A graph builder extension is used to add nodes to the graph.
550
+ *
551
+ * @param params.id The unique id of the extension.
552
+ * @param params.relation The relation the graph is being expanded from the existing node.
553
+ * @param params.position Affects the order the extensions are processed in.
554
+ * @param params.resolver A function to add nodes to the graph based on just the node id.
555
+ * @param params.connector A function to add nodes to the graph based on a connection to an existing node.
556
+ * @param params.actions A function to add actions to the graph based on a connection to an existing node.
557
+ * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
558
+ */
559
+ export type CreateExtensionRawOptions = {
560
+ id: string;
561
+ relation?: Node.RelationInput;
562
+ position?: Position;
563
+ resolver?: ResolverExtension;
564
+ connector?: ConnectorExtension;
565
+ actions?: ActionsExtension;
566
+ actionGroups?: ActionGroupsExtension;
567
+ };
568
+
569
+ /**
570
+ * Create a graph builder extension (low-level API that works directly with Atoms).
571
+ */
572
+ export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
573
+ const {
574
+ id,
575
+ position = 'static',
576
+ relation = 'child',
577
+ resolver: _resolver,
578
+ connector: _connector,
579
+ actions: _actions,
580
+ actionGroups: _actionGroups,
581
+ } = extension;
582
+ const normalizedRelation = normalizeRelation(relation);
583
+ const getId = (key: string) => `${id}/${key}`;
584
+
585
+ const resolver =
586
+ _resolver && Atom.family((id: string) => _resolver(id).pipe(Atom.withLabel(`graph-builder:_resolver:${id}`)));
587
+
588
+ const connector =
589
+ _connector &&
590
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
591
+ _connector(node).pipe(Atom.withLabel(`graph-builder:_connector:${id}`)),
592
+ );
593
+
594
+ const actionGroups =
595
+ _actionGroups &&
596
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
597
+ _actionGroups(node).pipe(Atom.withLabel(`graph-builder:_actionGroups:${id}`)),
598
+ );
599
+
600
+ const actions =
601
+ _actions &&
602
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
603
+ _actions(node).pipe(Atom.withLabel(`graph-builder:_actions:${id}`)),
604
+ );
605
+
606
+ return [
607
+ resolver ? { id: getId('resolver'), position, resolver } : undefined,
608
+ connector
609
+ ? ({
610
+ id: getId('connector'),
611
+ position,
612
+ relation: normalizedRelation,
613
+ connector: Atom.family((node) =>
614
+ Atom.make((get) => {
615
+ try {
616
+ return get(connector(node));
617
+ } catch (error) {
618
+ log.warn('Error in connector', { id: getId('connector'), node, error });
619
+ return [];
620
+ }
621
+ }).pipe(Atom.withLabel(`graph-builder:connector:${id}`)),
622
+ ),
623
+ } satisfies BuilderExtension)
624
+ : undefined,
625
+ actionGroups
626
+ ? ({
627
+ id: getId('actionGroups'),
628
+ position,
629
+ relation: Node.actionRelation(),
630
+ connector: Atom.family((node) =>
631
+ Atom.make((get) => {
632
+ try {
633
+ return get(actionGroups(node)).map((arg) => ({
634
+ ...arg,
635
+ data: Node.actionGroupSymbol,
636
+ type: Node.ActionGroupType,
637
+ }));
638
+ } catch (error) {
639
+ log.warn('Error in actionGroups', { id: getId('actionGroups'), node, error });
640
+ return [];
641
+ }
642
+ }).pipe(Atom.withLabel(`graph-builder:connector:actionGroups:${id}`)),
643
+ ),
644
+ } satisfies BuilderExtension)
645
+ : undefined,
646
+ actions
647
+ ? ({
648
+ id: getId('actions'),
649
+ position,
650
+ relation: Node.actionRelation(),
651
+ connector: Atom.family((node) =>
652
+ Atom.make((get) => {
653
+ try {
654
+ return get(actions(node)).map((arg) => ({ ...arg, type: Node.ActionType }));
655
+ } catch (error) {
656
+ log.warn('Error in actions', { id: getId('actions'), node, error });
657
+ return [];
658
+ }
659
+ }).pipe(Atom.withLabel(`graph-builder:connector:actions:${id}`)),
660
+ ),
661
+ } satisfies BuilderExtension)
662
+ : undefined,
663
+ ].filter(isNonNullable);
664
+ };
665
+
666
+ /**
667
+ * Options for creating a graph builder extension with simplified API.
668
+ * All callbacks must return Effects for dependency injection.
669
+ * Effects may fail - errors are caught, logged, and the extension returns empty results.
670
+ */
671
+ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
672
+ id: string;
673
+ match: (node: Node.Node) => Option.Option<TMatched>;
674
+ actions?: (
675
+ matched: TMatched,
676
+ get: Atom.Context,
677
+ ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], Error, R>;
678
+ connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
679
+ resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
680
+ relation?: Node.RelationInput;
681
+ position?: Position;
682
+ };
683
+
684
+ /**
685
+ * Run an Effect synchronously with the provided context.
686
+ * If the effect fails, logs the error and returns the fallback value.
687
+ * @internal
688
+ */
689
+ const runEffectSyncWithFallback = <T, R>(
690
+ effect: Effect.Effect<T, Error, R>,
691
+ context: Context.Context<R>,
692
+ extensionId: string,
693
+ fallback: T,
694
+ ): T => {
695
+ return Effect.runSync(
696
+ effect.pipe(
697
+ Effect.provide(context),
698
+ Effect.catchAll((error) => {
699
+ log.warn('Extension failed', { extension: extensionId, error });
700
+ return Effect.succeed(fallback);
701
+ }),
702
+ ),
703
+ );
704
+ };
705
+
706
+ /**
707
+ * Create a graph builder extension with simplified API.
708
+ * Returns an Effect to allow callbacks to access services via dependency injection.
709
+ */
710
+ export const createExtension = <TMatched = Node.Node, R = never>(
711
+ options: CreateExtensionOptions<TMatched, R>,
712
+ ): Effect.Effect<BuilderExtension[], never, R> =>
713
+ Effect.map(Effect.context<R>(), (context) => {
714
+ const { id, match, actions, connector, resolver, relation, position } = options;
715
+
716
+ const connectorExtension = connector ? createConnectorWithRuntime(id, match, connector, context) : undefined;
717
+
718
+ const actionsExtension = actions
719
+ ? (node: Atom.Atom<Option.Option<Node.Node>>) =>
720
+ Atom.make((get) =>
721
+ Function.pipe(
722
+ get(node),
723
+ Option.flatMap(match),
724
+ Option.map((matched) =>
725
+ runEffectSyncWithFallback(actions(matched, get), context, id, []).map((action) => ({
726
+ ...action,
727
+ // Attach captured context for action execution.
728
+ _actionContext: context,
729
+ })),
730
+ ),
731
+ Option.getOrElse(() => []),
732
+ ),
733
+ )
734
+ : undefined;
735
+
736
+ const resolverExtension = resolver
737
+ ? (nodeId: string) =>
738
+ Atom.make((get) => runEffectSyncWithFallback(resolver(nodeId, get), context, id, null) ?? null)
739
+ : undefined;
740
+
741
+ return createExtensionRaw({
742
+ id,
743
+ relation,
744
+ position,
745
+ connector: connectorExtension,
746
+ actions: actionsExtension,
747
+ resolver: resolverExtension,
748
+ });
428
749
  });
429
- });
430
750
 
431
751
  /**
432
- * Creates an Rx.Rx<T> from a MulticastObservable<T>
433
- * Will return the same rx instance for the same observable.
752
+ * Create a connector extension from a matcher and factory function.
753
+ * The factory's data type is inferred from the matcher's return type.
754
+ */
755
+ export const createConnector = <TData>(
756
+ matcher: (node: Node.Node) => Option.Option<TData>,
757
+ factory: (data: TData, get: Atom.Context) => Node.NodeArg<any>[],
758
+ ): ConnectorExtension => {
759
+ return (node: Atom.Atom<Option.Option<Node.Node>>) =>
760
+ Atom.make((get) =>
761
+ Function.pipe(
762
+ get(node),
763
+ Option.flatMap(matcher),
764
+ Option.map((data) => factory(data, get)),
765
+ Option.getOrElse(() => []),
766
+ ),
767
+ );
768
+ };
769
+
770
+ /**
771
+ * Create a connector extension from a matcher and factory function with Effect support.
772
+ * The factory must return an Effect. Errors are caught and logged.
773
+ * @internal
774
+ */
775
+ const createConnectorWithRuntime = <TData, R>(
776
+ extensionId: string,
777
+ matcher: (node: Node.Node) => Option.Option<TData>,
778
+ factory: (data: TData, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], Error, R>,
779
+ context: Context.Context<R>,
780
+ ): ConnectorExtension => {
781
+ return (node: Atom.Atom<Option.Option<Node.Node>>) =>
782
+ Atom.make((get) =>
783
+ Function.pipe(
784
+ get(node),
785
+ Option.flatMap(matcher),
786
+ Option.map((data) => runEffectSyncWithFallback(factory(data, get), context, extensionId, [])),
787
+ Option.getOrElse(() => []),
788
+ ),
789
+ );
790
+ };
791
+
792
+ /**
793
+ * Options for creating a type-based extension.
794
+ * All callbacks must return Effects for dependency injection.
795
+ * Effects may fail - errors are caught, logged, and the extension returns empty results.
434
796
  */
435
- export const rxFromObservable = <T>(observable: MulticastObservable<T>): Rx.Rx<T> => {
436
- return observableFamily(observable) as Rx.Rx<T>;
797
+ export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity, R = never> = {
798
+ id: string;
799
+ type: T;
800
+ actions?: (
801
+ object: Entity.Entity<Schema.Schema.Type<T>>,
802
+ get: Atom.Context,
803
+ ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>>, 'type'>[], Error, R>;
804
+ connector?: (
805
+ object: Entity.Entity<Schema.Schema.Type<T>>,
806
+ get: Atom.Context,
807
+ ) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
808
+ relation?: Node.RelationInput;
809
+ position?: Position;
810
+ };
811
+
812
+ /**
813
+ * Create an extension that matches nodes by schema type.
814
+ * The entity type is inferred from the schema type and works for both object and relation schemas.
815
+ * Returns an Effect to allow callbacks to access services via dependency injection.
816
+ */
817
+ export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
818
+ options: CreateTypeExtensionOptions<T, R>,
819
+ ): Effect.Effect<BuilderExtension[], never, R> => {
820
+ const { id, type, actions, connector, relation, position } = options;
821
+ return createExtension<Entity.Entity<Schema.Schema.Type<T>>, R>({
822
+ id,
823
+ match: NodeMatcher.whenEchoType(type),
824
+ actions,
825
+ connector,
826
+ relation,
827
+ position,
828
+ });
829
+ };
830
+
831
+ //
832
+ // Extension Utilities
833
+ //
834
+
835
+ /**
836
+ * Qualify node IDs by prefixing with the parent path.
837
+ * Validates that segment IDs do not contain the path separator.
838
+ * Recursively qualifies inline child nodes.
839
+ */
840
+ const qualifyNodeArgs =
841
+ (parentId: string) =>
842
+ (nodes: Node.NodeArg<any>[]): Node.NodeArg<any>[] =>
843
+ nodes.map((node) => {
844
+ validateSegmentId(node.id);
845
+ const qualified = qualifyId(parentId, node.id);
846
+ return {
847
+ ...node,
848
+ id: qualified,
849
+ nodes: node.nodes ? qualifyNodeArgs(qualified)(node.nodes) : undefined,
850
+ };
851
+ });
852
+
853
+ /**
854
+ * Recursively collect all inline-descendant IDs (the `nodes` arrays at every level)
855
+ * from a list of top-level NodeArgs. Top-level IDs are excluded because they are
856
+ * already tracked via `_connectorPrevious`.
857
+ */
858
+ const collectAllInlineIds = (nodes: Node.NodeArg<any>[]): string[] =>
859
+ nodes.flatMap((node) =>
860
+ node.nodes ? [...node.nodes.map((child) => child.id), ...collectAllInlineIds(node.nodes)] : [],
861
+ );
862
+
863
+ const connectorKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, Graph.relationKey(relation));
864
+
865
+ const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
866
+ const [id, encodedRelation] = primaryParts(key);
867
+ return { id, relation: Graph.relationFromKey(encodedRelation) };
868
+ };
869
+
870
+ const subscriptionKey = (id: string, kind: string, detail?: string): string =>
871
+ detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
872
+
873
+ export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
874
+ if (Array.isArray(extension)) {
875
+ return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
876
+ } else {
877
+ return [...acc, extension];
878
+ }
437
879
  };