@dxos/app-graph 0.8.2-main.f11618f → 0.8.2-main.fbd8ed0

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 (33) hide show
  1. package/dist/lib/browser/index.mjs +541 -789
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +533 -780
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +541 -789
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/experimental/graph-projections.test.d.ts +25 -0
  11. package/dist/types/src/experimental/graph-projections.test.d.ts.map +1 -0
  12. package/dist/types/src/graph-builder.d.ts +48 -91
  13. package/dist/types/src/graph-builder.d.ts.map +1 -1
  14. package/dist/types/src/graph.d.ts +191 -98
  15. package/dist/types/src/graph.d.ts.map +1 -1
  16. package/dist/types/src/node.d.ts +2 -2
  17. package/dist/types/src/node.d.ts.map +1 -1
  18. package/dist/types/src/signals-integration.test.d.ts +2 -0
  19. package/dist/types/src/signals-integration.test.d.ts.map +1 -0
  20. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  21. package/dist/types/src/testing.d.ts +5 -0
  22. package/dist/types/src/testing.d.ts.map +1 -0
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +23 -16
  25. package/src/experimental/graph-projections.test.ts +56 -0
  26. package/src/graph-builder.test.ts +293 -310
  27. package/src/graph-builder.ts +209 -317
  28. package/src/graph.test.ts +314 -463
  29. package/src/graph.ts +452 -458
  30. package/src/node.ts +2 -2
  31. package/src/signals-integration.test.ts +218 -0
  32. package/src/stories/EchoGraph.stories.tsx +56 -77
  33. package/src/testing.ts +20 -0
package/src/node.ts CHANGED
@@ -70,12 +70,12 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
70
70
 
71
71
  export type InvokeParams = {
72
72
  /** Node the invoked action is connected to. */
73
- node: Node;
73
+ parent?: Node;
74
74
 
75
75
  caller?: string;
76
76
  };
77
77
 
78
- export type ActionData = (params: InvokeParams) => MaybePromise<void>;
78
+ export type ActionData = (params?: InvokeParams) => MaybePromise<void>;
79
79
 
80
80
  export type Action<TProperties extends Record<string, any> = Record<string, any>> = Readonly<
