@dxos/app-graph 0.8.4-main.f9ba587 → 0.8.4-main.fffef41

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.
@@ -2,37 +2,45 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
6
  import { effect } from '@preact/signals-core';
7
- import { Array, type Option, pipe, Record } from 'effect';
7
+ import * as Array from 'effect/Array';
8
+ import * as Function from 'effect/Function';
9
+ import * as Option from 'effect/Option';
10
+ import * as Record from 'effect/Record';
8
11
 
9
- import { type MulticastObservable, type CleanupFn } from '@dxos/async';
12
+ import { type CleanupFn, type MulticastObservable, type Trigger } from '@dxos/async';
10
13
  import { log } from '@dxos/log';
11
- import { byPosition, getDebugName, isNode, isNonNullable, type MaybePromise, type Position } from '@dxos/util';
14
+ import { type MaybePromise, type Position, byPosition, getDebugName, isNode, isNonNullable } from '@dxos/util';
12
15
 
13
- import { ACTION_GROUP_TYPE, ACTION_TYPE, Graph, ROOT_ID, type GraphParams, type ExpandableGraph } from './graph';
14
- import { actionGroupSymbol, type ActionData, type Node, type NodeArg, type Relation } from './node';
16
+ import { ACTION_GROUP_TYPE, ACTION_TYPE, type ExpandableGraph, Graph, type GraphParams, ROOT_ID } from './graph';
17
+ import { type ActionData, type Node, type NodeArg, type Relation, actionGroupSymbol } from './node';
18
+
19
+ /**
20
+ * Graph builder extension for adding nodes to the graph based on a node id.
21
+ */
22
+ export type ResolverExtension = (id: string) => Atom.Atom<NodeArg<any> | null>;
15
23
 
16
24
  /**
17
25
  * Graph builder extension for adding nodes to the graph based on a connection to an existing node.
18
26
  *
19
27
  * @param params.node The existing node the returned nodes will be connected to.
20
28
  */
21
- export type ConnectorExtension = (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
29
+ export type ConnectorExtension = (node: Atom.Atom<Option.Option<Node>>) => Atom.Atom<NodeArg<any>[]>;
22
30
 
23
31
  /**
24
32
  * Constrained case of the connector extension for more easily adding actions to the graph.
25
33
  */
26
34
  export type ActionsExtension = (
27
- node: Rx.Rx<Option.Option<Node>>,
28
- ) => Rx.Rx<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
35
+ node: Atom.Atom<Option.Option<Node>>,
36
+ ) => Atom.Atom<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
29
37
 
30
38
  /**
31
39
  * Constrained case of the connector extension for more easily adding action groups to the graph.
32
40
  */
33
41
  export type ActionGroupsExtension = (
34
- node: Rx.Rx<Option.Option<Node>>,
35
- ) => Rx.Rx<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
42
+ node: Atom.Atom<Option.Option<Node>>,
43
+ ) => Atom.Atom<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
36
44
 
