@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.
Files changed (56) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +564 -384
  4. package/dist/lib/neutral/chunk-32XXJE6M.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  6. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  7. package/dist/lib/neutral/index.mjs +40 -0
  8. package/dist/lib/neutral/index.mjs.map +7 -0
  9. package/dist/lib/neutral/meta.json +1 -0
  10. package/dist/lib/neutral/scheduler.mjs +15 -0
  11. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  12. package/dist/lib/neutral/testing/index.mjs +40 -0
  13. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  14. package/dist/types/src/atoms.d.ts.map +1 -1
  15. package/dist/types/src/graph-builder.d.ts +13 -9
  16. package/dist/types/src/graph-builder.d.ts.map +1 -1
  17. package/dist/types/src/graph.d.ts +13 -17
  18. package/dist/types/src/graph.d.ts.map +1 -1
  19. package/dist/types/src/index.d.ts +1 -0
  20. package/dist/types/src/index.d.ts.map +1 -1
  21. package/dist/types/src/node-matcher.d.ts +46 -20
  22. package/dist/types/src/node-matcher.d.ts.map +1 -1
  23. package/dist/types/src/node.d.ts +21 -5
  24. package/dist/types/src/node.d.ts.map +1 -1
  25. package/dist/types/src/scheduler.browser.d.ts +2 -0
  26. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  27. package/dist/types/src/scheduler.d.ts +8 -0
  28. package/dist/types/src/scheduler.d.ts.map +1 -0
  29. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  30. package/dist/types/src/testing/index.d.ts +2 -0
  31. package/dist/types/src/testing/index.d.ts.map +1 -0
  32. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  33. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  34. package/dist/types/src/util.d.ts +40 -0
  35. package/dist/types/src/util.d.ts.map +1 -0
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +38 -27
  38. package/src/graph-builder.test.ts +695 -102
  39. package/src/graph-builder.ts +223 -76
  40. package/src/graph.test.ts +187 -52
  41. package/src/graph.ts +177 -98
  42. package/src/index.ts +1 -0
  43. package/src/node-matcher.ts +61 -31
  44. package/src/node.ts +46 -5
  45. package/src/scheduler.browser.ts +5 -0
  46. package/src/scheduler.ts +17 -0
  47. package/src/stories/EchoGraph.stories.tsx +94 -66
  48. package/src/stories/Tree.tsx +1 -1
  49. package/src/testing/index.ts +5 -0
  50. package/src/testing/setup-graph-builder.ts +41 -0
  51. package/src/util.ts +101 -0
  52. package/dist/lib/browser/index.mjs.map +0 -7
  53. package/dist/lib/browser/meta.json +0 -1
  54. package/dist/lib/node-esm/index.mjs +0 -1298
  55. package/dist/lib/node-esm/index.mjs.map +0 -7
  56. package/dist/lib/node-esm/meta.json +0 -1
@@ -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, isNode, isNonNullable } from '@dxos/util';
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: Position;
57
- relation?: Node.Relation; // Only for connector.
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?: Node.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 [id, relation] = key.split('+');
249
+ const { id, relation } = relationFromConnectorKey(key);
160
250
  const node = this._graph.node(id);
161
251
 
162
- return Function.pipe(
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(({ relation: _relation = 'outbound' }) => _relation === relation),
168
- Array.map(({ connector }) => connector?.(node)),
169
- Array.filter(isNonNullable),
170
- Array.flatMap((result) => get(result)),
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
- const connectors = this._connectors(`${id}+${relation}`);
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
- (nodes) => {
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
- const removed = previous.filter((id) => !ids.includes(id));
185
- previous = ids;
186
-
187
- log('update', { id, relation, ids, removed });
188
- const update = () => {
189
- Atom.batch(() => {
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
- Graph.addNodes(this._graph, [node]);
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
- Graph.removeNodes(this._graph, [id]);
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.get(id)?.();
253
- this._subscriptions.delete(id);
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 = 'outbound', visitor } = options;
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
- // TODO(wittjosiah): This is a workaround for esm not working in the test runner.
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 = Object.values(internal._registry.get(internal._extensions))
367
- .filter((extension) => relation === (extension.relation ?? 'outbound'))
368
- .map((extension) => extension.connector)
369
- .filter(isNonNullable)
370
- .flatMap((connector) => registry.get(connector(internal._graph.node(source))));
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.Relation;
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 = 'static',
468
- relation = 'outbound',
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: 'outbound',
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: 'outbound',
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.Relation;
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.Entity.Any = Type.Entity.Any, R = never> = {
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.Relation;
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.Entity.Any, R = never>(
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))];