@dxos/app-graph 0.9.1-main.c7dcc2e112 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/app-graph",
3
- "version": "0.9.1-main.c7dcc2e112",
3
+ "version": "0.10.0",
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/async": "0.9.1-main.c7dcc2e112",
45
- "@dxos/invariant": "0.9.1-main.c7dcc2e112",
46
- "@dxos/echo": "0.9.1-main.c7dcc2e112",
47
- "@dxos/keys": "0.9.1-main.c7dcc2e112",
48
- "@dxos/log": "0.9.1-main.c7dcc2e112",
49
- "@dxos/util": "0.9.1-main.c7dcc2e112",
50
- "@dxos/debug": "0.9.1-main.c7dcc2e112"
44
+ "@dxos/async": "0.10.0",
45
+ "@dxos/debug": "0.10.0",
46
+ "@dxos/echo": "0.10.0",
47
+ "@dxos/keys": "0.10.0",
48
+ "@dxos/log": "0.10.0",
49
+ "@dxos/util": "0.10.0",
50
+ "@dxos/invariant": "0.10.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@effect-atom/atom-react": "^0.5.0",
54
- "@effect/platform": "0.96.1",
55
- "@types/react": "~19.2.7",
54
+ "@effect/platform": "0.96.2",
55
+ "@types/react": "~19.2.17",
56
56
  "@types/react-dom": "~19.2.3",
57
- "effect": "3.21.3",
58
- "react": "~19.2.3",
59
- "react-dom": "~19.2.3",
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.1-main.c7dcc2e112",
62
- "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
63
- "@dxos/react-client": "0.9.1-main.c7dcc2e112",
64
- "@dxos/react-ui-list": "0.9.1-main.c7dcc2e112",
65
- "@dxos/storybook-utils": "0.9.1-main.c7dcc2e112",
66
- "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
67
- "@dxos/react-ui-tabs": "0.9.1-main.c7dcc2e112"
61
+ "@dxos/react-client": "0.10.0",
62
+ "@dxos/random": "0.10.0",
63
+ "@dxos/react-ui": "0.10.0",
64
+ "@dxos/react-ui-list": "0.10.0",
65
+ "@dxos/react-ui-tabs": "0.10.0",
66
+ "@dxos/ui-theme": "0.10.0",
67
+ "@dxos/storybook-utils": "0.10.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@effect-atom/atom-react": "^0.5.0",
71
- "@effect/platform": "0.96.1",
72
- "effect": "3.21.3",
73
- "react": "~19.2.3",
74
- "react-dom": "~19.2.3",
75
- "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
76
- "@dxos/react-ui": "0.9.1-main.c7dcc2e112"
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.10.0",
76
+ "@dxos/ui-theme": "0.10.0"
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.fail(new Error('Connector failed intentionally')),
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.fail(new Error('Actions failed intentionally')),
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.fail(new Error('Resolver failed intentionally')),
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.fail(new Error('This one fails')),
1406
+ connector: (node, get) => Effect.die('This one fails'),
1290
1407
  }),
1291
1408
  );
1292
1409
 
@@ -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, type Position, byPosition, getDebugName, isNonNullable } from '@dxos/util';
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(byPosition),
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(byPosition),
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 fail - errors are caught, logged, and the extension returns empty results.
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'>[], Error, R>;
696
- connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
697
- resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
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
- * If the effect fails, logs the error and returns the fallback value.
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, Error, R>,
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.catchAll((error) => {
717
- log.warn('Extension failed', { extension: extensionId, error });
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>[], Error, R>,
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'>[], Error, R>;
822
- connector?: (object: Type.InstanceType<T>, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
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
- node.nodes ? [...node.nodes.map((child) => child.id), ...collectAllInlineIds(node.nodes)] : [],
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 { qualifyId, getParentId, getSegmentId } from './util';
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';
@@ -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();
@@ -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
  };