@dxos/app-graph 0.8.4-main.fffef41 → 0.8.4-staging.60fe92afc8

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