@dxos/app-graph 0.8.4-main.69d29f4 → 0.8.4-main.6fa680abb7
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 +480 -198
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +480 -198
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/graph-builder.d.ts +11 -7
- 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/node-matcher.d.ts +4 -4
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +15 -5
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +34 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -25
- package/src/graph-builder.test.ts +444 -102
- package/src/graph-builder.ts +191 -72
- package/src/graph.test.ts +187 -52
- package/src/graph.ts +179 -98
- package/src/node-matcher.ts +8 -10
- package/src/node.ts +20 -5
- package/src/stories/EchoGraph.stories.tsx +86 -56
- package/src/stories/Tree.tsx +1 -1
- package/src/util.ts +71 -0
package/src/graph-builder.ts
CHANGED
|
@@ -11,15 +11,17 @@ import * as Option from 'effect/Option';
|
|
|
11
11
|
import * as Pipeable from 'effect/Pipeable';
|
|
12
12
|
import * as Record from 'effect/Record';
|
|
13
13
|
import type * as Schema from 'effect/Schema';
|
|
14
|
+
import { scheduleTask, yieldOrContinue } from 'main-thread-scheduling';
|
|
14
15
|
|
|
15
16
|
import { type CleanupFn, type Trigger } from '@dxos/async';
|
|
16
17
|
import { type Entity, type Type } from '@dxos/echo';
|
|
17
18
|
import { log } from '@dxos/log';
|
|
18
|
-
import { type MaybePromise, type Position, byPosition, getDebugName,
|
|
19
|
+
import { type MaybePromise, type Position, byPosition, getDebugName, isNonNullable } from '@dxos/util';
|
|
19
20
|
|
|
20
21
|
import * as Graph from './graph';
|
|
21
22
|
import * as Node from './node';
|
|
22
23
|
import * as NodeMatcher from './node-matcher';
|
|
24
|
+
import { Separators, nodeArgsUnchanged, normalizeRelation, qualifyId, validateSegmentId } from './util';
|
|
23
25
|
|
|
24
26
|
//
|
|
25
27
|
// Extension Types
|
|
@@ -54,7 +56,7 @@ export type ActionGroupsExtension = (
|
|
|
54
56
|
export type BuilderExtension = Readonly<{
|
|
55
57
|
id: string;
|
|
56
58
|
position: Position;
|
|
57
|
-
relation?: Node.
|
|
59
|
+
relation?: Node.RelationInput;
|
|
58
60
|
resolver?: ResolverExtension;
|
|
59
61
|
connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
|
|
60
62
|
}>;
|
|
@@ -69,7 +71,7 @@ export type GraphBuilderTraverseOptions = {
|
|
|
69
71
|
visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
|
|
70
72
|
registry?: Registry.Registry;
|
|
71
73
|
source?: string;
|
|
72
|
-
relation
|
|
74
|
+
relation: Node.RelationInput | Node.RelationInput[];
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
/**
|
|
@@ -103,13 +105,34 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
// TODO(wittjosiah): Use Context.
|
|
108
|
+
/** Active subscriptions keyed by composite ID, cleaned up on node removal. */
|
|
106
109
|
readonly _subscriptions = new Map<string, CleanupFn>();
|
|
110
|
+
/** Connector updates pending flush, keyed by connector key. */
|
|
111
|
+
readonly _dirtyConnectors = new Map<
|
|
112
|
+
string,
|
|
113
|
+
{
|
|
114
|
+
nodes: Node.NodeArg<any>[];
|
|
115
|
+
previous: string[];
|
|
116
|
+
}
|
|
117
|
+
>();
|
|
118
|
+
/** Last-flushed node IDs per connector key, used for edge removal on update. */
|
|
119
|
+
readonly _connectorPrevious = new Map<string, string[]>();
|
|
120
|
+
/** Last-flushed node args per connector key, used for change detection. */
|
|
121
|
+
readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
|
|
122
|
+
/** Whether a dirty-flush task is already scheduled. */
|
|
123
|
+
_flushScheduled = false;
|
|
124
|
+
/** Resolves when the current flush completes. */
|
|
125
|
+
_flushPromise: Promise<void> = Promise.resolve();
|
|
126
|
+
/** Registered builder extensions keyed by extension ID. */
|
|
107
127
|
readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
|
|
108
128
|
Atom.keepAlive,
|
|
109
129
|
Atom.withLabel('graph-builder:extensions'),
|
|
110
130
|
);
|
|
131
|
+
/** Triggers signalling that a node's resolver has fired at least once. */
|
|
111
132
|
readonly _initialized: Record<string, Trigger> = {};
|
|
133
|
+
/** Shared atom registry for reactive subscriptions. */
|
|
112
134
|
readonly _registry: Registry.Registry;
|
|
135
|
+
/** Backing graph with internal accessors for node atoms and construction. */
|
|
113
136
|
readonly _graph: Graph.Graph & {
|
|
114
137
|
_node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
|
|
115
138
|
_constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
|
|
@@ -139,6 +162,56 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
139
162
|
return this._extensions;
|
|
140
163
|
}
|
|
141
164
|
|
|
165
|
+
/** Apply a set of node changes for a single connector key. */
|
|
166
|
+
private _applyConnectorUpdate(key: string, nodes: Node.NodeArg<any>[], previous: string[]): void {
|
|
167
|
+
const { id, relation } = relationFromConnectorKey(key);
|
|
168
|
+
const ids = nodes.map((node) => node.id);
|
|
169
|
+
const removed = previous.filter((pid) => !ids.includes(pid));
|
|
170
|
+
this._connectorPrevious.set(key, ids);
|
|
171
|
+
this._connectorPreviousArgs.set(key, nodes);
|
|
172
|
+
|
|
173
|
+
Graph.removeEdges(
|
|
174
|
+
this._graph,
|
|
175
|
+
removed.map((target) => ({ source: id, target, relation })),
|
|
176
|
+
true,
|
|
177
|
+
);
|
|
178
|
+
Graph.addNodes(this._graph, nodes);
|
|
179
|
+
Graph.addEdges(
|
|
180
|
+
this._graph,
|
|
181
|
+
nodes.map((node) => ({ source: id, target: node.id, relation })),
|
|
182
|
+
);
|
|
183
|
+
if (ids.length > 0) {
|
|
184
|
+
const sortedIds = [...nodes]
|
|
185
|
+
.sort((a, b) =>
|
|
186
|
+
byPosition(a.properties ?? ({} as { position?: Position }), b.properties ?? ({} as { position?: Position })),
|
|
187
|
+
)
|
|
188
|
+
.map((n) => n.id);
|
|
189
|
+
Graph.sortEdges(this._graph, id, relation, sortedIds);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private _scheduleDirtyFlush(): void {
|
|
194
|
+
if (!this._flushScheduled) {
|
|
195
|
+
this._flushScheduled = true;
|
|
196
|
+
this._flushPromise = scheduleTask(
|
|
197
|
+
() => {
|
|
198
|
+
this._flushScheduled = false;
|
|
199
|
+
while (this._dirtyConnectors.size > 0) {
|
|
200
|
+
const entries = [...this._dirtyConnectors.entries()];
|
|
201
|
+
this._dirtyConnectors.clear();
|
|
202
|
+
|
|
203
|
+
Atom.batch(() => {
|
|
204
|
+
for (const [key, { nodes, previous }] of entries) {
|
|
205
|
+
this._applyConnectorUpdate(key, nodes, previous);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{ strategy: 'smooth' },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
142
215
|
private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
|
|
143
216
|
return Atom.make((get) => {
|
|
144
217
|
return Function.pipe(
|
|
@@ -156,70 +229,70 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
156
229
|
|
|
157
230
|
private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
|
|
158
231
|
return Atom.make((get) => {
|
|
159
|
-
const
|
|
232
|
+
const { id, relation } = relationFromConnectorKey(key);
|
|
160
233
|
const node = this._graph.node(id);
|
|
161
234
|
|
|
162
|
-
|
|
235
|
+
const sourceNode = Option.getOrElse(get(node), () => undefined);
|
|
236
|
+
if (!sourceNode) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const extensions = Function.pipe(
|
|
163
241
|
get(this._extensions),
|
|
164
242
|
Record.values,
|
|
165
|
-
// TODO(wittjosiah): Sort on write rather than read.
|
|
166
243
|
Array.sortBy(byPosition),
|
|
167
|
-
Array.filter(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
244
|
+
Array.filter(
|
|
245
|
+
(ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
|
|
246
|
+
Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
|
|
247
|
+
),
|
|
171
248
|
);
|
|
249
|
+
|
|
250
|
+
const nodes: Node.NodeArg<any>[] = [];
|
|
251
|
+
for (const ext of extensions) {
|
|
252
|
+
const result = get(ext.connector(node));
|
|
253
|
+
nodes.push(...result);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return nodes;
|
|
172
257
|
}).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
|
|
173
258
|
});
|
|
174
259
|
|
|
175
260
|
private _onExpand(id: string, relation: Node.Relation): void {
|
|
176
261
|
log('onExpand', { id, relation, registry: getDebugName(this._registry) });
|
|
177
|
-
|
|
262
|
+
this._expandRelation(id, relation);
|
|
263
|
+
|
|
264
|
+
// TODO(wittjosiah): Remove. This is for backwards compatibility.
|
|
265
|
+
if (relation.kind === 'child' && relation.direction === 'outbound') {
|
|
266
|
+
Graph.expand(this._graph, id, 'action');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private _expandRelation(id: string, relation: Node.RelationInput): void {
|
|
271
|
+
const key = connectorKey(id, relation);
|
|
272
|
+
const connectors = this._connectors(key);
|
|
178
273
|
|
|
179
|
-
let previous: string[] = [];
|
|
180
274
|
const cancel = this._registry.subscribe(
|
|
181
275
|
connectors,
|
|
182
|
-
(
|
|
276
|
+
(rawNodes) => {
|
|
277
|
+
const nodes = qualifyNodeArgs(id)(rawNodes);
|
|
278
|
+
const previous = this._connectorPrevious.get(key) ?? [];
|
|
183
279
|
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();
|
|
280
|
+
|
|
281
|
+
if (ids.length === previous.length && ids.every((nodeId, idx) => nodeId === previous[idx])) {
|
|
282
|
+
const prevArgs = this._connectorPreviousArgs.get(key);
|
|
283
|
+
if (prevArgs && nodeArgsUnchanged(prevArgs, nodes)) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
217
286
|
}
|
|
287
|
+
|
|
288
|
+
log('update', { id, relation, ids });
|
|
289
|
+
this._dirtyConnectors.set(key, { nodes, previous });
|
|
290
|
+
this._scheduleDirtyFlush();
|
|
218
291
|
},
|
|
219
292
|
{ immediate: true },
|
|
220
293
|
);
|
|
221
294
|
|
|
222
|
-
this._subscriptions.set(id, cancel);
|
|
295
|
+
this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
|
|
223
296
|
}
|
|
224
297
|
|
|
225
298
|
// TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
|
|
@@ -233,7 +306,10 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
233
306
|
const trigger = this._initialized[id];
|
|
234
307
|
Option.match(node, {
|
|
235
308
|
onSome: (node) => {
|
|
236
|
-
|
|
309
|
+
const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
|
|
310
|
+
if (!connectorOwned) {
|
|
311
|
+
Graph.addNodes(this._graph, [node]);
|
|
312
|
+
}
|
|
237
313
|
trigger?.wake();
|
|
238
314
|
},
|
|
239
315
|
onNone: () => {
|
|
@@ -245,12 +321,17 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
245
321
|
{ immediate: true },
|
|
246
322
|
);
|
|
247
323
|
|
|
248
|
-
this._subscriptions.set(id, cancel);
|
|
324
|
+
this._subscriptions.set(subscriptionKey(id, 'init'), cancel);
|
|
249
325
|
}
|
|
250
326
|
|
|
251
327
|
private _onRemoveNode(id: string): void {
|
|
252
|
-
|
|
253
|
-
this._subscriptions
|
|
328
|
+
const prefix = `${id}${Separators.primary}`;
|
|
329
|
+
for (const [key, cleanup] of this._subscriptions) {
|
|
330
|
+
if (key.startsWith(prefix)) {
|
|
331
|
+
cleanup();
|
|
332
|
+
this._subscriptions.delete(key);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
254
335
|
}
|
|
255
336
|
}
|
|
256
337
|
|
|
@@ -344,18 +425,13 @@ const exploreImpl = async (
|
|
|
344
425
|
path: string[] = [],
|
|
345
426
|
): Promise<void> => {
|
|
346
427
|
const internal = builder as GraphBuilderImpl;
|
|
347
|
-
const { registry = Registry.make(), source = Node.RootId, relation
|
|
428
|
+
const { registry = Registry.make(), source = Node.RootId, relation, visitor } = options;
|
|
348
429
|
// Break cycles.
|
|
349
430
|
if (path.includes(source)) {
|
|
350
431
|
return;
|
|
351
432
|
}
|
|
352
433
|
|
|
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
|
-
}
|
|
434
|
+
await yieldOrContinue('idle');
|
|
359
435
|
|
|
360
436
|
const node = registry.get(internal._graph.nodeOrThrow(source));
|
|
361
437
|
const shouldContinue = await visitor(node, [...path, node.id]);
|
|
@@ -363,11 +439,14 @@ const exploreImpl = async (
|
|
|
363
439
|
return;
|
|
364
440
|
}
|
|
365
441
|
|
|
366
|
-
const nodes =
|
|
367
|
-
.
|
|
368
|
-
.
|
|
369
|
-
.
|
|
370
|
-
.
|
|
442
|
+
const nodes = Function.pipe(
|
|
443
|
+
internal._registry.get(internal._extensions),
|
|
444
|
+
Record.values,
|
|
445
|
+
Array.map((extension) => extension.connector),
|
|
446
|
+
Array.filter(isNonNullable),
|
|
447
|
+
Array.flatMap((connector) => registry.get(connector(internal._graph.node(source)))),
|
|
448
|
+
qualifyNodeArgs(source),
|
|
449
|
+
);
|
|
371
450
|
|
|
372
451
|
await Promise.all(
|
|
373
452
|
nodes.map((nodeArg) => {
|
|
@@ -433,6 +512,13 @@ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder)
|
|
|
433
512
|
}
|
|
434
513
|
}
|
|
435
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Wait for all pending connector updates to be flushed.
|
|
517
|
+
*/
|
|
518
|
+
export const flush = (builder: GraphBuilder): Promise<void> => {
|
|
519
|
+
return (builder as GraphBuilderImpl)._flushPromise;
|
|
520
|
+
};
|
|
521
|
+
|
|
436
522
|
//
|
|
437
523
|
// Extension Creation
|
|
438
524
|
//
|
|
@@ -450,7 +536,7 @@ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder)
|
|
|
450
536
|
*/
|
|
451
537
|
export type CreateExtensionRawOptions = {
|
|
452
538
|
id: string;
|
|
453
|
-
relation?: Node.
|
|
539
|
+
relation?: Node.RelationInput;
|
|
454
540
|
position?: Position;
|
|
455
541
|
resolver?: ResolverExtension;
|
|
456
542
|
connector?: ConnectorExtension;
|
|
@@ -465,12 +551,13 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
465
551
|
const {
|
|
466
552
|
id,
|
|
467
553
|
position = 'static',
|
|
468
|
-
relation = '
|
|
554
|
+
relation = 'child',
|
|
469
555
|
resolver: _resolver,
|
|
470
556
|
connector: _connector,
|
|
471
557
|
actions: _actions,
|
|
472
558
|
actionGroups: _actionGroups,
|
|
473
559
|
} = extension;
|
|
560
|
+
const normalizedRelation = normalizeRelation(relation);
|
|
474
561
|
const getId = (key: string) => `${id}/${key}`;
|
|
475
562
|
|
|
476
563
|
const resolver =
|
|
@@ -500,7 +587,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
500
587
|
? ({
|
|
501
588
|
id: getId('connector'),
|
|
502
589
|
position,
|
|
503
|
-
relation,
|
|
590
|
+
relation: normalizedRelation,
|
|
504
591
|
connector: Atom.family((node) =>
|
|
505
592
|
Atom.make((get) => {
|
|
506
593
|
try {
|
|
@@ -517,7 +604,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
517
604
|
? ({
|
|
518
605
|
id: getId('actionGroups'),
|
|
519
606
|
position,
|
|
520
|
-
relation:
|
|
607
|
+
relation: Node.actionRelation(),
|
|
521
608
|
connector: Atom.family((node) =>
|
|
522
609
|
Atom.make((get) => {
|
|
523
610
|
try {
|
|
@@ -538,7 +625,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
538
625
|
? ({
|
|
539
626
|
id: getId('actions'),
|
|
540
627
|
position,
|
|
541
|
-
relation:
|
|
628
|
+
relation: Node.actionRelation(),
|
|
542
629
|
connector: Atom.family((node) =>
|
|
543
630
|
Atom.make((get) => {
|
|
544
631
|
try {
|
|
@@ -568,7 +655,7 @@ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
|
|
|
568
655
|
) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], Error, R>;
|
|
569
656
|
connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
|
|
570
657
|
resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
|
|
571
|
-
relation?: Node.
|
|
658
|
+
relation?: Node.RelationInput;
|
|
572
659
|
position?: Position;
|
|
573
660
|
};
|
|
574
661
|
|
|
@@ -685,7 +772,7 @@ const createConnectorWithRuntime = <TData, R>(
|
|
|
685
772
|
* All callbacks must return Effects for dependency injection.
|
|
686
773
|
* Effects may fail - errors are caught, logged, and the extension returns empty results.
|
|
687
774
|
*/
|
|
688
|
-
export type CreateTypeExtensionOptions<T extends Type.
|
|
775
|
+
export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity, R = never> = {
|
|
689
776
|
id: string;
|
|
690
777
|
type: T;
|
|
691
778
|
actions?: (
|
|
@@ -696,7 +783,7 @@ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.A
|
|
|
696
783
|
object: Entity.Entity<Schema.Schema.Type<T>>,
|
|
697
784
|
get: Atom.Context,
|
|
698
785
|
) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
|
|
699
|
-
relation?: Node.
|
|
786
|
+
relation?: Node.RelationInput;
|
|
700
787
|
position?: Position;
|
|
701
788
|
};
|
|
702
789
|
|
|
@@ -705,7 +792,7 @@ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.A
|
|
|
705
792
|
* The entity type is inferred from the schema type and works for both object and relation schemas.
|
|
706
793
|
* Returns an Effect to allow callbacks to access services via dependency injection.
|
|
707
794
|
*/
|
|
708
|
-
export const createTypeExtension = <T extends Type.
|
|
795
|
+
export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
|
|
709
796
|
options: CreateTypeExtensionOptions<T, R>,
|
|
710
797
|
): Effect.Effect<BuilderExtension[], never, R> => {
|
|
711
798
|
const { id, type, actions, connector, relation, position } = options;
|
|
@@ -723,6 +810,38 @@ export const createTypeExtension = <T extends Type.Entity.Any, R = never>(
|
|
|
723
810
|
// Extension Utilities
|
|
724
811
|
//
|
|
725
812
|
|
|
813
|
+
/**
|
|
814
|
+
* Qualify node IDs by prefixing with the parent path.
|
|
815
|
+
* Validates that segment IDs do not contain the path separator.
|
|
816
|
+
* Recursively qualifies inline child nodes.
|
|
817
|
+
*/
|
|
818
|
+
const qualifyNodeArgs =
|
|
819
|
+
(parentId: string) =>
|
|
820
|
+
(nodes: Node.NodeArg<any>[]): Node.NodeArg<any>[] =>
|
|
821
|
+
nodes.map((node) => {
|
|
822
|
+
validateSegmentId(node.id);
|
|
823
|
+
const qualified = qualifyId(parentId, node.id);
|
|
824
|
+
return {
|
|
825
|
+
...node,
|
|
826
|
+
id: qualified,
|
|
827
|
+
nodes: node.nodes ? qualifyNodeArgs(qualified)(node.nodes) : undefined,
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
const connectorKey = (id: string, relation: Node.RelationInput): string =>
|
|
832
|
+
`${id}${Separators.primary}${Graph.relationKey(relation)}`;
|
|
833
|
+
|
|
834
|
+
const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
|
|
835
|
+
const separatorIndex = key.indexOf(Separators.primary);
|
|
836
|
+
const id = key.slice(0, separatorIndex);
|
|
837
|
+
return { id, relation: Graph.relationFromKey(key.slice(separatorIndex + 1)) };
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const subscriptionKey = (id: string, kind: string, detail?: string): string =>
|
|
841
|
+
detail != null
|
|
842
|
+
? `${id}${Separators.primary}${kind}${Separators.primary}${detail}`
|
|
843
|
+
: `${id}${Separators.primary}${kind}`;
|
|
844
|
+
|
|
726
845
|
export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
727
846
|
if (Array.isArray(extension)) {
|
|
728
847
|
return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
|