81
81
  Omit<Node<ActionData, TProperties>, 'properties'> & {
@@ -0,0 +1,218 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Registry, Rx } from '@effect-rx/rx-react';
6
+ import { signal } from '@preact/signals-core';
7
+ import { afterEach, beforeEach, describe, expect, onTestFinished, test } from 'vitest';
8
+
9
+ import { Trigger } from '@dxos/async';
10
+ import { Filter } from '@dxos/echo-db';
11
+ import { EchoTestBuilder } from '@dxos/echo-db/testing';
12
+ import { Expando, Ref } from '@dxos/echo-schema';
13
+ import { registerSignalsRuntime } from '@dxos/echo-signals';
14
+ import { live } from '@dxos/live-object';
15
+
16
+ import { ROOT_ID } from './graph';
17
+ import { createExtension, GraphBuilder, rxFromSignal } from './graph-builder';
18
+ import { rxFromQuery } from './testing';
19
+
20
+ registerSignalsRuntime();
21
+
22
+ const EXAMPLE_TYPE = 'dxos.org/type/example';
23
+
24
+ describe('signals integration', () => {
25
+ test('creating rx from signal', () => {
26
+ const registry = Registry.make();
27
+ const state = signal<number>(0);
28
+ const value = rxFromSignal(() => state.value);
29
+ const inline = Rx.make((get) => {
30
+ // NOTE: This will create a new rx instance each time.
31
+ // This test is verifying that this behaves the same as using a stable rx instance.
32
+ // The parent will remain subscribed to one instance until the new one is created.
33
+ // The old one will then be garbage collected because it is no longer referenced.
34
+ const rx = rxFromSignal(() => get(value));
35
+ return get(rx);
36
+ });
37
+
38
+ let count = 0;
39
+ const cancel = registry.subscribe(value, (value) => {
40
+ count = value;
41
+ });
42
+ onTestFinished(() => cancel());
43
+
44
+ let inlineCount = 0;
45
+ const inlineCancel = registry.subscribe(inline, (value) => {
46
+ inlineCount = value;
47
+ });
48
+ onTestFinished(() => inlineCancel());
49
+
50
+ registry.get(value);
51
+ registry.get(inline);
52
+ expect(count).to.eq(0);
53
+ expect(inlineCount).to.eq(0);
54
+
55
+ state.value = 1;
56
+ expect(count).to.eq(1);
57
+ expect(inlineCount).to.eq(1);
58
+
59
+ state.value = 2;
60
+ expect(count).to.eq(2);
61
+ expect(inlineCount).to.eq(2);
62
+ });
63
+
64
+ describe('echo', () => {
65
+ let dbBuilder: EchoTestBuilder;
66
+
67
+ beforeEach(async () => {
68
+ dbBuilder = await new EchoTestBuilder().open();
69
+ });
70
+
71
+ afterEach(async () => {
72
+ await dbBuilder.close();
73
+ });
74
+
75
+ test('rx references are loaded lazily and receive signal notifications', async () => {
76
+ const registry = Registry.make();
77
+ await using peer = await dbBuilder.createPeer();
78
+
79
+ let outerId: string;
80
+ {
81
+ await using db = await peer.createDatabase();
82
+ const inner = db.add({ name: 'inner' });
83
+ const outer = db.add({ inner: Ref.make(inner) });
84
+ outerId = outer.id;
85
+ await db.flush();
86
+ }
87
+
88
+ await peer.reload();
89
+ {
90
+ await using db = await peer.openLastDatabase();
91
+ const outer = (await db.query({ id: outerId }).first()) as any;
92
+ const innerRx = rxFromSignal(() => outer.inner.target);
93
+
94
+ const loaded = new Trigger();
95
+ let count = 0;
96
+ const cancel = registry.subscribe(innerRx, (inner) => {
97
+ count++;
98
+ if (inner) {
99
+ loaded.wake();
100
+ }
101
+ });
102
+ onTestFinished(() => cancel());
103
+
104
+ expect(registry.get(innerRx)).to.eq(undefined);
105
+ expect(count).to.eq(1);
106
+
107
+ await loaded.wait();
108
+ expect(registry.get(innerRx)).to.include({ name: 'inner' });
109
+ expect(count).to.eq(2);
110
+ }
111
+ });
112
+
113
+ test('references graph builder', async () => {
114
+ const registry = Registry.make();
115
+ await using peer = await dbBuilder.createPeer();
116
+
117
+ let outerId, innerId: string;
118
+ {
119
+ await using db = await peer.createDatabase();
120
+ const inner = db.add({ name: 'inner' });
121
+ const outer = db.add({ inner: Ref.make(inner) });
122
+ innerId = inner.id;
123
+ outerId = outer.id;
124
+ await db.flush();
125
+ }
126
+
127
+ await peer.reload();
128
+
129
+ {
130
+ await using db = await peer.openLastDatabase();
131
+ const outer = (await db.query({ id: outerId }).first()) as any;
132
+ const innerRx = rxFromSignal(() => outer.inner.target);
133
+ const inner = registry.get(innerRx);
134
+ expect(inner).to.eq(undefined);
135
+
136
+ const builder = new GraphBuilder({ registry });
137
+ builder.addExtension(
138
+ createExtension({
139
+ id: 'outbound-connector',
140
+ connector: () =>
141
+ Rx.make((get) => {
142
+ const inner = get(innerRx) as any;
143
+ return inner ? [{ id: inner.id, type: EXAMPLE_TYPE, data: inner.name }] : [];
144
+ }),
145
+ }),
146
+ );
147
+
148
+ const graph = builder.graph;
149
+
150
+ const loaded = new Trigger();
151
+ let count = 0;
152
+ const cancel = registry.subscribe(graph.connections(ROOT_ID), (nodes) => {
153
+ count++;
154
+ if (nodes.length > 0) {
155
+ loaded.wake();
156
+ }
157
+ });
158
+ onTestFinished(() => cancel());
159
+ registry.get(graph.connections(ROOT_ID));
160
+ expect(count).to.eq(1);
161
+
162
+ graph.expand(ROOT_ID);
163
+ await loaded.wait();
164
+ expect(count).to.eq(2);
165
+
166
+ const nodes = registry.get(graph.connections(ROOT_ID));
167
+ expect(nodes).has.length(1);
168
+ expect(nodes[0].id).to.eq(innerId);
169
+ expect(nodes[0].data).to.eq('inner');
170
+ }
171
+ });
172
+
173
+ test('query graph builder', async () => {
174
+ const registry = Registry.make();
175
+ await using peer = await dbBuilder.createPeer();
176
+ await using db = await peer.createDatabase();
177
+ db.add(live(Expando, { name: 'a' }));
178
+ db.add(live(Expando, { name: 'b' }));
179
+
180
+ const builder = new GraphBuilder({ registry });
181
+ builder.addExtension(
182
+ createExtension({
183
+ id: 'expando',
184
+ connector: () => {
185
+ const query = db.query(Filter.type(Expando));
186
+
187
+ return Rx.make((get) => {
188
+ const objects = get(rxFromQuery(query));
189
+ return objects.map((object) => ({ id: object.id, type: EXAMPLE_TYPE, data: object.name }));
190
+ });
191
+ },
192
+ }),
193
+ );
194
+
195
+ const graph = builder.graph;
196
+ let count = 0;
197
+ const cancel = registry.subscribe(graph.connections(ROOT_ID), (nodes) => {
198
+ count = nodes.length;
199
+ });
200
+ onTestFinished(() => cancel());
201
+
202
+ registry.get(graph.connections(ROOT_ID));
203
+ expect(count).to.eq(0);
204
+
205
+ graph.expand(ROOT_ID);
206
+ expect(count).to.eq(2);
207
+
208
+ const object = db.add(live(Expando, { name: 'c' }));
209
+ await db.flush();
210
+ expect(count).to.eq(3);
211
+
212
+ // NOTE: This graph builder is not reactive to the object update.
213
+ object.name = 'updated';
214
+ await db.flush();
215
+ expect(count).to.eq(3);
216
+ });
217
+ });
218
+ });
@@ -4,37 +4,27 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
+ import { Rx, useRxValue } from '@effect-rx/rx-react';
7
8
  import { Pause, Play, Plus, Timer } from '@phosphor-icons/react';