37
45
  /**
38
46
  * A graph builder extension is used to add nodes to the graph.
@@ -49,8 +57,7 @@ export type CreateExtensionOptions = {
49
57
  id: string;
50
58
  relation?: Relation;
51
59
  position?: Position;
52
- // TODO(wittjosiah): On initialize to restore state from cache.
53
- // resolver?: ResolverExtension;
60
+ resolver?: ResolverExtension;
54
61
  connector?: ConnectorExtension;
55
62
  actions?: ActionsExtension;
56
63
  actionGroups?: ActionGroupsExtension;
@@ -64,44 +71,50 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
64
71
  id,
65
72
  position = 'static',
66
73
  relation = 'outbound',
74
+ resolver: _resolver,
67
75
  connector: _connector,
68
76
  actions: _actions,
69
77
  actionGroups: _actionGroups,
70
78
  } = extension;
71
79
  const getId = (key: string) => `${id}/${key}`;
72
80
 
81
+ const resolver =
82
+ _resolver && Atom.family((id: string) => _resolver(id).pipe(Atom.withLabel(`graph-builder:_resolver:${id}`)));
83
+
73
84
  const connector =
74
85
  _connector &&
75
- Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
76
- _connector(node).pipe(Rx.withLabel(`graph-builder:_connector:${id}`)),
86
+ Atom.family((node: Atom.Atom<Option.Option<Node>>) =>
87
+ _connector(node).pipe(Atom.withLabel(`graph-builder:_connector:${id}`)),
77
88
  );
78
89
 
79
90
  const actionGroups =
80
91
  _actionGroups &&
81
- Rx.family((node: Rx.Rx<Option.Option<Node>>) =>
82
- _actionGroups(node).pipe(Rx.withLabel(`graph-builder:_actionGroups:${id}`)),
92
+ Atom.family((node: Atom.Atom<Option.Option<Node>>) =>
93
+ _actionGroups(node).pipe(Atom.withLabel(`graph-builder:_actionGroups:${id}`)),
83
94
  );
84
95
 
85
96
  const actions =
86
97
  _actions &&
87
- Rx.family((node: Rx.Rx<Option.Option<Node>>) => _actions(node).pipe(Rx.withLabel(`graph-builder:_actions:${id}`)));
98
+ Atom.family((node: Atom.Atom<Option.Option<Node>>) =>
99
+ _actions(node).pipe(Atom.withLabel(`graph-builder:_actions:${id}`)),
100
+ );
88
101
 
89
102
  return [
90
- // resolver ? { id: getId('resolver'), position, resolver } : undefined,
103
+ resolver ? { id: getId('resolver'), position, resolver } : undefined,
91
104
  connector
92
105
  ? ({
93
106
  id: getId('connector'),
94
107
  position,
95
108
  relation,
96
- connector: Rx.family((node) =>
97
- Rx.make((get) => {
109
+ connector: Atom.family((node) =>
110
+ Atom.make((get) => {
98
111
  try {
99
112
  return get(connector(node));
100
113
  } catch {
101
114
  log.warn('Error in connector', { id: getId('connector'), node });
102
115
  return [];
103
116
  }
104
- }).pipe(Rx.withLabel(`graph-builder:connector:${id}`)),
117
+ }).pipe(Atom.withLabel(`graph-builder:connector:${id}`)),
105
118
  ),
106
119
  } satisfies BuilderExtension)
107
120
  : undefined,
@@ -110,8 +123,8 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
110
123
  id: getId('actionGroups'),
111
124
  position,
112
125
  relation: 'outbound',
113
- connector: Rx.family((node) =>
114
- Rx.make((get) => {
126
+ connector: Atom.family((node) =>
127
+ Atom.make((get) => {
115
128
  try {
116
129
  return get(actionGroups(node)).map((arg) => ({
117
130
  ...arg,
@@ -122,7 +135,7 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
122
135
  log.warn('Error in actionGroups', { id: getId('actionGroups'), node });
123
136
  return [];
124
137
  }
125
- }).pipe(Rx.withLabel(`graph-builder:connector:actionGroups:${id}`)),
138
+ }).pipe(Atom.withLabel(`graph-builder:connector:actionGroups:${id}`)),
126
139
  ),
127
140
  } satisfies BuilderExtension)
128
141
  : undefined,
@@ -131,15 +144,15 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
131
144
  id: getId('actions'),
132
145
  position,
133
146
  relation: 'outbound',
134
- connector: Rx.family((node) =>
135
- Rx.make((get) => {
147
+ connector: Atom.family((node) =>
148
+ Atom.make((get) => {
136
149
  try {
137
150
  return get(actions(node)).map((arg) => ({ ...arg, type: ACTION_TYPE }));
138
151
  } catch {
139
152
  log.warn('Error in actions', { id: getId('actions'), node });
140
153
  return [];
141
154
  }
142
- }).pipe(Rx.withLabel(`graph-builder:connector:actions:${id}`)),
155
+ }).pipe(Atom.withLabel(`graph-builder:connector:actions:${id}`)),
143
156
  ),
144
157
  } satisfies BuilderExtension)
145
158
  : undefined,
@@ -157,8 +170,8 @@ export type BuilderExtension = Readonly<{
157
170
  id: string;
158
171
  position: Position;
159
172
  relation?: Relation; // Only for connector.
160
- // resolver?: ResolverExtension;
161
- connector?: (node: Rx.Rx<Option.Option<Node>>) => Rx.Rx<NodeArg<any>[]>;
173
+ resolver?: ResolverExtension;
174
+ connector?: (node: Atom.Atom<Option.Option<Node>>) => Atom.Atom<NodeArg<any>[]>;
162
175
  }>;
163
176
 
164
177
  export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
@@ -179,12 +192,12 @@ export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExte
179
192
  // Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
180
193
  export class GraphBuilder {
181
194
  // TODO(wittjosiah): Use Context.
182
- private readonly _connectorSubscriptions = new Map<string, CleanupFn>();
183
- private readonly _extensions = Rx.make(Record.empty<string, BuilderExtension>()).pipe(
184
- Rx.keepAlive,
185
- Rx.withLabel('graph-builder:extensions'),
195
+ private readonly _subscriptions = new Map<string, CleanupFn>();
196
+ private readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
197
+ Atom.keepAlive,
198
+ Atom.withLabel('graph-builder:extensions'),
186
199
  );
187
-
200
+ private readonly _initialized: Record<string, Trigger> = {};
188
201
  private readonly _registry: Registry.Registry;
189
202
  private readonly _graph: Graph;
190
203
 
@@ -194,7 +207,7 @@ export class GraphBuilder {
194
207
  ...params,
195
208
  registry: this._registry,
196
209
  onExpand: (id, relation) => this._onExpand(id, relation),
197
- // onInitialize: (id) => this._onInitialize(id),
210
+ onInitialize: (id) => this._onInitialize(id),
198
211
  onRemoveNode: (id) => this._onRemoveNode(id),
199
212
  });
200
213
  }
@@ -275,16 +288,31 @@ export class GraphBuilder {
275
288
  }
276
289
 
277
290
  destroy(): void {
278
- this._connectorSubscriptions.forEach((unsubscribe) => unsubscribe());
279
- this._connectorSubscriptions.clear();
291
+ this._subscriptions.forEach((unsubscribe) => unsubscribe());
292
+ this._subscriptions.clear();
280
293
  }
281
294
 
282
- private readonly _connectors = Rx.family<string, Rx.Rx<NodeArg<any>[]>>((key) => {
283
- return Rx.make((get) => {
295
+ private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<NodeArg<any>>>>((id) => {
296
+ return Atom.make((get) => {
297
+ return Function.pipe(
298
+ get(this._extensions),
299
+ Record.values,
300
+ Array.sortBy(byPosition),
301
+ Array.map(({ resolver }) => resolver),
302
+ Array.filter(isNonNullable),
303
+ Array.map((resolver) => get(resolver(id))),
304
+ Array.filter(isNonNullable),
305
+ Array.head,
306
+ );
307
+ });
308
+ });
309
+
310
+ private readonly _connectors = Atom.family<string, Atom.Atom<NodeArg<any>[]>>((key) => {
311
+ return Atom.make((get) => {
284
312
  const [id, relation] = key.split('+');
285
313
  const node = this._graph.node(id);
286
314
 
287
- return pipe(
315
+ return Function.pipe(
288
316
  get(this._extensions),
289
317
  Record.values,
290
318
  // TODO(wittjosiah): Sort on write rather than read.
@@ -294,7 +322,7 @@ export class GraphBuilder {
294
322
  Array.filter(isNonNullable),
295
323
  Array.flatMap((result) => get(result)),
296
324
  );
297
- }).pipe(Rx.withLabel(`graph-builder:connectors:${key}`));
325
+ }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
298
326
  });
299
327
 
300
328
  private _onExpand(id: string, relation: Relation): void {
@@ -311,7 +339,7 @@ export class GraphBuilder {
311
339
 
312
340
  log('update', { id, relation, ids, removed });
313
341
  const update = () => {
314
- Rx.batch(() => {
342
+ Atom.batch(() => {
315
343
  this._graph.removeEdges(
316
344
  removed.map((target) => ({ source: id, target })),
317
345
  true,
@@ -341,26 +369,47 @@ export class GraphBuilder {
341
369
  { immediate: true },
342
370
  );
343
371
 
344
- this._connectorSubscriptions.set(id, cancel);
372
+ this._subscriptions.set(id, cancel);
345
373
  }
346
374
 
347
- // TODO(wittjosiah): On initialize to restore state from cache.
348
- // private async _onInitialize(id: string) {
349
- // log('onInitialize', { id });
350
- // }
375
+ // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
376
+ private async _onInitialize(id: string) {
377
+ log('onInitialize', { id });
378
+ const resolver = this._resolvers(id);
379
+
380
+ const cancel = this._registry.subscribe(
381
+ resolver,
382
+ (node) => {
383
+ const trigger = this._initialized[id];
384
+ Option.match(node, {
385
+ onSome: (node) => {
386
+ this._graph.addNodes([node]);
387
+ trigger?.wake();
388
+ },
389
+ onNone: () => {
390
+ trigger?.wake();
391
+ this._graph.removeNodes([id]);
392
+ },
393
+ });
394
+ },
395
+ { immediate: true },
396
+ );
397
+
398
+ this._subscriptions.set(id, cancel);
399
+ }
351
400
 
352
401
  private _onRemoveNode(id: string): void {
353
- this._connectorSubscriptions.get(id)?.();
354
- this._connectorSubscriptions.delete(id);
402
+ this._subscriptions.get(id)?.();
403
+ this._subscriptions.delete(id);
355
404
  }
356
405
  }
357
406
 
358
407
  /**
359
- * Creates an Rx.Rx<T> from a callback which accesses signals.
360
- * Will return a new rx instance each time.
408
+ * Creates an Atom.Atom<T> from a callback which accesses signals.
409
+ * Will return a new atom instance each time.
361
410
  */
