@dxos/app-graph 0.8.4-main.937b3ca → 0.8.4-main.9be5663bfe

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-W47H2NND.mjs +1604 -0
  2. package/dist/lib/browser/chunk-W47H2NND.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-LYZWNJ7J.mjs +1605 -0
  9. package/dist/lib/node-esm/chunk-LYZWNJ7J.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 +44 -18
  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 +177 -98
  38. package/src/index.ts +1 -0
  39. package/src/node-matcher.ts +59 -29
  40. package/src/node.ts +46 -5
  41. package/src/stories/EchoGraph.stories.tsx +93 -65
  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
@@ -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,10 +106,13 @@ 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
- <T extends Type.Entity.Any>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
115
+ <T extends Type.AnyEntity>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
113
116
  (node: Node.Node): Option.Option<Entity.Entity<Schema.Schema.Type<T>>> =>
114
117
  Obj.instanceOf(type, node.data) ? Option.some(node.data) : Option.none();
115
118
 
@@ -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,38 +151,53 @@ 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> => {
165
- for (const matcher of matchers) {
166
- const result = matcher(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();
182
+ for (const candidate of matchers) {
183
+ const result = candidate(node);
167
184
  if (Option.isNone(result)) {
168
185
  return Option.none();
169
186
  }
187
+ if (Option.isNone(first)) {
188
+ first = result;
189
+ }
170
190
  }
171
- return Option.some(node);
191
+ return first;
172
192
  };
173
193
 
174
194
  /**
175
195
  * Composes multiple matchers with OR logic - at least one matcher must match.
176
- * Returns the **node** (not the matched data) to enable further composition.
196
+ * The result data type is the union of all matchers' data types.
177
197
  *
178
198
  * @param matchers - The matchers to combine. At least one must return Option.some.
179
- * @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.
180
201
  *
181
202
  * @example
182
203
  * ```ts
@@ -187,13 +208,18 @@ export const whenAll =
187
208
  * );
188
209
  * ```
189
210
  */
190
- export const whenAny =
191
- (...matchers: NodeMatcher[]): NodeMatcher =>
192
- (node: Node.Node): Option.Option<Node.Node> => {
193
- for (const matcher of matchers) {
194
- const result = matcher(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) => {
219
+ for (const candidate of matchers) {
220
+ const result = candidate(node);
195
221
  if (Option.isSome(result)) {
196
- return Option.some(node);
222
+ return result;
197
223
  }
198
224
  }
199
225
  return Option.none();
@@ -229,7 +255,7 @@ export const whenAny =
229
255
  * @see {@link whenEchoType} - Use instead when you need the typed entity directly.
230
256
  */
231
257
  export const whenEchoTypeMatches =
232
- <T extends Type.Entity.Any>(type: T): NodeMatcher =>
258
+ <T extends Type.AnyEntity>(type: T): NodeMatcher =>
233
259
  (node: Node.Node): Option.Option<Node.Node> =>
234
260
  Obj.instanceOf(type, node.data) ? Option.some(node) : Option.none();
235
261
 
@@ -262,15 +288,19 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
262
288
  * Negates a matcher - matches when the given matcher does NOT match.
263
289
  * Useful for exclusion patterns like "any object EXCEPT type X".
264
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
+ *
265
295
  * @param matcher - The matcher to negate.
266
296
  * @returns A matcher that returns Option.some(node) if the input matcher returns none,
267
297
  * and Option.none() if the input matcher returns some.
268
298
  *
269
299
  * @example
270
300
  * ```ts
271
- * // Match any ECHO object that is NOT a Channel
301
+ * // Match any ECHO object that is NOT a Channel — result is NodeMatcher<Obj.Unknown>.
272
302
  * const whenCommentable = NodeMatcher.whenAll(
273
- * NodeMatcher.whenEchoObjectMatches,
303
+ * NodeMatcher.whenEchoObject,
274
304
  * NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
275
305
  * );
276
306
  *
@@ -279,6 +309,6 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
279
309
  * ```
280
310
  */
281
311
  export const whenNot =
282
- (matcher: NodeMatcher): NodeMatcher =>
283
- (node: Node.Node): Option.Option<Node.Node> =>
312
+ (matcher: NodeMatcher<any>): NodeMatcher<unknown> =>
313
+ (node: Node.Node): Option.Option<unknown> =>
284
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.
@@ -69,7 +69,19 @@ export type NodeFilter<TData = any, TProperties extends Record<string, any> = Re
69
69
  connectedNode: Node,
70
70
  ) => node is Node<TData, TProperties>;
71
71
 
72
- export type Relation = 'outbound' | 'inbound';
72
+ export type RelationDirection = 'outbound' | 'inbound';
73
+
74
+ export type Relation = Readonly<{
75
+ kind: string;
76
+ direction: RelationDirection;
77
+ }>;
78
+
79
+ export type RelationInput = Relation | string;
80
+
81
+ export const relation = (kind: string, direction: RelationDirection = 'outbound'): Relation => ({ kind, direction });
82
+ // TODO(wittjosiah): Consider moving these helpers out of the core API.
83
+ export const childRelation = (direction: RelationDirection = 'outbound'): Relation => relation('child', direction);
84
+ export const actionRelation = (direction: RelationDirection = 'outbound'): Relation => relation('action', direction);
73
85
 
74
86
  export const isGraphNode = (data: unknown): data is Node =>
75
87
  data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties
@@ -84,7 +96,7 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
84
96
  nodes?: NodeArg<unknown>[];
85
97
 
86
98
  /** Will automatically add specified edges. */
87
- edges?: [string, Relation][];
99
+ edges?: [string, RelationInput][];
88
100
  };
89
101
 
90
102
  //
@@ -95,6 +107,9 @@ export type InvokeProps = {
95
107
  /** Node the invoked action is connected to. */
96
108
  parent?: Node;
97
109
 
110
+ /** Path from root to the node in the current tree context. */
111
+ path?: string[];
112
+
98
113
  caller?: string;
99
114
  };
100
115
 
@@ -135,3 +150,29 @@ export const isActionGroup = (data: unknown): data is ActionGroup =>
135
150
  export type ActionLike = Action | ActionGroup;
136
151
 
137
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
+ });
@@ -10,22 +10,21 @@ import React, { type PropsWithChildren, useCallback, useContext, useEffect, useM
10
10
 
11
11
  import { Filter, type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
12
  import { Obj, Query } from '@dxos/echo';
13
- import { TestSchema } from '@dxos/echo/testing';
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 { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
19
20
  import { withTheme } from '@dxos/react-ui/testing';
20
- import { Path, Tree } from '@dxos/react-ui-list';
21
21
  import { getSize, mx } from '@dxos/ui-theme';
22
- import { byPosition, isNonNullable, safeParseInt } from '@dxos/util';
22
+ import { safeParseInt } from '@dxos/util';
23
23
 
24
24
  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;
@@ -64,7 +63,7 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
64
63
  const propertiesSnapshot = get(AtomObj.make(space.properties));
65
64
  return {
66
65
  id: space.id,
67
- type: 'dxos.org/type/Space',
66
+ type: 'org.dxos.type.space',
68
67
  properties: {
69
68
  label: propertiesSnapshot.name,
70
69
  },
@@ -88,7 +87,7 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
88
87
  const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
89
88
  return objects.map((object) => ({
90
89
  id: object.id,
91
- type: 'dxos.org/type/test',
90
+ type: 'org.dxos.type.test',
92
91
  properties: { label: object.name },
93
92
  data: object,
94
93
  }));
@@ -104,9 +103,9 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
104
103
  GraphBuilder.addExtension(builder, objectBuilderExtension);
105
104
  const graph = builder.graph;
106
105
  graph.onNodeChanged.on(({ id }) => {
107
- Graph.expand(graph, id);
106
+ Graph.expand(graph, id, 'child');
108
107
  });
109
- Graph.expand(graph, Node.RootId);
108
+ Graph.expand(graph, Node.RootId, 'child');
110
109
  (window as any).graph = graph;
111
110
  return graph;
112
111
  };
@@ -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.change(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.change(object, (object) => {
179
+ object.name = random.commerce.productName();
181
180
  });
182
181
  }
183
182
  break;
@@ -216,14 +215,13 @@ const Controls = ({ children }: PropsWithChildren) => {
216
215
  <Input.Root>
217
216
  <Input.TextInput
218
217
  autoComplete='off'
219
- size={5}
220
- classNames='is-[100px] text-right pie-[22px]'
218
+ classNames='w-[100px] text-right pe-[22px]'
221
219
  placeholder='Interval'
222
220
  value={actionInterval}
223
221
  onChange={({ target: { value } }) => setActionInterval(value)}
224
222
  />
225
223
  </Input.Root>
226
- <Icon icon='ph--timer--regular' classNames={mx('absolute inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
224
+ <Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
227
225
  </div>
228
226
  <IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
229
227
  <Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
@@ -252,9 +250,10 @@ const Controls = ({ children }: PropsWithChildren) => {
252
250
  const meta = {
253
251
  title: 'sdk/app-graph/EchoGraph',
254
252
  decorators: [
255
- withTheme,
253
+ withTheme(),
256
254
  withClientProvider({
257
255
  createIdentity: true,
256
+ types: [TestSchema.Expando],
258
257
  onCreateIdentity: async ({ client }) => {
259
258
  await client.spaces.create();
260
259
  await client.spaces.create();
@@ -291,60 +290,97 @@ export const TreeView: Story = {
291
290
  const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
292
291
 
293
292
  const getOrCreateState = useMemo(
294
- () => (path: string) => {
295
- let atom = stateRef.current.get(path);
293
+ () => (pathKey: string) => {
294
+ let atom = stateRef.current.get(pathKey);
296
295
  if (!atom) {
297
296
  atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
298
- stateRef.current.set(path, atom);
297
+ stateRef.current.set(pathKey, atom);
299
298
  }
300
299
  return atom;
301
300
  },
302
301
  [],
303
302
  );
304
303
 
305
- const useItems = useCallback(
306
- (node?: Node.Node, options?: { disposition?: string; sort?: boolean }) => {
307
- const connections = useAtomValue(graph.connections(node?.id ?? Node.RootId));
308
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
309
- },
304
+ const childIdsFamily = useMemo(
305
+ () =>
306
+ Atom.family((id: string) =>
307
+ Atom.make((get) => {
308
+ const connections = get(graph.connections(id, 'child'));
309
+ return connections.map((connection) => connection.id);
310
+ }),
311
+ ),
310
312
  [graph],
311
313
  );
312
314
 
313
- const getProps = useCallback(
314
- (node: Node.Node, path: string[]) => {
315
- const children = Graph.getConnections(graph, node.id, 'outbound')
316
- .map((n) => {
317
- // Break cycles.
318
- const nextPath = [...path, node.id];
319
- return nextPath.includes(n.id) ? undefined : (n as Node.Node);
320
- })
321
- .filter(isNonNullable) as Node.Node[];
322
- const parentOf =
323
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
324
- return {
325
- id: node.id,
326
- label: node.id,
327
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
328
- parentOf,
329
- };
330
- },
315
+ const itemFamily = useMemo(
316
+ () =>
317
+ Atom.family((id: string) =>
318
+ Atom.make((get) => {
319
+ const node = get(graph.node(id));
320
+ return Option.isSome(node) ? node.value : undefined;
321
+ }),
322
+ ),
331
323
  [graph],
332
324
  );
333
325
 
334
- // Hook that subscribes to item state via Atom.
335
- const useItemState = (_path: string[]) => {
336
- const path = useMemo(() => Path.create(..._path), [_path.join('~')]);
337
- const atom = getOrCreateState(path);
338
- return useAtomValue(atom);
339
- };
326
+ const itemPropsFamily = useMemo(
327
+ () =>
328
+ Atom.family((pathKey: string) => {
329
+ const path = pathKey.split('~');
330
+ const id = path[path.length - 1];
331
+ return Atom.make((get) => {
332
+ const nodeOpt = get(graph.node(id));
333
+ const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
334
+ if (!node) {
335
+ return { id, label: id };
336
+ }
337
+ const connections = get(graph.connections(node.id, 'child'));
338
+ const safeChildren = connections.filter((n) => !path.includes(n.id));
339
+ const parentOf =
340
+ safeChildren.length > 0
341
+ ? safeChildren.map(({ id }) => id)
342
+ : node.properties.role === 'branch'
343
+ ? []
344
+ : undefined;
345
+ return {
346
+ id: node.id,
347
+ label: node.id,
348
+ icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
349
+ parentOf,
350
+ };
351
+ });
352
+ }),
353
+ [graph],
354
+ );
340
355
 
341
- const useIsOpen = (_path: string[]) => {
342
- return useItemState(_path).open;
343
- };
356
+ const itemOpenFamily = useMemo(
357
+ () =>
358
+ Atom.family((pathKey: string) => {
359
+ const stateAtom = getOrCreateState(pathKey);
360
+ return Atom.make((get) => get(stateAtom).open);
361
+ }),
362
+ [getOrCreateState],
363
+ );
344
364
 
345
- const useIsCurrent = (_path: string[]) => {
346
- return useItemState(_path).current;
347
- };
365
+ const itemCurrentFamily = useMemo(
366
+ () =>
367
+ Atom.family((pathKey: string) => {
368
+ const stateAtom = getOrCreateState(pathKey);
369
+ return Atom.make((get) => get(stateAtom).current);
370
+ }),
371
+ [getOrCreateState],
372
+ );
373
+
374
+ const model: TreeModel<Node.Node> = useMemo(
375
+ () => ({
376
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
377
+ item: (id: string) => itemFamily(id),
378
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
379
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
380
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
381
+ }),
382
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
383
+ );
348
384
 
349
385
  const onOpenChange = useCallback(
350
386
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
@@ -373,15 +409,7 @@ export const TreeView: Story = {
373
409
  return (
374
410
  <>
375
411
  <Controls />
376
- <Tree
377
- id={Node.RootId}
378
- useItems={useItems}
379
- getProps={getProps}
380
- useIsOpen={useIsOpen}
381
- useIsCurrent={useIsCurrent}
382
- onOpenChange={onOpenChange}
383
- onSelect={onSelect}
384
- />
412
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
385
413
  </>
386
414
  );
387
415
  },
@@ -72,7 +72,7 @@ const Scalar: FC<{ value: any }> = ({ value }) => {
72
72
 
73
73
  const Box: FC<HTMLAttributes<HTMLDivElement>> = ({ children, className, ...props }) => {
74
74
  return (
75
- <div className={mx('flex pli-2 border border-l-0 font-mono truncate', className)} {...props}>
75
+ <div className={mx('flex px-2 border border-l-0 font-mono truncate', className)} {...props}>
76
76
  {children}
77
77
  </div>
78
78
  );
@@ -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
+ };