@dxos/app-graph 0.8.4-main.c85a9c8dae → 0.8.4-main.d05539e30a

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 (55) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +304 -349
  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 +3 -3
  16. package/dist/types/src/graph-builder.d.ts.map +1 -1
  17. package/dist/types/src/graph.d.ts +2 -2
  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 +44 -18
  22. package/dist/types/src/node-matcher.d.ts.map +1 -1
  23. package/dist/types/src/node.d.ts +11 -3
  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 +26 -10
  35. package/dist/types/src/util.d.ts.map +1 -1
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +38 -27
  38. package/src/graph-builder.test.ts +536 -34
  39. package/src/graph-builder.ts +87 -24
  40. package/src/graph.test.ts +21 -1
  41. package/src/graph.ts +32 -21
  42. package/src/index.ts +1 -0
  43. package/src/node-matcher.ts +59 -27
  44. package/src/node.ts +32 -3
  45. package/src/scheduler.browser.ts +5 -0
  46. package/src/scheduler.ts +17 -0
  47. package/src/stories/EchoGraph.stories.tsx +13 -15
  48. package/src/testing/index.ts +5 -0
  49. package/src/testing/setup-graph-builder.ts +41 -0
  50. package/src/util.ts +59 -13
  51. package/dist/lib/browser/index.mjs.map +0 -7
  52. package/dist/lib/browser/meta.json +0 -1
  53. package/dist/lib/node-esm/index.mjs +0 -1523
  54. package/dist/lib/node-esm/index.mjs.map +0 -7
  55. package/dist/lib/node-esm/meta.json +0 -1
@@ -11,17 +11,26 @@ 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';
15
14
 
16
15
  import { type CleanupFn, type Trigger } from '@dxos/async';
17
16
  import { type Entity, type Type } from '@dxos/echo';
18
17
  import { log } from '@dxos/log';
19
18
  import { type MaybePromise, type Position, byPosition, getDebugName, isNonNullable } from '@dxos/util';
20
19
 
20
+ import { scheduleTask, yieldOrContinue } from '#scheduler';
21
+
21
22
  import * as Graph from './graph';
22
23
  import * as Node from './node';
23
24
  import * as NodeMatcher from './node-matcher';
24
- import { Separators, nodeArgsUnchanged, normalizeRelation } from './util';
25
+ import {
26
+ getParentId,
27
+ nodeArgsUnchanged,
28
+ normalizeRelation,
29
+ primaryKey,
30
+ primaryParts,
31
+ qualifyId,
32
+ validateSegmentId,
33
+ } from './util';
25
34
 
26
35
  //
27
36
  // Extension Types