8
- import React, { useEffect, useState } from 'react';
9
+ import { Option, pipe } from 'effect';
10
+ import React, { useEffect, useMemo, useState } from 'react';
9
11
 
10
- import {
11
- live,
12
- isSpace,
13
- type Echo,
14
- type FilterSource,
15
- type Space,
16
- SpaceState,
17
- type QueryOptions,
18
- type Query,
19
- } from '@dxos/client/echo';
20
- import type { BaseObject } from '@dxos/echo-schema';
12
+ import { live, isSpace, Query, type QueryResult, type Space, SpaceState, Expando, type Live } from '@dxos/client/echo';
21
13
  import { faker } from '@dxos/random';
22
14
  import { type Client, useClient } from '@dxos/react-client';
23
15
  import { withClientProvider } from '@dxos/react-client/testing';
24
- import { Button, Input, Select, useAsyncEffect } from '@dxos/react-ui';
16
+ import { Button, Input, Select } from '@dxos/react-ui';
25
17
  import { getSize, mx } from '@dxos/react-ui-theme';
26
18
  import { withTheme } from '@dxos/storybook-utils';
27
19
  import { safeParseInt } from '@dxos/util';
28
20
 
29
21
  import { Tree } from './Tree';
30
- import { type Graph } from '../graph';
31
- import { GraphBuilder, cleanup, createExtension, memoize, toSignal } from '../graph-builder';
32
- import { type Node } from '../node';
22
+ import { type ExpandableGraph, ROOT_ID } from '../graph';
23
+ import { GraphBuilder, createExtension, rxFromObservable, rxFromSignal } from '../graph-builder';
24
+ import { rxFromQuery } from '../testing';
33
25
 
34
26
  const DEFAULT_PERIOD = 500;
35
27
 
