@dxos/graph 0.8.4-main.7ace549 → 0.8.4-main.937b3ca

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/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@dxos/graph",
3
- "version": "0.8.4-main.7ace549",
3
+ "version": "0.8.4-main.937b3ca",
4
4
  "description": "Low-level graph API",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
- "sideEffects": true,
13
+ "sideEffects": false,
10
14
  "type": "module",
11
15
  "exports": {
12
16
  ".": {
@@ -25,23 +29,21 @@
25
29
  "src"
26
30
  ],
27
31
  "dependencies": {
28
- "@preact/signals-core": "^1.12.1",
29
- "effect": "3.18.3",
30
- "@dxos/async": "0.8.4-main.7ace549",
31
- "@dxos/debug": "0.8.4-main.7ace549",
32
- "@dxos/echo-signals": "0.8.4-main.7ace549",
33
- "@dxos/invariant": "0.8.4-main.7ace549",
34
- "@dxos/log": "0.8.4-main.7ace549",
35
- "@dxos/util": "0.8.4-main.7ace549",
36
- "@dxos/live-object": "0.8.4-main.7ace549"
32
+ "@effect-atom/atom-react": "^0.4.6",
33
+ "@dxos/async": "0.8.4-main.937b3ca",
34
+ "@dxos/debug": "0.8.4-main.937b3ca",
35
+ "@dxos/invariant": "0.8.4-main.937b3ca",
36
+ "@dxos/util": "0.8.4-main.937b3ca",
37
+ "@dxos/log": "0.8.4-main.937b3ca"
37
38
  },
38
39
  "devDependencies": {
39
- "@dxos/echo": "0.8.4-main.7ace549",
40
- "@dxos/echo-db": "0.8.4-main.7ace549",
41
- "@dxos/random": "0.8.4-main.7ace549"
40
+ "effect": "3.19.11",
41
+ "@dxos/echo-db": "0.8.4-main.937b3ca",
42
+ "@dxos/echo": "0.8.4-main.937b3ca",
43
+ "@dxos/random": "0.8.4-main.937b3ca"
42
44
  },
43
45
  "peerDependencies": {
44
- "effect": "^3.13.3"
46
+ "effect": "3.19.11"
45
47
  },
46
48
  "publishConfig": {
47
49
  "access": "public"
package/src/Graph.ts ADDED
@@ -0,0 +1,81 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { invariant } from '@dxos/invariant';
8
+ import { type Specialize } from '@dxos/util';
9
+
10
+ //
11
+ // Node
12
+ //
13
+
14
+ export const Node = Schema.Struct({
15
+ id: Schema.String,
16
+ type: Schema.optional(Schema.String),
17
+ data: Schema.optional(Schema.Any),
18
+ });
19
+
20
+ interface BaseNode extends Schema.Schema.Type<typeof Node> {}
21
+
22
+ export declare namespace Node {
23
+ export type Any = Specialize<BaseNode, { data?: any }>;
24
+ export type Node<Data = any> = Specialize<BaseNode, { data: Data }>;
25
+ }
26
+
27
+ //
28
+ // Edge
29
+ //
30
+
31
+ export const Edge = Schema.Struct({
32
+ id: Schema.String,
33
+ type: Schema.optional(Schema.String),
34
+ source: Schema.String,
35
+ target: Schema.String,
36
+ data: Schema.optional(Schema.Any),
37
+ });
38
+
39
+ interface BaseEdge extends Schema.Schema.Type<typeof Edge> {}
40
+
41
+ export declare namespace Edge {
42
+ export type Any = Specialize<BaseEdge, { data?: any }>;
43
+ export type Edge<Data = any> = Specialize<BaseEdge, { data: Data }>;
44
+ }
45
+
46
+ const KEY_REGEX = /\w+/;
47
+
48
+ // NOTE: The `relation` is different from the `type`.
49
+ type EdgeMeta = { source: string; target: string; relation?: string };
50
+
51
+ export const createEdgeId = ({ source, target, relation }: EdgeMeta) => {
52
+ invariant(source.match(KEY_REGEX), `invalid source: ${source}`);
53
+ invariant(target.match(KEY_REGEX), `invalid target: ${target}`);
54
+ return [source, relation, target].join('_');
55
+ };
56
+
57
+ export const parseEdgeId = (id: string): EdgeMeta => {
58
+ const [source, relation, target] = id.split('_');
59
+ invariant(source.length && target.length);
60
+ return { source, relation: relation.length ? relation : undefined, target };
61
+ };
62
+
63
+ //
64
+ // Graph
65
+ //
66
+
67
+ export const Graph = Schema.Struct({
68
+ id: Schema.optional(Schema.String),
69
+ nodes: Schema.mutable(Schema.Array(Node)),
70
+ edges: Schema.mutable(Schema.Array(Edge)),
71
+ });
72
+
73
+ export interface Any extends Schema.Schema.Type<typeof Graph> {}
74
+
75
+ export type Graph<Node extends Node.Any, Edge extends Edge.Any> = Specialize<
76
+ Any,
77
+ {
78
+ nodes: Node[];
79
+ edges: Edge[];
80
+ }
81
+ >;
@@ -0,0 +1,265 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Registry } from '@effect-atom/atom-react';
6
+ import * as Schema from 'effect/Schema';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { Trigger } from '@dxos/async';
10
+
11
+ import * as Graph from './Graph';
12
+ import * as GraphModel from './GraphModel';
13
+
14
+ // Create a registry for tests.
15
+ const createRegistry = () => Registry.make();
16
+
17
+ const TestNode = Schema.extend(
18
+ Graph.Node,
19
+ Schema.Struct({
20
+ value: Schema.String,
21
+ }),
22
+ );
23
+
24
+ type TestNode = Schema.Schema.Type<typeof TestNode>;
25
+
26
+ type TestData = { value: string };
27
+
28
+ describe('Graph', () => {
29
+ test('empty', ({ expect }) => {
30
+ const graph = new GraphModel.GraphModel();
31
+ expect(graph.nodes).to.have.length(0);
32
+ expect(graph.edges).to.have.length(0);
33
+ expect(graph.toJSON()).to.deep.eq({ nodes: 0, edges: 0 });
34
+ });
35
+
36
+ test('extended', ({ expect }) => {
37
+ const graph = new GraphModel.GraphModel<TestNode>();
38
+ const node = graph.addNode({ id: 'test', value: 'test' });
39
+ expect(node.value.length).to.eq(4);
40
+ });
41
+
42
+ test('reactive model', async ({ expect }) => {
43
+ const registry = createRegistry();
44
+ const graph = new GraphModel.ReactiveGraphModel(registry);
45
+
46
+ const done = new Trigger<Graph.Any>();
47
+ const unsubscribe = graph.subscribe((model, g) => {
48
+ if (g.edges.length === 2) {
49
+ done.wake(g);
50
+ }
51
+ });
52
+
53
+ setTimeout(() => {
54
+ graph.addNode({ id: 'node-1' });
55
+ graph.addNode({ id: 'node-2' });
56
+ graph.addNode({ id: 'node-3' });
57
+ });
58
+
59
+ setTimeout(() => {
60
+ graph.addEdge({ source: 'node-1', target: 'node-2' });
61
+ graph.addEdge({ source: 'node-2', target: 'node-3' });
62
+ });
63
+
64
+ {
65
+ const g = await done.wait();
66
+ expect(g.nodes).to.have.length(3);
67
+ expect(g.edges).to.have.length(2);
68
+ }
69
+
70
+ unsubscribe();
71
+ });
72
+
73
+ test('reactive model fires immediately with fire option', ({ expect }) => {
74
+ const registry = createRegistry();
75
+ const graph = new GraphModel.ReactiveGraphModel(registry);
76
+ graph.addNode({ id: 'node-1' });
77
+
78
+ let callCount = 0;
79
+ let lastNodeCount = 0;
80
+
81
+ const unsubscribe = graph.subscribe(
82
+ (model) => {
83
+ callCount++;
84
+ lastNodeCount = model.nodes.length;
85
+ },
86
+ true, // fire immediately
87
+ );
88
+
89
+ // Should fire once immediately with fire option.
90
+ expect(callCount).to.eq(1);
91
+ expect(lastNodeCount).to.eq(1);
92
+
93
+ unsubscribe();
94
+ });
95
+
96
+ test('reactive model tracks node additions', ({ expect }) => {
97
+ const registry = createRegistry();
98
+ const graph = new GraphModel.ReactiveGraphModel(registry);
99
+
100
+ const nodeCountHistory: number[] = [];
101
+ const unsubscribe = graph.subscribe((model) => {
102
+ nodeCountHistory.push(model.nodes.length);
103
+ });
104
+
105
+ graph.addNode({ id: 'node-1' });
106
+ graph.addNode({ id: 'node-2' });
107
+ graph.addNode({ id: 'node-3' });
108
+
109
+ // Should have tracked the additions synchronously.
110
+ expect(nodeCountHistory).to.deep.eq([1, 2, 3]);
111
+
112
+ unsubscribe();
113
+ });
114
+
115
+ test('reactive model tracks node removals', ({ expect }) => {
116
+ const registry = createRegistry();
117
+ const graph = new GraphModel.ReactiveGraphModel(registry);
118
+ graph.addNode({ id: 'node-1' });
119
+ graph.addNode({ id: 'node-2' });
120
+ graph.addNode({ id: 'node-3' });
121
+
122
+ const nodeCountHistory: number[] = [];
123
+ const unsubscribe = graph.subscribe((model) => {
124
+ nodeCountHistory.push(model.nodes.length);
125
+ }, true);
126
+
127
+ expect(nodeCountHistory[0]).to.eq(3);
128
+
129
+ graph.removeNode('node-2');
130
+
131
+ expect(nodeCountHistory[nodeCountHistory.length - 1]).to.eq(2);
132
+
133
+ unsubscribe();
134
+ });
135
+
136
+ test('reactive model unsubscribe stops notifications', ({ expect }) => {
137
+ const registry = createRegistry();
138
+ const graph = new GraphModel.ReactiveGraphModel(registry);
139
+
140
+ let callCount = 0;
141
+ const unsubscribe = graph.subscribe(() => {
142
+ callCount++;
143
+ });
144
+
145
+ graph.addNode({ id: 'node-1' });
146
+
147
+ const countAfterFirstAdd = callCount;
148
+ expect(countAfterFirstAdd).to.eq(1);
149
+
150
+ unsubscribe();
151
+
152
+ graph.addNode({ id: 'node-2' });
153
+
154
+ // Should not have received more notifications after unsubscribe.
155
+ expect(callCount).to.eq(countAfterFirstAdd);
156
+ });
157
+
158
+ test('reactive model supports multiple subscribers', ({ expect }) => {
159
+ const registry = createRegistry();
160
+ const graph = new GraphModel.ReactiveGraphModel(registry);
161
+
162
+ let subscriber1Count = 0;
163
+ let subscriber2Count = 0;
164
+
165
+ const unsub1 = graph.subscribe(() => {
166
+ subscriber1Count++;
167
+ });
168
+
169
+ const unsub2 = graph.subscribe(() => {
170
+ subscriber2Count++;
171
+ });
172
+
173
+ graph.addNode({ id: 'node-1' });
174
+
175
+ expect(subscriber1Count).to.eq(1);
176
+ expect(subscriber2Count).to.eq(1);
177
+
178
+ unsub1();
179
+ unsub2();
180
+ });
181
+
182
+ test('optional', ({ expect }) => {
183
+ {
184
+ const graph = new GraphModel.GraphModel<Graph.Node.Node<string>>();
185
+ const node = graph.addNode({ id: 'test', data: 'test' });
186
+ expect(node.data.length).to.eq(4);
187
+ }
188
+
189
+ {
190
+ const graph = new GraphModel.GraphModel<Graph.Node.Any>();
191
+ const node = graph.addNode({ id: 'test' });
192
+ expect(node.data?.length).to.be.undefined;
193
+ }
194
+ });
195
+
196
+ test('add and remove subgraphs', ({ expect }) => {
197
+ const graph = new GraphModel.GraphModel<Graph.Node.Node<TestData>>();
198
+ graph.builder
199
+ .addNode({ id: 'node1', data: { value: 'test' } })
200
+ .addNode({ id: 'node2', data: { value: 'test' } })
201
+ .addNode({ id: 'node3', data: { value: 'test' } })
202
+ .addEdge({ source: 'node1', target: 'node2' })
203
+ .addEdge({ source: 'node2', target: 'node3' });
204
+ expect(graph.nodes).to.have.length(3);
205
+ expect(graph.edges).to.have.length(2);
206
+ const pre = graph.toJSON();
207
+
208
+ const node = graph.findNode('node2');
209
+ expect(node).to.exist;
210
+
211
+ const removed = graph.removeNode('node2');
212
+ expect(removed.nodes).to.have.length(1);
213
+ expect(removed.edges).to.have.length(2);
214
+ expect(graph.nodes).to.have.length(2);
215
+ expect(graph.edges).to.have.length(0);
216
+
217
+ graph.addGraph(removed);
218
+ const post = graph.toJSON();
219
+ expect(pre).to.deep.eq(post);
220
+
221
+ graph.clear();
222
+ expect(graph.nodes).to.have.length(0);
223
+ expect(graph.edges).to.have.length(0);
224
+ });
225
+
226
+ test('traverse', ({ expect }) => {
227
+ const graph = new GraphModel.GraphModel();
228
+ graph.builder
229
+ .addNode({ id: 'a' })
230
+ .addNode({ id: 'b' })
231
+ .addNode({ id: 'c' })
232
+ .addNode({ id: 'd' })
233
+ .addNode({ id: 'e' })
234
+ .addNode({ id: 'f' })
235
+ .addNode({ id: 'g' })
236
+ .addNode({ id: 'h' })
237
+ // Sub-graph 1.
238
+ .addEdge({ source: 'a', target: 'b' })
239
+ .addEdge({ source: 'a', target: 'c' })
240
+ .addEdge({ source: 'c', target: 'd' })
241
+ .addEdge({ source: 'd', target: 'e' })
242
+ .addEdge({ source: 'd', target: 'a' })
243
+ // Sub-graph 2.
244
+ .addEdge({ source: 'f', target: 'g' })
245
+ .addEdge({ source: 'g', target: 'h' });
246
+
247
+ const count = graph.nodes.length;
248
+
249
+ {
250
+ // Sub-graph 1.
251
+ const nodes = graph.traverse(graph.getNode('a'));
252
+ expect(nodes).to.have.length(5);
253
+ }
254
+
255
+ {
256
+ // Sub-graph 2.
257
+ const nodes = graph.traverse(graph.getNode('f'));
258
+ expect(nodes).to.have.length(3);
259
+
260
+ // Remove sub-graph.
261
+ graph.removeNodes(nodes.map((node) => node.id));
262
+ expect(graph.nodes).to.have.length(count - 3);
263
+ }
264
+ });
265
+ });