@dxos/app-graph 0.8.4-main.8360d9e660 → 0.8.4-main.8baae0fced
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/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/{browser/index.mjs → neutral/chunk-32XXJE6M.mjs} +247 -349
- package/dist/lib/neutral/chunk-32XXJE6M.mjs.map +7 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.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.map +1 -1
- package/dist/types/src/graph-builder.d.ts +2 -2
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +42 -16
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +9 -3
- 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.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 +20 -14
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +38 -27
- package/src/graph-builder.test.ts +276 -25
- package/src/graph-builder.ts +45 -17
- package/src/graph.ts +15 -17
- package/src/index.ts +1 -0
- package/src/node-matcher.ts +57 -25
- package/src/node.ts +29 -3
- package/src/scheduler.browser.ts +5 -0
- package/src/scheduler.ts +17 -0
- package/src/stories/EchoGraph.stories.tsx +10 -12
- package/src/testing/index.ts +5 -0
- package/src/testing/setup-graph-builder.ts +41 -0
- package/src/util.ts +48 -18
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -1580
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
package/src/graph-builder.ts
CHANGED
|
@@ -11,17 +11,26 @@ import * as Option from 'effect/Option';
|
|
|
11
11
|
import * as Pipeable from 'effect/Pipeable';
|
|
12
12
|
import * as Record from 'effect/Record';
|
|
13
13
|
import type * as Schema from 'effect/Schema';
|
|
14
|
-
import { scheduleTask, yieldOrContinue } from 'main-thread-scheduling';
|
|
15
14
|
|
|
16
15
|
import { type CleanupFn, type Trigger } from '@dxos/async';
|
|
17
16
|
import { type Entity, type Type } from '@dxos/echo';
|
|
18
17
|
import { log } from '@dxos/log';
|
|
19
18
|
import { type MaybePromise, type Position, byPosition, getDebugName, isNonNullable } from '@dxos/util';
|
|
20
19
|
|
|
20
|
+
import { scheduleTask, yieldOrContinue } from '#scheduler';
|
|
21
|
+
|
|
21
22
|
import * as Graph from './graph';
|
|
22
23
|
import * as Node from './node';
|
|
23
24
|
import * as NodeMatcher from './node-matcher';
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
getParentId,
|
|
27
|
+
nodeArgsUnchanged,
|
|
28
|
+
normalizeRelation,
|
|
29
|
+
primaryKey,
|
|
30
|
+
primaryParts,
|
|
31
|
+
qualifyId,
|
|
32
|
+
validateSegmentId,
|
|
33
|
+
} from './util';
|
|
25
34
|
|
|
26
35
|
//
|
|
27
36
|
// Extension Types
|
|
@@ -55,7 +64,7 @@ export type ActionGroupsExtension = (
|
|
|
55
64
|
|
|
56
65
|
export type BuilderExtension = Readonly<{
|
|
57
66
|
id: string;
|
|
58
|
-
position
|
|
67
|
+
position?: Position;
|
|
59
68
|
relation?: Node.RelationInput;
|
|
60
69
|
resolver?: ResolverExtension;
|
|
61
70
|
connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
|
|
@@ -117,6 +126,8 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
117
126
|
>();
|
|
118
127
|
/** Last-flushed node IDs per connector key, used for edge removal on update. */
|
|
119
128
|
readonly _connectorPrevious = new Map<string, string[]>();
|
|
129
|
+
/** All inline-descendant IDs per connector key, used to remove stale inline nodes on update. */
|
|
130
|
+
readonly _connectorPreviousInlineIds = new Map<string, string[]>();
|
|
120
131
|
/** Last-flushed node args per connector key, used for change detection. */
|
|
121
132
|
readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
|
|
122
133
|
/** Whether a dirty-flush task is already scheduled. */
|
|
@@ -170,6 +181,12 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
170
181
|
this._connectorPrevious.set(key, ids);
|
|
171
182
|
this._connectorPreviousArgs.set(key, nodes);
|
|
172
183
|
|
|
184
|
+
const currentInlineIds = collectAllInlineIds(nodes);
|
|
185
|
+
const previousInlineIds = this._connectorPreviousInlineIds.get(key) ?? [];
|
|
186
|
+
const staleInlineIds = previousInlineIds.filter((pid) => !currentInlineIds.includes(pid));
|
|
187
|
+
this._connectorPreviousInlineIds.set(key, currentInlineIds);
|
|
188
|
+
|
|
189
|
+
Graph.removeNodes(this._graph, staleInlineIds, true);
|
|
173
190
|
Graph.removeEdges(
|
|
174
191
|
this._graph,
|
|
175
192
|
removed.map((target) => ({ source: id, target, relation })),
|
|
@@ -295,7 +312,6 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
295
312
|
this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
|
|
296
313
|
}
|
|
297
314
|
|
|
298
|
-
// TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
|
|
299
315
|
private async _onInitialize(id: string) {
|
|
300
316
|
log('onInitialize', { id });
|
|
301
317
|
const resolver = this._resolvers(id);
|
|
@@ -304,17 +320,24 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
304
320
|
resolver,
|
|
305
321
|
(node) => {
|
|
306
322
|
const trigger = this._initialized[id];
|
|
323
|
+
const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
|
|
307
324
|
Option.match(node, {
|
|
308
325
|
onSome: (node) => {
|
|
309
|
-
const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
|
|
310
326
|
if (!connectorOwned) {
|
|
311
327
|
Graph.addNodes(this._graph, [node]);
|
|
328
|
+
// Connect resolved node to its parent via a child edge.
|
|
329
|
+
const parentId = getParentId(id);
|
|
330
|
+
if (parentId) {
|
|
331
|
+
Graph.addEdges(this._graph, [{ source: parentId, target: id, relation: 'child' }]);
|
|
332
|
+
}
|
|
312
333
|
}
|
|
313
334
|
trigger?.wake();
|
|
314
335
|
},
|
|
315
336
|
onNone: () => {
|
|
316
337
|
trigger?.wake();
|
|
317
|
-
|
|
338
|
+
if (!connectorOwned) {
|
|
339
|
+
Graph.removeNodes(this._graph, [id]);
|
|
340
|
+
}
|
|
318
341
|
},
|
|
319
342
|
});
|
|
320
343
|
},
|
|
@@ -325,9 +348,8 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
325
348
|
}
|
|
326
349
|
|
|
327
350
|
private _onRemoveNode(id: string): void {
|
|
328
|
-
const prefix = `${id}${Separators.primary}`;
|
|
329
351
|
for (const [key, cleanup] of this._subscriptions) {
|
|
330
|
-
if (key
|
|
352
|
+
if (primaryParts(key)[0] === id) {
|
|
331
353
|
cleanup();
|
|
332
354
|
this._subscriptions.delete(key);
|
|
333
355
|
}
|
|
@@ -550,7 +572,7 @@ export type CreateExtensionRawOptions = {
|
|
|
550
572
|
export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
|
|
551
573
|
const {
|
|
552
574
|
id,
|
|
553
|
-
position
|
|
575
|
+
position,
|
|
554
576
|
relation = 'child',
|
|
555
577
|
resolver: _resolver,
|
|
556
578
|
connector: _connector,
|
|
@@ -828,19 +850,25 @@ const qualifyNodeArgs =
|
|
|
828
850
|
};
|
|
829
851
|
});
|
|
830
852
|
|
|
831
|
-
|
|
832
|
-
|
|
853
|
+
/**
|
|
854
|
+
* Recursively collect all inline-descendant IDs (the `nodes` arrays at every level)
|
|
855
|
+
* from a list of top-level NodeArgs. Top-level IDs are excluded because they are
|
|
856
|
+
* already tracked via `_connectorPrevious`.
|
|
857
|
+
*/
|
|
858
|
+
const collectAllInlineIds = (nodes: Node.NodeArg<any>[]): string[] =>
|
|
859
|
+
nodes.flatMap((node) =>
|
|
860
|
+
node.nodes ? [...node.nodes.map((child) => child.id), ...collectAllInlineIds(node.nodes)] : [],
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
const connectorKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, Graph.relationKey(relation));
|
|
833
864
|
|
|
834
865
|
const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
return { id, relation: Graph.relationFromKey(key.slice(separatorIndex + 1)) };
|
|
866
|
+
const [id, encodedRelation] = primaryParts(key);
|
|
867
|
+
return { id, relation: Graph.relationFromKey(encodedRelation) };
|
|
838
868
|
};
|
|
839
869
|
|
|
840
870
|
const subscriptionKey = (id: string, kind: string, detail?: string): string =>
|
|
841
|
-
detail != null
|
|
842
|
-
? `${id}${Separators.primary}${kind}${Separators.primary}${detail}`
|
|
843
|
-
: `${id}${Separators.primary}${kind}`;
|
|
871
|
+
detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
|
|
844
872
|
|
|
845
873
|
export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
846
874
|
if (Array.isArray(extension)) {
|
package/src/graph.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { log } from '@dxos/log';
|
|
|
15
15
|
import { type MakeOptional, isNonNullable } from '@dxos/util';
|
|
16
16
|
|
|
17
17
|
import * as Node from './node';
|
|
18
|
-
import {
|
|
18
|
+
import { normalizeRelation, primaryKey, primaryParts, secondaryKey, secondaryParts, shallowEqual } from './util';
|
|
19
19
|
|
|
20
20
|
const graphSymbol = Symbol('graph');
|
|
21
21
|
|
|
@@ -183,7 +183,10 @@ class GraphImpl implements WritableGraph {
|
|
|
183
183
|
// TODO(wittjosiah): Atom feature request, support for something akin to `ComplexMap` to allow for complex arguments.
|
|
184
184
|
readonly _connections = Atom.family<string, Atom.Atom<Node.Node[]>>((key) => {
|
|
185
185
|
return Atom.make((get) => {
|
|
186
|
-
|
|
186
|
+
const parts = key ? primaryParts(key) : [];
|
|
187
|
+
// Empty id (e.g. from `useConnections(graph, undefined, ...)`) yields a key like `\u0001child\u0002outbound`,
|
|
188
|
+
// which has 2 parts but an empty id — treat as no connections rather than throwing.
|
|
189
|
+
if (parts.length < 2 || !parts[0]) {
|
|
187
190
|
return [];
|
|
188
191
|
}
|
|
189
192
|
const { id, relation } = relationFromConnectionKey(key);
|
|
@@ -706,7 +709,7 @@ const expandImpl = <T extends ExpandableGraph | WritableGraph>(
|
|
|
706
709
|
): T => {
|
|
707
710
|
const internal = getInternal(graph);
|
|
708
711
|
const normalizedRelation = normalizeRelation(relation);
|
|
709
|
-
const key =
|
|
712
|
+
const key = primaryKey(id, relationKey(normalizedRelation));
|
|
710
713
|
const nodeOpt = internal._registry.get(internal._node(id));
|
|
711
714
|
if (Option.isNone(nodeOpt)) {
|
|
712
715
|
// Node not yet in graph: record expand to run when the node is added.
|
|
@@ -900,11 +903,10 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
|
|
|
900
903
|
graph.onNodeChanged.emit({ id, node: newNode });
|
|
901
904
|
|
|
902
905
|
// Apply any expands that were deferred because this node did not exist yet.
|
|
903
|
-
const
|
|
904
|
-
const toApply = [...internal._pendingExpands].filter((k) => k.startsWith(prefix));
|
|
906
|
+
const toApply = [...internal._pendingExpands].filter((k) => primaryParts(k)[0] === id);
|
|
905
907
|
for (const pendingKey of toApply) {
|
|
906
908
|
internal._pendingExpands.delete(pendingKey);
|
|
907
|
-
const relation = relationFromKey(pendingKey
|
|
909
|
+
const relation = relationFromKey(primaryParts(pendingKey)[1]);
|
|
908
910
|
Record.set(internal._expanded, pendingKey, true);
|
|
909
911
|
internal._onExpand?.(id, relation);
|
|
910
912
|
}
|
|
@@ -1226,26 +1228,22 @@ export const make = (params?: GraphProps): Graph => {
|
|
|
1226
1228
|
|
|
1227
1229
|
export const relationKey = (relation: Node.RelationInput): string => {
|
|
1228
1230
|
const normalized = normalizeRelation(relation);
|
|
1229
|
-
return
|
|
1231
|
+
return secondaryKey(normalized.kind, normalized.direction);
|
|
1230
1232
|
};
|
|
1231
1233
|
|
|
1232
1234
|
export const relationFromKey = (encoded: string): Node.Relation => {
|
|
1233
|
-
const
|
|
1234
|
-
invariant(
|
|
1235
|
-
const kind =
|
|
1236
|
-
const directionRaw = encoded.slice(separatorIndex + 1);
|
|
1235
|
+
const parts = secondaryParts(encoded);
|
|
1236
|
+
invariant(parts.length === 2 && parts[0].length > 0 && parts[1].length > 0, `Invalid relation key: ${encoded}`);
|
|
1237
|
+
const [kind, directionRaw] = parts;
|
|
1237
1238
|
invariant(directionRaw === 'outbound' || directionRaw === 'inbound', `Invalid relation direction: ${directionRaw}`);
|
|
1238
1239
|
return Node.relation(kind, directionRaw);
|
|
1239
1240
|
};
|
|
1240
1241
|
|
|
1241
|
-
const connectionKey = (id: string, relation: Node.RelationInput): string =>
|
|
1242
|
-
`${id}${Separators.primary}${relationKey(relation)}`;
|
|
1242
|
+
const connectionKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, relationKey(relation));
|
|
1243
1243
|
|
|
1244
1244
|
const relationFromConnectionKey = (key: string): { id: string; relation: Node.Relation } => {
|
|
1245
|
-
const
|
|
1246
|
-
invariant(
|
|
1247
|
-
const id = key.slice(0, separatorIndex);
|
|
1248
|
-
const encodedRelation = key.slice(separatorIndex + 1);
|
|
1245
|
+
const [id, encodedRelation] = primaryParts(key);
|
|
1246
|
+
invariant(id && encodedRelation, `Invalid connection key: ${key}`);
|
|
1249
1247
|
return { id, relation: relationFromKey(encodedRelation) };
|
|
1250
1248
|
};
|
|
1251
1249
|
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * as Graph from './graph';
|
|
|
8
8
|
export * as GraphBuilder from './graph-builder';
|
|
9
9
|
export * as Node from './node';
|
|
10
10
|
export * as NodeMatcher from './node-matcher';
|
|
11
|
+
export { qualifyId, getParentId, getSegmentId } from './util';
|
|
11
12
|
|
|
12
13
|
// TODO(wittjosiah): Direct re-export needed for portable type references.
|
|
13
14
|
export type { BuilderExtensions } from './graph-builder';
|
package/src/node-matcher.ts
CHANGED
|
@@ -106,7 +106,10 @@ export const whenNodeType =
|
|
|
106
106
|
* });
|
|
107
107
|
* ```
|
|
108
108
|
*
|
|
109
|
-
* @
|
|
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.
|
|
110
113
|
*/
|
|
111
114
|
export const whenEchoType =
|
|
112
115
|
<T extends Type.AnyEntity>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
|
|
@@ -124,7 +127,7 @@ export const whenEchoType =
|
|
|
124
127
|
* @example
|
|
125
128
|
* ```ts
|
|
126
129
|
* GraphBuilder.createExtension({
|
|
127
|
-
* id: 'object-
|
|
130
|
+
* id: 'object-properties',
|
|
128
131
|
* match: NodeMatcher.whenEchoObject,
|
|
129
132
|
* connector: (object) => {
|
|
130
133
|
* // `object` is typed as Obj.Unknown
|
|
@@ -134,7 +137,10 @@ export const whenEchoType =
|
|
|
134
137
|
* });
|
|
135
138
|
* ```
|
|
136
139
|
*
|
|
137
|
-
* @
|
|
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.
|
|
138
144
|
*/
|
|
139
145
|
export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
|
|
140
146
|
Obj.isObject(node.data) ? Option.some(node.data) : Option.none();
|
|
@@ -145,53 +151,75 @@ export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
|
|
|
145
151
|
|
|
146
152
|
/**
|
|
147
153
|
* Composes multiple matchers with AND logic - all matchers must match for success.
|
|
148
|
-
*
|
|
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`).
|
|
149
157
|
*
|
|
150
158
|
* @param matchers - The matchers to combine. All must return Option.some for success.
|
|
151
|
-
* @returns A matcher
|
|
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.
|
|
152
161
|
*
|
|
153
162
|
* @example
|
|
154
163
|
* ```ts
|
|
155
|
-
* // Match ECHO objects that are NOT Channels
|
|
164
|
+
* // Match ECHO objects that are NOT Channels — result is NodeMatcher<Obj.Unknown>.
|
|
156
165
|
* const whenCommentable = NodeMatcher.whenAll(
|
|
157
|
-
* NodeMatcher.
|
|
166
|
+
* NodeMatcher.whenEchoObject,
|
|
158
167
|
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
|
|
159
168
|
* );
|
|
160
169
|
* ```
|
|
161
170
|
*/
|
|
162
|
-
export const whenAll
|
|
163
|
-
(
|
|
164
|
-
(
|
|
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();
|
|
165
182
|
for (const candidate of matchers) {
|
|
166
|
-
|
|
183
|
+
const result = candidate(node);
|
|
184
|
+
if (Option.isNone(result)) {
|
|
167
185
|
return Option.none();
|
|
168
186
|
}
|
|
187
|
+
if (Option.isNone(first)) {
|
|
188
|
+
first = result;
|
|
189
|
+
}
|
|
169
190
|
}
|
|
170
|
-
return
|
|
191
|
+
return first;
|
|
171
192
|
};
|
|
172
193
|
|
|
173
194
|
/**
|
|
174
195
|
* Composes multiple matchers with OR logic - at least one matcher must match.
|
|
175
|
-
*
|
|
196
|
+
* The result data type is the union of all matchers' data types.
|
|
176
197
|
*
|
|
177
198
|
* @param matchers - The matchers to combine. At least one must return Option.some.
|
|
178
|
-
* @returns A matcher
|
|
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.
|
|
179
201
|
*
|
|
180
202
|
* @example
|
|
181
203
|
* ```ts
|
|
182
|
-
* // Match nodes that are either Sequences or
|
|
204
|
+
* // Match nodes that are either Sequences or Routines
|
|
183
205
|
* const whenInvocable = NodeMatcher.whenAny(
|
|
184
206
|
* NodeMatcher.whenEchoTypeMatches(Sequence),
|
|
185
|
-
* NodeMatcher.whenEchoTypeMatches(
|
|
207
|
+
* NodeMatcher.whenEchoTypeMatches(Routine.Routine),
|
|
186
208
|
* );
|
|
187
209
|
* ```
|
|
188
210
|
*/
|
|
189
|
-
export const whenAny
|
|
190
|
-
(
|
|
191
|
-
(
|
|
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) => {
|
|
192
219
|
for (const candidate of matchers) {
|
|
193
|
-
|
|
194
|
-
|
|
220
|
+
const result = candidate(node);
|
|
221
|
+
if (Option.isSome(result)) {
|
|
222
|
+
return result;
|
|
195
223
|
}
|
|
196
224
|
}
|
|
197
225
|
return Option.none();
|
|
@@ -260,15 +288,19 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
|
|
|
260
288
|
* Negates a matcher - matches when the given matcher does NOT match.
|
|
261
289
|
* Useful for exclusion patterns like "any object EXCEPT type X".
|
|
262
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
|
+
*
|
|
263
295
|
* @param matcher - The matcher to negate.
|
|
264
296
|
* @returns A matcher that returns Option.some(node) if the input matcher returns none,
|
|
265
297
|
* and Option.none() if the input matcher returns some.
|
|
266
298
|
*
|
|
267
299
|
* @example
|
|
268
300
|
* ```ts
|
|
269
|
-
* // Match any ECHO object that is NOT a Channel
|
|
301
|
+
* // Match any ECHO object that is NOT a Channel — result is NodeMatcher<Obj.Unknown>.
|
|
270
302
|
* const whenCommentable = NodeMatcher.whenAll(
|
|
271
|
-
* NodeMatcher.
|
|
303
|
+
* NodeMatcher.whenEchoObject,
|
|
272
304
|
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
|
|
273
305
|
* );
|
|
274
306
|
*
|
|
@@ -277,6 +309,6 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
|
|
|
277
309
|
* ```
|
|
278
310
|
*/
|
|
279
311
|
export const whenNot =
|
|
280
|
-
(matcher: NodeMatcher): NodeMatcher =>
|
|
281
|
-
(node: Node.Node): Option.Option<
|
|
312
|
+
(matcher: NodeMatcher<any>): NodeMatcher<unknown> =>
|
|
313
|
+
(node: Node.Node): Option.Option<unknown> =>
|
|
282
314
|
Option.isNone(matcher(node)) ? Option.some(node) : Option.none();
|
package/src/node.ts
CHANGED
|
@@ -15,17 +15,17 @@ export const RootId = 'root';
|
|
|
15
15
|
/**
|
|
16
16
|
* Root node type.
|
|
17
17
|
*/
|
|
18
|
-
export const RootType = 'org.dxos.type.
|
|
18
|
+
export const RootType = 'org.dxos.type.graphRoot';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Action node type.
|
|
22
22
|
*/
|
|
23
|
-
export const ActionType = 'org.dxos.type.
|
|
23
|
+
export const ActionType = 'org.dxos.type.graphAction';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Action group node type.
|
|
27
27
|
*/
|
|
28
|
-
export const ActionGroupType = 'org.dxos.type.
|
|
28
|
+
export const ActionGroupType = 'org.dxos.type.graphActionGroup';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Represents a node in the graph.
|
|
@@ -150,3 +150,29 @@ export const isActionGroup = (data: unknown): data is ActionGroup =>
|
|
|
150
150
|
export type ActionLike = Action | ActionGroup;
|
|
151
151
|
|
|
152
152
|
export const isActionLike = (data: unknown): data is Action | ActionGroup => isAction(data) || isActionGroup(data);
|
|
153
|
+
|
|
154
|
+
//
|
|
155
|
+
// Node Factories
|
|
156
|
+
//
|
|
157
|
+
|
|
158
|
+
/** Typed factory for constructing a NodeArg. Provides auto-complete and type validation. */
|
|
159
|
+
export const make = <TData = any, TProperties extends Record<string, any> = Record<string, any>>(
|
|
160
|
+
arg: NodeArg<TData, TProperties>,
|
|
161
|
+
): NodeArg<TData, TProperties> => arg;
|
|
162
|
+
|
|
163
|
+
/** Create an action node. Automatically sets `type: ActionType`. */
|
|
164
|
+
export const makeAction = <R = never>(
|
|
165
|
+
arg: Omit<NodeArg<ActionData<R>>, 'type' | 'nodes' | 'edges'>,
|
|
166
|
+
): NodeArg<ActionData<R>> => ({
|
|
167
|
+
...arg,
|
|
168
|
+
type: ActionType,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** Create an action group node. Automatically sets `type` and `data`. */
|
|
172
|
+
export const makeActionGroup = (
|
|
173
|
+
arg: Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>,
|
|
174
|
+
): NodeArg<typeof actionGroupSymbol> => ({
|
|
175
|
+
...arg,
|
|
176
|
+
type: ActionGroupType,
|
|
177
|
+
data: actionGroupSymbol,
|
|
178
|
+
});
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// CF Workers / Node fallback: there is no main thread to yield to.
|
|
6
|
+
// scheduleTask runs the callback on the next microtask; yieldOrContinue is a microtask hop.
|
|
7
|
+
|
|
8
|
+
type ScheduleOptions = { strategy?: 'smooth' | 'interactive' | 'idle'; signal?: AbortSignal };
|
|
9
|
+
|
|
10
|
+
export const scheduleTask = async <T>(callback: () => T | Promise<T>, _options?: ScheduleOptions): Promise<T> => {
|
|
11
|
+
await Promise.resolve();
|
|
12
|
+
return callback();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const yieldOrContinue = async (_priority: 'smooth' | 'interactive' | 'idle'): Promise<void> => {
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
};
|
|
@@ -8,16 +8,16 @@ import * as Function from 'effect/Function';
|
|
|
8
8
|
import * as Option from 'effect/Option';
|
|
9
9
|
import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import { Obj, Query } from '@dxos/echo';
|
|
13
|
-
import { TestSchema } from '@dxos/echo/testing';
|
|
11
|
+
import { type Space, SpaceState, isSpace } from '@dxos/client/echo';
|
|
12
|
+
import { Filter, Obj, Query } from '@dxos/echo';
|
|
14
13
|
import { AtomObj, AtomQuery } from '@dxos/echo-atom';
|
|
15
|
-
import {
|
|
14
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
15
|
+
import { random } from '@dxos/random';
|
|
16
16
|
import { type Client, useClient } from '@dxos/react-client';
|
|
17
17
|
import { withClientProvider } from '@dxos/react-client/testing';
|
|
18
18
|
import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
|
|
19
|
-
import { withTheme } from '@dxos/react-ui/testing';
|
|
20
19
|
import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
|
|
20
|
+
import { withTheme } from '@dxos/react-ui/testing';
|
|
21
21
|
import { getSize, mx } from '@dxos/ui-theme';
|
|
22
22
|
import { safeParseInt } from '@dxos/util';
|
|
23
23
|
|
|
@@ -25,7 +25,6 @@ import * as CreateAtom from '../atoms';
|
|
|
25
25
|
import * as Graph from '../graph';
|
|
26
26
|
import * as GraphBuilder from '../graph-builder';
|
|
27
27
|
import * as Node from '../node';
|
|
28
|
-
|
|
29
28
|
import { JsonTree } from './Tree';
|
|
30
29
|
|
|
31
30
|
const DEFAULT_PERIOD = 500;
|
|
@@ -146,8 +145,8 @@ const runAction = async (client: Client, action: Action) => {
|
|
|
146
145
|
case Action.RENAME_SPACE: {
|
|
147
146
|
const space = getRandomSpace(client);
|
|
148
147
|
if (space) {
|
|
149
|
-
Obj.
|
|
150
|
-
|
|
148
|
+
Obj.update(space.properties, (obj) => {
|
|
149
|
+
obj.name = random.commerce.productName();
|
|
151
150
|
});
|
|
152
151
|
}
|
|
153
152
|
break;
|
|
@@ -157,7 +156,7 @@ const runAction = async (client: Client, action: Action) => {
|
|
|
157
156
|
getRandomSpace(client)?.db.add(
|
|
158
157
|
Obj.make(TestSchema.Expando, {
|
|
159
158
|
type: 'test',
|
|
160
|
-
name:
|
|
159
|
+
name: random.commerce.productName(),
|
|
161
160
|
}),
|
|
162
161
|
);
|
|
163
162
|
break;
|
|
@@ -176,8 +175,8 @@ const runAction = async (client: Client, action: Action) => {
|
|
|
176
175
|
if (space) {
|
|
177
176
|
const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
|
|
178
177
|
const object = objects[Math.floor(Math.random() * objects.length)];
|
|
179
|
-
Obj.
|
|
180
|
-
|
|
178
|
+
Obj.update(object, (object) => {
|
|
179
|
+
object.name = random.commerce.productName();
|
|
181
180
|
});
|
|
182
181
|
}
|
|
183
182
|
break;
|
|
@@ -216,7 +215,6 @@ const Controls = ({ children }: PropsWithChildren) => {
|
|
|
216
215
|
<Input.Root>
|
|
217
216
|
<Input.TextInput
|
|
218
217
|
autoComplete='off'
|
|
219
|
-
size={5}
|
|
220
218
|
classNames='w-[100px] text-right pe-[22px]'
|
|
221
219
|
placeholder='Interval'
|
|
222
220
|
value={actionInterval}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Registry } from '@effect-atom/atom-react';
|
|
6
|
+
import * as Option from 'effect/Option';
|
|
7
|
+
|
|
8
|
+
import * as Graph from '../graph';
|
|
9
|
+
import * as GraphBuilder from '../graph-builder';
|
|
10
|
+
import * as Node from '../node';
|
|
11
|
+
|
|
12
|
+
export type SetupGraphBuilderOptions = {
|
|
13
|
+
registry?: Registry.Registry;
|
|
14
|
+
extensions?: GraphBuilder.BuilderExtensions;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const setupGraphBuilder = ({ registry = Registry.make(), extensions }: SetupGraphBuilderOptions = {}) => {
|
|
18
|
+
const builder = GraphBuilder.make({ registry });
|
|
19
|
+
const graph = builder.graph;
|
|
20
|
+
|
|
21
|
+
if (extensions) {
|
|
22
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
registry,
|
|
27
|
+
builder,
|
|
28
|
+
graph,
|
|
29
|
+
addExtensions: (nextExtensions: GraphBuilder.BuilderExtensions) => {
|
|
30
|
+
GraphBuilder.addExtension(builder, nextExtensions);
|
|
31
|
+
},
|
|
32
|
+
expand: async (id: string, relation: Node.RelationInput = 'child') => {
|
|
33
|
+
Graph.expand(graph, id, relation);
|
|
34
|
+
await GraphBuilder.flush(builder);
|
|
35
|
+
},
|
|
36
|
+
flush: () => GraphBuilder.flush(builder),
|
|
37
|
+
getConnections: (id: string, relation: Node.RelationInput = 'child') =>
|
|
38
|
+
registry.get(graph.connections(id, relation)),
|
|
39
|
+
getNode: (id: string) => Graph.getNode(graph, id).pipe(Option.getOrNull),
|
|
40
|
+
};
|
|
41
|
+
};
|