@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.
- package/dist/lib/chunk-graph-builder.mjs +1901 -0
- package/dist/lib/chunk-graph-builder.mjs.map +1 -0
- package/dist/lib/index.mjs +281 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/scheduler.browser.mjs +2 -0
- package/dist/lib/scheduler.mjs +12 -0
- package/dist/lib/scheduler.mjs.map +1 -0
- package/dist/lib/testing.mjs +28 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/atoms.d.ts +1 -1
- package/dist/types/src/atoms.d.ts.map +1 -1
- package/dist/types/src/graph-builder.d.ts +128 -9
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +4 -1
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +1 -1
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +14 -2
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/path-resolution.d.ts +69 -0
- package/dist/types/src/path-resolution.d.ts.map +1 -0
- package/dist/types/src/path-resolution.test.d.ts +2 -0
- package/dist/types/src/path-resolution.test.d.ts.map +1 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing/setup-graph-builder.d.ts +1 -1
- package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -1
- package/dist/types/src/util.test.d.ts +2 -0
- package/dist/types/src/util.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -21
- package/src/atoms.ts +1 -1
- package/src/graph-builder.test.ts +21 -1
- package/src/graph-builder.ts +327 -58
- package/src/graph.test.ts +1 -1
- package/src/graph.ts +22 -16
- package/src/index.ts +1 -0
- package/src/node-matcher.test.ts +1 -1
- package/src/node-matcher.ts +1 -1
- package/src/node.ts +19 -2
- package/src/path-resolution.test.ts +520 -0
- package/src/path-resolution.ts +355 -0
- package/src/stories/EchoGraph.stories.tsx +97 -121
- package/src/testing/setup-graph-builder.ts +1 -1
- package/src/util.test.ts +85 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/neutral/chunk-YMTZ7MPW.mjs +0 -1509
- package/dist/lib/neutral/chunk-YMTZ7MPW.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -40
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/scheduler.mjs +0 -15
- package/dist/lib/neutral/scheduler.mjs.map +0 -7
- package/dist/lib/neutral/testing/index.mjs +0 -40
- package/dist/lib/neutral/testing/index.mjs.map +0 -7
package/src/graph-builder.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Atom, Registry } from '@effect-atom/atom
|
|
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 }:
|
|
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>[]>>(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
444
|
+
const sourceNode = Option.getOrElse(get(node), () => undefined);
|
|
445
|
+
if (!sourceNode) {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
253
448
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
(
|
|
291
|
-
const
|
|
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?:
|
|
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.
|
|
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
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|