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