@dxos/app-graph 0.8.4-main.c1de068 → 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 +1355 -611
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +1354 -611
- 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 +117 -60
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +188 -218
- 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 +8 -10
- 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 +37 -35
- package/src/atoms.ts +25 -0
- package/src/graph-builder.test.ts +673 -108
- package/src/graph-builder.ts +692 -266
- package/src/graph.test.ts +430 -122
- package/src/graph.ts +1047 -408
- 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 +172 -131
- 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,37 +2,509 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
import
|
|
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 {
|
|
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 { Separators, nodeArgsUnchanged, normalizeRelation } from './util';
|
|
25
|
+
|
|
26
|
+
//
|
|
27
|
+
// Extension Types
|
|
28
|
+
//
|
|
12
29
|
|
|
13
|
-
|
|
14
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Graph builder extension for adding nodes to the graph based on a node id.
|
|
32
|
+
*/
|
|
33
|
+
export type ResolverExtension = (id: string) => Atom.Atom<Node.NodeArg<any> | null>;
|
|
15
34
|
|
|
16
35
|
/**
|
|
17
36
|
* Graph builder extension for adding nodes to the graph based on a connection to an existing node.
|
|
18
37
|
*
|
|
19
38
|
* @param params.node The existing node the returned nodes will be connected to.
|
|
20
39
|
*/
|
|
21
|
-
export type ConnectorExtension = (node:
|
|
40
|
+
export type ConnectorExtension = (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
|
|
22
41
|
|
|
23
42
|
/**
|
|
24
43
|
* Constrained case of the connector extension for more easily adding actions to the graph.
|
|
25
44
|
*/
|
|
26
45
|
export type ActionsExtension = (
|
|
27
|
-
node:
|
|
28
|
-
) =>
|
|
46
|
+
node: Atom.Atom<Option.Option<Node.Node>>,
|
|
47
|
+
) => Atom.Atom<Omit<Node.NodeArg<Node.ActionData<any>>, 'type' | 'nodes' | 'edges'>[]>;
|
|
29
48
|
|
|
30
49
|
/**
|
|
31
50
|
* Constrained case of the connector extension for more easily adding action groups to the graph.
|
|
32
51
|
*/
|
|
33
52
|
export type ActionGroupsExtension = (
|
|
34
|
-
node:
|
|
35
|
-
) =>
|
|
53
|
+
node: Atom.Atom<Option.Option<Node.Node>>,
|
|
54
|
+
) => Atom.Atom<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
|
|
55
|
+
|
|
56
|
+
export type BuilderExtension = Readonly<{
|
|
57
|
+
id: string;
|
|
58
|
+
position: Position;
|
|
59
|
+
relation?: Node.RelationInput;
|
|
60
|
+
resolver?: ResolverExtension;
|
|
61
|
+
connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
|
|
62
|
+
}>;
|
|
63
|
+
|
|
64
|
+
export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
|
|
65
|
+
|
|
66
|
+
//
|
|
67
|
+
// GraphBuilder Core
|
|
68
|
+
//
|
|
69
|
+
|
|
70
|
+
export type GraphBuilderTraverseOptions = {
|
|
71
|
+
visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
|
|
72
|
+
registry?: Registry.Registry;
|
|
73
|
+
source?: string;
|
|
74
|
+
relation: Node.RelationInput;
|
|
75
|
+
};
|
|
76
|
+
|
|
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;
|
|
82
|
+
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The builder provides an extensible way to compose the construction of the graph.
|
|
94
|
+
* @internal
|
|
95
|
+
*/
|
|
96
|
+
// TODO(wittjosiah): Add api for setting subscription set and/or radius.
|
|
97
|
+
// Should unsubscribe from nodes that are not in the set/radius.
|
|
98
|
+
// Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
|
|
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
|
+
|
|
107
|
+
// TODO(wittjosiah): Use Context.
|
|
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'),
|
|
123
|
+
);
|
|
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'> = {}) {
|
|
132
|
+
this._registry = registry ?? Registry.make();
|
|
133
|
+
const graph = Graph.make({
|
|
134
|
+
...params,
|
|
135
|
+
registry: this._registry,
|
|
136
|
+
onExpand: (id, relation) => this._onExpand(id, relation),
|
|
137
|
+
onInitialize: (id) => this._onInitialize(id),
|
|
138
|
+
onRemoveNode: (id) => this._onRemoveNode(id),
|
|
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
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get graph(): Graph.ExpandableGraph {
|
|
148
|
+
return this._graph;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get extensions() {
|
|
152
|
+
return this._extensions;
|
|
153
|
+
}
|
|
154
|
+
|
|
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,
|
|
167
|
+
);
|
|
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);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
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
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
|
|
206
|
+
return Atom.make((get) => {
|
|
207
|
+
return Function.pipe(
|
|
208
|
+
get(this._extensions),
|
|
209
|
+
Record.values,
|
|
210
|
+
Array.sortBy(byPosition),
|
|
211
|
+
Array.map(({ resolver }) => resolver),
|
|
212
|
+
Array.filter(isNonNullable),
|
|
213
|
+
Array.map((resolver) => get(resolver(id))),
|
|
214
|
+
Array.filter(isNonNullable),
|
|
215
|
+
Array.head,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
|
|
221
|
+
return Atom.make((get) => {
|
|
222
|
+
const { id, relation } = relationFromConnectorKey(key);
|
|
223
|
+
const node = this._graph.node(id);
|
|
224
|
+
|
|
225
|
+
const sourceNode = Option.getOrElse(get(node), () => undefined);
|
|
226
|
+
if (!sourceNode) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const extensions = Function.pipe(
|
|
231
|
+
get(this._extensions),
|
|
232
|
+
Record.values,
|
|
233
|
+
Array.sortBy(byPosition),
|
|
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
|
+
),
|
|
238
|
+
);
|
|
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}`));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
private _onExpand(id: string, relation: Node.Relation): void {
|
|
251
|
+
log('onExpand', { id, relation, registry: getDebugName(this._registry) });
|
|
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);
|
|
263
|
+
|
|
264
|
+
const cancel = this._registry.subscribe(
|
|
265
|
+
connectors,
|
|
266
|
+
(nodes) => {
|
|
267
|
+
const previous = this._connectorPrevious.get(key) ?? [];
|
|
268
|
+
const ids = nodes.map((n) => n.id);
|
|
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
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
log('update', { id, relation, ids });
|
|
278
|
+
this._dirtyConnectors.set(key, { nodes, previous });
|
|
279
|
+
this._scheduleDirtyFlush();
|
|
280
|
+
},
|
|
281
|
+
{ immediate: true },
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
|
|
288
|
+
private async _onInitialize(id: string) {
|
|
289
|
+
log('onInitialize', { id });
|
|
290
|
+
const resolver = this._resolvers(id);
|
|
291
|
+
|
|
292
|
+
const cancel = this._registry.subscribe(
|
|
293
|
+
resolver,
|
|
294
|
+
(node) => {
|
|
295
|
+
const trigger = this._initialized[id];
|
|
296
|
+
Option.match(node, {
|
|
297
|
+
onSome: (node) => {
|
|
298
|
+
Graph.addNodes(this._graph, [node]);
|
|
299
|
+
trigger?.wake();
|
|
300
|
+
},
|
|
301
|
+
onNone: () => {
|
|
302
|
+
trigger?.wake();
|
|
303
|
+
Graph.removeNodes(this._graph, [id]);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
{ immediate: true },
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
this._subscriptions.set(subscriptionKey(id, 'init'), cancel);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private _onRemoveNode(id: string): void {
|
|
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
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Creates a new GraphBuilder instance.
|
|
326
|
+
*/
|
|
327
|
+
export const make = (params?: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'>): GraphBuilder => {
|
|
328
|
+
return new GraphBuilderImpl(params);
|
|
329
|
+
};
|
|
330
|
+
|
|
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
|
+
}
|
|
338
|
+
|
|
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));
|
|
351
|
+
});
|
|
352
|
+
return builder;
|
|
353
|
+
};
|
|
354
|
+
|
|
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
|
+
};
|
|
384
|
+
|
|
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
|
+
}
|
|
404
|
+
|
|
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
|
+
//
|
|
36
508
|
|
|
37
509
|
/**
|
|
38
510
|
* A graph builder extension is used to add nodes to the graph.
|
|
@@ -45,63 +517,69 @@ export type ActionGroupsExtension = (
|
|
|
45
517
|
* @param params.actions A function to add actions to the graph based on a connection to an existing node.
|
|
46
518
|
* @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
|
|
47
519
|
*/
|
|
48
|
-
export type
|
|
520
|
+
export type CreateExtensionRawOptions = {
|
|
49
521
|
id: string;
|
|
50
|
-
relation?:
|
|
522
|
+
relation?: Node.RelationInput;
|
|
51
523
|
position?: Position;
|
|
52
|
-
|
|
53
|
-
// resolver?: ResolverExtension;
|
|
524
|
+
resolver?: ResolverExtension;
|
|
54
525
|
connector?: ConnectorExtension;
|
|
55
526
|
actions?: ActionsExtension;
|
|
56
527
|
actionGroups?: ActionGroupsExtension;
|
|
57
528
|
};
|
|
58
529
|
|
|
59
530
|
/**
|
|
60
|
-
* Create a graph builder extension.
|
|
531
|
+
* Create a graph builder extension (low-level API that works directly with Atoms).
|
|
61
532
|
*/
|
|
62
|
-
export const
|
|
533
|
+
export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
|
|
63
534
|
const {
|
|
64
535
|
id,
|
|
65
536
|
position = 'static',
|
|
66
|
-
relation = '
|
|
537
|
+
relation = 'child',
|
|
538
|
+
resolver: _resolver,
|
|
67
539
|
connector: _connector,
|
|
68
540
|
actions: _actions,
|
|
69
541
|
actionGroups: _actionGroups,
|
|
70
542
|
} = extension;
|
|
543
|
+
const normalizedRelation = normalizeRelation(relation);
|
|
71
544
|
const getId = (key: string) => `${id}/${key}`;
|
|
72
545
|
|
|
546
|
+
const resolver =
|
|
547
|
+
_resolver && Atom.family((id: string) => _resolver(id).pipe(Atom.withLabel(`graph-builder:_resolver:${id}`)));
|
|
548
|
+
|
|
73
549
|
const connector =
|
|
74
550
|
_connector &&
|
|
75
|
-
|
|
76
|
-
_connector(node).pipe(
|
|
551
|
+
Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
|
|
552
|
+
_connector(node).pipe(Atom.withLabel(`graph-builder:_connector:${id}`)),
|
|
77
553
|
);
|
|
78
554
|
|
|
79
555
|
const actionGroups =
|
|
80
556
|
_actionGroups &&
|
|
81
|
-
|
|
82
|
-
_actionGroups(node).pipe(
|
|
557
|
+
Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
|
|
558
|
+
_actionGroups(node).pipe(Atom.withLabel(`graph-builder:_actionGroups:${id}`)),
|
|
83
559
|
);
|
|
84
560
|
|
|
85
561
|
const actions =
|
|
86
562
|
_actions &&
|
|
87
|
-
|
|
563
|
+
Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
|
|
564
|
+
_actions(node).pipe(Atom.withLabel(`graph-builder:_actions:${id}`)),
|
|
565
|
+
);
|
|
88
566
|
|
|
89
567
|
return [
|
|
90
|
-
|
|
568
|
+
resolver ? { id: getId('resolver'), position, resolver } : undefined,
|
|
91
569
|
connector
|
|
92
570
|
? ({
|
|
93
571
|
id: getId('connector'),
|
|
94
572
|
position,
|
|
95
|
-
relation,
|
|
96
|
-
connector:
|
|
97
|
-
|
|
573
|
+
relation: normalizedRelation,
|
|
574
|
+
connector: Atom.family((node) =>
|
|
575
|
+
Atom.make((get) => {
|
|
98
576
|
try {
|
|
99
577
|
return get(connector(node));
|
|
100
|
-
} catch {
|
|
101
|
-
log.warn('Error in connector', { id: getId('connector'), node });
|
|
578
|
+
} catch (error) {
|
|
579
|
+
log.warn('Error in connector', { id: getId('connector'), node, error });
|
|
102
580
|
return [];
|
|
103
581
|
}
|
|
104
|
-
}).pipe(
|
|
582
|
+
}).pipe(Atom.withLabel(`graph-builder:connector:${id}`)),
|
|
105
583
|
),
|
|
106
584
|
} satisfies BuilderExtension)
|
|
107
585
|
: undefined,
|
|
@@ -109,20 +587,20 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
|
|
|
109
587
|
? ({
|
|
110
588
|
id: getId('actionGroups'),
|
|
111
589
|
position,
|
|
112
|
-
relation:
|
|
113
|
-
connector:
|
|
114
|
-
|
|
590
|
+
relation: Node.actionRelation(),
|
|
591
|
+
connector: Atom.family((node) =>
|
|
592
|
+
Atom.make((get) => {
|
|
115
593
|
try {
|
|
116
594
|
return get(actionGroups(node)).map((arg) => ({
|
|
117
595
|
...arg,
|
|
118
|
-
data: actionGroupSymbol,
|
|
119
|
-
type:
|
|
596
|
+
data: Node.actionGroupSymbol,
|
|
597
|
+
type: Node.ActionGroupType,
|
|
120
598
|
}));
|
|
121
|
-
} catch {
|
|
122
|
-
log.warn('Error in actionGroups', { id: getId('actionGroups'), node });
|
|
599
|
+
} catch (error) {
|
|
600
|
+
log.warn('Error in actionGroups', { id: getId('actionGroups'), node, error });
|
|
123
601
|
return [];
|
|
124
602
|
}
|
|
125
|
-
}).pipe(
|
|
603
|
+
}).pipe(Atom.withLabel(`graph-builder:connector:actionGroups:${id}`)),
|
|
126
604
|
),
|
|
127
605
|
} satisfies BuilderExtension)
|
|
128
606
|
: undefined,
|
|
@@ -130,261 +608,209 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
|
|
|
130
608
|
? ({
|
|
131
609
|
id: getId('actions'),
|
|
132
610
|
position,
|
|
133
|
-
relation:
|
|
134
|
-
connector:
|
|
135
|
-
|
|
611
|
+
relation: Node.actionRelation(),
|
|
612
|
+
connector: Atom.family((node) =>
|
|
613
|
+
Atom.make((get) => {
|
|
136
614
|
try {
|
|
137
|
-
return get(actions(node)).map((arg) => ({ ...arg, type:
|
|
138
|
-
} catch {
|
|
139
|
-
log.warn('Error in actions', { id: getId('actions'), node });
|
|
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 });
|
|
140
618
|
return [];
|
|
141
619
|
}
|
|
142
|
-
}).pipe(
|
|
620
|
+
}).pipe(Atom.withLabel(`graph-builder:connector:actions:${id}`)),
|
|
143
621
|
),
|
|
144
622
|
} satisfies BuilderExtension)
|
|
145
623
|
: undefined,
|
|
146
624
|
].filter(isNonNullable);
|
|
147
625
|
};
|
|
148
626
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
export type BuilderExtension = Readonly<{
|
|
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> = {
|
|
157
633
|
id: string;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (Array.isArray(extension)) {
|
|
168
|
-
return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
|
|
169
|
-
} else {
|
|
170
|
-
return [...acc, extension];
|
|
171
|
-
}
|
|
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;
|
|
172
643
|
};
|
|
173
644
|
|
|
174
645
|
/**
|
|
175
|
-
*
|
|
646
|
+
* Run an Effect synchronously with the provided context.
|
|
647
|
+
* If the effect fails, logs the error and returns the fallback value.
|
|
648
|
+
* @internal
|
|
176
649
|
*/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
),
|
|
186
664
|
);
|
|
665
|
+
};
|
|
187
666
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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,
|
|
223
709
|
});
|
|
224
|
-
return this;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
removeExtension(id: string): GraphBuilder {
|
|
228
|
-
const extensions = this._registry.get(this._extensions);
|
|
229
|
-
this._registry.set(this._extensions, Record.remove(extensions, id));
|
|
230
|
-
return this;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async explore(
|
|
234
|
-
// TODO(wittjosiah): Currently defaulting to new registry.
|
|
235
|
-
// Currently unsure about how to handle nodes which are expanded in the background.
|
|
236
|
-
// This seems like a good place to start.
|
|
237
|
-
{ registry = Registry.make(), source = ROOT_ID, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
|
|
238
|
-
path: string[] = [],
|
|
239
|
-
): Promise<void> {
|
|
240
|
-
// Break cycles.
|
|
241
|
-
if (path.includes(source)) {
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// TODO(wittjosiah): This is a workaround for esm not working in the test runner.
|
|
246
|
-
// Switching to vitest is blocked by having node esm versions of echo-schema & echo-signals.
|
|
247
|
-
if (!isNode()) {
|
|
248
|
-
const { yieldOrContinue } = await import('main-thread-scheduling');
|
|
249
|
-
await yieldOrContinue('idle');
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const node = registry.get(this._graph.nodeOrThrow(source));
|
|
253
|
-
const shouldContinue = await visitor(node, [...path, node.id]);
|
|
254
|
-
if (shouldContinue === false) {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const nodes = Object.values(this._registry.get(this._extensions))
|
|
259
|
-
.filter((extension) => relation === (extension.relation ?? 'outbound'))
|
|
260
|
-
.map((extension) => extension.connector)
|
|
261
|
-
.filter(isNonNullable)
|
|
262
|
-
.flatMap((connector) => registry.get(connector(this._graph.node(source))));
|
|
263
|
-
|
|
264
|
-
await Promise.all(
|
|
265
|
-
nodes.map((nodeArg) => {
|
|
266
|
-
registry.set(this._graph._node(nodeArg.id), this._graph._constructNode(nodeArg));
|
|
267
|
-
return this.explore({ registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
|
|
268
|
-
}),
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
if (registry !== this._registry) {
|
|
272
|
-
registry.reset();
|
|
273
|
-
registry.dispose();
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
destroy(): void {
|
|
278
|
-
this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
279
|
-
this._connectorSubscriptions.clear();
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
private readonly _connectors = Rx.family<string, Rx.Rx<NodeArg<any>[]>>((key) => {
|
|
283
|
-
return Rx.make((get) => {
|
|
284
|
-
const [id, relation] = key.split('+');
|
|
285
|
-
const node = this._graph.node(id);
|
|
286
|
-
|
|
287
|
-
return pipe(
|
|
288
|
-
get(this._extensions),
|
|
289
|
-
Record.values,
|
|
290
|
-
// TODO(wittjosiah): Sort on write rather than read.
|
|
291
|
-
Array.sortBy(byPosition),
|
|
292
|
-
Array.filter(({ relation: _relation = 'outbound' }) => _relation === relation),
|
|
293
|
-
Array.map(({ connector }) => connector?.(node)),
|
|
294
|
-
Array.filter(isNonNullable),
|
|
295
|
-
Array.flatMap((result) => get(result)),
|
|
296
|
-
);
|
|
297
|
-
}).pipe(Rx.withLabel(`graph-builder:connectors:${key}`));
|
|
298
710
|
});
|
|
299
711
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
removed.map((target) => ({ source: id, target })),
|
|
317
|
-
true,
|
|
318
|
-
);
|
|
319
|
-
this._graph.addNodes(nodes);
|
|
320
|
-
this._graph.addEdges(
|
|
321
|
-
nodes.map((node) =>
|
|
322
|
-
relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
|
|
323
|
-
),
|
|
324
|
-
);
|
|
325
|
-
this._graph.sortEdges(
|
|
326
|
-
id,
|
|
327
|
-
relation,
|
|
328
|
-
nodes.map(({ id }) => id),
|
|
329
|
-
);
|
|
330
|
-
});
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
// TODO(wittjosiah): Remove `requestAnimationFrame` once we have a better solution.
|
|
334
|
-
// This is a workaround to avoid a race condition where the graph is updated during React render.
|
|
335
|
-
if (typeof requestAnimationFrame === 'function') {
|
|
336
|
-
requestAnimationFrame(update);
|
|
337
|
-
} else {
|
|
338
|
-
update();
|
|
339
|
-
}
|
|
340
|
-
},
|
|
341
|
-
{ immediate: true },
|
|
712
|
+
/**
|
|
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
|
+
),
|
|
342
728
|
);
|
|
343
|
-
|
|
344
|
-
this._connectorSubscriptions.set(id, cancel);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// TODO(wittjosiah): On initialize to restore state from cache.
|
|
348
|
-
// private async _onInitialize(id: string) {
|
|
349
|
-
// log('onInitialize', { id });
|
|
350
|
-
// }
|
|
351
|
-
|
|
352
|
-
private _onRemoveNode(id: string): void {
|
|
353
|
-
this._connectorSubscriptions.get(id)?.();
|
|
354
|
-
this._connectorSubscriptions.delete(id);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
729
|
+
};
|
|
357
730
|
|
|
358
731
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
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
|
|
361
735
|
*/
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
};
|
|
367
752
|
|
|
368
|
-
|
|
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
|
+
};
|
|
369
772
|
|
|
370
|
-
|
|
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.
|
|
777
|
+
*/
|
|
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,
|
|
371
789
|
});
|
|
372
790
|
};
|
|
373
791
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
792
|
+
//
|
|
793
|
+
// Extension Utilities
|
|
794
|
+
//
|
|
377
795
|
|
|
378
|
-
|
|
796
|
+
const connectorKey = (id: string, relation: Node.RelationInput): string =>
|
|
797
|
+
`${id}${Separators.primary}${Graph.relationKey(relation)}`;
|
|
379
798
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
};
|
|
383
804
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
|
390
816
|
};
|