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