@dxos/app-graph 0.8.4-main.c85a9c8dae → 0.8.4-main.d05539e30a
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} +304 -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 +3 -3
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +2 -2
- 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 +44 -18
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +11 -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 +26 -10
- 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 +536 -34
- package/src/graph-builder.ts +87 -24
- package/src/graph.test.ts +21 -1
- package/src/graph.ts +32 -21
- package/src/index.ts +1 -0
- package/src/node-matcher.ts +59 -27
- package/src/node.ts +32 -3
- package/src/scheduler.browser.ts +5 -0
- package/src/scheduler.ts +17 -0
- package/src/stories/EchoGraph.stories.tsx +13 -15
- package/src/testing/index.ts +5 -0
- package/src/testing/setup-graph-builder.ts +41 -0
- package/src/util.ts +59 -13
- 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 -1523
- 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>[]>;
|
|
@@ -71,7 +80,7 @@ export type GraphBuilderTraverseOptions = {
|
|
|
71
80
|
visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
|
|
72
81
|
registry?: Registry.Registry;
|
|
73
82
|
source?: string;
|
|
74
|
-
relation: Node.RelationInput;
|
|
83
|
+
relation: Node.RelationInput | Node.RelationInput[];
|
|
75
84
|
};
|
|
76
85
|
|
|
77
86
|
/**
|
|
@@ -105,7 +114,9 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
// TODO(wittjosiah): Use Context.
|
|
117
|
+
/** Active subscriptions keyed by composite ID, cleaned up on node removal. */
|
|
108
118
|
readonly _subscriptions = new Map<string, CleanupFn>();
|
|
119
|
+
/** Connector updates pending flush, keyed by connector key. */
|
|
109
120
|
readonly _dirtyConnectors = new Map<
|
|
110
121
|
string,
|
|
111
122
|
{
|
|
@@ -113,16 +124,26 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
113
124
|
previous: string[];
|
|
114
125
|
}
|
|
115
126
|
>();
|
|
127
|
+
/** Last-flushed node IDs per connector key, used for edge removal on update. */
|
|
116
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[]>();
|
|
131
|
+
/** Last-flushed node args per connector key, used for change detection. */
|
|
117
132
|
readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
|
|
133
|
+
/** Whether a dirty-flush task is already scheduled. */
|
|
118
134
|
_flushScheduled = false;
|
|
135
|
+
/** Resolves when the current flush completes. */
|
|
119
136
|
_flushPromise: Promise<void> = Promise.resolve();
|
|
137
|
+
/** Registered builder extensions keyed by extension ID. */
|
|
120
138
|
readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
|
|
121
139
|
Atom.keepAlive,
|
|
122
140
|
Atom.withLabel('graph-builder:extensions'),
|
|
123
141
|
);
|
|
142
|
+
/** Triggers signalling that a node's resolver has fired at least once. */
|
|
124
143
|
readonly _initialized: Record<string, Trigger> = {};
|
|
144
|
+
/** Shared atom registry for reactive subscriptions. */
|
|
125
145
|
readonly _registry: Registry.Registry;
|
|
146
|
+
/** Backing graph with internal accessors for node atoms and construction. */
|
|
126
147
|
readonly _graph: Graph.Graph & {
|
|
127
148
|
_node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
|
|
128
149
|
_constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
|
|
@@ -160,6 +181,12 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
160
181
|
this._connectorPrevious.set(key, ids);
|
|
161
182
|
this._connectorPreviousArgs.set(key, nodes);
|
|
162
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);
|
|
163
190
|
Graph.removeEdges(
|
|
164
191
|
this._graph,
|
|
165
192
|
removed.map((target) => ({ source: id, target, relation })),
|
|
@@ -263,7 +290,8 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
263
290
|
|
|
264
291
|
const cancel = this._registry.subscribe(
|
|
265
292
|
connectors,
|
|
266
|
-
(
|
|
293
|
+
(rawNodes) => {
|
|
294
|
+
const nodes = qualifyNodeArgs(id)(rawNodes);
|
|
267
295
|
const previous = this._connectorPrevious.get(key) ?? [];
|
|
268
296
|
const ids = nodes.map((n) => n.id);
|
|
269
297
|
|
|
@@ -284,7 +312,6 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
284
312
|
this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
|
|
285
313
|
}
|
|
286
314
|
|
|
287
|
-
// TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
|
|
288
315
|
private async _onInitialize(id: string) {
|
|
289
316
|
log('onInitialize', { id });
|
|
290
317
|
const resolver = this._resolvers(id);
|
|
@@ -293,14 +320,24 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
293
320
|
resolver,
|
|
294
321
|
(node) => {
|
|
295
322
|
const trigger = this._initialized[id];
|
|
323
|
+
const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
|
|
296
324
|
Option.match(node, {
|
|
297
325
|
onSome: (node) => {
|
|
298
|
-
|
|
326
|
+
if (!connectorOwned) {
|
|
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
|
+
}
|
|
333
|
+
}
|
|
299
334
|
trigger?.wake();
|
|
300
335
|
},
|
|
301
336
|
onNone: () => {
|
|
302
337
|
trigger?.wake();
|
|
303
|
-
|
|
338
|
+
if (!connectorOwned) {
|
|
339
|
+
Graph.removeNodes(this._graph, [id]);
|
|
340
|
+
}
|
|
304
341
|
},
|
|
305
342
|
});
|
|
306
343
|
},
|
|
@@ -311,9 +348,8 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
311
348
|
}
|
|
312
349
|
|
|
313
350
|
private _onRemoveNode(id: string): void {
|
|
314
|
-
const prefix = `${id}${Separators.primary}`;
|
|
315
351
|
for (const [key, cleanup] of this._subscriptions) {
|
|
316
|
-
if (key
|
|
352
|
+
if (primaryParts(key)[0] === id) {
|
|
317
353
|
cleanup();
|
|
318
354
|
this._subscriptions.delete(key);
|
|
319
355
|
}
|
|
@@ -425,11 +461,14 @@ const exploreImpl = async (
|
|
|
425
461
|
return;
|
|
426
462
|
}
|
|
427
463
|
|
|
428
|
-
const nodes =
|
|
429
|
-
.
|
|
430
|
-
.
|
|
431
|
-
.
|
|
432
|
-
.
|
|
464
|
+
const nodes = Function.pipe(
|
|
465
|
+
internal._registry.get(internal._extensions),
|
|
466
|
+
Record.values,
|
|
467
|
+
Array.map((extension) => extension.connector),
|
|
468
|
+
Array.filter(isNonNullable),
|
|
469
|
+
Array.flatMap((connector) => registry.get(connector(internal._graph.node(source)))),
|
|
470
|
+
qualifyNodeArgs(source),
|
|
471
|
+
);
|
|
433
472
|
|
|
434
473
|
await Promise.all(
|
|
435
474
|
nodes.map((nodeArg) => {
|
|
@@ -533,7 +572,7 @@ export type CreateExtensionRawOptions = {
|
|
|
533
572
|
export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
|
|
534
573
|
const {
|
|
535
574
|
id,
|
|
536
|
-
position
|
|
575
|
+
position,
|
|
537
576
|
relation = 'child',
|
|
538
577
|
resolver: _resolver,
|
|
539
578
|
connector: _connector,
|
|
@@ -793,19 +832,43 @@ export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
|
|
|
793
832
|
// Extension Utilities
|
|
794
833
|
//
|
|
795
834
|
|
|
796
|
-
|
|
797
|
-
|
|
835
|
+
/**
|
|
836
|
+
* Qualify node IDs by prefixing with the parent path.
|
|
837
|
+
* Validates that segment IDs do not contain the path separator.
|
|
838
|
+
* Recursively qualifies inline child nodes.
|
|
839
|
+
*/
|
|
840
|
+
const qualifyNodeArgs =
|
|
841
|
+
(parentId: string) =>
|
|
842
|
+
(nodes: Node.NodeArg<any>[]): Node.NodeArg<any>[] =>
|
|
843
|
+
nodes.map((node) => {
|
|
844
|
+
validateSegmentId(node.id);
|
|
845
|
+
const qualified = qualifyId(parentId, node.id);
|
|
846
|
+
return {
|
|
847
|
+
...node,
|
|
848
|
+
id: qualified,
|
|
849
|
+
nodes: node.nodes ? qualifyNodeArgs(qualified)(node.nodes) : undefined,
|
|
850
|
+
};
|
|
851
|
+
});
|
|
852
|
+
|
|
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));
|
|
798
864
|
|
|
799
865
|
const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
return { id, relation: Graph.relationFromKey(key.slice(separatorIndex + 1)) };
|
|
866
|
+
const [id, encodedRelation] = primaryParts(key);
|
|
867
|
+
return { id, relation: Graph.relationFromKey(encodedRelation) };
|
|
803
868
|
};
|
|
804
869
|
|
|
805
870
|
const subscriptionKey = (id: string, kind: string, detail?: string): string =>
|
|
806
|
-
detail != null
|
|
807
|
-
? `${id}${Separators.primary}${kind}${Separators.primary}${detail}`
|
|
808
|
-
: `${id}${Separators.primary}${kind}`;
|
|
871
|
+
detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
|
|
809
872
|
|
|
810
873
|
export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
|
|
811
874
|
if (Array.isArray(extension)) {
|
package/src/graph.test.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as Node from './node';
|
|
|
12
12
|
|
|
13
13
|
const exampleId = (id: number) => `dx:test:${id}`;
|
|
14
14
|
const EXAMPLE_ID = exampleId(1);
|
|
15
|
-
const EXAMPLE_TYPE = 'dxos.
|
|
15
|
+
const EXAMPLE_TYPE = 'org.dxos.type.example';
|
|
16
16
|
const CHILD_RELATION_KEY = Graph.relationKey('child');
|
|
17
17
|
const CHILD_INBOUND_RELATION_KEY = Graph.relationKey(Node.childRelation('inbound'));
|
|
18
18
|
const ACTIONS_RELATION_KEY = Graph.relationKey('action');
|
|
@@ -602,6 +602,26 @@ describe('Graph', () => {
|
|
|
602
602
|
expect(nodes).to.deep.equal(['root', 'test1']);
|
|
603
603
|
});
|
|
604
604
|
|
|
605
|
+
test('traversal with multiple relations follows all edge types', () => {
|
|
606
|
+
const graph = Graph.make();
|
|
607
|
+
Graph.addNode(graph, {
|
|
608
|
+
id: Node.RootId,
|
|
609
|
+
type: Node.RootType,
|
|
610
|
+
nodes: [{ id: 'child1', type: 'test' }],
|
|
611
|
+
});
|
|
612
|
+
Graph.addNode(graph, { id: 'action1', type: Node.ActionType });
|
|
613
|
+
Graph.addEdge(graph, { source: Node.RootId, target: 'action1', relation: 'action' });
|
|
614
|
+
|
|
615
|
+
const nodes: string[] = [];
|
|
616
|
+
Graph.traverse(graph, {
|
|
617
|
+
relation: ['child', 'action'],
|
|
618
|
+
visitor: (node) => {
|
|
619
|
+
nodes.push(node.id);
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
expect(nodes).to.deep.equal(['root', 'child1', 'action1']);
|
|
623
|
+
});
|
|
624
|
+
|
|
605
625
|
test('traverse curried', () => {
|
|
606
626
|
const graph = Graph.make();
|
|
607
627
|
Graph.addNode(graph, {
|
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
|
|
|
@@ -49,8 +49,8 @@ export type GraphTraversalOptions = {
|
|
|
49
49
|
*/
|
|
50
50
|
source?: string;
|
|
51
51
|
|
|
52
|
-
/** The relation to traverse graph edges. */
|
|
53
|
-
relation: Node.RelationInput;
|
|
52
|
+
/** The relation(s) to traverse graph edges. */
|
|
53
|
+
relation: Node.RelationInput | Node.RelationInput[];
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
export type GraphProps = {
|
|
@@ -183,6 +183,12 @@ 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
|
+
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]) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
186
192
|
const { id, relation } = relationFromConnectionKey(key);
|
|
187
193
|
const edges = get(this._edges(id));
|
|
188
194
|
return (edges[relationKey(relation)] ?? [])
|
|
@@ -194,6 +200,9 @@ class GraphImpl implements WritableGraph {
|
|
|
194
200
|
|
|
195
201
|
readonly _actions = Atom.family<string, Atom.Atom<(Node.Action | Node.ActionGroup)[]>>((id) => {
|
|
196
202
|
return Atom.make((get) => {
|
|
203
|
+
if (!id) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
197
206
|
return get(this._connections(connectionKey(id, Node.actionRelation()))) as (Node.Action | Node.ActionGroup)[];
|
|
198
207
|
}).pipe(Atom.withLabel(`graph:actions:${id}`));
|
|
199
208
|
});
|
|
@@ -515,9 +524,16 @@ const traverseImpl = (graph: BaseGraph, options: GraphTraversalOptions, path: st
|
|
|
515
524
|
return;
|
|
516
525
|
}
|
|
517
526
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
527
|
+
const relations = Array.isArray(relation) ? relation : [relation];
|
|
528
|
+
const seen = new Set<string>();
|
|
529
|
+
for (const rel of relations) {
|
|
530
|
+
for (const connected of getConnections(graph, source, rel)) {
|
|
531
|
+
if (!seen.has(connected.id)) {
|
|
532
|
+
seen.add(connected.id);
|
|
533
|
+
traverseImpl(graph, { source: connected.id, relation, visitor }, [...path, source]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
521
537
|
};
|
|
522
538
|
|
|
523
539
|
/**
|
|
@@ -693,7 +709,7 @@ const expandImpl = <T extends ExpandableGraph | WritableGraph>(
|
|
|
693
709
|
): T => {
|
|
694
710
|
const internal = getInternal(graph);
|
|
695
711
|
const normalizedRelation = normalizeRelation(relation);
|
|
696
|
-
const key =
|
|
712
|
+
const key = primaryKey(id, relationKey(normalizedRelation));
|
|
697
713
|
const nodeOpt = internal._registry.get(internal._node(id));
|
|
698
714
|
if (Option.isNone(nodeOpt)) {
|
|
699
715
|
// Node not yet in graph: record expand to run when the node is added.
|
|
@@ -887,11 +903,10 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
|
|
|
887
903
|
graph.onNodeChanged.emit({ id, node: newNode });
|
|
888
904
|
|
|
889
905
|
// Apply any expands that were deferred because this node did not exist yet.
|
|
890
|
-
const
|
|
891
|
-
const toApply = [...internal._pendingExpands].filter((k) => k.startsWith(prefix));
|
|
906
|
+
const toApply = [...internal._pendingExpands].filter((k) => primaryParts(k)[0] === id);
|
|
892
907
|
for (const pendingKey of toApply) {
|
|
893
908
|
internal._pendingExpands.delete(pendingKey);
|
|
894
|
-
const relation = relationFromKey(pendingKey
|
|
909
|
+
const relation = relationFromKey(primaryParts(pendingKey)[1]);
|
|
895
910
|
Record.set(internal._expanded, pendingKey, true);
|
|
896
911
|
internal._onExpand?.(id, relation);
|
|
897
912
|
}
|
|
@@ -1213,26 +1228,22 @@ export const make = (params?: GraphProps): Graph => {
|
|
|
1213
1228
|
|
|
1214
1229
|
export const relationKey = (relation: Node.RelationInput): string => {
|
|
1215
1230
|
const normalized = normalizeRelation(relation);
|
|
1216
|
-
return
|
|
1231
|
+
return secondaryKey(normalized.kind, normalized.direction);
|
|
1217
1232
|
};
|
|
1218
1233
|
|
|
1219
1234
|
export const relationFromKey = (encoded: string): Node.Relation => {
|
|
1220
|
-
const
|
|
1221
|
-
invariant(
|
|
1222
|
-
const kind =
|
|
1223
|
-
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;
|
|
1224
1238
|
invariant(directionRaw === 'outbound' || directionRaw === 'inbound', `Invalid relation direction: ${directionRaw}`);
|
|
1225
1239
|
return Node.relation(kind, directionRaw);
|
|
1226
1240
|
};
|
|
1227
1241
|
|
|
1228
|
-
const connectionKey = (id: string, relation: Node.RelationInput): string =>
|
|
1229
|
-
`${id}${Separators.primary}${relationKey(relation)}`;
|
|
1242
|
+
const connectionKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, relationKey(relation));
|
|
1230
1243
|
|
|
1231
1244
|
const relationFromConnectionKey = (key: string): { id: string; relation: Node.Relation } => {
|
|
1232
|
-
const
|
|
1233
|
-
invariant(
|
|
1234
|
-
const id = key.slice(0, separatorIndex);
|
|
1235
|
-
const encodedRelation = key.slice(separatorIndex + 1);
|
|
1245
|
+
const [id, encodedRelation] = primaryParts(key);
|
|
1246
|
+
invariant(id && encodedRelation, `Invalid connection key: ${key}`);
|
|
1236
1247
|
return { id, relation: relationFromKey(encodedRelation) };
|
|
1237
1248
|
};
|
|
1238
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
|
@@ -69,7 +69,7 @@ export const whenId =
|
|
|
69
69
|
* ```ts
|
|
70
70
|
* GraphBuilder.createExtension({
|
|
71
71
|
* id: 'space-settings-extension',
|
|
72
|
-
* match: NodeMatcher.whenNodeType('dxos.
|
|
72
|
+
* match: NodeMatcher.whenNodeType('org.dxos.plugin.space.settings'),
|
|
73
73
|
* connector: (node) => Effect.succeed([...]),
|
|
74
74
|
* });
|
|
75
75
|
* ```
|
|
@@ -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,17 +127,20 @@ 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
|
|
131
134
|
* const id = Obj.getDXN(object).toString();
|
|
132
|
-
* return Effect.succeed([{ id: `${id}
|
|
135
|
+
* return Effect.succeed([{ id: `${id}.settings`, ... }]);
|
|
133
136
|
* },
|
|
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 = 'dxos.
|
|
18
|
+
export const RootType = 'org.dxos.type.graphRoot';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Action node type.
|
|
22
22
|
*/
|
|
23
|
-
export const ActionType = 'dxos.
|
|
23
|
+
export const ActionType = 'org.dxos.type.graphAction';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Action group node type.
|
|
27
27
|
*/
|
|
28
|
-
export const ActionGroupType = 'dxos.
|
|
28
|
+
export const ActionGroupType = 'org.dxos.type.graphActionGroup';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Represents a node in the graph.
|
|
@@ -107,6 +107,9 @@ export type InvokeProps = {
|
|
|
107
107
|
/** Node the invoked action is connected to. */
|
|
108
108
|
parent?: Node;
|
|
109
109
|
|
|
110
|
+
/** Path from root to the node in the current tree context. */
|
|
111
|
+
path?: string[];
|
|
112
|
+
|
|
110
113
|
caller?: string;
|
|
111
114
|
};
|
|
112
115
|
|
|
@@ -147,3 +150,29 @@ export const isActionGroup = (data: unknown): data is ActionGroup =>
|
|
|
147
150
|
export type ActionLike = Action | ActionGroup;
|
|
148
151
|
|
|
149
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
|
+
};
|