@dxos/app-graph 0.8.4-main.bc674ce → 0.8.4-main.bcb3aa67d6

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 (45) hide show
  1. package/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
  2. package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +17 -1276
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +39 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs +1604 -0
  9. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +17 -1276
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +40 -0
  14. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  15. package/dist/types/src/graph-builder.d.ts +11 -7
  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 +43 -17
  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/stories/EchoGraph.stories.d.ts.map +1 -1
  26. package/dist/types/src/testing/index.d.ts +2 -0
  27. package/dist/types/src/testing/index.d.ts.map +1 -0
  28. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  29. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  30. package/dist/types/src/util.d.ts +39 -0
  31. package/dist/types/src/util.d.ts.map +1 -0
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +36 -26
  34. package/src/graph-builder.test.ts +569 -102
  35. package/src/graph-builder.ts +202 -74
  36. package/src/graph.test.ts +187 -52
  37. package/src/graph.ts +174 -98
  38. package/src/index.ts +1 -0
  39. package/src/node-matcher.ts +58 -28
  40. package/src/node.ts +46 -5
  41. package/src/stories/EchoGraph.stories.tsx +90 -61
  42. package/src/stories/Tree.tsx +1 -1
  43. package/src/testing/index.ts +5 -0
  44. package/src/testing/setup-graph-builder.ts +41 -0
  45. package/src/util.ts +95 -0
@@ -11,15 +11,25 @@ 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 {
25
+ getParentId,
26
+ nodeArgsUnchanged,
27
+ normalizeRelation,
28
+ primaryKey,
29
+ primaryParts,
30
+ qualifyId,
31
+ validateSegmentId,
32
+ } from './util';
23
33
 
24
34
  //
25
35
  // Extension Types
@@ -54,7 +64,7 @@ export type ActionGroupsExtension = (
54
64
  export type BuilderExtension = Readonly<{
55
65
  id: string;
56
66
  position: Position;
57
- relation?: Node.Relation; // Only for connector.
67
+ relation?: Node.RelationInput;
58
68
  resolver?: ResolverExtension;
59
69
  connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
60
70
  }>;
@@ -69,7 +79,7 @@ export type GraphBuilderTraverseOptions = {
69
79
  visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
70
80
  registry?: Registry.Registry;
71
81
  source?: string;
72
- relation?: Node.Relation;
82
+ relation: Node.RelationInput | Node.RelationInput[];
73
83
  };
74
84
 
75
85
  /**
@@ -103,13 +113,34 @@ class GraphBuilderImpl implements GraphBuilder {
103
113
  }
104
114
 
105
115
  // TODO(wittjosiah): Use Context.
116
+ /** Active subscriptions keyed by composite ID, cleaned up on node removal. */
106
117
  readonly _subscriptions = new Map<string, CleanupFn>();
118
+ /** Connector updates pending flush, keyed by connector key. */
119
+ readonly _dirtyConnectors = new Map<
120
+ string,
121
+ {
122
+ nodes: Node.NodeArg<any>[];
123
+ previous: string[];
124
+ }
125
+ >();
126
+ /** Last-flushed node IDs per connector key, used for edge removal on update. */
127
+ readonly _connectorPrevious = new Map<string, string[]>();
128
+ /** Last-flushed node args per connector key, used for change detection. */
129
+ readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
130
+ /** Whether a dirty-flush task is already scheduled. */
131
+ _flushScheduled = false;
132
+ /** Resolves when the current flush completes. */
133
+ _flushPromise: Promise<void> = Promise.resolve();
134
+ /** Registered builder extensions keyed by extension ID. */
107
135
  readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
108
136
  Atom.keepAlive,
109
137
  Atom.withLabel('graph-builder:extensions'),
110
138
  );
139
+ /** Triggers signalling that a node's resolver has fired at least once. */
111
140
  readonly _initialized: Record<string, Trigger> = {};
141
+ /** Shared atom registry for reactive subscriptions. */
112
142
  readonly _registry: Registry.Registry;
