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