@dxos/app-graph 0.8.4-main.8360d9e660 → 0.8.4-main.8baae0fced

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 (53) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +247 -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 +2 -2
  16. package/dist/types/src/graph-builder.d.ts.map +1 -1
  17. package/dist/types/src/graph.d.ts.map +1 -1
  18. package/dist/types/src/index.d.ts +1 -0
  19. package/dist/types/src/index.d.ts.map +1 -1
  20. package/dist/types/src/node-matcher.d.ts +42 -16
  21. package/dist/types/src/node-matcher.d.ts.map +1 -1
  22. package/dist/types/src/node.d.ts +9 -3
  23. package/dist/types/src/node.d.ts.map +1 -1
  24. package/dist/types/src/scheduler.browser.d.ts +2 -0
  25. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  26. package/dist/types/src/scheduler.d.ts +8 -0
  27. package/dist/types/src/scheduler.d.ts.map +1 -0
  28. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  29. package/dist/types/src/testing/index.d.ts +2 -0
  30. package/dist/types/src/testing/index.d.ts.map +1 -0
  31. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  32. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  33. package/dist/types/src/util.d.ts +20 -14
  34. package/dist/types/src/util.d.ts.map +1 -1
  35. package/dist/types/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +38 -27
  37. package/src/graph-builder.test.ts +276 -25
  38. package/src/graph-builder.ts +45 -17
  39. package/src/graph.ts +15 -17
  40. package/src/index.ts +1 -0
  41. package/src/node-matcher.ts +57 -25
  42. package/src/node.ts +29 -3
  43. package/src/scheduler.browser.ts +5 -0
  44. package/src/scheduler.ts +17 -0
  45. package/src/stories/EchoGraph.stories.tsx +10 -12
  46. package/src/testing/index.ts +5 -0
  47. package/src/testing/setup-graph-builder.ts +41 -0
  48. package/src/util.ts +48 -18
  49. package/dist/lib/browser/index.mjs.map +0 -7
  50. package/dist/lib/browser/meta.json +0 -1
  51. package/dist/lib/node-esm/index.mjs +0 -1580
  52. package/dist/lib/node-esm/index.mjs.map +0 -7
  53. 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, qualifyId, validateSegmentId } 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>[]>;
@@ -117,6 +126,8 @@ class GraphBuilderImpl implements GraphBuilder {
117
126
  >();
118
127
  /** Last-flushed node IDs per connector key, used for edge removal on update. */
119
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[]>();
120
131
  /** Last-flushed node args per connector key, used for change detection. */
121
132
  readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
122
133
  /** Whether a dirty-flush task is already scheduled. */
@@ -170,6 +181,12 @@ class GraphBuilderImpl implements GraphBuilder {
170
181
  this._connectorPrevious.set(key, ids);
171
182
  this._connectorPreviousArgs.set(key, nodes);
172
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);
173
190
  Graph.removeEdges(
174
191
  this._graph,
175
192
  removed.map((target) => ({ source: id, target, relation })),
@@ -295,7 +312,6 @@ class GraphBuilderImpl implements GraphBuilder {
295
312
  this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
296
313
  }
297
314
 
298
- // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
299
315
  private async _onInitialize(id: string) {
300
316
  log('onInitialize', { id });
301
317
  const resolver = this._resolvers(id);
@@ -304,17 +320,24 @@ class GraphBuilderImpl implements GraphBuilder {
304
320
  resolver,
305
321
  (node) => {
306
322
  const trigger = this._initialized[id];
323
+ const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
307
324
  Option.match(node, {
308
325
  onSome: (node) => {
309
- const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
310
326
  if (!connectorOwned) {
311
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
+ }
312
333
  }
313
334
  trigger?.wake();
314
335
  },
315
336
  onNone: () => {
316
337
  trigger?.wake();
317
- Graph.removeNodes(this._graph, [id]);
338
+ if (!connectorOwned) {
339
+ Graph.removeNodes(this._graph, [id]);
340
+ }
318
341
  },
319
342
  });
320
343
  },
@@ -325,9 +348,8 @@ class GraphBuilderImpl implements GraphBuilder {
325
348
  }
326
349
 
327
350
  private _onRemoveNode(id: string): void {
328
- const prefix = `${id}${Separators.primary}`;
329
351
  for (const [key, cleanup] of this._subscriptions) {
330
- if (key.startsWith(prefix)) {
352
+ if (primaryParts(key)[0] === id) {
331
353
  cleanup();
332
354
  this._subscriptions.delete(key);
333
355
  }
@@ -550,7 +572,7 @@ export type CreateExtensionRawOptions = {
550
572
  export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
551
573
  const {
552
574
  id,
553
- position = 'static',
575
+ position,
554
576
  relation = 'child',
555
577
  resolver: _resolver,
556
578
  connector: _connector,
@@ -828,19 +850,25 @@ const qualifyNodeArgs =
828
850
  };
829
851
  });
830
852
 
831
- const connectorKey = (id: string, relation: Node.RelationInput): string =>
832
- `${id}${Separators.primary}${Graph.relationKey(relation)}`;
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));
833
864
 
834
865
  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)) };