143
+ /** Backing graph with internal accessors for node atoms and construction. */
113
144
  readonly _graph: Graph.Graph & {
114
145
  _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
115
146
  _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
@@ -139,6 +170,56 @@ class GraphBuilderImpl implements GraphBuilder {
139
170
  return this._extensions;
140
171
  }
141
172
 
173
+ /** Apply a set of node changes for a single connector key. */
174
+ private _applyConnectorUpdate(key: string, nodes: Node.NodeArg<any>[], previous: string[]): void {
175
+ const { id, relation } = relationFromConnectorKey(key);
176
+ const ids = nodes.map((node) => node.id);
177
+ const removed = previous.filter((pid) => !ids.includes(pid));
178
+ this._connectorPrevious.set(key, ids);
179
+ this._connectorPreviousArgs.set(key, nodes);
180
+
181
+ Graph.removeEdges(
182
+ this._graph,
183
+ removed.map((target) => ({ source: id, target, relation })),
184
+ true,
185
+ );
186
+ Graph.addNodes(this._graph, nodes);
187
+ Graph.addEdges(
188
+ this._graph,
189
+ nodes.map((node) => ({ source: id, target: node.id, relation })),
190
+ );
191
+ if (ids.length > 0) {
192
+ const sortedIds = [...nodes]
193
+ .sort((a, b) =>
194
+ byPosition(a.properties ?? ({} as { position?: Position }), b.properties ?? ({} as { position?: Position })),
195
+ )
196
+ .map((n) => n.id);
197
+ Graph.sortEdges(this._graph, id, relation, sortedIds);
198
+ }
199
+ }
200
+
201
+ private _scheduleDirtyFlush(): void {
202
+ if (!this._flushScheduled) {
203
+ this._flushScheduled = true;
204
+ this._flushPromise = scheduleTask(
205
+ () => {
206
+ this._flushScheduled = false;
207
+ while (this._dirtyConnectors.size > 0) {
208
+ const entries = [...this._dirtyConnectors.entries()];
209
+ this._dirtyConnectors.clear();
210
+
211
+ Atom.batch(() => {
212
+ for (const [key, { nodes, previous }] of entries) {
213
+ this._applyConnectorUpdate(key, nodes, previous);
214
+ }
215
+ });
216
+ }
217
+ },
218
+ { strategy: 'smooth' },
219
+ );
220
+ }
221
+ }
222
+
142
223
  private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
143
224
  return Atom.make((get) => {
144
225
  return Function.pipe(
@@ -156,73 +237,72 @@ class GraphBuilderImpl implements GraphBuilder {
156
237
 
157
238
  private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
158
239
  return Atom.make((get) => {
159
- const [id, relation] = key.split('+');
240
+ const { id, relation } = relationFromConnectorKey(key);
160
241
  const node = this._graph.node(id);
161
242
 
162
- return Function.pipe(
243
+ const sourceNode = Option.getOrElse(get(node), () => undefined);
244
+ if (!sourceNode) {
245
+ return [];
246
+ }
247
+
248
+ const extensions = Function.pipe(
163
249
  get(this._extensions),
164
250
  Record.values,
165
- // TODO(wittjosiah): Sort on write rather than read.
166
251
  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)),
252
+ Array.filter(
253
+ (ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
254
+ Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
255
+ ),
171
256
  );
257
+
258
+ const nodes: Node.NodeArg<any>[] = [];
259
+ for (const ext of extensions) {
260
+ const result = get(ext.connector(node));
261
+ nodes.push(...result);
262
+ }
263
+
264
+ return nodes;
172
265
  }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
173
266
  });
174
267
 
175
268
  private _onExpand(id: string, relation: Node.Relation): void {
176
269
  log('onExpand', { id, relation, registry: getDebugName(this._registry) });
177
- const connectors = this._connectors(`${id}+${relation}`);
270
+ this._expandRelation(id, relation);
271
+
272
+ // TODO(wittjosiah): Remove. This is for backwards compatibility.
273
+ if (relation.kind === 'child' && relation.direction === 'outbound') {
274
+ Graph.expand(this._graph, id, 'action');
275
+ }
276
+ }
277
+
278
+ private _expandRelation(id: string, relation: Node.RelationInput): void {
279
+ const key = connectorKey(id, relation);
280
+ const connectors = this._connectors(key);
178
281
 
179
- let previous: string[] = [];
180
282
  const cancel = this._registry.subscribe(
181
283
  connectors,
182
- (nodes) => {
284
+ (rawNodes) => {
285
+ const nodes = qualifyNodeArgs(id)(rawNodes);
286
+ const previous = this._connectorPrevious.get(key) ?? [];
183
287
  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();
288
+
289
+ if (ids.length === previous.length && ids.every((nodeId, idx) => nodeId === previous[idx])) {
290
+ const prevArgs = this._connectorPreviousArgs.get(key);
291
+ if (prevArgs && nodeArgsUnchanged(prevArgs, nodes)) {
292
+ return;
293
+ }
217
294
  }
295
+
296
+ log('update', { id, relation, ids });
297
+ this._dirtyConnectors.set(key, { nodes, previous });
298
+ this._scheduleDirtyFlush();
218
299
  },
219
300
  { immediate: true },
220
301
  );
221
302
 
222
- this._subscriptions.set(id, cancel);
303
+ this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
223
304
  }
224
305
 
225
- // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
226
306
  private async _onInitialize(id: string) {
227
307
  log('onInitialize', { id });
228
308
  const resolver = this._resolvers(id);
@@ -231,26 +311,40 @@ class GraphBuilderImpl implements GraphBuilder {
231
311
  resolver,
232
312
  (node) => {
233
313
  const trigger = this._initialized[id];
314
+ const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
234
315
  Option.match(node, {
235
316
  onSome: (node) => {
236
- Graph.addNodes(this._graph, [node]);
317
+ if (!connectorOwned) {
318
+ Graph.addNodes(this._graph, [node]);
319
+ // Connect resolved node to its parent via a child edge.
320
+ const parentId = getParentId(id);
321
+ if (parentId) {
322
+ Graph.addEdges(this._graph, [{ source: parentId, target: id, relation: 'child' }]);
323
+ }
324
+ }
237
325
  trigger?.wake();
238
326
  },
239
327
  onNone: () => {
240
328
  trigger?.wake();
241
- Graph.removeNodes(this._graph, [id]);
329
+ if (!connectorOwned) {
330
+ Graph.removeNodes(this._graph, [id]);
331
+ }
242
332
  },
243
333
  });
244
334
  },
245
335
  { immediate: true },
246
336
  );
247
337
 
248
- this._subscriptions.set(id, cancel);
338
+ this._subscriptions.set(subscriptionKey(id, 'init'), cancel);
249
339
  }
250
340
 
251
341
  private _onRemoveNode(id: string): void {
252
- this._subscriptions.get(id)?.();
253
- this._subscriptions.delete(id);
342
+ for (const [key, cleanup] of this._subscriptions) {
343
+ if (primaryParts(key)[0] === id) {
344
+ cleanup();
345
+ this._subscriptions.delete(key);
346
+ }
347
+ }
254
348
  }
255
349
  }
256
350
 
@@ -344,18 +438,13 @@ const exploreImpl = async (
344
438
  path: string[] = [],
345
439
  ): Promise<void> => {
346
440
  const internal = builder as GraphBuilderImpl;
347
- const { registry = Registry.make(), source = Node.RootId, relation = 'outbound', visitor } = options;
441
+ const { registry = Registry.make(), source = Node.RootId, relation, visitor } = options;
348
442
  // Break cycles.
349
443
  if (path.includes(source)) {
350
444
  return;
351
445
  }
352
446
 
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
- }
447
+ await yieldOrContinue('idle');
359
448
 
360
449
  const node = registry.get(internal._graph.nodeOrThrow(source));
361
450
  const shouldContinue = await visitor(node, [...path, node.id]);
@@ -363,11 +452,14 @@ const exploreImpl = async (
363
452
  return;
364
453
  }
365
454
 
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))));
455
+ const nodes = Function.pipe(
456
+ internal._registry.get(internal._extensions),
457
+ Record.values,
458
+ Array.map((extension) => extension.connector),
459
+ Array.filter(isNonNullable),
460
+ Array.flatMap((connector) => registry.get(connector(internal._graph.node(source)))),
461
+ qualifyNodeArgs(source),
462
+ );
371
463
 
