@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
@@ -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
 
@@ -129,12 +132,15 @@ export const whenEchoType =
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
+ });
@@ -17,9 +17,9 @@ 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
19
  import { withTheme } from '@dxos/react-ui/testing';
20
- import { Path, Tree } from '@dxos/react-ui-list';
20
+ import { Path, Tree, type TreeModel } 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';
@@ -64,7 +64,7 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
64
64
  const propertiesSnapshot = get(AtomObj.make(space.properties));
65
65
  return {
66
66
  id: space.id,
67
- type: 'dxos.org/type/Space',
67
+ type: 'org.dxos.type.space',
68
68
  properties: {
69
69
  label: propertiesSnapshot.name,
70
70
  },
@@ -88,7 +88,7 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
88
88
  const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
89
89
  return objects.map((object) => ({
90
90
  id: object.id,
91
- type: 'dxos.org/type/test',
91
+ type: 'org.dxos.type.test',
92
92
  properties: { label: object.name },
93
93
  data: object,
94
94
  }));
@@ -104,9 +104,9 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
104
104
  GraphBuilder.addExtension(builder, objectBuilderExtension);
105
105
  const graph = builder.graph;
106
106
  graph.onNodeChanged.on(({ id }) => {
107
- Graph.expand(graph, id);
107
+ Graph.expand(graph, id, 'child');
108
108
  });
109
- Graph.expand(graph, Node.RootId);
109
+ Graph.expand(graph, Node.RootId, 'child');
110
110
  (window as any).graph = graph;
111
111
  return graph;
112
112
  };
