@dxos/app-graph 0.8.3 → 0.8.4-main.1c7ec43d41
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/neutral/chunk-J5LGTIGS.mjs +10 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
- package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
- package/dist/lib/neutral/chunk-WJJ5KEOH.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +40 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/scheduler.mjs +15 -0
- package/dist/lib/neutral/scheduler.mjs.map +7 -0
- package/dist/lib/neutral/testing/index.mjs +40 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/atoms.d.ts +8 -0
- package/dist/types/src/atoms.d.ts.map +1 -0
- package/dist/types/src/graph-builder.d.ts +117 -60
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +188 -218
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +7 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +244 -0
- package/dist/types/src/node-matcher.d.ts.map +1 -0
- package/dist/types/src/node-matcher.test.d.ts +2 -0
- package/dist/types/src/node-matcher.test.d.ts.map +1 -0
- package/dist/types/src/node.d.ts +50 -5
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/scheduler.browser.d.ts +2 -0
- package/dist/types/src/scheduler.browser.d.ts.map +1 -0
- package/dist/types/src/scheduler.d.ts +8 -0
- package/dist/types/src/scheduler.d.ts.map +1 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts +6 -13
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
- package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +40 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +53 -42
- package/src/atoms.ts +25 -0
- package/src/graph-builder.test.ts +1193 -126
- package/src/graph-builder.ts +753 -264
- package/src/graph.test.ts +451 -123
- package/src/graph.ts +1057 -407
- package/src/index.ts +10 -3
- package/src/node-matcher.test.ts +301 -0
- package/src/node-matcher.ts +314 -0
- package/src/node.ts +83 -7
- package/src/scheduler.browser.ts +5 -0
- package/src/scheduler.ts +17 -0
- package/src/stories/EchoGraph.stories.tsx +178 -255
- package/src/stories/Tree.tsx +1 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/setup-graph-builder.ts +41 -0
- package/src/util.ts +101 -0
- package/dist/lib/browser/index.mjs +0 -778
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node/index.cjs +0 -816
- package/dist/lib/node/index.cjs.map +0 -7
- package/dist/lib/node/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -780
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
- package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
- package/dist/types/src/signals-integration.test.d.ts +0 -2
- package/dist/types/src/signals-integration.test.d.ts.map +0 -1
- package/dist/types/src/testing.d.ts +0 -5
- package/dist/types/src/testing.d.ts.map +0 -1
- package/src/experimental/graph-projections.test.ts +0 -56
- package/src/signals-integration.test.ts +0 -218
- package/src/testing.ts +0 -20
package/src/index.ts
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
5
|
+
// TODO(wittjosiah): What is a good name for this module?
|
|
6
|
+
export * as CreateAtom from './atoms';
|
|
7
|
+
export * as Graph from './graph';
|
|
8
|
+
export * as GraphBuilder from './graph-builder';
|
|
9
|
+
export * as Node from './node';
|
|
10
|
+
export * as NodeMatcher from './node-matcher';
|
|
11
|
+
export { qualifyId, getParentId, getSegmentId } from './util';
|
|
12
|
+
|
|
13
|
+
// TODO(wittjosiah): Direct re-export needed for portable type references.
|
|
14
|
+
export type { BuilderExtensions } from './graph-builder';
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { Obj } from '@dxos/echo';
|
|
9
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
10
|
+
|
|
11
|
+
import * as Node from './node';
|
|
12
|
+
import * as NodeMatcher from './node-matcher';
|
|
13
|
+
|
|
14
|
+
describe('NodeMatcher', () => {
|
|
15
|
+
describe('whenRoot', () => {
|
|
16
|
+
test('matches root node', () => {
|
|
17
|
+
const rootNode: Node.Node = {
|
|
18
|
+
id: Node.RootId,
|
|
19
|
+
type: Node.RootType,
|
|
20
|
+
properties: {},
|
|
21
|
+
data: null,
|
|
22
|
+
};
|
|
23
|
+
const result = NodeMatcher.whenRoot(rootNode);
|
|
24
|
+
expect(Option.isSome(result)).to.be.true;
|
|
25
|
+
expect(Option.getOrNull(result)).to.equal(rootNode);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('does not match non-root node', () => {
|
|
29
|
+
const node: Node.Node = {
|
|
30
|
+
id: 'other',
|
|
31
|
+
type: 'test',
|
|
32
|
+
properties: {},
|
|
33
|
+
data: null,
|
|
34
|
+
};
|
|
35
|
+
const result = NodeMatcher.whenRoot(node);
|
|
36
|
+
expect(Option.isNone(result)).to.be.true;
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('whenId', () => {
|
|
41
|
+
test('matches node by ID', () => {
|
|
42
|
+
const node: Node.Node = {
|
|
43
|
+
id: 'test-id',
|
|
44
|
+
type: 'test',
|
|
45
|
+
properties: {},
|
|
46
|
+
data: null,
|
|
47
|
+
};
|
|
48
|
+
const matcher = NodeMatcher.whenId('test-id');
|
|
49
|
+
const result = matcher(node);
|
|
50
|
+
expect(Option.isSome(result)).to.be.true;
|
|
51
|
+
expect(Option.getOrNull(result)).to.equal(node);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('does not match different ID', () => {
|
|
55
|
+
const node: Node.Node = {
|
|
56
|
+
id: 'test-id',
|
|
57
|
+
type: 'test',
|
|
58
|
+
properties: {},
|
|
59
|
+
data: null,
|
|
60
|
+
};
|
|
61
|
+
const matcher = NodeMatcher.whenId('other-id');
|
|
62
|
+
const result = matcher(node);
|
|
63
|
+
expect(Option.isNone(result)).to.be.true;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('whenNodeType', () => {
|
|
68
|
+
test('matches node by type', () => {
|
|
69
|
+
const node: Node.Node = {
|
|
70
|
+
id: 'test',
|
|
71
|
+
type: 'test-type',
|
|
72
|
+
properties: {},
|
|
73
|
+
data: null,
|
|
74
|
+
};
|
|
75
|
+
const matcher = NodeMatcher.whenNodeType('test-type');
|
|
76
|
+
const result = matcher(node);
|
|
77
|
+
expect(Option.isSome(result)).to.be.true;
|
|
78
|
+
expect(Option.getOrNull(result)).to.equal(node);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('does not match different type', () => {
|
|
82
|
+
const node: Node.Node = {
|
|
83
|
+
id: 'test',
|
|
84
|
+
type: 'test-type',
|
|
85
|
+
properties: {},
|
|
86
|
+
data: null,
|
|
87
|
+
};
|
|
88
|
+
const matcher = NodeMatcher.whenNodeType('other-type');
|
|
89
|
+
const result = matcher(node);
|
|
90
|
+
expect(Option.isNone(result)).to.be.true;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('whenEchoType', () => {
|
|
95
|
+
test('creates matcher function', () => {
|
|
96
|
+
const matcher = NodeMatcher.whenEchoType(TestSchema.Person);
|
|
97
|
+
expect(typeof matcher).to.equal('function');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns none for non-instance', () => {
|
|
101
|
+
const node: Node.Node = {
|
|
102
|
+
id: 'test',
|
|
103
|
+
type: 'test',
|
|
104
|
+
properties: {},
|
|
105
|
+
data: { name: 'Test' },
|
|
106
|
+
};
|
|
107
|
+
const matcher = NodeMatcher.whenEchoType(TestSchema.Person);
|
|
108
|
+
const result = matcher(node);
|
|
109
|
+
expect(Option.isNone(result)).to.be.true;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('returns some for instance of type', () => {
|
|
113
|
+
const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
|
|
114
|
+
const node: Node.Node = {
|
|
115
|
+
id: 'test',
|
|
116
|
+
type: 'test',
|
|
117
|
+
properties: {},
|
|
118
|
+
data: testObject,
|
|
119
|
+
};
|
|
120
|
+
const matcher = NodeMatcher.whenEchoType(TestSchema.Person);
|
|
121
|
+
const result = matcher(node);
|
|
122
|
+
expect(Option.isSome(result)).to.be.true;
|
|
123
|
+
expect(Option.getOrNull(result)).to.equal(testObject);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('whenEchoObject', () => {
|
|
128
|
+
test('creates matcher function', () => {
|
|
129
|
+
const node: Node.Node = {
|
|
130
|
+
id: 'test',
|
|
131
|
+
type: 'test',
|
|
132
|
+
properties: {},
|
|
133
|
+
data: { name: 'Test' },
|
|
134
|
+
};
|
|
135
|
+
const result = NodeMatcher.whenEchoObject(node);
|
|
136
|
+
// Just verify the function works - actual object matching depends on Obj.isObject implementation.
|
|
137
|
+
expect(Option.isNone(result) || Option.isSome(result)).to.be.true;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('does not match non-object data', () => {
|
|
141
|
+
const node: Node.Node = {
|
|
142
|
+
id: 'test',
|
|
143
|
+
type: 'test',
|
|
144
|
+
properties: {},
|
|
145
|
+
data: 'string',
|
|
146
|
+
};
|
|
147
|
+
const result = NodeMatcher.whenEchoObject(node);
|
|
148
|
+
expect(Option.isNone(result)).to.be.true;
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('whenEchoTypeMatches', () => {
|
|
153
|
+
test('returns node for matching type', () => {
|
|
154
|
+
const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
|
|
155
|
+
const node: Node.Node = {
|
|
156
|
+
id: 'test',
|
|
157
|
+
type: 'test',
|
|
158
|
+
properties: {},
|
|
159
|
+
data: testObject,
|
|
160
|
+
};
|
|
161
|
+
const matcher = NodeMatcher.whenEchoTypeMatches(TestSchema.Person);
|
|
162
|
+
const result = matcher(node);
|
|
163
|
+
expect(Option.isSome(result)).to.be.true;
|
|
164
|
+
expect(Option.getOrNull(result)).to.equal(node);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('returns none for non-matching type', () => {
|
|
168
|
+
const node: Node.Node = {
|
|
169
|
+
id: 'test',
|
|
170
|
+
type: 'test',
|
|
171
|
+
properties: {},
|
|
172
|
+
data: { name: 'Test' },
|
|
173
|
+
};
|
|
174
|
+
const matcher = NodeMatcher.whenEchoTypeMatches(TestSchema.Person);
|
|
175
|
+
const result = matcher(node);
|
|
176
|
+
expect(Option.isNone(result)).to.be.true;
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('whenEchoObjectMatches', () => {
|
|
181
|
+
test('returns node for ECHO object', () => {
|
|
182
|
+
const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
|
|
183
|
+
const node: Node.Node = {
|
|
184
|
+
id: 'test',
|
|
185
|
+
type: 'test',
|
|
186
|
+
properties: {},
|
|
187
|
+
data: testObject,
|
|
188
|
+
};
|
|
189
|
+
const result = NodeMatcher.whenEchoObjectMatches(node);
|
|
190
|
+
expect(Option.isSome(result)).to.be.true;
|
|
191
|
+
expect(Option.getOrNull(result)).to.equal(node);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('returns none for non-ECHO object', () => {
|
|
195
|
+
const node: Node.Node = {
|
|
196
|
+
id: 'test',
|
|
197
|
+
type: 'test',
|
|
198
|
+
properties: {},
|
|
199
|
+
data: 'string',
|
|
200
|
+
};
|
|
201
|
+
const result = NodeMatcher.whenEchoObjectMatches(node);
|
|
202
|
+
expect(Option.isNone(result)).to.be.true;
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('whenNot', () => {
|
|
207
|
+
test('negates a matching matcher', () => {
|
|
208
|
+
const rootNode: Node.Node = {
|
|
209
|
+
id: Node.RootId,
|
|
210
|
+
type: Node.RootType,
|
|
211
|
+
properties: {},
|
|
212
|
+
data: null,
|
|
213
|
+
};
|
|
214
|
+
const matcher = NodeMatcher.whenNot(NodeMatcher.whenRoot);
|
|
215
|
+
const result = matcher(rootNode);
|
|
216
|
+
expect(Option.isNone(result)).to.be.true;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('returns node when matcher does not match', () => {
|
|
220
|
+
const node: Node.Node = {
|
|
221
|
+
id: 'other',
|
|
222
|
+
type: 'test',
|
|
223
|
+
properties: {},
|
|
224
|
+
data: null,
|
|
225
|
+
};
|
|
226
|
+
const matcher = NodeMatcher.whenNot(NodeMatcher.whenRoot);
|
|
227
|
+
const result = matcher(node);
|
|
228
|
+
expect(Option.isSome(result)).to.be.true;
|
|
229
|
+
expect(Option.getOrNull(result)).to.equal(node);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('works with whenAll for complex patterns', () => {
|
|
233
|
+
const testObject = Obj.make(TestSchema.Person, { name: 'Test' });
|
|
234
|
+
const node: Node.Node = {
|
|
235
|
+
id: 'test',
|
|
236
|
+
type: 'test',
|
|
237
|
+
properties: {},
|
|
238
|
+
data: testObject,
|
|
239
|
+
};
|
|
240
|
+
// Match ECHO objects that are NOT Person type.
|
|
241
|
+
const matcher = NodeMatcher.whenAll(
|
|
242
|
+
NodeMatcher.whenEchoObjectMatches,
|
|
243
|
+
NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(TestSchema.Person)),
|
|
244
|
+
);
|
|
245
|
+
const result = matcher(node);
|
|
246
|
+
expect(Option.isNone(result)).to.be.true;
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('whenAll', () => {
|
|
251
|
+
test('matches when all matchers match', () => {
|
|
252
|
+
const node: Node.Node = {
|
|
253
|
+
id: 'test-id',
|
|
254
|
+
type: 'test-type',
|
|
255
|
+
properties: {},
|
|
256
|
+
data: null,
|
|
257
|
+
};
|
|
258
|
+
const matcher = NodeMatcher.whenAll(NodeMatcher.whenId('test-id'), NodeMatcher.whenNodeType('test-type'));
|
|
259
|
+
const result = matcher(node);
|
|
260
|
+
expect(Option.isSome(result)).to.be.true;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('does not match when any matcher fails', () => {
|
|
264
|
+
const node: Node.Node = {
|
|
265
|
+
id: 'test-id',
|
|
266
|
+
type: 'test-type',
|
|
267
|
+
properties: {},
|
|
268
|
+
data: null,
|
|
269
|
+
};
|
|
270
|
+
const matcher = NodeMatcher.whenAll(NodeMatcher.whenId('test-id'), NodeMatcher.whenNodeType('other-type'));
|
|
271
|
+
const result = matcher(node);
|
|
272
|
+
expect(Option.isNone(result)).to.be.true;
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('whenAny', () => {
|
|
277
|
+
test('matches when any matcher matches', () => {
|
|
278
|
+
const node: Node.Node = {
|
|
279
|
+
id: 'test-id',
|
|
280
|
+
type: 'test-type',
|
|
281
|
+
properties: {},
|
|
282
|
+
data: null,
|
|
283
|
+
};
|
|
284
|
+
const matcher = NodeMatcher.whenAny(NodeMatcher.whenId('other-id'), NodeMatcher.whenNodeType('test-type'));
|
|
285
|
+
const result = matcher(node);
|
|
286
|
+
expect(Option.isSome(result)).to.be.true;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('does not match when all matchers fail', () => {
|
|
290
|
+
const node: Node.Node = {
|
|
291
|
+
id: 'test-id',
|
|
292
|
+
type: 'test-type',
|
|
293
|
+
properties: {},
|
|
294
|
+
data: null,
|
|
295
|
+
};
|
|
296
|
+
const matcher = NodeMatcher.whenAny(NodeMatcher.whenId('other-id'), NodeMatcher.whenNodeType('other-type'));
|
|
297
|
+
const result = matcher(node);
|
|
298
|
+
expect(Option.isNone(result)).to.be.true;
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
6
|
+
import type * as Schema from 'effect/Schema';
|
|
7
|
+
|
|
8
|
+
import { type Entity, Obj, type Type } from '@dxos/echo';
|
|
9
|
+
|
|
10
|
+
import * as Node from './node';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Type for a node matcher function that returns an Option of the matched data.
|
|
14
|
+
* Matchers are used to filter and transform nodes in the app graph.
|
|
15
|
+
*
|
|
16
|
+
* @template TData - The type of data returned when the matcher succeeds.
|
|
17
|
+
* Defaults to Node.Node, but can be a more specific type (e.g., an ECHO entity).
|
|
18
|
+
*/
|
|
19
|
+
export type NodeMatcher<TData = Node.Node> = (node: Node.Node) => Option.Option<TData>;
|
|
20
|
+
|
|
21
|
+
//
|
|
22
|
+
// Basic Node Matchers
|
|
23
|
+
//
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Matches the root node of the graph.
|
|
27
|
+
*
|
|
28
|
+
* @returns Option.some(node) if the node is the root, Option.none() otherwise.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* GraphBuilder.createExtension({
|
|
33
|
+
* id: 'my-extension',
|
|
34
|
+
* match: NodeMatcher.whenRoot,
|
|
35
|
+
* connector: (node) => Effect.succeed([...]),
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export const whenRoot = (node: Node.Node): Option.Option<Node.Node> =>
|
|
40
|
+
node.id === Node.RootId ? Option.some(node) : Option.none();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Matches a node by its exact ID.
|
|
44
|
+
*
|
|
45
|
+
* @param id - The node ID to match against.
|
|
46
|
+
* @returns A matcher that returns Option.some(node) if IDs match, Option.none() otherwise.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* GraphBuilder.createExtension({
|
|
51
|
+
* id: 'spaces-extension',
|
|
52
|
+
* match: NodeMatcher.whenId('spaces'),
|
|
53
|
+
* connector: (node) => Effect.succeed([...]),
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export const whenId =
|
|
58
|
+
(id: string) =>
|
|
59
|
+
(node: Node.Node): Option.Option<Node.Node> =>
|
|
60
|
+
node.id === id ? Option.some(node) : Option.none();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Matches a node by its type string (the `node.type` property).
|
|
64
|
+
*
|
|
65
|
+
* @param type - The node type string to match against.
|
|
66
|
+
* @returns A matcher that returns Option.some(node) if types match, Option.none() otherwise.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* GraphBuilder.createExtension({
|
|
71
|
+
* id: 'space-settings-extension',
|
|
72
|
+
* match: NodeMatcher.whenNodeType('org.dxos.plugin.space.settings'),
|
|
73
|
+
* connector: (node) => Effect.succeed([...]),
|
|
74
|
+
* });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const whenNodeType =
|
|
78
|
+
(type: string) =>
|
|
79
|
+
(node: Node.Node): Option.Option<Node.Node> =>
|
|
80
|
+
node.type === type ? Option.some(node) : Option.none();
|
|
81
|
+
|
|
82
|
+
//
|
|
83
|
+
// ECHO Data Matchers
|
|
84
|
+
//
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Matches a node whose data is an instance of the given ECHO schema type.
|
|
88
|
+
* Returns the **typed entity data** (not the node) for direct use in callbacks.
|
|
89
|
+
*
|
|
90
|
+
* Use this when you need to work directly with the typed ECHO entity in your
|
|
91
|
+
* connector or actions callback.
|
|
92
|
+
*
|
|
93
|
+
* @template T - The ECHO schema type to match against.
|
|
94
|
+
* @param type - The ECHO schema (e.g., `Collection.Collection`, `Document.Document`).
|
|
95
|
+
* @returns A matcher that returns Option.some(entity) if the data matches, Option.none() otherwise.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* GraphBuilder.createExtension({
|
|
100
|
+
* id: 'collection-extension',
|
|
101
|
+
* match: NodeMatcher.whenEchoType(Collection.Collection),
|
|
102
|
+
* connector: (collection) => {
|
|
103
|
+
* // `collection` is typed as Collection.Collection
|
|
104
|
+
* return Effect.succeed(collection.objects.map(...));
|
|
105
|
+
* },
|
|
106
|
+
* });
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
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.
|
|
113
|
+
*/
|
|
114
|
+
export const whenEchoType =
|
|
115
|
+
<T extends Type.AnyEntity>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
|
|
116
|
+
(node: Node.Node): Option.Option<Entity.Entity<Schema.Schema.Type<T>>> =>
|
|
117
|
+
Obj.instanceOf(type, node.data) ? Option.some(node.data) : Option.none();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Matches a node whose data is any ECHO object.
|
|
121
|
+
* Returns the **object data** (not the node) for direct use in callbacks.
|
|
122
|
+
*
|
|
123
|
+
* Use this when you need to work with any ECHO object regardless of its specific type.
|
|
124
|
+
*
|
|
125
|
+
* @returns Option.some(object) if the node's data is an ECHO object, Option.none() otherwise.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* GraphBuilder.createExtension({
|
|
130
|
+
* id: 'object-properties',
|
|
131
|
+
* match: NodeMatcher.whenEchoObject,
|
|
132
|
+
* connector: (object) => {
|
|
133
|
+
* // `object` is typed as Obj.Unknown
|
|
134
|
+
* const id = Obj.getDXN(object).toString();
|
|
135
|
+
* return Effect.succeed([{ id: `${id}.settings`, ... }]);
|
|
136
|
+
* },
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
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.
|
|
144
|
+
*/
|
|
145
|
+
export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
|
|
146
|
+
Obj.isObject(node.data) ? Option.some(node.data) : Option.none();
|
|
147
|
+
|
|
148
|
+
//
|
|
149
|
+
// Composition Matchers
|
|
150
|
+
//
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Composes multiple matchers with AND logic - all matchers must match for success.
|
|
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`).
|
|
157
|
+
*
|
|
158
|
+
* @param matchers - The matchers to combine. All must return Option.some for success.
|
|
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.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* // Match ECHO objects that are NOT Channels — result is NodeMatcher<Obj.Unknown>.
|
|
165
|
+
* const whenCommentable = NodeMatcher.whenAll(
|
|
166
|
+
* NodeMatcher.whenEchoObject,
|
|
167
|
+
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
|
|
168
|
+
* );
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
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);
|
|
184
|
+
if (Option.isNone(result)) {
|
|
185
|
+
return Option.none();
|
|
186
|
+
}
|
|
187
|
+
if (Option.isNone(first)) {
|
|
188
|
+
first = result;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return first;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Composes multiple matchers with OR logic - at least one matcher must match.
|
|
196
|
+
* The result data type is the union of all matchers' data types.
|
|
197
|
+
*
|
|
198
|
+
* @param matchers - The matchers to combine. At least one must return Option.some.
|
|
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.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* // Match nodes that are either Sequences or Routines
|
|
205
|
+
* const whenInvocable = NodeMatcher.whenAny(
|
|
206
|
+
* NodeMatcher.whenEchoTypeMatches(Sequence),
|
|
207
|
+
* NodeMatcher.whenEchoTypeMatches(Routine.Routine),
|
|
208
|
+
* );
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
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);
|
|
221
|
+
if (Option.isSome(result)) {
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return Option.none();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Matches a node whose data is an instance of the given ECHO schema type.
|
|
230
|
+
* Returns the **node** (not the data) to enable composition with whenAll/whenAny/whenNot.
|
|
231
|
+
*
|
|
232
|
+
* Use this instead of {@link whenEchoType} when you need to combine matchers.
|
|
233
|
+
* The difference is what's returned:
|
|
234
|
+
* - `whenEchoType` returns the typed entity (for direct use)
|
|
235
|
+
* - `whenEchoTypeMatches` returns the node (for composition)
|
|
236
|
+
*
|
|
237
|
+
* @template T - The ECHO schema type to match against.
|
|
238
|
+
* @param type - The ECHO schema (e.g., `Channel.Channel`, `Document.Document`).
|
|
239
|
+
* @returns A matcher that returns Option.some(node) if the data matches, Option.none() otherwise.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* // Use with whenAny for OR logic
|
|
244
|
+
* const whenPresentable = NodeMatcher.whenAny(
|
|
245
|
+
* NodeMatcher.whenEchoTypeMatches(Collection.Collection),
|
|
246
|
+
* NodeMatcher.whenEchoTypeMatches(Markdown.Document),
|
|
247
|
+
* );
|
|
248
|
+
*
|
|
249
|
+
* // Use with whenNot for exclusion
|
|
250
|
+
* const whenNotChannel = NodeMatcher.whenNot(
|
|
251
|
+
* NodeMatcher.whenEchoTypeMatches(Channel.Channel),
|
|
252
|
+
* );
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* @see {@link whenEchoType} - Use instead when you need the typed entity directly.
|
|
256
|
+
*/
|
|
257
|
+
export const whenEchoTypeMatches =
|
|
258
|
+
<T extends Type.AnyEntity>(type: T): NodeMatcher =>
|
|
259
|
+
(node: Node.Node): Option.Option<Node.Node> =>
|
|
260
|
+
Obj.instanceOf(type, node.data) ? Option.some(node) : Option.none();
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Matches a node whose data is any ECHO object.
|
|
264
|
+
* Returns the **node** (not the data) to enable composition with whenAll/whenAny/whenNot.
|
|
265
|
+
*
|
|
266
|
+
* Use this instead of {@link whenEchoObject} when you need to combine matchers.
|
|
267
|
+
* The difference is what's returned:
|
|
268
|
+
* - `whenEchoObject` returns the object data (for direct use)
|
|
269
|
+
* - `whenEchoObjectMatches` returns the node (for composition)
|
|
270
|
+
*
|
|
271
|
+
* @returns Option.some(node) if the node's data is an ECHO object, Option.none() otherwise.
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* ```ts
|
|
275
|
+
* // Match ECHO objects that are not system types
|
|
276
|
+
* const whenUserObject = NodeMatcher.whenAll(
|
|
277
|
+
* NodeMatcher.whenEchoObjectMatches,
|
|
278
|
+
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(SystemType)),
|
|
279
|
+
* );
|
|
280
|
+
* ```
|
|
281
|
+
*
|
|
282
|
+
* @see {@link whenEchoObject} - Use instead when you need the object data directly.
|
|
283
|
+
*/
|
|
284
|
+
export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node> =>
|
|
285
|
+
Obj.isObject(node.data) ? Option.some(node) : Option.none();
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Negates a matcher - matches when the given matcher does NOT match.
|
|
289
|
+
* Useful for exclusion patterns like "any object EXCEPT type X".
|
|
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
|
+
*
|
|
295
|
+
* @param matcher - The matcher to negate.
|
|
296
|
+
* @returns A matcher that returns Option.some(node) if the input matcher returns none,
|
|
297
|
+
* and Option.none() if the input matcher returns some.
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```ts
|
|
301
|
+
* // Match any ECHO object that is NOT a Channel — result is NodeMatcher<Obj.Unknown>.
|
|
302
|
+
* const whenCommentable = NodeMatcher.whenAll(
|
|
303
|
+
* NodeMatcher.whenEchoObject,
|
|
304
|
+
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
|
|
305
|
+
* );
|
|
306
|
+
*
|
|
307
|
+
* // Match any node that is NOT the root
|
|
308
|
+
* const whenNotRoot = NodeMatcher.whenNot(NodeMatcher.whenRoot);
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
export const whenNot =
|
|
312
|
+
(matcher: NodeMatcher<any>): NodeMatcher<unknown> =>
|
|
313
|
+
(node: Node.Node): Option.Option<unknown> =>
|
|
314
|
+
Option.isNone(matcher(node)) ? Option.some(node) : Option.none();
|