372
464
  await Promise.all(
373
465
  nodes.map((nodeArg) => {
@@ -433,6 +525,13 @@ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder)
433
525
  }
434
526
  }
435
527
 
528
+ /**
529
+ * Wait for all pending connector updates to be flushed.
530
+ */
531
+ export const flush = (builder: GraphBuilder): Promise<void> => {
532
+ return (builder as GraphBuilderImpl)._flushPromise;
533
+ };
534
+
436
535
  //
437
536
  // Extension Creation
438
537
  //
@@ -450,7 +549,7 @@ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder)
450
549
  */
451
550
  export type CreateExtensionRawOptions = {
452
551
  id: string;
453
- relation?: Node.Relation;
552
+ relation?: Node.RelationInput;
454
553
  position?: Position;
455
554
  resolver?: ResolverExtension;
456
555
  connector?: ConnectorExtension;
@@ -465,12 +564,13 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
465
564
  const {
466
565
  id,
467
566
  position = 'static',
468
- relation = 'outbound',
567
+ relation = 'child',
469
568
  resolver: _resolver,
470
569
  connector: _connector,
471
570
  actions: _actions,
472
571
  actionGroups: _actionGroups,
473
572
  } = extension;
573
+ const normalizedRelation = normalizeRelation(relation);
474
574
  const getId = (key: string) => `${id}/${key}`;
475
575
 
476
576
  const resolver =
@@ -500,7 +600,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
500
600
  ? ({
501
601
  id: getId('connector'),
502
602
  position,
503
- relation,
603
+ relation: normalizedRelation,
504
604
  connector: Atom.family((node) =>
505
605
  Atom.make((get) => {
506
606
  try {
@@ -517,7 +617,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
517
617
  ? ({
518
618
  id: getId('actionGroups'),
519
619
  position,
520
- relation: 'outbound',
620
+ relation: Node.actionRelation(),
521
621
  connector: Atom.family((node) =>
522
622
  Atom.make((get) => {
523
623
  try {
@@ -538,7 +638,7 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
538
638
  ? ({
539
639
  id: getId('actions'),
540
640
  position,
541
- relation: 'outbound',
641
+ relation: Node.actionRelation(),
542
642
  connector: Atom.family((node) =>
543
643
  Atom.make((get) => {
544
644
  try {
@@ -568,7 +668,7 @@ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
568
668
  ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], Error, R>;
569
669
  connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
570
670
  resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
571
- relation?: Node.Relation;
671
+ relation?: Node.RelationInput;
572
672
  position?: Position;
573
673
  };
574
674
 
@@ -685,7 +785,7 @@ const createConnectorWithRuntime = <TData, R>(
685
785
  * All callbacks must return Effects for dependency injection.
686
786
  * Effects may fail - errors are caught, logged, and the extension returns empty results.
687
787
  */
688
- export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.Any, R = never> = {
788
+ export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity, R = never> = {
689
789
  id: string;
690
790
  type: T;
691
791
  actions?: (
@@ -696,7 +796,7 @@ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.A
696
796
  object: Entity.Entity<Schema.Schema.Type<T>>,
697
797
  get: Atom.Context,
698
798
  ) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
699
- relation?: Node.Relation;
799
+ relation?: Node.RelationInput;
700
800
  position?: Position;
701
801
  };
702
802
 
@@ -705,7 +805,7 @@ export type CreateTypeExtensionOptions<T extends Type.Entity.Any = Type.Entity.A
705
805
  * The entity type is inferred from the schema type and works for both object and relation schemas.
706
806
  * Returns an Effect to allow callbacks to access services via dependency injection.
707
807
  */
708
- export const createTypeExtension = <T extends Type.Entity.Any, R = never>(
808
+ export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
709
809
  options: CreateTypeExtensionOptions<T, R>,
710
810
  ): Effect.Effect<BuilderExtension[], never, R> => {
711
811
  const { id, type, actions, connector, relation, position } = options;
@@ -723,6 +823,34 @@ export const createTypeExtension = <T extends Type.Entity.Any, R = never>(
723
823
  // Extension Utilities
724
824
  //
725
825
 
826
+ /**
827
+ * Qualify node IDs by prefixing with the parent path.
828
+ * Validates that segment IDs do not contain the path separator.
829
+ * Recursively qualifies inline child nodes.
830
+ */
831
+ const qualifyNodeArgs =
832
+ (parentId: string) =>
833
+ (nodes: Node.NodeArg<any>[]): Node.NodeArg<any>[] =>
834
+ nodes.map((node) => {
835
+ validateSegmentId(node.id);
836
+ const qualified = qualifyId(parentId, node.id);
837
+ return {
838
+ ...node,
839
+ id: qualified,
840
+ nodes: node.nodes ? qualifyNodeArgs(qualified)(node.nodes) : undefined,
841
+ };
842
+ });
843
+
844
+ const connectorKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, Graph.relationKey(relation));
845
+
846
+ const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
847
+ const [id, encodedRelation] = primaryParts(key);
848
+ return { id, relation: Graph.relationFromKey(encodedRelation) };
849
+ };
850
+
851
+ const subscriptionKey = (id: string, kind: string, detail?: string): string =>
852
+ detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
853
+
726
854
  export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
727
855
  if (Array.isArray(extension)) {
728
856
  return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];