@@ -146,8 +146,8 @@ const runAction = async (client: Client, action: Action) => {
146
146
  case Action.RENAME_SPACE: {
147
147
  const space = getRandomSpace(client);
148
148
  if (space) {
149
- Obj.change(space.properties, (p) => {
150
- p.name = faker.commerce.productName();
149
+ Obj.change(space.properties, (obj) => {
150
+ obj.name = faker.commerce.productName();
151
151
  });
152
152
  }
153
153
  break;
@@ -176,8 +176,8 @@ const runAction = async (client: Client, action: Action) => {
176
176
  if (space) {
177
177
  const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
178
178
  const object = objects[Math.floor(Math.random() * objects.length)];
179
- Obj.change(object, (o) => {
180
- o.name = faker.commerce.productName();
179
+ Obj.change(object, (obj) => {
180
+ obj.name = faker.commerce.productName();
181
181
  });
182
182
  }
183
183
  break;
@@ -216,14 +216,13 @@ const Controls = ({ children }: PropsWithChildren) => {
216
216
  <Input.Root>
217
217
  <Input.TextInput
218
218
  autoComplete='off'
219
- size={5}
220
- classNames='is-[100px] text-right pie-[22px]'
219
+ classNames='w-[100px] text-right pe-[22px]'
221
220
  placeholder='Interval'
222
221
  value={actionInterval}
223
222
  onChange={({ target: { value } }) => setActionInterval(value)}
224
223
  />
225
224
  </Input.Root>
226
- <Icon icon='ph--timer--regular' classNames={mx('absolute inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
225
+ <Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
227
226
  </div>
228
227
  <IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
229
228
  <Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
@@ -252,9 +251,10 @@ const Controls = ({ children }: PropsWithChildren) => {
252
251
  const meta = {
253
252
  title: 'sdk/app-graph/EchoGraph',
254
253
  decorators: [
255
- withTheme,
254
+ withTheme(),
256
255
  withClientProvider({
257
256
  createIdentity: true,
257
+ types: [TestSchema.Expando],
258
258
  onCreateIdentity: async ({ client }) => {
259
259
  await client.spaces.create();
260
260
  await client.spaces.create();
@@ -291,60 +291,97 @@ export const TreeView: Story = {
291
291
  const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
292
292
 
293
293
  const getOrCreateState = useMemo(
294
- () => (path: string) => {
295
- let atom = stateRef.current.get(path);
294
+ () => (pathKey: string) => {
295
+ let atom = stateRef.current.get(pathKey);
296
296
  if (!atom) {
297
297
  atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
298
- stateRef.current.set(path, atom);
298
+ stateRef.current.set(pathKey, atom);
299
299
  }
300
300
  return atom;
301
301
  },
302
302
  [],
303
303
  );
304
304
 
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
- },
305
+ const childIdsFamily = useMemo(
306
+ () =>
307
+ Atom.family((id: string) =>
308
+ Atom.make((get) => {
309
+ const connections = get(graph.connections(id, 'child'));
310
+ return connections.map((connection) => connection.id);
311
+ }),
312
+ ),
310
313
  [graph],
311
314
  );
312
315
 
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
- },
316
+ const itemFamily = useMemo(
317
+ () =>
318
+ Atom.family((id: string) =>
319
+ Atom.make((get) => {
320
+ const node = get(graph.node(id));
321
+ return Option.isSome(node) ? node.value : undefined;
322
+ }),
323
+ ),
324
+ [graph],
325
+ );
326
+
327
+ const itemPropsFamily = useMemo(
328
+ () =>
329
+ Atom.family((pathKey: string) => {
330
+ const path = pathKey.split('~');
331
+ const id = path[path.length - 1];
332
+ return Atom.make((get) => {
333
+ const nodeOpt = get(graph.node(id));
334
+ const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
335
+ if (!node) {
336
+ return { id, label: id };
337
+ }
338
+ const connections = get(graph.connections(node.id, 'child'));
339
+ const safeChildren = connections.filter((n) => !path.includes(n.id));
340
+ const parentOf =
341
+ safeChildren.length > 0
342
+ ? safeChildren.map(({ id }) => id)
343
+ : node.properties.role === 'branch'
344
+ ? []
345
+ : undefined;
346
+ return {
347
+ id: node.id,
348
+ label: node.id,
349
+ icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
350
+ parentOf,
351
+ };
352
+ });
353
+ }),
331
354
  [graph],
332
355
  );
333
356
 
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
- };
357
+ const itemOpenFamily = useMemo(
358
+ () =>
359
+ Atom.family((pathKey: string) => {
360
+ const stateAtom = getOrCreateState(pathKey);
361
+ return Atom.make((get) => get(stateAtom).open);
362
+ }),
363
+ [getOrCreateState],
364
+ );
340
365
 
341
- const useIsOpen = (_path: string[]) => {
342
- return useItemState(_path).open;
343
- };
366
+ const itemCurrentFamily = useMemo(
367
+ () =>
368
+ Atom.family((pathKey: string) => {
369
+ const stateAtom = getOrCreateState(pathKey);
370
+ return Atom.make((get) => get(stateAtom).current);
371
+ }),
372
+ [getOrCreateState],
373
+ );
344
374
 
345
- const useIsCurrent = (_path: string[]) => {
346
- return useItemState(_path).current;
347
- };
375
+ const model: TreeModel<Node.Node> = useMemo(
376
+ () => ({
377
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
378
+ item: (id: string) => itemFamily(id),
379
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
380
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
381
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
382
+ }),
383
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
384
+ );
348
385
 
349
386
  const onOpenChange = useCallback(
350
387
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
@@ -373,15 +410,7 @@ export const TreeView: Story = {
373
410
  return (
374
411
  <>
375
412
  <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
- />
413
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
385
414
  </>
386
415
  );
387
416
  },
@@ -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
+ };
package/src/util.ts ADDED
@@ -0,0 +1,95 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { invariant } from '@dxos/invariant';
6
+
7
+ import * as Node from './node';
8
+
9
+ // PRIMARY separates top-level components (e.g., node ID from relation) in compound string keys used within the app-graph package.
10
+ const PRIMARY = '\u0001';
11
+
12
+ // SECONDARY separates sub-components within an encoded value (e.g., relation kind from direction) in the same context.
13
+ const SECONDARY = '\u0002';
14
+
15
+ // PATH separates segments in qualified node IDs (e.g., parent path from local segment).
16
+ const PATH = '/';
17
+
18
+ /** Join parts with the primary separator. */
19
+ export const primaryKey = (...parts: string[]): string => parts.join(PRIMARY);
20
+
21
+ /** Split a key on the primary separator. */
22
+ export const primaryParts = (key: string): string[] => key.split(PRIMARY);
23
+
24
+ /** Join parts with the secondary separator. */
25
+ export const secondaryKey = (...parts: string[]): string => parts.join(SECONDARY);
26
+
27
+ /** Split a key on the secondary separator. */
28
+ export const secondaryParts = (key: string): string[] => key.split(SECONDARY);
29
+
30
+ /**
31
+ * Normalize a relation input to a full Relation object.
32
+ */
33
+ export const normalizeRelation = (relation?: Node.RelationInput): Node.Relation =>
34
+ relation == null ? Node.childRelation() : typeof relation === 'string' ? Node.relation(relation) : relation;
35
+
36
+ /**
37
+ * Shallow-compare two values: same reference, or same own-keys with === values.
38
+ */
39
+ export const shallowEqual = (a: unknown, b: unknown): boolean => {
40
+ if (a === b) return true;
41
+ if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false;
42
+ const keysA = Object.keys(a as Record<string, unknown>);
43
+ const keysB = Object.keys(b as Record<string, unknown>);
44
+ if (keysA.length !== keysB.length) {
45
+ return false;
46
+ }
47
+ return keysA.every((k) => (a as Record<string, unknown>)[k] === (b as Record<string, unknown>)[k]);
48
+ };
49
+
50
+ /**
51
+ * Returns true if two NodeArg arrays are semantically identical (same id, type, data, properties per index).
52
+ */
53
+ export const nodeArgsUnchanged = (prev: Node.NodeArg<any>[], next: Node.NodeArg<any>[]): boolean => {
54
+ if (prev.length !== next.length) {
55
+ return false;
56
+ }
57
+
58
+ return prev.every((prevNode, idx) => {
59
+ const nextNode = next[idx];
60
+ return (
61
+ prevNode.id === nextNode.id &&
62
+ prevNode.type === nextNode.type &&
63
+ shallowEqual(prevNode.data, nextNode.data) &&
64
+ shallowEqual(prevNode.properties, nextNode.properties)
65
+ );
66
+ });
67
+ };
68
+
69
+ /**
70
+ * Build a qualified node ID by joining path segments.
71
+ */
72
+ export const qualifyId = (parentId: string, ...segmentIds: string[]): string => [parentId, ...segmentIds].join(PATH);
73
+
74
+ /**
75
+ * Validate that a segment ID does not contain the path separator.
76
+ */
77
+ export const validateSegmentId = (id: string): void => {
78
+ invariant(!id.includes(PATH), `Node segment ID must not contain '${PATH}': ${id}`);
79
+ };
80
+
81
+ /**
82
+ * Extract the parent qualified ID (everything before the last path separator).
83
+ * Returns undefined for IDs with no parent (single segment).
84
+ */
85
+ export const getParentId = (qualifiedId: string): string | undefined => {
86
+ const lastSlash = qualifiedId.lastIndexOf(PATH);
87
+ return lastSlash > 0 ? qualifiedId.slice(0, lastSlash) : undefined;
88
+ };
89
+
90
+ /**
91
+ * Extract the last segment of a qualified ID.
92
+ */
93
+ export const getSegmentId = (qualifiedId: string): string => {
94
+ return qualifiedId.split(PATH).pop() ?? qualifiedId;
95
+ };