@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.
- package/dist/lib/browser/index.mjs +249 -191
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +249 -191
- package/dist/lib/node-esm/index.mjs.map +3 -3
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/graph-builder.d.ts +29 -18
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +25 -21
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +1 -1
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts +8 -10
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing.d.ts +3 -3
- package/dist/types/src/testing.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +32 -34
- package/src/graph-builder.test.ts +90 -32
- package/src/graph-builder.ts +109 -60
- package/src/graph.test.ts +4 -4
- package/src/graph.ts +130 -89
- package/src/node.ts +5 -3
- package/src/signals-integration.test.ts +29 -28
- package/src/stories/EchoGraph.stories.tsx +49 -39
- package/src/stories/Tree.tsx +1 -1
- package/src/testing.ts +4 -4
package/src/graph-builder.ts
CHANGED
|
@@ -2,37 +2,45 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Atom, Registry } from '@effect-atom/atom-react';
|
|
6
6
|
import { effect } from '@preact/signals-core';
|
|
7
|
-
import
|
|
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
|
|
12
|
+
import { type CleanupFn, type MulticastObservable, type Trigger } from '@dxos/async';
|
|
10
13
|
import { log } from '@dxos/log';
|
|
11
|
-
import { byPosition, getDebugName, isNode, isNonNullable
|
|
14
|
+
import { type MaybePromise, type Position, byPosition, getDebugName, isNode, isNonNullable } from '@dxos/util';
|
|
12
15
|
|
|
13
|
-
import { ACTION_GROUP_TYPE, ACTION_TYPE,
|
|
14
|
-
import {
|
|
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:
|
|
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:
|
|
28
|
-
) =>
|
|
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:
|
|
35
|
-
) =>
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
_connector(node).pipe(
|
|
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
|
-
|
|
82
|
-
_actionGroups(node).pipe(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
97
|
-
|
|
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(
|
|
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:
|
|
114
|
-
|
|
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(
|
|
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:
|
|
135
|
-
|
|
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(
|
|
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
|
-
|
|
161
|
-
connector?: (node:
|
|
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
|
|
183
|
-
private readonly _extensions =
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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.
|
|
279
|
-
this.
|
|
291
|
+
this._subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
292
|
+
this._subscriptions.clear();
|
|
280
293
|
}
|
|
281
294
|
|
|
282
|
-
private readonly
|
|
283
|
-
return
|
|
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(
|
|
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
|
-
|
|
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.
|
|
372
|
+
this._subscriptions.set(id, cancel);
|
|
345
373
|
}
|
|
346
374
|
|
|
347
|
-
// TODO(wittjosiah):
|
|
348
|
-
|
|
349
|
-
|
|
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.
|
|
354
|
-
this.
|
|
402
|
+
this._subscriptions.get(id)?.();
|
|
403
|
+
this._subscriptions.delete(id);
|
|
355
404
|
}
|
|
356
405
|
}
|
|
357
406
|
|
|
358
407
|
/**
|
|
359
|
-
* Creates an
|
|
360
|
-
* Will return a new
|
|
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
|
|
363
|
-
return
|
|
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 =
|
|
375
|
-
return
|
|
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
|
|
386
|
-
* Will return the same
|
|
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
|
|
389
|
-
return observableFamily(observable) as
|
|
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 {
|
|
6
|
-
import
|
|
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 {
|
|
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
|
-
|
|
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
|
});
|