362
- export const rxFromSignal = <T>(cb: () => T): Rx.Rx<T> => {
363
- return Rx.make((get) => {
411
+ export const atomFromSignal = <T>(cb: () => T): Atom.Atom<T> => {
412
+ return Atom.make((get) => {
364
413
  const dispose = effect(() => {
365
414
  get.setSelf(cb());
366
415
  });
@@ -371,8 +420,8 @@ export const rxFromSignal = <T>(cb: () => T): Rx.Rx<T> => {
371
420
  });
372
421
  };
373
422
 
374
- const observableFamily = Rx.family((observable: MulticastObservable<any>) => {
375
- return Rx.make((get) => {
423
+ const observableFamily = Atom.family((observable: MulticastObservable<any>) => {
424
+ return Atom.make((get) => {
376
425
  const subscription = observable.subscribe((value) => get.setSelf(value));
377
426
 
378
427
  get.addFinalizer(() => subscription.unsubscribe());
@@ -382,9 +431,9 @@ const observableFamily = Rx.family((observable: MulticastObservable<any>) => {
382
431
  });
383
432
 
384
433
  /**
385
- * Creates an Rx.Rx<T> from a MulticastObservable<T>
386
- * Will return the same rx instance for the same observable.
434
+ * Creates an Atom.Atom<T> from a MulticastObservable<T>
435
+ * Will return the same atom instance for the same observable.
387
436
  */
388
- export const rxFromObservable = <T>(observable: MulticastObservable<T>): Rx.Rx<T> => {
389
- return observableFamily(observable) as Rx.Rx<T>;
437
+ export const atomFromObservable = <T>(observable: MulticastObservable<T>): Atom.Atom<T> => {
438
+ return observableFamily(observable) as Atom.Atom<T>;
390
439
  };
package/src/graph.test.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Registry, Rx } from '@effect-rx/rx-react';
6
- import { Option } from 'effect';
5
+ import { Atom, Registry } from '@effect-atom/atom-react';
6
+ import * as Option from 'effect/Option';
7
7
  import { assert, describe, expect, onTestFinished, test } from 'vitest';
8
8
 
9
- import { getGraph, Graph, ROOT_ID, ROOT_TYPE } from './graph';
9
+ import { Graph, ROOT_ID, ROOT_TYPE, getGraph } from './graph';
10
10
  import { type Node } from './node';
11
11
 
12
12
  const exampleId = (id: number) => `dx:test:${id}`;
@@ -238,7 +238,7 @@ describe('Graph', () => {
238
238
  expect(count).toEqual(5);
239
239
 
240
240
  // Batching the edge and node updates fires a single update.
241
- Rx.batch(() => {
241
+ Atom.batch(() => {
242
242
  graph.addEdge({ source: exampleId(1), target: exampleId(6) });
243
243
  graph.addNode({ id: exampleId(6), type: EXAMPLE_TYPE });
244
244
  });