@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.
@@ -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, isNode, isNonNullable } from '@dxos/util';
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.Relation; // Only for connector.
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?: Node.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 [id, relation] = key.split('+');
232
+ const { id, relation } = relationFromConnectorKey(key);
160
233
  const node = this._graph.node(id);
161
234
 
162
- return Function.pipe(
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(({ relation: _relation = 'outbound' }) => _relation === relation),
168
- Array.map(({ connector }) => connector?.(node)),
169
- Array.filter(isNonNullable),
170
- Array.flatMap((result) => get(result)),
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
- const connectors = this._connectors(`${id}+${relation}`);
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
- (nodes) => {
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
- 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();
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
- Graph.addNodes(this._graph, [node]);
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
- this._subscriptions.get(id)?.();
253
- this._subscriptions.delete(id);
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 = 'outbound', visitor } = options;
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
- // 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
- }
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 = 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))));
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.Relation;
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 = 'outbound',
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: 'outbound',
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: 'outbound',
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.Relation;
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.Entity.Any = Type.Entity.Any, R = never> = {
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.Relation;
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.Entity.Any, R = never>(
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))];