866
+ const [id, encodedRelation] = primaryParts(key);
867
+ return { id, relation: Graph.relationFromKey(encodedRelation) };
838
868
  };
839
869
 
840
870
  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}`;
871
+ detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
844
872
 
845
873
  export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
846
874
  if (Array.isArray(extension)) {
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
 
@@ -183,7 +183,10 @@ 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
- if (!key || key.indexOf(Separators.primary) <= 0) {
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]) {
187
190
  return [];
188
191
  }
189
192
  const { id, relation } = relationFromConnectionKey(key);
@@ -706,7 +709,7 @@ const expandImpl = <T extends ExpandableGraph | WritableGraph>(
706
709
  ): T => {
707
710
  const internal = getInternal(graph);
708
711
  const normalizedRelation = normalizeRelation(relation);
709
- const key = `${id}${Separators.primary}${relationKey(normalizedRelation)}`;
712
+ const key = primaryKey(id, relationKey(normalizedRelation));
710
713
  const nodeOpt = internal._registry.get(internal._node(id));
711
714
  if (Option.isNone(nodeOpt)) {
712
715
  // Node not yet in graph: record expand to run when the node is added.
@@ -900,11 +903,10 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
900
903
  graph.onNodeChanged.emit({ id, node: newNode });
901
904
 
902
905
  // Apply any expands that were deferred because this node did not exist yet.
903
- const prefix = `${id}${Separators.primary}`;
904
- const toApply = [...internal._pendingExpands].filter((k) => k.startsWith(prefix));
906
+ const toApply = [...internal._pendingExpands].filter((k) => primaryParts(k)[0] === id);
905
907
  for (const pendingKey of toApply) {
906
908
  internal._pendingExpands.delete(pendingKey);
907
- const relation = relationFromKey(pendingKey.slice(prefix.length));
909
+ const relation = relationFromKey(primaryParts(pendingKey)[1]);
908
910
  Record.set(internal._expanded, pendingKey, true);
909
911
  internal._onExpand?.(id, relation);
910
912
  }
@@ -1226,26 +1228,22 @@ export const make = (params?: GraphProps): Graph => {
1226
1228
 
1227
1229
  export const relationKey = (relation: Node.RelationInput): string => {
1228
1230
  const normalized = normalizeRelation(relation);
1229
- return `${normalized.kind}${Separators.secondary}${normalized.direction}`;
1231
+ return secondaryKey(normalized.kind, normalized.direction);
1230
1232
  };
1231
1233
 
1232
1234
  export const relationFromKey = (encoded: string): Node.Relation => {
1233
- const separatorIndex = encoded.lastIndexOf(Separators.secondary);
1234
- invariant(separatorIndex > 0 && separatorIndex < encoded.length - 1, `Invalid relation key: ${encoded}`);
1235
- const kind = encoded.slice(0, separatorIndex);
1236
- 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;
1237
1238
  invariant(directionRaw === 'outbound' || directionRaw === 'inbound', `Invalid relation direction: ${directionRaw}`);
1238
1239
  return Node.relation(kind, directionRaw);
1239
1240
  };
1240
1241
 
1241
- const connectionKey = (id: string, relation: Node.RelationInput): string =>
1242
- `${id}${Separators.primary}${relationKey(relation)}`;
1242
+ const connectionKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, relationKey(relation));
1243
1243
 
1244
1244
  const relationFromConnectionKey = (key: string): { id: string; relation: Node.Relation } => {
1245
- const separatorIndex = key.indexOf(Separators.primary);
1246
- invariant(separatorIndex > 0 && separatorIndex < key.length - 1, `Invalid connection key: ${key}`);
1247
- const id = key.slice(0, separatorIndex);
1248
- const encodedRelation = key.slice(separatorIndex + 1);
1245
+ const [id, encodedRelation] = primaryParts(key);
1246
+ invariant(id && encodedRelation, `Invalid connection key: ${key}`);
1249
1247
  return { id, relation: relationFromKey(encodedRelation) };
1250
1248
  };
1251
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';
@@ -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,7 +127,7 @@ 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
@@ -134,7 +137,10 @@ export const whenEchoType =
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 = 'org.dxos.type.graph-root';
18
+ export const RootType = 'org.dxos.type.graphRoot';
19
19
 
20
20
  /**
21
21
  * Action node type.
22
22
  */
23
- export const ActionType = 'org.dxos.type.graph-action';
23
+ export const ActionType = 'org.dxos.type.graphAction';
24
24
 
25
25
  /**
26
26
  * Action group node type.
27
27
  */
28
- export const ActionGroupType = 'org.dxos.type.graph-action-group';
28
+ export const ActionGroupType = 'org.dxos.type.graphActionGroup';
29
29
 
30
30
  /**
31
31
  * Represents a node in the graph.
@@ -150,3 +150,29 @@ export const isActionGroup = (data: unknown): data is ActionGroup =>
150
150
  export type ActionLike = Action | ActionGroup;
151
151
 
152
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
+ };
@@ -8,16 +8,16 @@ import * as Function from 'effect/Function';
8
8
  import * as Option from 'effect/Option';
9
9
  import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
10
10
 
11
- import { Filter, type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
- import { Obj, Query } from '@dxos/echo';
13
- import { TestSchema } from '@dxos/echo/testing';
11
+ import { type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
+ import { Filter, Obj, Query } from '@dxos/echo';
14
13
  import { AtomObj, AtomQuery } from '@dxos/echo-atom';
15
- import { faker } from '@dxos/random';
14
+ import { TestSchema } from '@dxos/echo/testing';
15
+ import { random } from '@dxos/random';
16
16
  import { type Client, useClient } from '@dxos/react-client';
17
17
  import { withClientProvider } from '@dxos/react-client/testing';
18
18
  import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
19
- import { withTheme } from '@dxos/react-ui/testing';
20
19
  import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
20
+ import { withTheme } from '@dxos/react-ui/testing';
21
21
  import { getSize, mx } from '@dxos/ui-theme';
22
22
  import { safeParseInt } from '@dxos/util';
23
23
 
@@ -25,7 +25,6 @@ import * as CreateAtom from '../atoms';
25
25
  import * as Graph from '../graph';
26
26
  import * as GraphBuilder from '../graph-builder';
27
27
  import * as Node from '../node';
28
-
29
28
  import { JsonTree } from './Tree';
30
29
 
31
30
  const DEFAULT_PERIOD = 500;
@@ -146,8 +145,8 @@ const runAction = async (client: Client, action: Action) => {
146
145
  case Action.RENAME_SPACE: {
147
146
  const space = getRandomSpace(client);
148
147
  if (space) {
149
- Obj.change(space.properties, (p) => {
150
- p.name = faker.commerce.productName();
148
+ Obj.update(space.properties, (obj) => {
149
+ obj.name = random.commerce.productName();
151
150
  });
152
151
  }
153
152
  break;
@@ -157,7 +156,7 @@ const runAction = async (client: Client, action: Action) => {
157
156
  getRandomSpace(client)?.db.add(
158
157
  Obj.make(TestSchema.Expando, {
159
158
  type: 'test',
160
- name: faker.commerce.productName(),
159
+ name: random.commerce.productName(),
161
160
  }),
162
161
  );
163
162
  break;
@@ -176,8 +175,8 @@ const runAction = async (client: Client, action: Action) => {
176
175
  if (space) {
177
176
  const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
178
177
  const object = objects[Math.floor(Math.random() * objects.length)];
179
- Obj.change(object, (o) => {
180
- o.name = faker.commerce.productName();
178
+ Obj.update(object, (object) => {
179
+ object.name = random.commerce.productName();
181
180
  });
182
181
  }
183
182
  break;
@@ -216,7 +215,6 @@ const Controls = ({ children }: PropsWithChildren) => {
216
215
  <Input.Root>
217
216
  <Input.TextInput
218
217
  autoComplete='off'
219
- size={5}
220
218
  classNames='w-[100px] text-right pe-[22px]'
221
219
  placeholder='Interval'
222
220
  value={actionInterval}
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './setup-graph-builder';
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry } from '@effect-atom/atom-react';
6
+ import * as Option from 'effect/Option';
7
+
8
+ import * as Graph from '../graph';
9
+ import * as GraphBuilder from '../graph-builder';
10
+ import * as Node from '../node';
11
+
12
+ export type SetupGraphBuilderOptions = {
13
+ registry?: Registry.Registry;
14
+ extensions?: GraphBuilder.BuilderExtensions;
15
+ };
16
+
17
+ export const setupGraphBuilder = ({ registry = Registry.make(), extensions }: SetupGraphBuilderOptions = {}) => {
18
+ const builder = GraphBuilder.make({ registry });
19
+ const graph = builder.graph;
20
+
21
+ if (extensions) {
22
+ GraphBuilder.addExtension(builder, extensions);
23
+ }
24
+
25
+ return {
26
+ registry,
27
+ builder,
28
+ graph,
29
+ addExtensions: (nextExtensions: GraphBuilder.BuilderExtensions) => {
30
+ GraphBuilder.addExtension(builder, nextExtensions);
31
+ },
32
+ expand: async (id: string, relation: Node.RelationInput = 'child') => {
33
+ Graph.expand(graph, id, relation);
34
+ await GraphBuilder.flush(builder);
35
+ },
36
+ flush: () => GraphBuilder.flush(builder),
37
+ getConnections: (id: string, relation: Node.RelationInput = 'child') =>
38
+ registry.get(graph.connections(id, relation)),
39
+ getNode: (id: string) => Graph.getNode(graph, id).pipe(Option.getOrNull),
40
+ };
41
+ };