@dxos/app-graph 0.10.0 → 0.11.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.
Files changed (57) hide show
  1. package/dist/lib/chunk-graph-builder.mjs +1901 -0
  2. package/dist/lib/chunk-graph-builder.mjs.map +1 -0
  3. package/dist/lib/index.mjs +281 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/scheduler.browser.mjs +2 -0
  6. package/dist/lib/scheduler.mjs +12 -0
  7. package/dist/lib/scheduler.mjs.map +1 -0
  8. package/dist/lib/testing.mjs +28 -0
  9. package/dist/lib/testing.mjs.map +1 -0
  10. package/dist/types/src/atoms.d.ts +1 -1
  11. package/dist/types/src/atoms.d.ts.map +1 -1
  12. package/dist/types/src/graph-builder.d.ts +128 -9
  13. package/dist/types/src/graph-builder.d.ts.map +1 -1
  14. package/dist/types/src/graph.d.ts +4 -1
  15. package/dist/types/src/graph.d.ts.map +1 -1
  16. package/dist/types/src/index.d.ts +1 -0
  17. package/dist/types/src/index.d.ts.map +1 -1
  18. package/dist/types/src/node-matcher.d.ts +1 -1
  19. package/dist/types/src/node-matcher.d.ts.map +1 -1
  20. package/dist/types/src/node.d.ts +14 -2
  21. package/dist/types/src/node.d.ts.map +1 -1
  22. package/dist/types/src/path-resolution.d.ts +69 -0
  23. package/dist/types/src/path-resolution.d.ts.map +1 -0
  24. package/dist/types/src/path-resolution.test.d.ts +2 -0
  25. package/dist/types/src/path-resolution.test.d.ts.map +1 -0
  26. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  27. package/dist/types/src/testing/setup-graph-builder.d.ts +1 -1
  28. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -1
  29. package/dist/types/src/util.test.d.ts +2 -0
  30. package/dist/types/src/util.test.d.ts.map +1 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +22 -21
  33. package/src/atoms.ts +1 -1
  34. package/src/graph-builder.test.ts +21 -1
  35. package/src/graph-builder.ts +327 -58
  36. package/src/graph.test.ts +1 -1
  37. package/src/graph.ts +22 -16
  38. package/src/index.ts +1 -0
  39. package/src/node-matcher.test.ts +1 -1
  40. package/src/node-matcher.ts +1 -1
  41. package/src/node.ts +19 -2
  42. package/src/path-resolution.test.ts +520 -0
  43. package/src/path-resolution.ts +355 -0
  44. package/src/stories/EchoGraph.stories.tsx +97 -121
  45. package/src/testing/setup-graph-builder.ts +1 -1
  46. package/src/util.test.ts +85 -0
  47. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  48. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  49. package/dist/lib/neutral/chunk-YMTZ7MPW.mjs +0 -1509
  50. package/dist/lib/neutral/chunk-YMTZ7MPW.mjs.map +0 -7
  51. package/dist/lib/neutral/index.mjs +0 -40
  52. package/dist/lib/neutral/index.mjs.map +0 -7
  53. package/dist/lib/neutral/meta.json +0 -1
  54. package/dist/lib/neutral/scheduler.mjs +0 -15
  55. package/dist/lib/neutral/scheduler.mjs.map +0 -7
  56. package/dist/lib/neutral/testing/index.mjs +0 -40
  57. package/dist/lib/neutral/testing/index.mjs.map +0 -7
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, Registry } from '@effect-atom/atom-react';
5
+ import { Atom, Registry } from '@effect-atom/atom';
6
6
  import * as Array from 'effect/Array';
7
7
  import type * as Context from 'effect/Context';
8
8
  import * as Effect from 'effect/Effect';
