@dxos/app-graph 0.9.0 → 0.9.1-staging.ee54ba693a
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-7BYCDV55.mjs → chunk-YMTZ7MPW.mjs} +59 -34
- package/dist/lib/neutral/chunk-YMTZ7MPW.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +1 -1
- package/dist/lib/neutral/meta.json +1 -1
- package/dist/lib/neutral/testing/index.mjs +1 -1
- package/dist/types/src/graph-builder.d.ts +14 -13
- 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 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +7 -1
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +2 -0
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +26 -26
- package/src/graph-builder.test.ts +121 -4
- package/src/graph-builder.ts +33 -30
- package/src/graph.ts +20 -0
- package/src/index.ts +1 -1
- package/src/node-matcher.test.ts +17 -11
- package/src/node-matcher.ts +13 -7
- package/src/node.ts +3 -0
- package/src/util.ts +2 -1
- package/dist/lib/neutral/chunk-7BYCDV55.mjs.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/app-graph",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1-staging.ee54ba693a",
|
|
4
4
|
"description": "Constructs knowledge graphs for the purpose of building applications on top of",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -41,39 +41,39 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"main-thread-scheduling": "^14.1.1",
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/
|
|
47
|
-
"@dxos/
|
|
48
|
-
"@dxos/
|
|
49
|
-
"@dxos/
|
|
50
|
-
"@dxos/
|
|
44
|
+
"@dxos/invariant": "0.9.1-staging.ee54ba693a",
|
|
45
|
+
"@dxos/async": "0.9.1-staging.ee54ba693a",
|
|
46
|
+
"@dxos/debug": "0.9.1-staging.ee54ba693a",
|
|
47
|
+
"@dxos/echo": "0.9.1-staging.ee54ba693a",
|
|
48
|
+
"@dxos/keys": "0.9.1-staging.ee54ba693a",
|
|
49
|
+
"@dxos/log": "0.9.1-staging.ee54ba693a",
|
|
50
|
+
"@dxos/util": "0.9.1-staging.ee54ba693a"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@effect-atom/atom-react": "^0.5.0",
|
|
54
|
-
"@effect/platform": "0.96.
|
|
55
|
-
"@types/react": "~19.2.
|
|
54
|
+
"@effect/platform": "0.96.2",
|
|
55
|
+
"@types/react": "~19.2.17",
|
|
56
56
|
"@types/react-dom": "~19.2.3",
|
|
57
|
-
"effect": "3.21.
|
|
58
|
-
"react": "~19.2.
|
|
59
|
-
"react-dom": "~19.2.
|
|
57
|
+
"effect": "3.21.4",
|
|
58
|
+
"react": "~19.2.7",
|
|
59
|
+
"react-dom": "~19.2.7",
|
|
60
60
|
"vite": "^8.0.16",
|
|
61
|
-
"@dxos/random": "0.9.
|
|
62
|
-
"@dxos/react-client": "0.9.
|
|
63
|
-
"@dxos/react-ui": "0.9.
|
|
64
|
-
"@dxos/react-ui
|
|
65
|
-
"@dxos/
|
|
66
|
-
"@dxos/
|
|
67
|
-
"@dxos/ui-
|
|
61
|
+
"@dxos/random": "0.9.1-staging.ee54ba693a",
|
|
62
|
+
"@dxos/react-client": "0.9.1-staging.ee54ba693a",
|
|
63
|
+
"@dxos/react-ui-list": "0.9.1-staging.ee54ba693a",
|
|
64
|
+
"@dxos/react-ui": "0.9.1-staging.ee54ba693a",
|
|
65
|
+
"@dxos/storybook-utils": "0.9.1-staging.ee54ba693a",
|
|
66
|
+
"@dxos/ui-theme": "0.9.1-staging.ee54ba693a",
|
|
67
|
+
"@dxos/react-ui-tabs": "0.9.1-staging.ee54ba693a"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@effect-atom/atom-react": "^0.5.0",
|
|
71
|
-
"@effect/platform": "0.96.
|
|
72
|
-
"effect": "3.21.
|
|
73
|
-
"react": "~19.2.
|
|
74
|
-
"react-dom": "~19.2.
|
|
75
|
-
"@dxos/react-ui": "0.9.
|
|
76
|
-
"@dxos/ui-theme": "0.9.
|
|
71
|
+
"@effect/platform": "0.96.2",
|
|
72
|
+
"effect": "3.21.4",
|
|
73
|
+
"react": "~19.2.7",
|
|
74
|
+
"react-dom": "~19.2.7",
|
|
75
|
+
"@dxos/react-ui": "0.9.1-staging.ee54ba693a",
|
|
76
|
+
"@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
|
|
77
77
|
},
|
|
78
78
|
"publishConfig": {
|
|
79
79
|
"access": "public"
|
|
@@ -609,6 +609,123 @@ describe('GraphBuilder', () => {
|
|
|
609
609
|
|
|
610
610
|
expect(Graph.getNode(graph, 'root/parent-node/child/grandchild').pipe(Option.getOrNull)?.data).to.equal('v2');
|
|
611
611
|
});
|
|
612
|
+
|
|
613
|
+
describe('NodeArg.actions', () => {
|
|
614
|
+
const withInlineAction = (actionId = 'delete'): Node.NodeArg<any> => ({
|
|
615
|
+
id: 'parent-node',
|
|
616
|
+
type: EXAMPLE_TYPE,
|
|
617
|
+
data: null,
|
|
618
|
+
actions: [Node.makeAction({ id: actionId, data: () => Effect.void, properties: { label: 'Delete' } })],
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('inline actions appear on connector-produced nodes', async ({ expect }) => {
|
|
622
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
623
|
+
registry.set(nodesAtom, [withInlineAction()]);
|
|
624
|
+
await GraphBuilder.flush(builder);
|
|
625
|
+
|
|
626
|
+
const actions = registry.get(graph.actions('root/parent-node'));
|
|
627
|
+
expect(actions).to.have.length(1);
|
|
628
|
+
expect(actions[0].id).to.equal('root/parent-node/delete');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('inline actions reactively update when connector re-runs', async ({ expect }) => {
|
|
632
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
633
|
+
registry.set(nodesAtom, [withInlineAction('delete')]);
|
|
634
|
+
await GraphBuilder.flush(builder);
|
|
635
|
+
|
|
636
|
+
expect(registry.get(graph.actions('root/parent-node'))).to.have.length(1);
|
|
637
|
+
|
|
638
|
+
registry.set(nodesAtom, [{ ...withInlineAction('delete'), actions: [] }]);
|
|
639
|
+
await GraphBuilder.flush(builder);
|
|
640
|
+
|
|
641
|
+
expect(registry.get(graph.actions('root/parent-node'))).to.have.length(0);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test('inline actions appear on inline child nodes (filter pattern)', async ({ expect }) => {
|
|
645
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
646
|
+
|
|
647
|
+
const withFilterChild = (): Node.NodeArg<any> => ({
|
|
648
|
+
id: 'parent-node',
|
|
649
|
+
type: EXAMPLE_TYPE,
|
|
650
|
+
data: null,
|
|
651
|
+
nodes: [
|
|
652
|
+
{
|
|
653
|
+
id: 'filter',
|
|
654
|
+
type: EXAMPLE_TYPE,
|
|
655
|
+
data: null,
|
|
656
|
+
actions: [Node.makeAction({ id: 'delete', data: () => Effect.void, properties: { label: 'Delete' } })],
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
registry.set(nodesAtom, [withFilterChild()]);
|
|
662
|
+
await GraphBuilder.flush(builder);
|
|
663
|
+
|
|
664
|
+
const filterNodeId = 'root/parent-node/filter';
|
|
665
|
+
expect(Graph.getNode(graph, filterNodeId).pipe(Option.getOrNull)).to.not.be.null;
|
|
666
|
+
|
|
667
|
+
const actions = registry.get(graph.actions(filterNodeId));
|
|
668
|
+
expect(actions).to.have.length(1);
|
|
669
|
+
expect(actions[0].id).to.equal(`${filterNodeId}/delete`);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('inline actions on child nodes are removed when child is removed', async ({ expect }) => {
|
|
673
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
674
|
+
|
|
675
|
+
const filterChild: Node.NodeArg<any> = {
|
|
676
|
+
id: 'filter',
|
|
677
|
+
type: EXAMPLE_TYPE,
|
|
678
|
+
data: null,
|
|
679
|
+
actions: [Node.makeAction({ id: 'delete', data: () => Effect.void, properties: { label: 'Delete' } })],
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
registry.set(nodesAtom, [{ id: 'parent-node', type: EXAMPLE_TYPE, data: null, nodes: [filterChild] }]);
|
|
683
|
+
await GraphBuilder.flush(builder);
|
|
684
|
+
|
|
685
|
+
expect(Graph.getNode(graph, 'root/parent-node/filter/delete').pipe(Option.getOrNull)).to.not.be.null;
|
|
686
|
+
|
|
687
|
+
// Remove the filter child entirely.
|
|
688
|
+
registry.set(nodesAtom, [{ id: 'parent-node', type: EXAMPLE_TYPE, data: null, nodes: [] }]);
|
|
689
|
+
await GraphBuilder.flush(builder);
|
|
690
|
+
|
|
691
|
+
expect(Graph.getNode(graph, 'root/parent-node/filter').pipe(Option.getOrNull)).to.be.null;
|
|
692
|
+
expect(Graph.getNode(graph, 'root/parent-node/filter/delete').pipe(Option.getOrNull)).to.be.null;
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test('are reordered when connector re-runs with different child order', async ({ expect }) => {
|
|
697
|
+
const makeMultiParent = (childIds: string[]): Node.NodeArg<any> => ({
|
|
698
|
+
id: 'parent-node',
|
|
699
|
+
type: EXAMPLE_TYPE,
|
|
700
|
+
data: null,
|
|
701
|
+
nodes: childIds.map((cid) => ({ id: cid, type: EXAMPLE_TYPE, data: cid })),
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const { registry, builder, graph, nodesAtom } = makeGraph();
|
|
705
|
+
registry.set(nodesAtom, [makeMultiParent(['c1', 'c2', 'c3'])]);
|
|
706
|
+
await GraphBuilder.flush(builder);
|
|
707
|
+
|
|
708
|
+
{
|
|
709
|
+
const children = registry.get(graph.connections('root/parent-node', 'child'));
|
|
710
|
+
expect(children.map((n) => n.id)).to.deep.equal([
|
|
711
|
+
'root/parent-node/c1',
|
|
712
|
+
'root/parent-node/c2',
|
|
713
|
+
'root/parent-node/c3',
|
|
714
|
+
]);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
registry.set(nodesAtom, [makeMultiParent(['c3', 'c1', 'c2'])]);
|
|
718
|
+
await GraphBuilder.flush(builder);
|
|
719
|
+
|
|
720
|
+
{
|
|
721
|
+
const children = registry.get(graph.connections('root/parent-node', 'child'));
|
|
722
|
+
expect(children.map((n) => n.id)).to.deep.equal([
|
|
723
|
+
'root/parent-node/c3',
|
|
724
|
+
'root/parent-node/c1',
|
|
725
|
+
'root/parent-node/c2',
|
|
726
|
+
]);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
612
729
|
});
|
|
613
730
|
|
|
614
731
|
test('sort edges', async () => {
|
|
@@ -1208,7 +1325,7 @@ describe('GraphBuilder', () => {
|
|
|
1208
1325
|
GraphBuilder.createExtension({
|
|
1209
1326
|
id: 'failingExtension',
|
|
1210
1327
|
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1211
|
-
connector: (node, get) => Effect.
|
|
1328
|
+
connector: (node, get) => Effect.die('Connector failed intentionally'),
|
|
1212
1329
|
}),
|
|
1213
1330
|
);
|
|
1214
1331
|
|
|
@@ -1235,7 +1352,7 @@ describe('GraphBuilder', () => {
|
|
|
1235
1352
|
GraphBuilder.createExtension({
|
|
1236
1353
|
id: 'failingActionsExtension',
|
|
1237
1354
|
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1238
|
-
actions: (node, get) => Effect.
|
|
1355
|
+
actions: (node, get) => Effect.die('Actions failed intentionally'),
|
|
1239
1356
|
}),
|
|
1240
1357
|
);
|
|
1241
1358
|
|
|
@@ -1262,7 +1379,7 @@ describe('GraphBuilder', () => {
|
|
|
1262
1379
|
GraphBuilder.createExtension({
|
|
1263
1380
|
id: 'failingResolverExtension',
|
|
1264
1381
|
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1265
|
-
resolver: (id, get) => Effect.
|
|
1382
|
+
resolver: (id, get) => Effect.die('Resolver failed intentionally'),
|
|
1266
1383
|
}),
|
|
1267
1384
|
);
|
|
1268
1385
|
|
|
@@ -1286,7 +1403,7 @@ describe('GraphBuilder', () => {
|
|
|
1286
1403
|
GraphBuilder.createExtension({
|
|
1287
1404
|
id: 'failingExtension',
|
|
1288
1405
|
match: NodeMatcher.whenNodeType(EXAMPLE_TYPE),
|
|
1289
|
-
connector: (node, get) => Effect.
|
|
1406
|
+
connector: (node, get) => Effect.die('This one fails'),
|
|
1290
1407
|
}),
|
|
1291
1408
|
);
|
|
1292
1409
|
|
package/src/graph-builder.ts
CHANGED
|
@@ -14,7 +14,7 @@ import * as Record from 'effect/Record';
|
|
|
14
14
|
import { type CleanupFn, type Trigger } from '@dxos/async';
|
|
15
15
|
import { type Type } from '@dxos/echo';
|
|
16
16
|
import { log } from '@dxos/log';
|
|
17
|
-
import { type MaybePromise,
|
|
17
|
+
import { type MaybePromise, Position, getDebugName, isNonNullable } from '@dxos/util';
|
|
18
18
|
|
|
19
19
|
import { scheduleTask, yieldOrContinue } from '#scheduler';
|
|
20
20
|
|
|
@@ -63,7 +63,7 @@ export type ActionGroupsExtension = (
|
|
|
63
63
|
|
|
64
64
|
export type BuilderExtension = Readonly<{
|
|
65
65
|
id: string;
|
|
66
|
-
position?: Position;
|
|
66
|
+
position?: Position.Position;
|
|
67
67
|
relation?: Node.RelationInput;
|
|
68
68
|
resolver?: ResolverExtension;
|
|
69
69
|
connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
|
|
@@ -198,9 +198,7 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
198
198
|
);
|
|
199
199
|
if (ids.length > 0) {
|
|
200
200
|
const sortedIds = [...nodes]
|
|
201
|
-
.sort((a, b) =>
|
|
202
|
-
byPosition(a.properties ?? ({} as { position?: Position }), b.properties ?? ({} as { position?: Position })),
|
|
203
|
-
)
|
|
201
|
+
.sort((a, b) => Position.compare({ position: a.properties?.position }, { position: b.properties?.position }))
|
|
204
202
|
.map((n) => n.id);
|
|
205
203
|
Graph.sortEdges(this._graph, id, relation, sortedIds);
|
|
206
204
|
}
|
|
@@ -233,7 +231,7 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
233
231
|
return Function.pipe(
|
|
234
232
|
get(this._extensions),
|
|
235
233
|
Record.values,
|
|
236
|
-
Array.sortBy(
|
|
234
|
+
Array.sortBy(Position.compare),
|
|
237
235
|
Array.map(({ resolver }) => resolver),
|
|
238
236
|
Array.filter(isNonNullable),
|
|
239
237
|
Array.map((resolver) => get(resolver(id))),
|
|
@@ -256,7 +254,7 @@ class GraphBuilderImpl implements GraphBuilder {
|
|
|
256
254
|
const extensions = Function.pipe(
|
|
257
255
|
get(this._extensions),
|
|
258
256
|
Record.values,
|
|
259
|
-
Array.sortBy(
|
|
257
|
+
Array.sortBy(Position.compare),
|
|
260
258
|
Array.filter(
|
|
261
259
|
(ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
|
|
262
260
|
Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
|
|
@@ -558,7 +556,7 @@ export const flush = (builder: GraphBuilder): Promise<void> => {
|
|
|
558
556
|
export type CreateExtensionRawOptions = {
|
|
559
557
|
id: string;
|
|
560
558
|
relation?: Node.RelationInput;
|
|
561
|
-
position?: Position;
|
|
559
|
+
position?: Position.Position;
|
|
562
560
|
resolver?: ResolverExtension;
|
|
563
561
|
connector?: ConnectorExtension;
|
|
564
562
|
actions?: ActionsExtension;
|
|
@@ -684,28 +682,29 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
|
|
|
684
682
|
/**
|
|
685
683
|
* Options for creating a graph builder extension with simplified API.
|
|
686
684
|
* All callbacks must return Effects for dependency injection.
|
|
687
|
-
* Effects may
|
|
685
|
+
* Effects may defect — defects are caught, logged, and the extension returns empty results.
|
|
686
|
+
* Use Effect.orDie on any failable effects inside callbacks.
|
|
688
687
|
*/
|
|
689
688
|
export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
|
|
690
689
|
id: string;
|
|
691
|
-
match: (node: Node.Node) => Option.Option<TMatched>;
|
|
690
|
+
match: (node: Node.Node, get: Atom.Context) => Option.Option<TMatched>;
|
|
692
691
|
actions?: (
|
|
693
692
|
matched: TMatched,
|
|
694
693
|
get: Atom.Context,
|
|
695
|
-
) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[],
|
|
696
|
-
connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[],
|
|
697
|
-
resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null,
|
|
694
|
+
) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], never, R>;
|
|
695
|
+
connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], never, R>;
|
|
696
|
+
resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, never, R>;
|
|
698
697
|
relation?: Node.RelationInput;
|
|
699
|
-
position?: Position;
|
|
698
|
+
position?: Position.Position;
|
|
700
699
|
};
|
|
701
700
|
|
|
702
701
|
/**
|
|
703
702
|
* Run an Effect synchronously with the provided context.
|
|
704
|
-
*
|
|
703
|
+
* Defects are caught, logged, and the fallback value is returned.
|
|
705
704
|
* @internal
|
|
706
705
|
*/
|
|
707
706
|
const runEffectSyncWithFallback = <T, R>(
|
|
708
|
-
effect: Effect.Effect<T,
|
|
707
|
+
effect: Effect.Effect<T, never, R>,
|
|
709
708
|
context: Context.Context<R>,
|
|
710
709
|
extensionId: string,
|
|
711
710
|
fallback: T,
|
|
@@ -713,8 +712,8 @@ const runEffectSyncWithFallback = <T, R>(
|
|
|
713
712
|
return Effect.runSync(
|
|
714
713
|
effect.pipe(
|
|
715
714
|
Effect.provide(context),
|
|
716
|
-
Effect.
|
|
717
|
-
log.warn('Extension failed', { extension: extensionId,
|
|
715
|
+
Effect.catchAllDefect((defect) => {
|
|
716
|
+
log.warn('Extension failed', { extension: extensionId, defect });
|
|
718
717
|
return Effect.succeed(fallback);
|
|
719
718
|
}),
|
|
720
719
|
),
|
|
@@ -738,7 +737,7 @@ export const createExtension = <TMatched = Node.Node, R = never>(
|
|
|
738
737
|
Atom.make((get) =>
|
|
739
738
|
Function.pipe(
|
|
740
739
|
get(node),
|
|
741
|
-
Option.flatMap(match),
|
|
740
|
+
Option.flatMap((matchedNode) => match(matchedNode, get)),
|
|
742
741
|
Option.map((matched) =>
|
|
743
742
|
runEffectSyncWithFallback(actions(matched, get), context, id, []).map((action) => ({
|
|
744
743
|
...action,
|
|
@@ -771,14 +770,14 @@ export const createExtension = <TMatched = Node.Node, R = never>(
|
|
|
771
770
|
* The factory's data type is inferred from the matcher's return type.
|
|
772
771
|
*/
|
|
773
772
|
export const createConnector = <TData>(
|
|
774
|
-
matcher: (node: Node.Node) => Option.Option<TData>,
|
|
773
|
+
matcher: (node: Node.Node, get: Atom.Context) => Option.Option<TData>,
|
|
775
774
|
factory: (data: TData, get: Atom.Context) => Node.NodeArg<any>[],
|
|
776
775
|
): ConnectorExtension => {
|
|
777
776
|
return (node: Atom.Atom<Option.Option<Node.Node>>) =>
|
|
778
777
|
Atom.make((get) =>
|
|
779
778
|
Function.pipe(
|
|
780
779
|
get(node),
|
|
781
|
-
Option.flatMap(matcher),
|
|
780
|
+
Option.flatMap((matchedNode) => matcher(matchedNode, get)),
|
|
782
781
|
Option.map((data) => factory(data, get)),
|
|
783
782
|
Option.getOrElse(() => []),
|
|
784
783
|
),
|
|
@@ -792,15 +791,15 @@ export const createConnector = <TData>(
|
|
|
792
791
|
*/
|
|
793
792
|
const createConnectorWithRuntime = <TData, R>(
|
|
794
793
|
extensionId: string,
|
|
795
|
-
matcher: (node: Node.Node) => Option.Option<TData>,
|
|
796
|
-
factory: (data: TData, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[],
|
|
794
|
+
matcher: (node: Node.Node, get: Atom.Context) => Option.Option<TData>,
|
|
795
|
+
factory: (data: TData, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], never, R>,
|
|
797
796
|
context: Context.Context<R>,
|
|
798
797
|
): ConnectorExtension => {
|
|
799
798
|
return (node: Atom.Atom<Option.Option<Node.Node>>) =>
|
|
800
799
|
Atom.make((get) =>
|
|
801
800
|
Function.pipe(
|
|
802
801
|
get(node),
|
|
803
|
-
Option.flatMap(matcher),
|
|
802
|
+
Option.flatMap((matchedNode) => matcher(matchedNode, get)),
|
|
804
803
|
Option.map((data) => runEffectSyncWithFallback(factory(data, get), context, extensionId, [])),
|
|
805
804
|
Option.getOrElse(() => []),
|
|
806
805
|
),
|
|
@@ -818,10 +817,10 @@ export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity
|
|
|
818
817
|
actions?: (
|
|
819
818
|
object: Type.InstanceType<T>,
|
|
820
819
|
get: Atom.Context,
|
|
821
|
-
) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>>, 'type'>[],
|
|
822
|
-
connector?: (object: Type.InstanceType<T>, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[],
|
|
820
|
+
) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>>, 'type'>[], never, R>;
|
|
821
|
+
connector?: (object: Type.InstanceType<T>, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], never, R>;
|
|
823
822
|
relation?: Node.RelationInput;
|
|
824
|
-
position?: Position;
|
|
823
|
+
position?: Position.Position;
|
|
825
824
|
};
|
|
826
825
|
|
|
827
826
|
/**
|
|
@@ -862,6 +861,7 @@ const qualifyNodeArgs =
|
|
|
862
861
|
...node,
|
|
863
862
|
id: qualified,
|
|
864
863
|
nodes: node.nodes ? qualifyNodeArgs(qualified)(node.nodes) : undefined,
|
|
864
|
+
actions: node.actions ? qualifyNodeArgs(qualified)(node.actions) : undefined,
|
|
865
865
|
};
|
|
866
866
|
});
|
|
867
867
|
|
|
@@ -871,9 +871,12 @@ const qualifyNodeArgs =
|
|
|
871
871
|
* already tracked via `_connectorPrevious`.
|
|
872
872
|
*/
|
|
873
873
|
const collectAllInlineIds = (nodes: Node.NodeArg<any>[]): string[] =>
|
|
874
|
-
nodes.flatMap((node) =>
|
|
875
|
-
|
|
876
|
-
|
|
874
|
+
nodes.flatMap((node) => {
|
|
875
|
+
const childNodes = node.nodes ?? [];
|
|
876
|
+
const actionNodes = node.actions ?? [];
|
|
877
|
+
const allInline = [...childNodes, ...actionNodes];
|
|
878
|
+
return allInline.length > 0 ? [...allInline.map((child) => child.id), ...collectAllInlineIds(allInline)] : [];
|
|
879
|
+
});
|
|
877
880
|
|
|
878
881
|
const connectorKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, Graph.relationKey(relation));
|
|
879
882
|
|
package/src/graph.ts
CHANGED
|
@@ -861,6 +861,7 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
|
|
|
861
861
|
// Extract known NodeArg fields, preserve any extra fields (like _actionContext) in rest.
|
|
862
862
|
const {
|
|
863
863
|
nodes,
|
|
864
|
+
actions,
|
|
864
865
|
edges,
|
|
865
866
|
id,
|
|
866
867
|
type,
|
|
@@ -917,6 +918,25 @@ const addNodeImpl = <T extends WritableGraph>(graph: T, nodeArg: Node.NodeArg<an
|
|
|
917
918
|
addNodesImpl(graph, nodes);
|
|
918
919
|
const _edges = nodes.map((node) => ({ source: id, target: node.id, relation: 'child' as const }));
|
|
919
920
|
addEdgesImpl(graph, _edges);
|
|
921
|
+
sortEdgesImpl(
|
|
922
|
+
graph,
|
|
923
|
+
id,
|
|
924
|
+
'child',
|
|
925
|
+
nodes.map((n) => n.id),
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (actions) {
|
|
930
|
+
addNodesImpl(graph, actions);
|
|
931
|
+
const actionRelation = Node.actionRelation();
|
|
932
|
+
const _edges = actions.map((node) => ({ source: id, target: node.id, relation: actionRelation }));
|
|
933
|
+
addEdgesImpl(graph, _edges);
|
|
934
|
+
sortEdgesImpl(
|
|
935
|
+
graph,
|
|
936
|
+
id,
|
|
937
|
+
actionRelation,
|
|
938
|
+
actions.map((node) => node.id),
|
|
939
|
+
);
|
|
920
940
|
}
|
|
921
941
|
|
|
922
942
|
if (edges) {
|
package/src/index.ts
CHANGED
|
@@ -8,7 +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 {
|
|
11
|
+
export { getParentId, getSegmentId, qualifyId } from './util';
|
|
12
12
|
|
|
13
13
|
// TODO(wittjosiah): Direct re-export needed for portable type references.
|
|
14
14
|
export type { BuilderExtensions } from './graph-builder';
|
package/src/node-matcher.test.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { Atom, Registry } from '@effect-atom/atom-react';
|
|
5
6
|
import * as Option from 'effect/Option';
|
|
6
7
|
import { describe, expect, test } from 'vitest';
|
|
7
8
|
|
|
@@ -105,7 +106,7 @@ describe('NodeMatcher', () => {
|
|
|
105
106
|
data: { name: 'Test' },
|
|
106
107
|
};
|
|
107
108
|
const matcher = NodeMatcher.whenEchoType(TestSchema.Person);
|
|
108
|
-
const result = matcher(node);
|
|
109
|
+
const result = matcher(node, get);
|
|
109
110
|
expect(Option.isNone(result)).to.be.true;
|
|
110
111
|
});
|
|
111
112
|
|
|
@@ -118,7 +119,7 @@ describe('NodeMatcher', () => {
|
|
|
118
119
|
data: testObject,
|
|
119
120
|
};
|
|
120
121
|
const matcher = NodeMatcher.whenEchoType(TestSchema.Person);
|
|
121
|
-
const result = matcher(node);
|
|
122
|
+
const result = matcher(node, get);
|
|
122
123
|
expect(Option.isSome(result)).to.be.true;
|
|
123
124
|
expect(Option.getOrNull(result)).to.equal(testObject);
|
|
124
125
|
});
|
|
@@ -159,7 +160,7 @@ describe('NodeMatcher', () => {
|
|
|
159
160
|
data: testObject,
|
|
160
161
|
};
|
|
161
162
|
const matcher = NodeMatcher.whenEchoTypeMatches(TestSchema.Person);
|
|
162
|
-
const result = matcher(node);
|
|
163
|
+
const result = matcher(node, get);
|
|
163
164
|
expect(Option.isSome(result)).to.be.true;
|
|
164
165
|
expect(Option.getOrNull(result)).to.equal(node);
|
|
165
166
|
});
|
|
@@ -172,7 +173,7 @@ describe('NodeMatcher', () => {
|
|
|
172
173
|
data: { name: 'Test' },
|
|
173
174
|
};
|
|
174
175
|
const matcher = NodeMatcher.whenEchoTypeMatches(TestSchema.Person);
|
|
175
|
-
const result = matcher(node);
|
|
176
|
+
const result = matcher(node, get);
|
|
176
177
|
expect(Option.isNone(result)).to.be.true;
|
|
177
178
|
});
|
|
178
179
|
});
|
|
@@ -212,7 +213,7 @@ describe('NodeMatcher', () => {
|
|
|
212
213
|
data: null,
|
|
213
214
|
};
|
|
214
215
|
const matcher = NodeMatcher.whenNot(NodeMatcher.whenRoot);
|
|
215
|
-
const result = matcher(rootNode);
|
|
216
|
+
const result = matcher(rootNode, get);
|
|
216
217
|
expect(Option.isNone(result)).to.be.true;
|
|
217
218
|
});
|
|
218
219
|
|
|
@@ -224,7 +225,7 @@ describe('NodeMatcher', () => {
|
|
|
224
225
|
data: null,
|
|
225
226
|
};
|
|
226
227
|
const matcher = NodeMatcher.whenNot(NodeMatcher.whenRoot);
|
|
227
|
-
const result = matcher(node);
|
|
228
|
+
const result = matcher(node, get);
|
|
228
229
|
expect(Option.isSome(result)).to.be.true;
|
|
229
230
|
expect(Option.getOrNull(result)).to.equal(node);
|
|
230
231
|
});
|
|
@@ -242,7 +243,7 @@ describe('NodeMatcher', () => {
|
|
|
242
243
|
NodeMatcher.whenEchoObjectMatches,
|
|
243
244
|
NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(TestSchema.Person)),
|
|
244
245
|
);
|
|
245
|
-
const result = matcher(node);
|
|
246
|
+
const result = matcher(node, get);
|
|
246
247
|
expect(Option.isNone(result)).to.be.true;
|
|
247
248
|
});
|
|
248
249
|
});
|
|
@@ -256,7 +257,7 @@ describe('NodeMatcher', () => {
|
|
|
256
257
|
data: null,
|
|
257
258
|
};
|
|
258
259
|
const matcher = NodeMatcher.whenAll(NodeMatcher.whenId('test-id'), NodeMatcher.whenNodeType('test-type'));
|
|
259
|
-
const result = matcher(node);
|
|
260
|
+
const result = matcher(node, get);
|
|
260
261
|
expect(Option.isSome(result)).to.be.true;
|
|
261
262
|
});
|
|
262
263
|
|
|
@@ -268,7 +269,7 @@ describe('NodeMatcher', () => {
|
|
|
268
269
|
data: null,
|
|
269
270
|
};
|
|
270
271
|
const matcher = NodeMatcher.whenAll(NodeMatcher.whenId('test-id'), NodeMatcher.whenNodeType('other-type'));
|
|
271
|
-
const result = matcher(node);
|
|
272
|
+
const result = matcher(node, get);
|
|
272
273
|
expect(Option.isNone(result)).to.be.true;
|
|
273
274
|
});
|
|
274
275
|
});
|
|
@@ -282,7 +283,7 @@ describe('NodeMatcher', () => {
|
|
|
282
283
|
data: null,
|
|
283
284
|
};
|
|
284
285
|
const matcher = NodeMatcher.whenAny(NodeMatcher.whenId('other-id'), NodeMatcher.whenNodeType('test-type'));
|
|
285
|
-
const result = matcher(node);
|
|
286
|
+
const result = matcher(node, get);
|
|
286
287
|
expect(Option.isSome(result)).to.be.true;
|
|
287
288
|
});
|
|
288
289
|
|
|
@@ -294,8 +295,13 @@ describe('NodeMatcher', () => {
|
|
|
294
295
|
data: null,
|
|
295
296
|
};
|
|
296
297
|
const matcher = NodeMatcher.whenAny(NodeMatcher.whenId('other-id'), NodeMatcher.whenNodeType('other-type'));
|
|
297
|
-
const result = matcher(node);
|
|
298
|
+
const result = matcher(node, get);
|
|
298
299
|
expect(Option.isNone(result)).to.be.true;
|
|
299
300
|
});
|
|
300
301
|
});
|
|
301
302
|
});
|
|
303
|
+
|
|
304
|
+
// Real reactive context for matchers that take `get`. The matchers under test
|
|
305
|
+
// inspect only the node, so the context is never read — it satisfies the arity.
|
|
306
|
+
const captureContext = (): Atom.Context => Registry.make().get(Atom.make((context): Atom.Context => context));
|
|
307
|
+
const get = captureContext();
|
package/src/node-matcher.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { Atom } from '@effect-atom/atom-react';
|
|
5
6
|
import * as Option from 'effect/Option';
|
|
6
7
|
|
|
7
8
|
import { Entity, Obj, type Type } from '@dxos/echo';
|
|
@@ -12,10 +13,15 @@ import * as Node from './node';
|
|
|
12
13
|
* Type for a node matcher function that returns an Option of the matched data.
|
|
13
14
|
* Matchers are used to filter and transform nodes in the app graph.
|
|
14
15
|
*
|
|
16
|
+
* Matchers receive the reactive atom context (`get`) so a match decision can
|
|
17
|
+
* depend on reactive state (e.g. an ECHO query for related objects). Reading an
|
|
18
|
+
* atom via `get` subscribes the extension to it, so the match re-runs when that
|
|
19
|
+
* state changes. Matchers that only inspect the node itself simply ignore `get`.
|
|
20
|
+
*
|
|
15
21
|
* @template TData - The type of data returned when the matcher succeeds.
|
|
16
22
|
* Defaults to Node.Node, but can be a more specific type (e.g., an ECHO entity).
|
|
17
23
|
*/
|
|
18
|
-
export type NodeMatcher<TData = Node.Node> = (node: Node.Node) => Option.Option<TData>;
|
|
24
|
+
export type NodeMatcher<TData = Node.Node> = (node: Node.Node, get: Atom.Context) => Option.Option<TData>;
|
|
19
25
|
|
|
20
26
|
//
|
|
21
27
|
// Basic Node Matchers
|
|
@@ -176,10 +182,10 @@ export const whenAll: {
|
|
|
176
182
|
(...matchers: NodeMatcher<any>[]): NodeMatcher<any>;
|
|
177
183
|
} =
|
|
178
184
|
(...matchers: NodeMatcher<any>[]): NodeMatcher<any> =>
|
|
179
|
-
(node: Node.Node) => {
|
|
185
|
+
(node: Node.Node, get: Atom.Context) => {
|
|
180
186
|
let first: Option.Option<any> = Option.none();
|
|
181
187
|
for (const candidate of matchers) {
|
|
182
|
-
const result = candidate(node);
|
|
188
|
+
const result = candidate(node, get);
|
|
183
189
|
if (Option.isNone(result)) {
|
|
184
190
|
return Option.none();
|
|
185
191
|
}
|
|
@@ -214,9 +220,9 @@ export const whenAny: {
|
|
|
214
220
|
(...matchers: NodeMatcher<any>[]): NodeMatcher<any>;
|
|
215
221
|
} =
|
|
216
222
|
(...matchers: NodeMatcher<any>[]): NodeMatcher<any> =>
|
|
217
|
-
(node: Node.Node) => {
|
|
223
|
+
(node: Node.Node, get: Atom.Context) => {
|
|
218
224
|
for (const candidate of matchers) {
|
|
219
|
-
const result = candidate(node);
|
|
225
|
+
const result = candidate(node, get);
|
|
220
226
|
if (Option.isSome(result)) {
|
|
221
227
|
return result;
|
|
222
228
|
}
|
|
@@ -309,5 +315,5 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
|
|
|
309
315
|
*/
|
|
310
316
|
export const whenNot =
|
|
311
317
|
(matcher: NodeMatcher<any>): NodeMatcher<unknown> =>
|
|
312
|
-
(node: Node.Node): Option.Option<unknown> =>
|
|
313
|
-
Option.isNone(matcher(node)) ? Option.some(node) : Option.none();
|
|
318
|
+
(node: Node.Node, get: Atom.Context): Option.Option<unknown> =>
|
|
319
|
+
Option.isNone(matcher(node, get)) ? Option.some(node) : Option.none();
|
package/src/node.ts
CHANGED
|
@@ -95,6 +95,9 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
|
|
|
95
95
|
/** Will automatically add nodes with an edge from this node to each. */
|
|
96
96
|
nodes?: NodeArg<unknown>[];
|
|
97
97
|
|
|
98
|
+
/** Will automatically add actions with an edge from this node to each. */
|
|
99
|
+
actions?: NodeArg<ActionData<any>>[];
|
|
100
|
+
|
|
98
101
|
/** Will automatically add specified edges. */
|
|
99
102
|
edges?: [string, RelationInput][];
|
|
100
103
|
};
|
package/src/util.ts
CHANGED
|
@@ -67,7 +67,8 @@ export const nodeArgsUnchanged = (prev: Node.NodeArg<any>[], next: Node.NodeArg<
|
|
|
67
67
|
prevNode.type === nextNode.type &&
|
|
68
68
|
shallowEqual(prevNode.data, nextNode.data) &&
|
|
69
69
|
shallowEqual(prevNode.properties, nextNode.properties) &&
|
|
70
|
-
nodeArgsUnchanged(prevNode.nodes ?? [], nextNode.nodes ?? [])
|
|
70
|
+
nodeArgsUnchanged(prevNode.nodes ?? [], nextNode.nodes ?? []) &&
|
|
71
|
+
nodeArgsUnchanged(prevNode.actions ?? [], nextNode.actions ?? [])
|
|
71
72
|
);
|
|
72
73
|
});
|
|
73
74
|
};
|