36
- const EMPTY_ARRAY: never[] = [];
37
-
38
28
  enum Action {
39
29
  CREATE_SPACE = 'CREATE_SPACE',
40
30
  CLOSE_SPACE = 'CLOSE_SPACE',
@@ -53,70 +43,61 @@ const actionWeights = {
53
43
  [Action.RENAME_OBJECT]: 4,
54
44
  };
55
45
 
56
- // TODO(wittjosiah): Factor out.
57
- const memoizeQuery = <T extends BaseObject>(
58
- spaceOrEcho?: Space | Echo,
59
- filter?: FilterSource<T>,
60
- options?: QueryOptions,
61
- ): T[] => {
62
- const key = isSpace(spaceOrEcho) ? spaceOrEcho.id : undefined;
63
- const query = memoize(
64
- () =>
65
- isSpace(spaceOrEcho)
66
- ? spaceOrEcho.db.query(filter, options)
67
- : (spaceOrEcho?.query(filter, options) as Query<T> | undefined),
68
- key,
69
- );
70
- const unsubscribe = memoize(() => query?.subscribe(), key);
71
- cleanup(() => unsubscribe?.());
72
-
73
- return query?.objects ?? EMPTY_ARRAY;
74
- };
75
-
76
- const createGraph = async (client: Client): Promise<Graph> => {
46
+ const createGraph = (client: Client): ExpandableGraph => {
77
47
  const spaceBuilderExtension = createExtension({
78
48
  id: 'space',
79
- filter: (node): node is Node<null> => node.id === 'root',
80
- connector: ({ node }) => {
81
- const spaces = toSignal(
82
- (onChange) => client.spaces.subscribe(() => onChange()).unsubscribe,
83
- () => client.spaces.get(),
84
- );
85
- if (!spaces) {
86
- return;
87
- }
88
-
89
- return spaces
90
- .filter((space) => space.state.get() === SpaceState.SPACE_READY)
91
- .map((space) => ({
92
- id: space.id,
93
- type: 'dxos.org/type/Space',
94
- properties: { label: space.properties.name },
95
- data: space,
96
- }));
97
- },
49
+ connector: (node) =>
50
+ Rx.make((get) =>
51
+ pipe(
52
+ get(node),
53
+ Option.flatMap((node) => (node.id === ROOT_ID ? Option.some(node) : Option.none())),
54
+ Option.map(() => {
55
+ const spaces = get(rxFromObservable(client.spaces)) ?? [];
56
+ return spaces
57
+ .filter((space) => get(rxFromObservable(space.state)) === SpaceState.SPACE_READY)
58
+ .map((space) => ({
59
+ id: space.id,
60
+ type: 'dxos.org/type/Space',
61
+ properties: { label: get(rxFromSignal(() => space.properties.name)) },
62
+ data: space,
63
+ }));
64
+ }),
65
+ Option.getOrElse(() => []),
66
+ ),
67
+ ),
98
68
  });
99
69
 
100
70
  const objectBuilderExtension = createExtension({
101
71
  id: 'object',
102
- filter: (node): node is Node<Space> => isSpace(node.data),
103
- connector: ({ node }) => {
104
- const objects = memoizeQuery(node.data, { type: 'test' });
105
- return objects.map((object) => ({
106
- id: object.id,
107
- type: 'dxos.org/type/test',
108
- properties: { label: object.name },
109
- data: object,
110
- }));
72
+ connector: (node) => {
73
+ let query: QueryResult<Live<Expando>> | undefined;
74
+ return Rx.make((get) =>
75
+ pipe(
76
+ get(node),
77
+ Option.flatMap((node) => (isSpace(node.data) ? Option.some(node.data) : Option.none())),
78
+ Option.map((space) => {
79
+ if (!query) {
80
+ query = space.db.query(Query.type(Expando, { type: 'test' }));
81
+ }
82
+ return get(rxFromQuery(query)).map((object) => ({
83
+ id: object.id,
84
+ type: 'dxos.org/type/test',
85
+ properties: { label: object.name },
86
+ data: object,
87
+ }));
88
+ }),
89
+ Option.getOrElse(() => []),
90
+ ),
91
+ );
111
92
  },
112
93
  });
113
94
 
114
95
  const graph = new GraphBuilder().addExtension(spaceBuilderExtension).addExtension(objectBuilderExtension).graph;
115
- graph.subscribeTraverse({
116
- visitor: (node) => {
117
- void graph.expand(node);
118
- },
96
+ graph.onNodeChanged.on(({ id }) => {
97
+ console.log('onNodeChanged', { id });
98
+ graph.expand(id);
119
99
  });
100
+ graph.expand(ROOT_ID);
120
101
 
121
102
  return graph;
122
103
  };
@@ -189,10 +170,8 @@ const DefaultStory = () => {
189
170
  const [action, setAction] = useState<Action>();
190
171
 
191
172
  const client = useClient();
192
- const [graph, setGraph] = useState<Graph>();
193
- useAsyncEffect(async () => {
194
- setGraph(await createGraph(client));
195
- }, [client]);
173
+ const graph = useMemo(() => createGraph(client), [client]);
174
+ const data = useRxValue(graph.json());
196
175
 
197
176
  useEffect(() => {
198
177
  if (!generating) {
@@ -244,7 +223,7 @@ const DefaultStory = () => {
244
223
  </Select.Portal>
245
224
  </Select.Root>
246
225
  </div>
247
- {graph && <Tree data={graph.toJSON()} />}
226
+ {data && <Tree data={data} />}
248
227
  </>
249
228
  );
250
229
  };
@@ -256,7 +235,7 @@ export default {
256
235
  withTheme,
257
236
  withClientProvider({
258
237
  createIdentity: true,
259
- onInitialized: async (client: Client) => {
238
+ onIdentityCreated: async ({ client }) => {
260
239
  await client.spaces.create();
261
240
  await client.spaces.create();
262
241
  },
package/src/testing.ts ADDED
@@ -0,0 +1,20 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Rx } from '@effect-rx/rx-react';
6
+
7
+ import { type QueryResult } from '@dxos/echo-db';
8
+ import { type BaseObject } from '@dxos/echo-schema';
9
+
10
+ export const rxFromQuery = <T extends BaseObject>(query: QueryResult<T>): Rx.Rx<T[]> => {
11
+ return Rx.make((get) => {
12
+ const unsubscribe = query.subscribe((result) => {
13
+ get.setSelf(result.objects);
14
+ });
15
+
16
+ get.addFinalizer(() => unsubscribe());
17
+
18
+ return query.objects;
19
+ });
20
+ };