@@ -35,11 +35,6 @@ import {
35
35
  // Extension Types
36
36
  //
37
37
 
38
- /**
39
- * Graph builder extension for adding nodes to the graph based on a node id.
40
- */
41
- export type ResolverExtension = (id: string) => Atom.Atom<Node.NodeArg<any> | null>;
42
-
43
38
  /**
44
39
  * Graph builder extension for adding nodes to the graph based on a connection to an existing node.
45
40
  *
@@ -61,16 +56,175 @@ export type ActionGroupsExtension = (
61
56
  node: Atom.Atom<Option.Option<Node.Node>>,
62
57
  ) => Atom.Atom<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
63
58
 
59
+ /**
60
+ * Graph builder extension for adding nodes to the graph based on a node id.
61
+ *
62
+ * TODO(wittjosiah): Remove? Superseded by the declared `url` binding — URL resolution no longer
63
+ * materializes a node from a bare id. Retained alongside `Graph.initialize`, which fires it.
64
+ */
65
+ export type ResolverExtension = (id: string) => Atom.Atom<Node.NodeArg<any> | null>;
66
+
64
67
  export type BuilderExtension = Readonly<{
65
68
  id: string;
66
69
  position?: Position.Position;
67
70
  relation?: Node.RelationInput;
71
+ /**
72
+ * URL binding for the nodes this extension's connector produces: the registered prefix key plus how
73
+ * it resolves. Omitted when the extension's nodes are not URL-addressable. See {@link UrlBinding} and
74
+ * `path-resolution.ts` for how the key table is derived and used.
75
+ */
76
+ url?: UrlBinding;
68
77
  resolver?: ResolverExtension;
69
78
  connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
70
79
  }>;
71
80
 
81
+ /**
82
+ * How an extension's nodes map to (and from) the URL pair chain — one binding per extension, holding
83
+ * the whole URL contract for the nodes it produces. The `kind` is the *resolution tier*: what a pair
84
+ * with this key resolves against.
85
+ *
86
+ * - `'item'` — Resolves against the current anchor (workspace) base, addressed by a variable id.
87
+ * The default addressable node; may itself have children (e.g. a mailbox). (`doc/<id>`).
88
+ * - `'singleton'` — Resolves against the current anchor base, but is a single fixed node per anchor, so
89
+ * it carries no id — its terminal node-id segment is the key itself. (`settings`).
90
+ *
91
+ * The anchor and linked tiers are not declared per extension: they are fixed keys of the URL grammar,
92
+ * configured once on the builder as {@link UrlGrammar}.
93
+ *
94
+ * `path` is how the node is located, in one of two forms:
95
+ * - `string[]` — fixed ancestor node-id segments between the workspace base and the node (the common,
96
+ * deterministic case): the node is `${Node.RootId}/<workspace>/<...segments>/<id>`. Fixed-depth
97
+ * dynamic tails beyond the segments are `+`-encoded into the id.
98
+ * - {@link PathResolver} — a dynamic resolver, for data-dependent shapes (e.g. nested collections at
99
+ * arbitrary depth) that cannot declare static segments.
100
+ *
101
+ * Read by `path-resolution.ts` (which derives the parse table's `hasId`/`anchor` from `kind`) and
102
+ * consumed by `UrlPath.parse`.
103
+ */
104
+ export type UrlBinding = { key: string; kind: 'item' | 'singleton'; path: string[] | PathResolver };
105
+
106
+ /**
107
+ * The URL grammar the builder resolves and stamps against, configured once at construction.
108
+ *
109
+ * The two keys are fixed tiers no extension declares (no connector produces their nodes): `anchorKey`
110
+ * establishes the base that following pairs resolve against and is consumed as a rebase
111
+ * (`w/<workspace>`); `linkedKey` addresses the linked-segment child of the preceding item
112
+ * (`companion/<variant>`), resolved structurally. The separators are the id-encoding conventions:
113
+ * `linkedPrefix` marks a linked segment (`<parent>/~<variant>`), and `tailSeparator` joins the
114
+ * fixed-depth node-id segments between a key's static `path` and the object id into one URL id
115
+ * (`db/<slug>+<id>`) so a fixed-depth nested shape needs no resolver.
116
+ */
117
+ export type UrlGrammar = {
118
+ anchorKey?: string;
119
+ linkedKey?: string;
120
+ linkedPrefix: string;
121
+ tailSeparator: string;
122
+ };
123
+
124
+ /** {@link UrlGrammar} as supplied at construction: the separators fall back to their defaults. */
125
+ export type UrlGrammarProps = Partial<UrlGrammar>;
126
+
127
+ /** Default linked-segment prefix; mirrors `@dxos/react-ui-attention`'s `linkedSegment`. Internal: read
128
+ * the resolved value from `builder.urlGrammar` rather than the default. */
129
+ const DEFAULT_LINKED_PREFIX = '~';
130
+
131
+ /** Default tail separator; never appears in an entity id or a type slug. Internal, as above. */
132
+ const DEFAULT_TAIL_SEPARATOR = '+';
133
+
134
+ /** Params passed to a {@link PathResolver} for a single `(key, id)` URL pair. */
135
+ export type PathResolveParams = {
136
+ /** The id segment from the `(key, id)` pair. */
137
+ id: string;
138
+ /** The workspace segment from the URL. */
139
+ workspace: string;
140
+ /** Qualified id of the workspace base node (`${Node.RootId}/<workspace>`). */
141
+ workspaceBaseId: string;
142
+ };
143
+
144
+ /**
145
+ * Dynamic forward URL resolver for an extension whose node-id shape is data-dependent and so cannot
146
+ * declare a static {@link UrlBinding.path}. Returns the candidate qualified node id —
147
+ * `path-resolution.ts` then materializes its ancestors and verifies it — or `null` if the id can't be
148
+ * located. Must be self-contained (the declaring plugin closes over any services it needs), so
149
+ * `@dxos/app-graph` stays free of service dependencies.
150
+ */
151
+ export type PathResolver = (params: PathResolveParams) => Effect.Effect<string | null>;
152
+
72
153
  export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
73
154
 
155
+ /**
156
+ * The `(key, id?)` URL representation of a node under a given {@link UrlBinding} — the reverse of forward
157
+ * resolution, minus the workspace (always the node id's second segment). A singleton has no id; a
158
+ * resolver-backed key keeps just the object id; a static path encodes the segments between the path and
159
+ * the id, `+`-joined (empty when the node sits at the path — a container whose children are the items).
160
+ */
161
+ export const urlRepresentation = (
162
+ nodeId: string,
163
+ url: UrlBinding,
164
+ tailSeparator: string = DEFAULT_TAIL_SEPARATOR,
165
+ ): { key: string; id?: string } => {
166
+ // A singleton carries no path-based id: its terminal node-id segment is the key itself.
167
+ if (url.kind === 'singleton') {
168
+ return { key: url.key };
169
+ }
170
+ const segments = nodeId.split('/');
171
+ const id =
172
+ typeof url.path === 'function'
173
+ ? segments[segments.length - 1]
174
+ : segments.slice(2 + url.path.length).join(tailSeparator);
175
+ return { key: url.key, id };
176
+ };
177
+
178
+ /**
179
+ * A node's own URL pair segment — `/<key>[/<id>]`, with no workspace/anchor prefix — or `undefined` when
180
+ * the node is not addressable in its own right (a container node sitting at the binding's `path`, whose
181
+ * children are the addressable items). A full URL is composed by prefixing `/w/<workspace>`.
182
+ */
183
+ export const nodeUrlSegment = (
184
+ nodeId: string,
185
+ url: UrlBinding,
186
+ tailSeparator: string = DEFAULT_TAIL_SEPARATOR,
187
+ ): string | undefined => {
188
+ const { key, id } = urlRepresentation(nodeId, url, tailSeparator);
189
+ if (id === undefined) {
190
+ return `/${key}`; // singleton
191
+ }
192
+ return id === '' ? undefined : `/${key}/${id}`; // empty id: container at the path, not addressable
193
+ };
194
+
195
+ /**
196
+ * A graph node with its computed {@link nodeUrlSegment} attached at `properties.urlSegment` when the node
197
+ * is URL-addressable. The core {@link Node.Node} stays URL-agnostic; this is the typed view for reading
198
+ * the segment — an open properties record with an explicit `urlSegment` field — mirroring how
199
+ * `@dxos/react-ui-menu` wraps `Node` for menu items.
200
+ */
201
+ export type BuilderNode<TData = any> = Node.Node<TData, { urlSegment?: string } & Record<string, any>>;
202
+
203
+ /**
204
+ * Return a copy of `node` (and its inline descendants) with `properties.urlSegment` stamped. A linked
205
+ * node (id ending in a `~<variant>` segment) is stamped from the `linked` tier key, independent of its
206
+ * producing extension's binding; any other node is stamped from `url` (its producer's binding), if any.
207
+ */
208
+ const stampUrlSegment = (
209
+ node: Node.NodeArg<any>,
210
+ url: UrlBinding | undefined,
211
+ grammar: UrlGrammar,
212
+ ): Node.NodeArg<any> => {
213
+ const lastSegment = node.id.slice(node.id.lastIndexOf('/') + 1);
214
+ const segment = lastSegment.startsWith(grammar.linkedPrefix)
215
+ ? grammar.linkedKey && `/${grammar.linkedKey}/${lastSegment.slice(grammar.linkedPrefix.length)}`
216
+ : url && nodeUrlSegment(node.id, url, grammar.tailSeparator);
217
+ const nodes = node.nodes?.map((child) => stampUrlSegment(child, url, grammar));
218
+ if (!segment && !nodes) {
219
+ return node;
220
+ }
221
+ return {
222
+ ...node,
223
+ ...(segment && { properties: { ...node.properties, urlSegment: segment } }),
224
+ ...(nodes && { nodes }),
225
+ };
226
+ };
227
+
74
228
  //
75
229
  // GraphBuilder Core
76
230
  //
@@ -95,6 +249,16 @@ export interface GraphBuilder extends Pipeable.Pipeable {
95
249
  readonly [GraphBuilderTypeId]: GraphBuilderTypeId;
96
250
  readonly graph: Graph.ExpandableGraph;
97
251
  readonly extensions: Atom.Atom<Record<string, BuilderExtension>>;
252
+ /** The URL grammar this builder resolves and stamps against (separators always resolved). */
253
+ readonly urlGrammar: UrlGrammar;
254
+ /** Read the currently registered extensions synchronously (used for URL key-table derivation). */
255
+ getExtensions(): Record<string, BuilderExtension>;
256
+ /**
257
+ * The id of the extension whose connector produced the given node, if known. Populated as
258
+ * connectors materialize nodes and cleared on removal; used by `path-resolution.ts` for
259
+ * reverse (node → URL) mapping.
260
+ */
261
+ getNodeExtensionId(nodeId: string): string | undefined;
98
262
  }
99
263
 
100
264
  /**
@@ -138,8 +302,16 @@ class GraphBuilderImpl implements GraphBuilder {
138
302
  Atom.keepAlive,
139
303
  Atom.withLabel('graph-builder:extensions'),
140
304
  );
305
+ /**
306
+ * Node id -> id of the extension whose connector produced it. Non-reactive: updated directly
307
+ * as connectors materialize/remove nodes, so reverse (node → URL) mapping in
308
+ * `path-resolution.ts` can look up the producing extension without a reactive read.
309
+ */
310
+ readonly _nodeExtensions = new Map<string, string>();
141
311
  /** Triggers signalling that a node's resolver has fired at least once. */
142
312
  readonly _initialized: Record<string, Trigger> = {};
313
+ /** The URL grammar (see {@link UrlGrammar}); the keys are absent when URLs are not in play. */
314
+ readonly urlGrammar: UrlGrammar;
143
315
  /** Shared atom registry for reactive subscriptions. */
144
316
  readonly _registry: Registry.Registry;
145
317
  /** Backing graph with internal accessors for node atoms and construction. */
@@ -148,7 +320,12 @@ class GraphBuilderImpl implements GraphBuilder {
148
320
  _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
149
321
  };
150
322
 
151
- constructor({ registry, ...params }: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'> = {}) {
323
+ constructor({ registry, urlGrammar, ...params }: GraphBuilderProps = {}) {
324
+ this.urlGrammar = {
325
+ linkedPrefix: DEFAULT_LINKED_PREFIX,
326
+ tailSeparator: DEFAULT_TAIL_SEPARATOR,
327
+ ...urlGrammar,
328
+ };
152
329
  this._registry = registry ?? Registry.make();
153
330
  const graph = Graph.make({
154
331
  ...params,
@@ -172,6 +349,22 @@ class GraphBuilderImpl implements GraphBuilder {
172
349
  return this._extensions;
173
350
  }
174
351
 
352
+ getExtensions(): Record<string, BuilderExtension> {
353
+ return this._registry.get(this._extensions);
354
+ }
355
+
356
+ getNodeExtensionId(nodeId: string): string | undefined {
357
+ return this._nodeExtensions.get(nodeId);
358
+ }
359
+
360
+ /** Record `extensionId` as the producer of a qualified node and all of its inline `nodes` descendants. */
361
+ private _recordProvenance(node: Node.NodeArg<any>, extensionId: string): void {
362
+ this._nodeExtensions.set(node.id, extensionId);
363
+ for (const child of node.nodes ?? []) {
364
+ this._recordProvenance(child, extensionId);
365
+ }
366
+ }
367
+
175
368
  /** Apply a set of node changes for a single connector key. */
176
369
  private _applyConnectorUpdate(key: string, nodes: Node.NodeArg<any>[], previous: string[]): void {
177
370
  const { id, relation } = relationFromConnectorKey(key);
@@ -226,6 +419,7 @@ class GraphBuilderImpl implements GraphBuilder {
226
419
  }
227
420
  }
228
421
 
422
+ /** A connector-produced node, tagged with the id of the extension that produced it (provenance). */
229
423
  private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
230
424
  return Atom.make((get) => {
231
425
  return Function.pipe(
@@ -241,35 +435,39 @@ class GraphBuilderImpl implements GraphBuilder {
241
435
  });
242
436
  });
243
437
 
244
- private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
245
- return Atom.make((get) => {
246
- const { id, relation } = relationFromConnectorKey(key);
247
- const node = this._graph.node(id);
438
+ private readonly _connectors = Atom.family<string, Atom.Atom<{ extensionId: string; node: Node.NodeArg<any> }[]>>(
439
+ (key) => {
440
+ return Atom.make((get) => {
441
+ const { id, relation } = relationFromConnectorKey(key);
442
+ const node = this._graph.node(id);
248
443
 
249
- const sourceNode = Option.getOrElse(get(node), () => undefined);
250
- if (!sourceNode) {
251
- return [];
252
- }
444
+ const sourceNode = Option.getOrElse(get(node), () => undefined);
445
+ if (!sourceNode) {
446
+ return [];
447
+ }
253
448
 
254
- const extensions = Function.pipe(
255
- get(this._extensions),
256
- Record.values,
257
- Array.sortBy(Position.compare),
258
- Array.filter(
259
- (ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
260
- Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
261
- ),
262
- );
449
+ const extensions = Function.pipe(
450
+ get(this._extensions),
451
+ Record.values,
452
+ Array.sortBy(Position.compare),
453
+ Array.filter(
454
+ (ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
455
+ Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
456
+ ),
457
+ );
263
458
 
264
- const nodes: Node.NodeArg<any>[] = [];
265
- for (const ext of extensions) {
266
- const result = get(ext.connector(node));
267
- nodes.push(...result);
268
- }
459
+ const entries: { extensionId: string; node: Node.NodeArg<any> }[] = [];
460
+ for (const ext of extensions) {
461
+ const result = get(ext.connector(node));
462
+ for (const nodeArg of result) {
463
+ entries.push({ extensionId: ext.id, node: nodeArg });
464
+ }
465
+ }
269
466
 
270
- return nodes;
271
- }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
272
- });
467
+ return entries;
468
+ }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
469
+ },
470
+ );
273
471
 
274
472
  private _onExpand(id: string, relation: Node.Relation): void {
275
473
  log('onExpand', { id, relation, registry: getDebugName(this._registry) });
@@ -287,8 +485,23 @@ class GraphBuilderImpl implements GraphBuilder {
287
485
 
288
486
  const cancel = this._registry.subscribe(
289
487
  connectors,
290
- (rawNodes) => {
291
- const nodes = qualifyNodeArgs(id)(rawNodes);
488
+ (entries) => {
489
+ const extensions = this.getExtensions();
490
+ const grammar = this.urlGrammar;
491
+ // Stamp `properties.urlSegment` on each produced node (and its inline descendants) so the computed
492
+ // segment is readable off the node (see `BuilderNode`): `/<key>[/<id>]` from the producing
493
+ // extension's binding, or `/<linkedKey>/<variant>` for a `~<variant>` linked node.
494
+ const nodes = qualifyNodeArgs(id)(entries.map((entry) => entry.node)).map((node, index) =>
495
+ stampUrlSegment(node, extensions[entries[index].extensionId]?.url, grammar),
496
+ );
497
+ // Record provenance for each qualified node — top-level and inline descendants alike — so
498
+ // reverse (node → URL) mapping can find the producing extension's `url` binding. Inline children
499
+ // (e.g. a TypeSection's objects, returned in the section node's `nodes` array) are produced by the
500
+ // same extension, so they carry the same provenance; without this they would have no URL representation.
501
+ entries.forEach((entry, index) => {
502
+ this._recordProvenance(nodes[index], entry.extensionId);
503
+ });
504
+
292
505
  const previous = this._connectorPrevious.get(key) ?? [];
293
506
  const ids = nodes.map((n) => n.id);
294
507
 
@@ -345,6 +558,7 @@ class GraphBuilderImpl implements GraphBuilder {
345
558
  }
346
559
 
347
560
  private _onRemoveNode(id: string): void {
561
+ this._nodeExtensions.delete(id);
348
562
  for (const [key, cleanup] of this._subscriptions) {
349
563
  if (primaryParts(key)[0] === id) {
350
564
  cleanup();
@@ -354,23 +568,28 @@ class GraphBuilderImpl implements GraphBuilder {
354
568
  }
355
569
  }
356
570
 
571
+ /** Construction params: the backing graph's props plus the URL grammar's fixed keys. */
572
+ export type GraphBuilderProps = Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'> & {
573
+ urlGrammar?: UrlGrammarProps;
574
+ };
575
+
357
576
  /**
358
577
  * Creates a new GraphBuilder instance.
359
578
  */
360
- export const make = (params?: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'>): GraphBuilder => {
579
+ export const make = (params?: GraphBuilderProps): GraphBuilder => {
361
580
  return new GraphBuilderImpl(params);
362
581
  };
363
582
 
364
583
  /**
365
584
  * Creates a GraphBuilder from a serialized pickle string.
366
585
  */
367
- export const from = (pickle?: string, registry?: Registry.Registry): GraphBuilder => {
586
+ export const from = (pickle?: string, registry?: Registry.Registry, urlGrammar?: UrlGrammarProps): GraphBuilder => {
368
587
  if (!pickle) {
369
- return make({ registry });
588
+ return make({ registry, urlGrammar });
370
589
  }
371
590
 
372
591
  const { nodes, edges } = JSON.parse(pickle);
373
- return make({ nodes, edges, registry });
592
+ return make({ nodes, edges, registry, urlGrammar });
374
593
  };
375
594
 
376
595
  /**
@@ -548,7 +767,7 @@ export const flush = (builder: GraphBuilder): Promise<void> => {
548
767
  * @param params.id The unique id of the extension.
549
768
  * @param params.relation The relation the graph is being expanded from the existing node.
550
769
  * @param params.position Affects the order the extensions are processed in.
551
- * @param params.resolver A function to add nodes to the graph based on just the node id.
770
+ * @param params.url URL binding for the nodes this extension produces (key + resolution); see {@link UrlBinding}.
552
771
  * @param params.connector A function to add nodes to the graph based on a connection to an existing node.
553
772
  * @param params.actions A function to add actions to the graph based on a connection to an existing node.
554
773
  * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
@@ -557,6 +776,7 @@ export type CreateExtensionRawOptions = {
557
776
  id: string;
558
777
  relation?: Node.RelationInput;
559
778
  position?: Position.Position;
779
+ url?: UrlBinding;
560
780
  resolver?: ResolverExtension;
561
781
  connector?: ConnectorExtension;
562
782
  actions?: ActionsExtension;
@@ -564,22 +784,18 @@ export type CreateExtensionRawOptions = {
564
784
  };
565
785
 
566
786
  /**
567
- * Validates that a graph extension or surface local ID follows NSID conventions:
568
- * the final dot-separated segment must be camelCase (letters and digits only,
569
- * starting with a letter — no hyphens or underscores). This mirrors the rule
570
- * enforced when the id is appended to a plugin's NSID to form a full DXN path.
787
+ * Whether a graph extension local ID follows NSID conventions: the final
788
+ * dot-separated segment must be camelCase (letters and digits only, starting
789
+ * with a letter — no hyphens or underscores). This mirrors the rule enforced
790
+ * when the id is appended to a plugin's NSID to form a full DXN path.
791
+ *
792
+ * An extension with an invalid id is dropped rather than rejected, so a single
793
+ * malformed contribution cannot crash plugin activation.
571
794
  *
572
795
  * @example Valid: 'about', 'devtools', 'integrationsSection'
573
796
  * @example Invalid: 'integration-article', 'plugin-spec'
574
797
  */
575
- const validateLocalId = (id: string): void => {
576
- const finalSegment = id.split('.').pop()!;
577
- if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(finalSegment)) {
578
- throw new Error(
579
- `Invalid extension id: "${id}". The final segment "${finalSegment}" must be camelCase (letters and digits only, starting with a letter — no hyphens or underscores).`,
580
- );
581
- }
582
- };
798
+ const isValidLocalId = (id: string): boolean => /^[a-zA-Z][a-zA-Z0-9]*$/.test(id.split('.').pop() ?? '');
583
799
 
584
800
  /**
585
801
  * Create a graph builder extension (low-level API that works directly with Atoms).
@@ -589,12 +805,21 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
589
805
  id,
590
806
  position,
591
807
  relation = 'child',
808
+ url,
592
809
  resolver: _resolver,
593
810
  connector: _connector,
594
811
  actions: _actions,
595
812
  actionGroups: _actionGroups,
596
813
  } = extension;
597
- validateLocalId(id);
814
+ if (!isValidLocalId(id)) {
815
+ log.warn(
816
+ 'dropping graph extension with invalid id; the final segment must be camelCase (no hyphens or underscores)',
817
+ {
818
+ id,
819
+ },
820
+ );
821
+ return [];
822
+ }
598
823
  const normalizedRelation = normalizeRelation(relation);
599
824
  const getId = (key: string) => `${id}/${key}`;
600
825
 
@@ -619,13 +844,14 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
619
844
  _actions(node).pipe(Atom.withLabel(`graph-builder:_actions:${id}`)),
620
845
  );
621
846
 
622
- return [
623
- resolver ? { id: getId('resolver'), position, resolver } : undefined,
847
+ const extensions = [
848
+ resolver ? ({ id: getId('resolver'), position, resolver } satisfies BuilderExtension) : undefined,
624
849
  connector
625
850
  ? ({
626
851
  id: getId('connector'),
627
852
  position,
628
853
  relation: normalizedRelation,
854
+ url,
629
855
  connector: Atom.family((node) =>
630
856
  Atom.make((get) => {
631
857
  try {
@@ -677,6 +903,15 @@ export const createExtensionRaw = (extension: CreateExtensionRawOptions): Builde
677
903
  } satisfies BuilderExtension)
678
904
  : undefined,
679
905
  ].filter(isNonNullable);
906
+
907
+ // A declaration-only extension: a `url` binding with no connector/actions (e.g. the workspace anchor,
908
+ // which registers a key for the parser/serializer but produces no nodes of its own). Emit it so the
909
+ // key table sees the binding; it has no connector so it never runs.
910
+ if (extensions.length === 0 && url) {
911
+ return [{ id, position, relation: normalizedRelation, url } satisfies BuilderExtension];
912
+ }
913
+
914
+ return extensions;
680
915
  };
681
916
 
682
917
  /**
@@ -692,10 +927,18 @@ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
692
927
  matched: TMatched,
693
928
  get: Atom.Context,
694
929
  ) => 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>;
930
+ /** Contribute dropdown action groups (each with nested `actions`) to the matched node; the group's
931
+ * `type`/`data` are set automatically, so returning `Node.makeActionGroup(...)` output is fine. */
932
+ actionGroups?: (
933
+ matched: TMatched,
934
+ get: Atom.Context,
935
+ ) => Effect.Effect<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data'>[], never, R>;
696
936
  resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, never, R>;
937
+ connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], never, R>;
697
938
  relation?: Node.RelationInput;
698
939
  position?: Position.Position;
940
+ /** URL binding for the nodes this extension produces (key + resolution); see {@link UrlBinding}. */
941
+ url?: UrlBinding;
699
942
  };
700
943
 
701
944
  /**
@@ -728,7 +971,7 @@ export const createExtension = <TMatched = Node.Node, R = never>(
728
971
  options: CreateExtensionOptions<TMatched, R>,
729
972
  ): Effect.Effect<BuilderExtension[], never, R> =>
730
973
  Effect.map(Effect.context<R>(), (context) => {
731
- const { id, match, actions, connector, resolver, relation, position } = options;
974
+ const { id, match, actions, actionGroups, connector, resolver, relation, position, url } = options;
732
975
 
733
976
  const connectorExtension = connector ? createConnectorWithRuntime(id, match, connector, context) : undefined;
734
977
 
@@ -750,6 +993,25 @@ export const createExtension = <TMatched = Node.Node, R = never>(
750
993
  )
751
994
  : undefined;
752
995
 
996
+ const actionGroupsExtension = actionGroups
997
+ ? (node: Atom.Atom<Option.Option<Node.Node>>) =>
998
+ Atom.make((get) =>
999
+ Function.pipe(
1000
+ get(node),
1001
+ Option.flatMap((matchedNode) => match(matchedNode, get)),
1002
+ Option.map((matched) =>
1003
+ runEffectSyncWithFallback(actionGroups(matched, get), context, id, []).map((group) => ({
1004
+ ...group,
1005
+ // Attach captured context to the group's child actions so they execute with the
1006
+ // extension's services (e.g. Capability.Service) even without an explicit runner.
1007
+ actions: group.actions?.map((action) => ({ ...action, _actionContext: context })),
1008
+ })),
1009
+ ),
1010
+ Option.getOrElse(() => []),
1011
+ ),
1012
+ )
1013
+ : undefined;
1014
+
753
1015
  const resolverExtension = resolver
754
1016
  ? (nodeId: string) =>
755
1017
  Atom.make((get) => runEffectSyncWithFallback(resolver(nodeId, get), context, id, null) ?? null)
@@ -759,9 +1021,11 @@ export const createExtension = <TMatched = Node.Node, R = never>(
759
1021
  id,
760
1022
  relation,
761
1023
  position,
1024
+ url,
1025
+ resolver: resolverExtension,
762
1026
  connector: connectorExtension,
763
1027
  actions: actionsExtension,
764
- resolver: resolverExtension,
1028
+ actionGroups: actionGroupsExtension,
765
1029
  });
766
1030
  });
767
1031
 
@@ -818,6 +1082,10 @@ export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity
818
1082
  object: Type.InstanceType<T>,
819
1083
  get: Atom.Context,
820
1084
  ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>>, 'type'>[], never, R>;
1085
+ actionGroups?: (
1086
+ object: Type.InstanceType<T>,
1087
+ get: Atom.Context,
1088
+ ) => Effect.Effect<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data'>[], never, R>;
821
1089
  connector?: (object: Type.InstanceType<T>, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], never, R>;
822
1090
  relation?: Node.RelationInput;
823
1091
  position?: Position.Position;
@@ -831,11 +1099,12 @@ export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity
831
1099
  export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
832
1100
  options: CreateTypeExtensionOptions<T, R>,
833
1101
  ): Effect.Effect<BuilderExtension[], never, R> => {
834
- const { id, type, actions, connector, relation, position } = options;
1102
+ const { id, type, actions, actionGroups, connector, relation, position } = options;
835
1103
  return createExtension<Type.InstanceType<T>, R>({
836
1104
  id,
837
1105
  match: NodeMatcher.whenEchoType(type),
838
1106
  actions,
1107
+ actionGroups,
839
1108
  connector,
840
1109
  relation,
841
1110
  position,
package/src/graph.test.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, Registry } from '@effect-atom/atom-react';
5
+ import { Atom, Registry } from '@effect-atom/atom';
6
6
  import * as Option from 'effect/Option';
7
7
  import { assert, describe, expect, onTestFinished, test } from 'vitest';
8
8