@dxos/app-graph 0.8.4-main.dedc0f3 → 0.8.4-main.e00bdcdb52

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