@@ -55,7 +64,7 @@ export type ActionGroupsExtension = (
55
64
 
56
65
  export type BuilderExtension = Readonly<{
57
66
  id: string;
58
- position: Position;
67
+ position?: Position;
59
68
  relation?: Node.RelationInput;
60
69
  resolver?: ResolverExtension;
61
70
  connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
@@ -71,7 +80,7 @@ export type GraphBuilderTraverseOptions = {
71
80
  visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
72
81
  registry?: Registry.Registry;
73
82
  source?: string;
74
- relation: Node.RelationInput;
83
+ relation: Node.RelationInput | Node.RelationInput[];
75
84
  };
76
85
 
77
86
  /**
@@ -105,7 +114,9 @@ class GraphBuilderImpl implements GraphBuilder {
105
114
  }
106
115
 
107
116
  // TODO(wittjosiah): Use Context.
117
+ /** Active subscriptions keyed by composite ID, cleaned up on node removal. */
108
118
  readonly _subscriptions = new Map<string, CleanupFn>();
119
+ /** Connector updates pending flush, keyed by connector key. */
109
120
  readonly _dirtyConnectors = new Map<
110
121
  string,
111
122
  {
@@ -113,16 +124,26 @@ class GraphBuilderImpl implements GraphBuilder {
113
124
  previous: string[];
114
125
  }
115
126
  >();
127
+ /** Last-flushed node IDs per connector key, used for edge removal on update. */
116
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. */
117
132
  readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
133
+ /** Whether a dirty-flush task is already scheduled. */
118
134
  _flushScheduled = false;
135
+ /** Resolves when the current flush completes. */
119
136
  _flushPromise: Promise<void> = Promise.resolve();
137
+ /** Registered builder extensions keyed by extension ID. */
120
138
  readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
121
139
  Atom.keepAlive,
122
140
  Atom.withLabel('graph-builder:extensions'),
123
141
  );
142
+ /** Triggers signalling that a node's resolver has fired at least once. */
124
143
  readonly _initialized: Record<string, Trigger> = {};
144
+ /** Shared atom registry for reactive subscriptions. */
125
145
  readonly _registry: Registry.Registry;
146
+ /** Backing graph with internal accessors for node atoms and construction. */
126
147
  readonly _graph: Graph.Graph & {
127
148
  _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
128
149
  _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
@@ -160,6 +181,12 @@ class GraphBuilderImpl implements GraphBuilder {
160
181
  this._connectorPrevious.set(key, ids);
161
182
  this._connectorPreviousArgs.set(key, nodes);
162
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);
163
190
  Graph.removeEdges(
164
191
  this._graph,
165
192
  removed.map((target) => ({ source: id, target, relation })),
@@ -263,7 +290,8 @@ class GraphBuilderImpl implements GraphBuilder {
263
290
 
264
291
  const cancel = this._registry.subscribe(
265
292
  connectors,
266
- (nodes) => {
293
+ (rawNodes) => {
294
+ const nodes = qualifyNodeArgs(id)(rawNodes);
267
295
  const previous = this._connectorPrevious.get(key) ?? [];
268
296
  const ids = nodes.map((n) => n.id);
269
297
 
@@ -284,7 +312,6 @@ class GraphBuilderImpl implements GraphBuilder {
284
312
  this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
285
313
  }
286
314
 
287
- // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
288
315
  private async _onInitialize(id: string) {
289
316
  log('onInitialize', { id });
290
317
  const resolver = this._resolvers(id);
@@ -293,14 +320,24 @@ class GraphBuilderImpl implements GraphBuilder {
293
320
  resolver,
294
321
  (node) => {
295
322
  const trigger = this._initialized[id];
323
+ const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
296
324
  Option.match(node, {
297
325
  onSome: (node) => {
298
- 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
+ }
299
334
  trigger?.wake();
300
335
  },
301
336
  onNone: () => {
302
337
  trigger?.wake();
303
- Graph.removeNodes(this._graph, [id]);
338
+ if (!connectorOwned) {
339
+ Graph.removeNodes(this._graph, [id]);
340
+ }
304
341
  },
305
342
  });
306
343
  },
@@ -311,9 +348,8 @@ class GraphBuilderImpl implements GraphBuilder {
311
348
  }
312
349
 
313
350
  private _onRemoveNode(id: string): void {
314
- const prefix = `${id}${Separators.primary}`;
315
351
  for (const [key, cleanup] of this._subscriptions) {
316
- if (key.startsWith(prefix)) {
352
+ if (primaryParts(key)[0] === id) {
317
353
  cleanup();
318
354
  this._subscriptions.delete(key);
319
355
  }
@@ -425,11 +461,14 @@ const exploreImpl = async (
425
461
  return;
426
462
  }
427
463
 
428
- const nodes = Object.values(internal._registry.get(internal._extensions))
429
- .filter((extension) => Graph.relationKey(extension.relation ?? 'child') === Graph.relationKey(relation))
430
- .map((extension) => extension.connector)
431
- .filter(isNonNullable)
432
- .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
+ );
433
472
 
434
473
  await Promise.all(
435
474
  nodes.map((nodeArg) => {
@@ -533,7 +572,7 @@ export type CreateExtensionRawOptions = {
533
572
  export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
534
573
  const {
535
574
  id,
536
- position = 'static',
575
+ position,
537
576
  relation = 'child',
538
577
  resolver: _resolver,
539
578
  connector: _connector,
@@ -793,19 +832,43 @@ export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
793
832
  // Extension Utilities
794
833
  //
795
834
 
796
- const connectorKey = (id: string, relation: Node.RelationInput): string =>
797
- `${id}${Separators.primary}${Graph.relationKey(relation)}`;
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));
798
864
 
799
865
  const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
800
- const separatorIndex = key.indexOf(Separators.primary);
801
- const id = key.slice(0, separatorIndex);
802
- return { id, relation: Graph.relationFromKey(key.slice(separatorIndex + 1)) };
866
+ const [id, encodedRelation] = primaryParts(key);
867
+ return { id, relation: Graph.relationFromKey(encodedRelation) };
803
868
  };
804
869
 
805
870
  const subscriptionKey = (id: string, kind: string, detail?: string): string =>
806
- detail != null
807
- ? `${id}${Separators.primary}${kind}${Separators.primary}${detail}`
808
- : `${id}${Separators.primary}${kind}`;
871
+ detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
809
872
 
810
873
  export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
811
874
  if (Array.isArray(extension)) {
package/src/graph.test.ts CHANGED
@@ -12,7 +12,7 @@ import * as Node from './node';
12
12
 
13
13
  const exampleId = (id: number) => `dx:test:${id}`;
14
14
  const EXAMPLE_ID = exampleId(1);
15
- const EXAMPLE_TYPE = 'dxos.org/type/example';
15
+ const EXAMPLE_TYPE = 'org.dxos.type.example';
16
16
  const CHILD_RELATION_KEY = Graph.relationKey('child');
17
17
  const CHILD_INBOUND_RELATION_KEY = Graph.relationKey(Node.childRelation('inbound'));
18
18
  const ACTIONS_RELATION_KEY = Graph.relationKey('action');
@@ -602,6 +602,26 @@ describe('Graph', () => {
602
602
  expect(nodes).to.deep.equal(['root', 'test1']);
603
603
  });
604
604
 
605
+ test('traversal with multiple relations follows all edge types', () => {
606
+ const graph = Graph.make();
607
+ Graph.addNode(graph, {
608
+ id: Node.RootId,
609
+ type: Node.RootType,
610
+ nodes: [{ id: 'child1', type: 'test' }],
611
+ });
612
+ Graph.addNode(graph, { id: 'action1', type: Node.ActionType });
613
+ Graph.addEdge(graph, { source: Node.RootId, target: 'action1', relation: 'action' });
614
+
615
+ const nodes: string[] = [];
616
+ Graph.traverse(graph, {
617
+ relation: ['child', 'action'],
618
+ visitor: (node) => {
619
+ nodes.push(node.id);
620
+ },
621
+ });
622
+ expect(nodes).to.deep.equal(['root', 'child1', 'action1']);
623
+ });
624
+
605
625
  test('traverse curried', () => {
606
626
  const graph = Graph.make();
607
627
  Graph.addNode(graph, {
package/src/graph.ts CHANGED
@@ -15,7 +15,7 @@ import { log } from '@dxos/log';
15
15
  import { type MakeOptional, isNonNullable } from '@dxos/util';
16
16
 
17
17
  import * as Node from './node';
18
- import { Separators, normalizeRelation, shallowEqual } from './util';
18
+ import { normalizeRelation, primaryKey, primaryParts, secondaryKey, secondaryParts, shallowEqual } from './util';
19
19
 
20
20
  const graphSymbol = Symbol('graph');
21
21
 
@@ -49,8 +49,8 @@ export type GraphTraversalOptions = {
49
49
  */
50
50
  source?: string;
51
51
 
52
- /** The relation to traverse graph edges. */
53
- relation: Node.RelationInput;
52
+ /** The relation(s) to traverse graph edges. */
53
+ relation: Node.RelationInput | Node.RelationInput[];
54
54
  };
55
55
 
56
56
  export type GraphProps = {
@@ -183,6 +183,12 @@ class GraphImpl implements WritableGraph {
183
183
  // TODO(wittjosiah): Atom feature request, support for something akin to `ComplexMap` to allow for complex arguments.
184
184
  readonly _connections = Atom.family<string, Atom.Atom<Node.Node[]>>((key) => {
185
185
  return Atom.make((get) => {
186
+ const parts = key ? primaryParts(key) : [];
187
+ // Empty id (e.g. from `useConnections(graph, undefined, ...)`) yields a key like `\u0001child\u0002outbound`,
188
+ // which has 2 parts but an empty id — treat as no connections rather than throwing.
189
+ if (parts.length < 2 || !parts[0]) {
190
+ return [];
191
+ }
186
192
  const { id, relation } = relationFromConnectionKey(key);
187
193
  const edges = get(this._edges(id));
188
194
  return (edges[relationKey(relation)] ?? [])
@@ -194,6 +200,9 @@ class GraphImpl implements WritableGraph {
194
200
 
195
201
  readonly _actions = Atom.family<string, Atom.Atom<(Node.Action | Node.ActionGroup)[]>>((id) => {
196
202
  return Atom.make((get) => {
203
+ if (!id) {
204
+ return [];
205
+ }
197
206
  return get(this._connections(connectionKey(id, Node.actionRelation()))) as (Node.Action | Node.ActionGroup)[];
198
207
  }).pipe(Atom.withLabel(`graph:actions:${id}`));
199
208
  });
@@ -515,9 +524,16 @@ const traverseImpl = (graph: BaseGraph, options: GraphTraversalOptions, path: st
515
524
  return;
516
525
  }
517
526
 
518
- Object.values(getConnections(graph, source, relation)).forEach((child) =>
519
- traverseImpl(graph, { source: child.id, relation, visitor }, [...path, source]),
520
- );
527
+ const relations = Array.isArray(relation) ? relation : [relation];
528
+ const seen = new Set<string>();
529
+ for (const rel of relations) {
530
+ for (const connected of getConnections(graph, source, rel)) {
531
+ if (!seen.has(connected.id)) {
532
+ seen.add(connected.id);
533
+ traverseImpl(graph, { source: connected.id, relation, visitor }, [...path, source]);
534
+ }
535
+ }
536
+ }
521
537
  };
522
538
 
523
539
  /**
@@ -693,7 +709,7 @@ const expandImpl = <T extends ExpandableGraph | WritableGraph>(
693
709
  ): T => {
694
710
  const internal = getInternal(graph);
695
711
  const normalizedRelation = normalizeRelation(relation);
696
- const key = `${id}${Separators.primary}${relationKey(normalizedRelation)}`;
712
+ const key = primaryKey(id, relationKey(normalizedRelation));
697
713
  const nodeOpt = internal._registry.get(internal._node(id));
698
714
  if (Option.isNone(nodeOpt)) {
699
715
  // Node not yet in graph: record expand to run when the node is added.
@@ -887,11 +903,10 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
887
903
  graph.onNodeChanged.emit({ id, node: newNode });
888
904
 
889
905
  // Apply any expands that were deferred because this node did not exist yet.
890
- const prefix = `${id}${Separators.primary}`;
891
- const toApply = [...internal._pendingExpands].filter((k) => k.startsWith(prefix));
906
+ const toApply = [...internal._pendingExpands].filter((k) => primaryParts(k)[0] === id);
892
907
  for (const pendingKey of toApply) {
893
908
  internal._pendingExpands.delete(pendingKey);
894
- const relation = relationFromKey(pendingKey.slice(prefix.length));
909
+ const relation = relationFromKey(primaryParts(pendingKey)[1]);
895
910
  Record.set(internal._expanded, pendingKey, true);
896
911
  internal._onExpand?.(id, relation);
897
912
  }
@@ -1213,26 +1228,22 @@ export const make = (params?: GraphProps): Graph => {
1213
1228
 
1214
1229
  export const relationKey = (relation: Node.RelationInput): string => {
1215
1230
  const normalized = normalizeRelation(relation);
1216
- return `${normalized.kind}${Separators.secondary}${normalized.direction}`;
1231
+ return secondaryKey(normalized.kind, normalized.direction);
1217
1232
  };
1218
1233
 
1219
1234
  export const relationFromKey = (encoded: string): Node.Relation => {
1220
- const separatorIndex = encoded.lastIndexOf(Separators.secondary);
1221
- invariant(separatorIndex > 0 && separatorIndex < encoded.length - 1, `Invalid relation key: ${encoded}`);
1222
- const kind = encoded.slice(0, separatorIndex);
1223
- const directionRaw = encoded.slice(separatorIndex + 1);
1235
+ const parts = secondaryParts(encoded);
1236
+ invariant(parts.length === 2 && parts[0].length > 0 && parts[1].length > 0, `Invalid relation key: ${encoded}`);
1237
+ const [kind, directionRaw] = parts;
1224
1238
  invariant(directionRaw === 'outbound' || directionRaw === 'inbound', `Invalid relation direction: ${directionRaw}`);
1225
1239
  return Node.relation(kind, directionRaw);
1226
1240
  };
1227
1241
 
1228
- const connectionKey = (id: string, relation: Node.RelationInput): string =>
1229
- `${id}${Separators.primary}${relationKey(relation)}`;
1242
+ const connectionKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, relationKey(relation));
1230
1243
 
1231
1244
  const relationFromConnectionKey = (key: string): { id: string; relation: Node.Relation } => {
1232
- const separatorIndex = key.indexOf(Separators.primary);
1233
- invariant(separatorIndex > 0 && separatorIndex < key.length - 1, `Invalid connection key: ${key}`);
1234
- const id = key.slice(0, separatorIndex);
1235
- const encodedRelation = key.slice(separatorIndex + 1);
1245
+ const [id, encodedRelation] = primaryParts(key);
1246
+ invariant(id && encodedRelation, `Invalid connection key: ${key}`);
1236
1247
  return { id, relation: relationFromKey(encodedRelation) };
1237
1248
  };
1238
1249
 
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * as Graph from './graph';
8
8
  export * as GraphBuilder from './graph-builder';
9
9
  export * as Node from './node';
10
10
  export * as NodeMatcher from './node-matcher';
11
+ export { qualifyId, getParentId, getSegmentId } from './util';
11
12
 
12
13
  // TODO(wittjosiah): Direct re-export needed for portable type references.
13
14
  export type { BuilderExtensions } from './graph-builder';
@@ -69,7 +69,7 @@ export const whenId =
69
69
  * ```ts
70
70
  * GraphBuilder.createExtension({
71
71
  * id: 'space-settings-extension',
72
- * match: NodeMatcher.whenNodeType('dxos.org/plugin/space/settings'),
72
+ * match: NodeMatcher.whenNodeType('org.dxos.plugin.space.settings'),
73
73
  * connector: (node) => Effect.succeed([...]),
74
74
  * });
75
75
  * ```
@@ -106,7 +106,10 @@ export const whenNodeType =
106
106
  * });
107
107
  * ```
108
108
  *
109
- * @see {@link whenEchoTypeMatches} - Use instead when composing with whenAll/whenAny.
109
+ * Can be composed directly with {@link whenAll}/{@link whenAny}/{@link whenNot} while
110
+ * preserving the typed entity data in the result.
111
+ *
112
+ * @see {@link whenEchoTypeMatches} - Returns the node instead of data for legacy composition.
110
113
  */
111
114
  export const whenEchoType =
112
115
  <T extends Type.AnyEntity>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
@@ -124,17 +127,20 @@ export const whenEchoType =
124
127
  * @example
125
128
  * ```ts
126
129
  * GraphBuilder.createExtension({
127
- * id: 'object-settings',
130
+ * id: 'object-properties',
128
131
  * match: NodeMatcher.whenEchoObject,
129
132
  * connector: (object) => {
130
133
  * // `object` is typed as Obj.Unknown
131
134
  * const id = Obj.getDXN(object).toString();
132
- * return Effect.succeed([{ id: `${id}/settings`, ... }]);
135
+ * return Effect.succeed([{ id: `${id}.settings`, ... }]);
133
136
  * },
134
137
  * });
135
138
  * ```
136
139
  *
137
- * @see {@link whenEchoObjectMatches} - Use instead when composing with whenAll/whenAny.
140
+ * Can be composed directly with {@link whenAll}/{@link whenAny}/{@link whenNot} while
141
+ * preserving the `Obj.Unknown` data type in the result.
142
+ *
143
+ * @see {@link whenEchoObjectMatches} - Returns the node instead of data for legacy composition.
138
144
  */
139
145
  export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
140
146
  Obj.isObject(node.data) ? Option.some(node.data) : Option.none();
@@ -145,53 +151,75 @@ export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
145
151
 
146
152
  /**
147
153
  * Composes multiple matchers with AND logic - all matchers must match for success.
148
- * Returns the **node** (not the matched data) to enable further composition.
154
+ * The result data type is the intersection of all matchers' data types.
155
+ * Filter matchers like {@link whenNot} return `unknown`, making them transparent
156
+ * in the intersection (since `T & unknown = T`).
149
157
  *
150
158
  * @param matchers - The matchers to combine. All must return Option.some for success.
151
- * @returns A matcher that returns Option.some(node) if all match, Option.none() otherwise.
159
+ * @returns A matcher whose data type is the intersection of all input matchers' data types.
160
+ * Returns the first matcher's value when all match, Option.none() otherwise.
152
161
  *
153
162
  * @example
154
163
  * ```ts
155
- * // Match ECHO objects that are NOT Channels
164
+ * // Match ECHO objects that are NOT Channels — result is NodeMatcher<Obj.Unknown>.
156
165
  * const whenCommentable = NodeMatcher.whenAll(
157
- * NodeMatcher.whenEchoObjectMatches,
166
+ * NodeMatcher.whenEchoObject,
158
167
  * NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
159
168
  * );
160
169
  * ```
161
170
  */
162
- export const whenAll =
163
- (...matchers: NodeMatcher[]): NodeMatcher =>
164
- (node: Node.Node): Option.Option<Node.Node> => {
171
+ export const whenAll: {
172
+ <A>(a: NodeMatcher<A>, b: NodeMatcher<unknown>): NodeMatcher<A>;
173
+ <A>(a: NodeMatcher<unknown>, b: NodeMatcher<A>): NodeMatcher<A>;
174
+ <A, B>(a: NodeMatcher<A>, b: NodeMatcher<B>): NodeMatcher<A & B>;
175
+ <A, B, C>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>): NodeMatcher<A & B & C>;
176
+ <A, B, C, D>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>, d: NodeMatcher<D>): NodeMatcher<A & B & C & D>;
177
+ (...matchers: NodeMatcher<any>[]): NodeMatcher<any>;
178
+ } =
179
+ (...matchers: NodeMatcher<any>[]): NodeMatcher<any> =>
180
+ (node: Node.Node) => {
181
+ let first: Option.Option<any> = Option.none();
165
182
  for (const candidate of matchers) {
166
- if (Option.isNone(candidate(node))) {
183
+ const result = candidate(node);
184
+ if (Option.isNone(result)) {
167
185
  return Option.none();
168
186
  }
187
+ if (Option.isNone(first)) {
188
+ first = result;
189
+ }
169
190
  }
170
- return Option.some(node);
191
+ return first;
171
192
  };
172
193
 
173
194
  /**
174
195
  * Composes multiple matchers with OR logic - at least one matcher must match.
175
- * Returns the **node** (not the matched data) to enable further composition.
196
+ * The result data type is the union of all matchers' data types.
176
197
  *
177
198
  * @param matchers - The matchers to combine. At least one must return Option.some.
178
- * @returns A matcher that returns Option.some(node) if any match, Option.none() otherwise.
199
+ * @returns A matcher whose data type is the union of all input matchers' data types.
200
+ * Returns the first matching matcher's value, or Option.none() if none match.
179
201
  *
180
202
  * @example
181
203
  * ```ts
182
- * // Match nodes that are either Sequences or Prompts
204
+ * // Match nodes that are either Sequences or Routines
183
205
  * const whenInvocable = NodeMatcher.whenAny(
184
206
  * NodeMatcher.whenEchoTypeMatches(Sequence),
185
- * NodeMatcher.whenEchoTypeMatches(Prompt.Prompt),
207
+ * NodeMatcher.whenEchoTypeMatches(Routine.Routine),
186
208
  * );
187
209
  * ```
188
210
  */
189
- export const whenAny =
190
- (...matchers: NodeMatcher[]): NodeMatcher =>
191
- (node: Node.Node): Option.Option<Node.Node> => {
211
+ export const whenAny: {
212
+ <A, B>(a: NodeMatcher<A>, b: NodeMatcher<B>): NodeMatcher<A | B>;
213
+ <A, B, C>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>): NodeMatcher<A | B | C>;
214
+ <A, B, C, D>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>, d: NodeMatcher<D>): NodeMatcher<A | B | C | D>;
215
+ (...matchers: NodeMatcher<any>[]): NodeMatcher<any>;
216
+ } =
217
+ (...matchers: NodeMatcher<any>[]): NodeMatcher<any> =>
218
+ (node: Node.Node) => {
192
219
  for (const candidate of matchers) {
193
- if (Option.isSome(candidate(node))) {
194
- return Option.some(node);
220
+ const result = candidate(node);
221
+ if (Option.isSome(result)) {
222
+ return result;
195
223
  }
196
224
  }
197
225
  return Option.none();
@@ -260,15 +288,19 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
260
288
  * Negates a matcher - matches when the given matcher does NOT match.
261
289
  * Useful for exclusion patterns like "any object EXCEPT type X".
262
290
  *
291
+ * Returns `NodeMatcher<unknown>` because negation is a filter — it doesn't provide
292
+ * typed data. This makes it transparent in {@link whenAll} intersections
293
+ * (since `T & unknown = T`).
294
+ *
263
295
  * @param matcher - The matcher to negate.
264
296
  * @returns A matcher that returns Option.some(node) if the input matcher returns none,
265
297
  * and Option.none() if the input matcher returns some.
266
298
  *
267
299
  * @example
268
300
  * ```ts
269
- * // Match any ECHO object that is NOT a Channel
301
+ * // Match any ECHO object that is NOT a Channel — result is NodeMatcher<Obj.Unknown>.
270
302
  * const whenCommentable = NodeMatcher.whenAll(
271
- * NodeMatcher.whenEchoObjectMatches,
303
+ * NodeMatcher.whenEchoObject,
272
304
  * NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
273
305
  * );
274
306
  *
@@ -277,6 +309,6 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
277
309
  * ```
278
310
  */
279
311
  export const whenNot =
280
- (matcher: NodeMatcher): NodeMatcher =>
281
- (node: Node.Node): Option.Option<Node.Node> =>
312
+ (matcher: NodeMatcher<any>): NodeMatcher<unknown> =>
313
+ (node: Node.Node): Option.Option<unknown> =>
282
314
  Option.isNone(matcher(node)) ? Option.some(node) : Option.none();
package/src/node.ts CHANGED
@@ -15,17 +15,17 @@ export const RootId = 'root';
15
15
  /**
16
16
  * Root node type.
17
17
  */
18
- export const RootType = 'dxos.org/type/GraphRoot';
18
+ export const RootType = 'org.dxos.type.graphRoot';
19
19
 
20
20
  /**
21
21
  * Action node type.
22
22
  */
23
- export const ActionType = 'dxos.org/type/GraphAction';
23
+ export const ActionType = 'org.dxos.type.graphAction';
24
24
 
25
25
  /**
26
26
  * Action group node type.
27
27
  */
28
- export const ActionGroupType = 'dxos.org/type/GraphActionGroup';
28
+ export const ActionGroupType = 'org.dxos.type.graphActionGroup';
29
29
 
30
30
  /**
31
31
  * Represents a node in the graph.
@@ -107,6 +107,9 @@ export type InvokeProps = {
107
107
  /** Node the invoked action is connected to. */
108
108
  parent?: Node;
109
109
 
110
+ /** Path from root to the node in the current tree context. */
111
+ path?: string[];
112
+
110
113
  caller?: string;
111
114
  };
112
115
 
@@ -147,3 +150,29 @@ export const isActionGroup = (data: unknown): data is ActionGroup =>
147
150
  export type ActionLike = Action | ActionGroup;
148
151
 
149
152
  export const isActionLike = (data: unknown): data is Action | ActionGroup => isAction(data) || isActionGroup(data);
153
+
154
+ //
155
+ // Node Factories
156
+ //
157
+
158
+ /** Typed factory for constructing a NodeArg. Provides auto-complete and type validation. */
159
+ export const make = <TData = any, TProperties extends Record<string, any> = Record<string, any>>(
160
+ arg: NodeArg<TData, TProperties>,
161
+ ): NodeArg<TData, TProperties> => arg;
162
+
163
+ /** Create an action node. Automatically sets `type: ActionType`. */
164
+ export const makeAction = <R = never>(
165
+ arg: Omit<NodeArg<ActionData<R>>, 'type' | 'nodes' | 'edges'>,
166
+ ): NodeArg<ActionData<R>> => ({
167
+ ...arg,
168
+ type: ActionType,
169
+ });
170
+
171
+ /** Create an action group node. Automatically sets `type` and `data`. */
172
+ export const makeActionGroup = (
173
+ arg: Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>,
174
+ ): NodeArg<typeof actionGroupSymbol> => ({
175
+ ...arg,
176
+ type: ActionGroupType,
177
+ data: actionGroupSymbol,
178
+ });
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { scheduleTask, yieldOrContinue } from 'main-thread-scheduling';
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // CF Workers / Node fallback: there is no main thread to yield to.
6
+ // scheduleTask runs the callback on the next microtask; yieldOrContinue is a microtask hop.
7
+
8
+ type ScheduleOptions = { strategy?: 'smooth' | 'interactive' | 'idle'; signal?: AbortSignal };
9
+
10
+ export const scheduleTask = async <T>(callback: () => T | Promise<T>, _options?: ScheduleOptions): Promise<T> => {
11
+ await Promise.resolve();
12
+ return callback();
13
+ };
14
+
15
+ export const yieldOrContinue = async (_priority: 'smooth' | 'interactive' | 'idle'): Promise<void> => {
16
+ await Promise.resolve();
17
+ };