@dxos/app-graph 0.8.4-main.ef1bc66f44 → 0.8.4-main.f466a3d56e
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/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +564 -384
- package/dist/lib/neutral/chunk-32XXJE6M.mjs.map +7 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.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.map +1 -1
- package/dist/types/src/graph-builder.d.ts +13 -9
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +13 -17
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +46 -20
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +21 -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.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 +38 -27
- package/src/graph-builder.test.ts +695 -102
- package/src/graph-builder.ts +223 -76
- package/src/graph.test.ts +187 -52
- package/src/graph.ts +177 -98
- package/src/index.ts +1 -0
- package/src/node-matcher.ts +61 -31
- package/src/node.ts +46 -5
- package/src/scheduler.browser.ts +5 -0
- package/src/scheduler.ts +17 -0
- package/src/stories/EchoGraph.stories.tsx +94 -66
- 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.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -1298
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
package/src/graph-builder.ts
CHANGED
|
@@ -15,11 +15,22 @@ import type * as Schema from 'effect/Schema';
|
|
|
15
15
|
import { type CleanupFn, type Trigger } from '@dxos/async';
|
|
16
16
|
import { type Entity, type Type } from '@dxos/echo';
|
|
17
17
|
import { log } from '@dxos/log';
|
|
18
|
-
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';
|
|
19
21
|
|
|
20
22
|
import * as Graph from './graph';
|
|
21
23
|
import * as Node from './node';
|
|
22
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';
|
|
23
34
|
|
|
24
35
|
//
|
|
25
36
|
// Extension Types
|
|
@@ -53,8 +64,8 @@ export type ActionGroupsExtension = (
|
|
|
53
64
|
|
|
54
65
|
export type BuilderExtension = Readonly<{
|
|
55
66
|
id: string;
|
|
56
|
-
position
|
|
57
|
-
relation?: Node.
|
|
67
|
+
position?: Position;
|
|
68
|
+
relation?: Node.RelationInput;
|
|
58
69
|
resolver?: ResolverExtension;
|
|
59
70
|
connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
|
|
60
71
|
}>;
|
|
@@ -69,7 +80,7 @@ export type GraphBuilderTraverseOptions = {
|
|
|
69
80
|
visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
|
|
70
81
|
registry?: Registry.Registry;
|
|
71
82
|
source?: string;
|
|
72
|
-
relation
|
|
83
|
+
relation: Node.RelationInput | Node.RelationInput[];
|
|
73
84
|
};
|
|
74
85
|
|
|
75
86
|
/**
|
|
@@ -103,13 +114,36 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
// TODO(wittjosiah): Use Context.
|
|
117
|
+
/** Active subscriptions keyed by composite ID, cleaned up on node removal. */
|
|
106
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. */
|
|
107
138
|
readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
|
|
108
139
|
Atom.keepAlive,
|
|
109
140
|
Atom.withLabel('graph-builder:extensions'),
|
|
110
141
|
);
|
|
142
|
+
/** Triggers signalling that a node's resolver has fired at least once. */
|
|
111
143
|
readonly _initialized: Record<string, Trigger> = {};
|
|
144
|
+
/** Shared atom registry for reactive subscriptions. */
|
|
112
145
|
readonly _registry: Registry.Registry;
|
|
146
|
+
/** Backing graph with internal accessors for node atoms and construction. */
|
|
113
147
|
readonly _graph: Graph.Graph & {
|
|
114
148
|
_node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
|
|
115
149
|
_constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
|
|
@@ -139,6 +173,62 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
139
173
|
return this._extensions;
|
|
140
174
|
}
|
|
141
175
|
|
|
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,
|
|
194
|
+
);
|
|
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);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
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
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
142
232
|
private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
|
|
143
233
|
return Atom.make((get) => {
|
|
144
234
|
return Function.pipe(
|
|
@@ -156,73 +246,72 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
156
246
|
|
|
157
247
|
private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
|
|
158
248
|
return Atom.make((get) => {
|
|
159
|
-
const
|
|
249
|
+
const { id, relation } = relationFromConnectorKey(key);
|
|
160
250
|
const node = this._graph.node(id);
|
|
161
251
|
|
|
162
|
-
|
|
252
|
+
const sourceNode = Option.getOrElse(get(node), () => undefined);
|
|
253
|
+
if (!sourceNode) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const extensions = Function.pipe(
|
|
163
258
|
get(this._extensions),
|
|
164
259
|
Record.values,
|
|
165
|
-
// TODO(wittjosiah): Sort on write rather than read.
|
|
166
260
|
Array.sortBy(byPosition),
|
|
167
|
-
Array.filter(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
),
|
|
171
265
|
);
|
|
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;
|
|
172
274
|
}).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
|
|
173
275
|
});
|
|
174
276
|
|
|
175
277
|
private _onExpand(id: string, relation: Node.Relation): void {
|
|
176
278
|
log('onExpand', { id, relation, registry: getDebugName(this._registry) });
|
|
177
|
-
|
|
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);
|
|
178
290
|
|
|
179
|
-
let previous: string[] = [];
|
|
180
291
|
const cancel = this._registry.subscribe(
|
|
181
292
|
connectors,
|
|
182
|
-
(
|
|
293
|
+
(rawNodes) => {
|
|
294
|
+
const nodes = qualifyNodeArgs(id)(rawNodes);
|
|
295
|
+
const previous = this._connectorPrevious.get(key) ?? [];
|
|
183
296
|
const ids = nodes.map((n) => n.id);
|
|
184
|
-
|
|
185
|
-
previous
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
Graph.removeEdges(
|
|
191
|
-
this._graph,
|
|
192
|
-
removed.map((target) => ({ source: id, target })),
|
|
193
|
-
true,
|
|
194
|
-
);
|
|
195
|
-
Graph.addNodes(this._graph, nodes);
|
|
196
|
-
Graph.addEdges(
|
|
197
|
-
this._graph,
|
|
198
|
-
nodes.map((node) =>
|
|
199
|
-
relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
|
|
200
|
-
),
|
|
201
|
-
);
|
|
202
|
-
Graph.sortEdges(
|
|
203
|
-
this._graph,
|
|
204
|
-
id,
|
|
205
|
-
relation,
|
|
206
|
-
nodes.map(({ id }) => id),
|
|
207
|
-
);
|
|
208
|
-
});
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
// TODO(wittjosiah): Remove `requestAnimationFrame` once we have a better solution.
|
|
212
|
-
// This is a workaround to avoid a race condition where the graph is updated during React render.
|
|
213
|
-
if (typeof requestAnimationFrame === 'function') {
|
|
214
|
-
requestAnimationFrame(update);
|
|
215
|
-
} else {
|
|
216
|
-
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
|
+
}
|
|
217
303
|
}
|
|
304
|
+
|
|
305
|
+
log('update', { id, relation, ids });
|
|
306
|
+
this._dirtyConnectors.set(key, { nodes, previous });
|
|
307
|
+
this._scheduleDirtyFlush();
|
|
218
308
|
},
|
|
219
309
|
{ immediate: true },
|
|
220
310
|
);
|
|
221
311
|
|
|
222
|
-
this._subscriptions.set(id, cancel);
|
|
312
|
+
this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
|
|
223
313
|
}
|
|
224
314
|
|
|
225
|
-
// TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
|
|
226
315
|
private async _onInitialize(id: string) {
|
|
227
316
|
log('onInitialize', { id });
|
|
228
317
|
const resolver = this._resolvers(id);
|
|
@@ -231,26 +320,40 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
231
320
|
resolver,
|
|
232
321
|
(node) => {
|
|
233
322
|
const trigger = this._initialized[id];
|
|
323
|
+
const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
|
|
234
324
|
Option.match(node, {
|
|
235
325
|
onSome: (node) => {
|
|
236
|
-
|
|
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
|
+
}
|
|
237
334
|
trigger?.wake();
|
|
238
335
|
},
|
|
239
336
|
onNone: () => {
|
|
240
337
|
trigger?.wake();
|
|
241
|
-
|
|
338
|
+
if (!connectorOwned) {
|
|
339
|
+
Graph.removeNodes(this._graph, [id]);
|
|
340
|
+
}
|
|
242
341
|
},
|
|
243
342
|
});
|
|
244
343
|
},
|
|
245
344
|
{ immediate: true },
|
|
246
345
|
);
|
|
247
346
|
|
|
248
|
-
this._subscriptions.set(id, cancel);
|
|
347
|
+
this._subscriptions.set(subscriptionKey(id, 'init'), cancel);
|
|
249
348
|
}
|
|
250
349
|
|
|
251
350
|
private _onRemoveNode(id: string): void {
|
|
252
|
-
this._subscriptions
|
|
253
|
-
|
|
351
|
+
for (const [key, cleanup] of this._subscriptions) {
|
|
352
|
+
if (primaryParts(key)[0] === id) {
|
|
353
|
+
cleanup();
|
|
354
|
+
this._subscriptions.delete(key);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
254
357
|
}
|
|
255
358
|
}
|
|
256
359
|
|
|
@@ -344,18 +447,13 @@ const exploreImpl = async (
|
|
|
344
447
|
path: string[] = [],
|
|
345
448
|
): Promise<void> => {
|
|
346
449
|
const internal = builder as GraphBuilderImpl;
|
|
347
|
-
const { registry = Registry.make(), source = Node.RootId, relation
|
|
450
|
+
const { registry = Registry.make(), source = Node.RootId, relation, visitor } = options;
|
|
348
451
|
// Break cycles.
|
|
349
452
|
if (path.includes(source)) {
|
|
350
453
|
return;
|
|
351
454
|
}
|
|
352
455
|
|
|
353
|
-
|
|
354
|
-
// Switching to vitest is blocked by having node esm versions of echo-schema & echo-signals.
|
|
355
|
-
if (!isNode()) {
|
|
356
|
-
const { yieldOrContinue } = await import('main-thread-scheduling');
|
|
357
|
-
await yieldOrContinue('idle');
|
|
358
|
-
}
|
|
456
|
+
await yieldOrContinue('idle');
|
|
359
457
|
|
|
360
458
|
const node = registry.get(internal._graph.nodeOrThrow(source));
|
|
361
459
|
const shouldContinue = await visitor(node, [...path, node.id]);
|
|
@@ -363,11 +461,14 @@ const exploreImpl = async (
|
|
|
363
461
|
return;
|
|
364
462
|
}
|
|
365
463
|
|
|
366
|
-
const nodes =
|
|
367
|
-
.
|
|
368
|
-
.
|
|
369
|
-
.
|
|
370
|
-
.
|
|
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
|
+
);
|
|
371
472
|
|
|
372
473
|
await Promise.all(
|
|
373
474
|
nodes.map((nodeArg) => {
|
|
@@ -433,6 +534,13 @@ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder)
|
|
|
433
534
|
}
|
|
434
535
|
}
|
|
435
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
|
+
|
|
436
544
|
//
|
|
437
545
|
// Extension Creation
|
|
438
546
|
//
|
|
@@ -450,7 +558,7 @@ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder)
|
|
|
450
558
|
*/
|
|
451
559
|
export type CreateExtensionRawOptions = {
|
|
452
560
|
id: string;
|
|
453
|
-
relation?: Node.
|
|
561
|
+
relation?: Node.RelationInput;
|
|
454
562
|
position?: Position;
|
|
455
563
|
resolver?: ResolverExtension;
|
|
456
564
|
connector?: ConnectorExtension;
|
|
@@ -464,13 +572,14 @@ export type CreateExtensionRawOptions = {
|
|
|
464
572
|
export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
|
|
465
573
|
const {
|
|
466
574
|
id,
|
|
467
|
-
position
|
|
468
|
-
relation = '
|
|
575
|
+
position,
|
|
576
|
+
relation = 'child',
|
|
469
577
|
resolver: _resolver,
|
|
470
578
|
connector: _connector,
|
|
471
579
|
actions: _actions,
|
|
472
580
|
actionGroups: _actionGroups,
|
|
473
581
|
} = extension;
|
|
582
|
+
const normalizedRelation = normalizeRelation(relation);
|
|
474
583
|
const getId = (key: string) => `${id}/${key}`;
|
|
475
584
|
|
|
476
585
|
const resolver =
|
|
@@ -500,7 +609,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
500
609
|
? ({
|
|
501
610
|
id: getId('connector'),
|
|
502
611
|
position,
|
|
503
|
-
relation,
|
|
612
|
+
relation: normalizedRelation,
|
|
504
613
|
connector: Atom.family((node) =>
|
|
505
614
|
Atom.make((get) => {
|
|
506
615
|
try {
|
|
@@ -517,7 +626,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
517
626
|
? ({
|
|
518
627
|
id: getId('actionGroups'),
|
|
519
628
|
position,
|
|
520
|
-
relation:
|
|
629
|
+
relation: Node.actionRelation(),
|
|
521
630
|
connector: Atom.family((node) =>
|
|
522
631
|
Atom.make((get) => {
|
|
523
632
|
try {
|
|
@@ -538,7 +647,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
538
647
|
? ({
|
|
539
648
|
id: getId('actions'),
|
|
540
649
|
position,
|
|
541
|
-
relation:
|
|
650
|
+
relation: Node.actionRelation(),
|
|
542
651
|
connector: Atom.family((node) =>
|
|
543
652
|
Atom.make((get) => {
|
|
544
653
|
try {
|
|
@@ -568,7 +677,7 @@ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
|
|
|
568
677
|
) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], Error, R>;
|
|
569
678
|
connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
|
|
570
679
|
resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
|
|
571
|
-
relation?: Node.
|
|
680
|
+
relation?: Node.RelationInput;
|
|
572
681
|
position?: Position;
|
|
573
682
|
};
|
|
574
683
|
|
|
@@ -685,7 +794,7 @@ const createConnectorWithRuntime = <TData, R>(
|
|
|
685
794
|
* All callbacks must return Effects for dependency injection.
|
|
686
795
|
* Effects may fail - errors are caught, logged, and the extension returns empty results.
|
|
687
796
|
*/
|
|
688
|
-
export type CreateTypeExtensionOptions<T extends Type.
|
|
797
|
+
export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity, R = never> = {
|
|
689
798
|
id: string;
|
|
690
799
|
type: T;
|
|
691
800
|
actions?: (
|
|
@@ -696,7 +805,7 @@ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.A
|
|
|
696
805
|
object: Entity.Entity<Schema.Schema.Type<T>>,
|
|
697
806
|
get: Atom.Context,
|
|
698
807
|
) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
|
|
699
|
-
relation?: Node.
|
|
808
|
+
relation?: Node.RelationInput;
|
|
700
809
|
position?: Position;
|
|
701
810
|
};
|
|
702
811
|
|
|
@@ -705,7 +814,7 @@ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.A
|
|
|
705
814
|
* The entity type is inferred from the schema type and works for both object and relation schemas.
|
|
706
815
|
* Returns an Effect to allow callbacks to access services via dependency injection.
|
|
707
816
|
*/
|
|
708
|
-
export const createTypeExtension = <T extends Type.
|
|
817
|
+
export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
|
|
709
818
|
options: CreateTypeExtensionOptions<T, R>,
|
|
710
819
|
): Effect.Effect<BuilderExtension[], never, R> => {
|
|
711
820
|
const { id, type, actions, connector, relation, position } = options;
|
|
@@ -723,6 +832,44 @@ export const createTypeExtension = <T extends Type.Entity.Any, R = never>(
|
|
|
723
832
|
// Extension Utilities
|
|
724
833
|
//
|
|
725
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
|
+
|
|
726
873
|
export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
727
874
|
if (Array.isArray(extension)) {
|
|
728
875
|
return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
|